Skip to content

Commit 07672f2

Browse files
authored
🤖 fix: persist agent status across reloads (#1038)
_Generated with `mux`_ Fixes agent status (emoji, message, URL) disappearing after page reload or compaction. ## Changes - Persist full status payload to localStorage under `statusState:{workspaceId}` - Load persisted status on aggregator init; restore on reload if history lacks `status_set` - Use zod schema for type-safe parsing - Keep in-memory `lastStatusUrl` for URL fallback across turns ## Testing - Added unit tests for persistence through reload and compaction scenarios - `make static-check` passes
1 parent 97c4c06 commit 07672f2

File tree

3 files changed

+178
-57
lines changed

3 files changed

+178
-57
lines changed

src/browser/utils/messages/StreamingMessageAggregator.status.test.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,51 @@
1-
import { describe, it, expect } from "bun:test";
1+
import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test";
2+
import { getStatusStateKey } from "@/common/constants/storage";
23
import { StreamingMessageAggregator } from "./StreamingMessageAggregator";
34

5+
const originalLocalStorage: Storage | undefined = (globalThis as { localStorage?: Storage })
6+
.localStorage;
7+
8+
const createMockLocalStorage = () => {
9+
const store = new Map<string, string>();
10+
return {
11+
get length() {
12+
return store.size;
13+
},
14+
key: (index: number) => Array.from(store.keys())[index] ?? null,
15+
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
16+
setItem: (key: string, value: string) => {
17+
store.set(key, value);
18+
},
19+
removeItem: (key: string) => {
20+
store.delete(key);
21+
},
22+
clear: () => {
23+
store.clear();
24+
},
25+
} satisfies Storage;
26+
};
27+
28+
beforeEach(() => {
29+
const mock = createMockLocalStorage();
30+
Object.defineProperty(globalThis, "localStorage", {
31+
value: mock,
32+
configurable: true,
33+
});
34+
});
35+
36+
afterEach(() => {
37+
const ls = (globalThis as { localStorage?: Storage }).localStorage;
38+
ls?.clear?.();
39+
});
40+
41+
afterAll(() => {
42+
if (originalLocalStorage !== undefined) {
43+
Object.defineProperty(globalThis, "localStorage", { value: originalLocalStorage });
44+
} else {
45+
delete (globalThis as { localStorage?: Storage }).localStorage;
46+
}
47+
});
48+
449
describe("StreamingMessageAggregator - Agent Status", () => {
550
it("should start with undefined agent status", () => {
651
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
@@ -504,6 +549,67 @@ describe("StreamingMessageAggregator - Agent Status", () => {
504549
expect(aggregator.getAgentStatus()).toBeUndefined();
505550
});
506551

552+
it("should retain last status_set even if later assistant messages omit it", () => {
553+
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
554+
555+
const historicalMessages = [
556+
{
557+
id: "assistant1",
558+
role: "assistant" as const,
559+
parts: [
560+
{
561+
type: "dynamic-tool" as const,
562+
toolCallId: "tool1",
563+
toolName: "status_set",
564+
state: "output-available" as const,
565+
input: { emoji: "🧪", message: "Running tests" },
566+
output: { success: true, emoji: "🧪", message: "Running tests" },
567+
timestamp: 1000,
568+
},
569+
],
570+
metadata: { timestamp: 1000, historySequence: 1 },
571+
},
572+
{
573+
id: "assistant2",
574+
role: "assistant" as const,
575+
parts: [{ type: "text" as const, text: "[compaction summary]" }],
576+
metadata: { timestamp: 2000, historySequence: 2 },
577+
},
578+
];
579+
580+
aggregator.loadHistoricalMessages(historicalMessages);
581+
582+
const status = aggregator.getAgentStatus();
583+
expect(status?.emoji).toBe("🧪");
584+
expect(status?.message).toBe("Running tests");
585+
});
586+
587+
it("should restore persisted status when history is compacted away", () => {
588+
const workspaceId = "workspace1";
589+
const persistedStatus = {
590+
emoji: "🔗",
591+
message: "PR open",
592+
url: "https://example.com/pr/123",
593+
} as const;
594+
localStorage.setItem(getStatusStateKey(workspaceId), JSON.stringify(persistedStatus));
595+
596+
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z", workspaceId);
597+
598+
// History with no status_set (e.g., after compaction removes older tool calls)
599+
const historicalMessages = [
600+
{
601+
id: "assistant2",
602+
role: "assistant" as const,
603+
parts: [{ type: "text" as const, text: "[compacted history]" }],
604+
metadata: { timestamp: 3000, historySequence: 1 },
605+
},
606+
];
607+
608+
aggregator.loadHistoricalMessages(historicalMessages);
609+
610+
expect(aggregator.getAgentStatus()).toEqual(persistedStatus);
611+
});
612+
507613
it("should use truncated message from output, not original input", () => {
508614
const aggregator = new StreamingMessageAggregator(new Date().toISOString());
509615

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 65 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,20 @@ import type {
2929
DynamicToolPartAvailable,
3030
} from "@/common/types/toolParts";
3131
import { isDynamicToolPart } from "@/common/types/toolParts";
32+
import { z } from "zod";
3233
import { createDeltaStorage, type DeltaRecordStorage } from "./StreamingTPSCalculator";
3334
import { 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>;
3846
const MAX_DISPLAYED_MESSAGES = 128;
3947

4048
interface 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
}

src/common/constants/storage.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,12 @@ export function getFileTreeExpandStateKey(workspaceId: string): string {
213213
}
214214

215215
/**
216-
* Get the localStorage key for persisted status URL for a workspace
217-
* Stores the last URL set via status_set tool (survives compaction)
218-
* Format: "statusUrl:{workspaceId}"
216+
* Get the localStorage key for persisted agent status for a workspace
217+
* Stores the most recent successful status_set payload (emoji, message, url)
218+
* Format: "statusState:{workspaceId}"
219219
*/
220-
export function getStatusUrlKey(workspaceId: string): string {
221-
return `statusUrl:${workspaceId}`;
220+
export function getStatusStateKey(workspaceId: string): string {
221+
return `statusState:${workspaceId}`;
222222
}
223223

224224
/**
@@ -283,7 +283,7 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string>
283283
getReviewSearchStateKey,
284284
getReviewsKey,
285285
getAutoCompactionEnabledKey,
286-
getStatusUrlKey,
286+
getStatusStateKey,
287287
// Note: getAutoCompactionThresholdKey is per-model, not per-workspace
288288
];
289289

0 commit comments

Comments
 (0)