Skip to content

Commit 99d3070

Browse files
authored
🤖 fix: persist status URL across sessions via localStorage (#822)
Status URLs now persist through page reloads and compactions via localStorage. ## Changes - `StreamingMessageAggregator` takes optional `workspaceId` in constructor - Saves `lastStatusUrl` to localStorage keyed by workspace when set - Loads on construction so URL survives history rewrites Regression introduced in cc64299. _Generated with `mux`_
1 parent 1c2dde8 commit 99d3070

File tree

3 files changed

+144
-10
lines changed

3 files changed

+144
-10
lines changed

src/browser/stores/WorkspaceStore.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,8 +685,8 @@ export class WorkspaceStore {
685685
createdAt: string
686686
): StreamingMessageAggregator {
687687
if (!this.aggregators.has(workspaceId)) {
688-
// Create new aggregator with required createdAt
689-
this.aggregators.set(workspaceId, new StreamingMessageAggregator(createdAt));
688+
// Create new aggregator with required createdAt and workspaceId for localStorage persistence
689+
this.aggregators.set(workspaceId, new StreamingMessageAggregator(createdAt, workspaceId));
690690
this.workspaceCreatedAt.set(workspaceId, createdAt);
691691
}
692692

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,4 +759,73 @@ describe("StreamingMessageAggregator - Agent Status", () => {
759759
expect(finalStatus?.message).toBe("Tests passed");
760760
expect(finalStatus?.url).toBe(testUrl); // URL from previous stream persists!
761761
});
762+
763+
it("should persist URL across multiple assistant messages when loading from history", () => {
764+
// Regression test: URL should persist even when only the most recent assistant message
765+
// has a status_set without a URL - the URL from an earlier message should be used
766+
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
767+
const testUrl = "https://github.com/owner/repo/pull/123";
768+
769+
// Historical messages: first assistant sets URL, second assistant updates status without URL
770+
const historicalMessages = [
771+
{
772+
id: "user1",
773+
role: "user" as const,
774+
parts: [{ type: "text" as const, text: "Make a PR" }],
775+
metadata: { timestamp: 1000, historySequence: 1 },
776+
},
777+
{
778+
id: "assistant1",
779+
role: "assistant" as const,
780+
parts: [
781+
{
782+
type: "dynamic-tool" as const,
783+
toolName: "status_set",
784+
toolCallId: "tool1",
785+
state: "output-available" as const,
786+
input: { emoji: "🔗", message: "PR submitted", url: testUrl },
787+
output: { success: true, emoji: "🔗", message: "PR submitted", url: testUrl },
788+
timestamp: 1001,
789+
tokens: 10,
790+
},
791+
],
792+
metadata: { timestamp: 1001, historySequence: 2 },
793+
},
794+
{
795+
id: "user2",
796+
role: "user" as const,
797+
parts: [{ type: "text" as const, text: "Continue" }],
798+
metadata: { timestamp: 2000, historySequence: 3 },
799+
},
800+
{
801+
id: "assistant2",
802+
role: "assistant" as const,
803+
parts: [
804+
{
805+
type: "dynamic-tool" as const,
806+
toolName: "status_set",
807+
toolCallId: "tool2",
808+
state: "output-available" as const,
809+
input: { emoji: "✅", message: "Tests passed" },
810+
output: { success: true, emoji: "✅", message: "Tests passed" }, // No URL!
811+
timestamp: 2001,
812+
tokens: 10,
813+
},
814+
],
815+
metadata: { timestamp: 2001, historySequence: 4 },
816+
},
817+
];
818+
819+
aggregator.loadHistoricalMessages(historicalMessages);
820+
821+
const status = aggregator.getAgentStatus();
822+
expect(status?.emoji).toBe("✅");
823+
expect(status?.message).toBe("Tests passed");
824+
// URL from the first assistant message should persist!
825+
expect(status?.url).toBe(testUrl);
826+
});
827+
828+
// Note: URL persistence through compaction is handled via localStorage,
829+
// which is tested in integration tests. The aggregator saves lastStatusUrl
830+
// to localStorage when it changes, and loads it on construction.
762831
});

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,13 @@ export class StreamingMessageAggregator {
8888
private agentStatus: { emoji: string; message: string; url?: string } | undefined = undefined;
8989

9090
// Last URL set via status_set - persists even when agentStatus is cleared
91-
// This ensures URL stays available across stream boundaries
91+
// This ensures URL stays available across stream boundaries and through compaction
92+
// Persisted to localStorage keyed by workspaceId
9293
private lastStatusUrl: string | undefined = undefined;
9394

95+
// Workspace ID for localStorage persistence
96+
private readonly workspaceId: string | undefined;
97+
9498
// Workspace init hook state (ephemeral, not persisted to history)
9599
private initState: {
96100
status: "running" | "success" | "error";
@@ -111,10 +115,47 @@ export class StreamingMessageAggregator {
111115
// REQUIRED: Backend guarantees every workspace has createdAt via config.ts
112116
private readonly createdAt: string;
113117

114-
constructor(createdAt: string) {
118+
constructor(createdAt: string, workspaceId?: string) {
115119
this.createdAt = createdAt;
120+
this.workspaceId = workspaceId;
121+
// Load persisted lastStatusUrl from localStorage
122+
if (workspaceId) {
123+
this.lastStatusUrl = this.loadLastStatusUrl();
124+
}
116125
this.updateRecency();
117126
}
127+
128+
/** localStorage key for persisting lastStatusUrl. Only call when workspaceId is defined. */
129+
private getStatusUrlKey(): string | undefined {
130+
if (!this.workspaceId) return undefined;
131+
return `mux:workspace:${this.workspaceId}:lastStatusUrl`;
132+
}
133+
134+
/** Load lastStatusUrl from localStorage */
135+
private loadLastStatusUrl(): string | undefined {
136+
const key = this.getStatusUrlKey();
137+
if (!key) return undefined;
138+
try {
139+
const stored = localStorage.getItem(key);
140+
return stored ?? undefined;
141+
} catch {
142+
return undefined;
143+
}
144+
}
145+
146+
/**
147+
* Persist lastStatusUrl to localStorage.
148+
* Once set, the URL can only be replaced with a new URL, never deleted.
149+
*/
150+
private saveLastStatusUrl(url: string): void {
151+
const key = this.getStatusUrlKey();
152+
if (!key) return;
153+
try {
154+
localStorage.setItem(key, url);
155+
} catch {
156+
// Ignore localStorage errors
157+
}
158+
}
118159
private invalidateCache(): void {
119160
this.cachedAllMessages = null;
120161
this.cachedDisplayedMessages = null;
@@ -232,16 +273,39 @@ export class StreamingMessageAggregator {
232273
this.messages.set(message.id, message);
233274
}
234275

235-
// Then, reconstruct derived state from the most recent assistant message
236276
// Use "streaming" context if there's an active stream (reconnection), otherwise "historical"
237277
const context = hasActiveStream ? "streaming" : "historical";
238278

239-
const sortedMessages = [...messages].sort(
240-
(a, b) => (b.metadata?.historySequence ?? 0) - (a.metadata?.historySequence ?? 0)
279+
// Sort messages in chronological order for processing
280+
const chronologicalMessages = [...messages].sort(
281+
(a, b) => (a.metadata?.historySequence ?? 0) - (b.metadata?.historySequence ?? 0)
241282
);
242283

243-
// Find the most recent assistant message
244-
const lastAssistantMessage = sortedMessages.find((msg) => msg.role === "assistant");
284+
// First pass: scan all messages to build up lastStatusUrl from tool calls
285+
// This ensures URL persistence works even if the URL was set in an earlier message
286+
// Also persists to localStorage for future loads (survives compaction)
287+
for (const message of chronologicalMessages) {
288+
if (message.role === "assistant") {
289+
for (const part of message.parts) {
290+
if (
291+
isDynamicToolPart(part) &&
292+
part.state === "output-available" &&
293+
part.toolName === "status_set" &&
294+
hasSuccessResult(part.output)
295+
) {
296+
const result = part.output as Extract<StatusSetToolResult, { success: true }>;
297+
if (result.url) {
298+
this.lastStatusUrl = result.url;
299+
this.saveLastStatusUrl(result.url);
300+
}
301+
}
302+
}
303+
}
304+
}
305+
306+
// Second pass: reconstruct derived state from the most recent assistant message only
307+
// (TODOs and agentStatus should reflect only the latest state)
308+
const lastAssistantMessage = chronologicalMessages.findLast((msg) => msg.role === "assistant");
245309

246310
if (lastAssistantMessage) {
247311
// Process all tool results from the most recent assistant message
@@ -577,9 +641,10 @@ export class StreamingMessageAggregator {
577641
if (toolName === "status_set" && hasSuccessResult(output)) {
578642
const result = output as Extract<StatusSetToolResult, { success: true }>;
579643

580-
// Update lastStatusUrl if a new URL is provided
644+
// Update lastStatusUrl if a new URL is provided, and persist to localStorage
581645
if (result.url) {
582646
this.lastStatusUrl = result.url;
647+
this.saveLastStatusUrl(result.url);
583648
}
584649

585650
// Use the provided URL, or fall back to the last URL ever set

0 commit comments

Comments
 (0)