Skip to content

Commit 496a3ea

Browse files
committed
🤖 fix: persist status_url when loading historical messages
The loadHistoricalMessages function was only processing the most recent assistant message, which meant that lastStatusUrl wasn't populated from earlier messages. This caused the URL to be lost when: 1. User sends a message with status_set that includes a URL 2. User sends another message with status_set WITHOUT a URL 3. Page reloads or workspace switches Now we scan ALL historical messages in chronological order to build up lastStatusUrl before processing the final state. Additionally, status URLs now persist through compaction: - CompactionHandler extracts lastStatusUrl from messages being compacted - The URL is preserved in the compacted summary message metadata - Frontend reads lastStatusUrl from message metadata on historical load Regression introduced in cc64299 by ammar-agent. _Generated with `mux`_
1 parent 284dbc7 commit 496a3ea

File tree

5 files changed

+304
-10
lines changed

5 files changed

+304
-10
lines changed

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

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,4 +759,121 @@ 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+
it("should restore URL from compacted message metadata", () => {
829+
// Simulates loading history after compaction preserved the lastStatusUrl
830+
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
831+
const testUrl = "https://github.com/owner/repo/pull/123";
832+
833+
// Compacted summary message with preserved lastStatusUrl
834+
const historicalMessages = [
835+
{
836+
id: "summary1",
837+
role: "assistant" as const,
838+
parts: [{ type: "text" as const, text: "Summary of conversation..." }],
839+
metadata: {
840+
timestamp: 1000,
841+
historySequence: 1,
842+
compacted: true,
843+
lastStatusUrl: testUrl, // Preserved from before compaction
844+
},
845+
},
846+
{
847+
id: "user1",
848+
role: "user" as const,
849+
parts: [{ type: "text" as const, text: "Continue work" }],
850+
metadata: { timestamp: 2000, historySequence: 2 },
851+
},
852+
{
853+
id: "assistant1",
854+
role: "assistant" as const,
855+
parts: [
856+
{
857+
type: "dynamic-tool" as const,
858+
toolName: "status_set",
859+
toolCallId: "tool1",
860+
state: "output-available" as const,
861+
input: { emoji: "✅", message: "Done" },
862+
output: { success: true, emoji: "✅", message: "Done" }, // No URL
863+
timestamp: 2001,
864+
tokens: 10,
865+
},
866+
],
867+
metadata: { timestamp: 2001, historySequence: 3 },
868+
},
869+
];
870+
871+
aggregator.loadHistoricalMessages(historicalMessages);
872+
873+
const status = aggregator.getAgentStatus();
874+
expect(status?.emoji).toBe("✅");
875+
expect(status?.message).toBe("Done");
876+
// URL from the compacted message metadata should be used
877+
expect(status?.url).toBe(testUrl);
878+
});
762879
});

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,16 +232,44 @@ export class StreamingMessageAggregator {
232232
this.messages.set(message.id, message);
233233
}
234234

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

239-
const sortedMessages = [...messages].sort(
240-
(a, b) => (b.metadata?.historySequence ?? 0) - (a.metadata?.historySequence ?? 0)
238+
// Sort messages in chronological order for processing
239+
const chronologicalMessages = [...messages].sort(
240+
(a, b) => (a.metadata?.historySequence ?? 0) - (b.metadata?.historySequence ?? 0)
241241
);
242242

243-
// Find the most recent assistant message
244-
const lastAssistantMessage = sortedMessages.find((msg) => msg.role === "assistant");
243+
// First pass: scan all messages to build up lastStatusUrl
244+
// This ensures URL persistence works even if the URL was set in an earlier message
245+
// or was preserved through compaction in message metadata
246+
for (const message of chronologicalMessages) {
247+
// Check if compaction preserved a lastStatusUrl in metadata
248+
if (message.metadata?.lastStatusUrl) {
249+
this.lastStatusUrl = message.metadata.lastStatusUrl;
250+
}
251+
252+
// Then check tool calls for status_set with URL
253+
if (message.role === "assistant") {
254+
for (const part of message.parts) {
255+
if (
256+
isDynamicToolPart(part) &&
257+
part.state === "output-available" &&
258+
part.toolName === "status_set" &&
259+
hasSuccessResult(part.output)
260+
) {
261+
const result = part.output as Extract<StatusSetToolResult, { success: true }>;
262+
if (result.url) {
263+
this.lastStatusUrl = result.url;
264+
}
265+
}
266+
}
267+
}
268+
}
269+
270+
// Second pass: reconstruct derived state from the most recent assistant message only
271+
// (TODOs and agentStatus should reflect only the latest state)
272+
const lastAssistantMessage = chronologicalMessages.findLast((msg) => msg.role === "assistant");
245273

246274
if (lastAssistantMessage) {
247275
// Process all tool results from the most recent assistant message

src/common/types/message.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface MuxMetadata {
5050
cmuxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box
5151
muxMetadata?: MuxFrontendMetadata; // Frontend-defined metadata, backend treats as black-box
5252
historicalUsage?: ChatUsageDisplay; // Cumulative usage from all messages before this compaction (only present on compaction summaries)
53+
lastStatusUrl?: string; // Last status_set URL, persisted through compaction so it survives history rewrite
5354
}
5455

5556
// Extended tool part type that supports interrupted tool calls (input-available state)

src/node/services/compactionHandler.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,110 @@ describe("CompactionHandler", () => {
329329
const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
330330
expect(appendedMsg.metadata?.compacted).toBe(true);
331331
});
332+
333+
it("should preserve lastStatusUrl from status_set tool calls", async () => {
334+
const testUrl = "https://github.com/owner/repo/pull/123";
335+
336+
// Create message with status_set tool call containing URL
337+
const messageWithStatus: MuxMessage = {
338+
id: "asst-with-status",
339+
role: "assistant",
340+
parts: [
341+
{
342+
type: "dynamic-tool",
343+
toolName: "status_set",
344+
toolCallId: "tool1",
345+
state: "output-available",
346+
input: { emoji: "🔗", message: "PR submitted", url: testUrl },
347+
output: { success: true, emoji: "🔗", message: "PR submitted", url: testUrl },
348+
},
349+
],
350+
metadata: { historySequence: 1 },
351+
};
352+
353+
const compactionReq = createCompactionRequest();
354+
compactionReq.metadata = { ...compactionReq.metadata, historySequence: 2 };
355+
356+
mockHistoryService.mockGetHistory(Ok([messageWithStatus, compactionReq]));
357+
mockHistoryService.mockClearHistory(Ok([1, 2]));
358+
mockHistoryService.mockAppendToHistory(Ok(undefined));
359+
360+
const event = createStreamEndEvent("Summary");
361+
await handler.handleCompletion(event);
362+
363+
const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
364+
expect(appendedMsg.metadata?.lastStatusUrl).toBe(testUrl);
365+
});
366+
367+
it("should preserve lastStatusUrl from previous compaction metadata", async () => {
368+
const testUrl = "https://github.com/owner/repo/pull/456";
369+
370+
// Create compacted summary with preserved lastStatusUrl
371+
const compactedSummary: MuxMessage = {
372+
id: "summary-prev",
373+
role: "assistant",
374+
parts: [{ type: "text", text: "Previous summary" }],
375+
metadata: { historySequence: 1, compacted: true, lastStatusUrl: testUrl },
376+
};
377+
378+
const compactionReq = createCompactionRequest();
379+
compactionReq.metadata = { ...compactionReq.metadata, historySequence: 2 };
380+
381+
mockHistoryService.mockGetHistory(Ok([compactedSummary, compactionReq]));
382+
mockHistoryService.mockClearHistory(Ok([1, 2]));
383+
mockHistoryService.mockAppendToHistory(Ok(undefined));
384+
385+
const event = createStreamEndEvent("New summary");
386+
await handler.handleCompletion(event);
387+
388+
const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
389+
// URL from previous compaction should be preserved
390+
expect(appendedMsg.metadata?.lastStatusUrl).toBe(testUrl);
391+
});
392+
393+
it("should use most recent URL when both metadata and tool call have URLs", async () => {
394+
const olderUrl = "https://github.com/owner/repo/pull/111";
395+
const newerUrl = "https://github.com/owner/repo/pull/222";
396+
397+
// Compacted summary with older URL
398+
const compactedSummary: MuxMessage = {
399+
id: "summary-prev",
400+
role: "assistant",
401+
parts: [{ type: "text", text: "Previous summary" }],
402+
metadata: { historySequence: 1, compacted: true, lastStatusUrl: olderUrl },
403+
};
404+
405+
// Newer message with status_set containing newer URL
406+
const messageWithStatus: MuxMessage = {
407+
id: "asst-with-status",
408+
role: "assistant",
409+
parts: [
410+
{
411+
type: "dynamic-tool",
412+
toolName: "status_set",
413+
toolCallId: "tool1",
414+
state: "output-available",
415+
input: { emoji: "🔗", message: "New PR", url: newerUrl },
416+
output: { success: true, emoji: "🔗", message: "New PR", url: newerUrl },
417+
},
418+
],
419+
metadata: { historySequence: 2 },
420+
};
421+
422+
const compactionReq = createCompactionRequest();
423+
compactionReq.metadata = { ...compactionReq.metadata, historySequence: 3 };
424+
425+
mockHistoryService.mockGetHistory(Ok([compactedSummary, messageWithStatus, compactionReq]));
426+
mockHistoryService.mockClearHistory(Ok([1, 2, 3]));
427+
mockHistoryService.mockAppendToHistory(Ok(undefined));
428+
429+
const event = createStreamEndEvent("Summary");
430+
await handler.handleCompletion(event);
431+
432+
const appendedMsg = mockHistoryService.appendToHistory.mock.calls[0][1] as MuxMessage;
433+
// Newer URL from status_set should win
434+
expect(appendedMsg.metadata?.lastStatusUrl).toBe(newerUrl);
435+
});
332436
});
333437

334438
describe("handleCompletion() - Deduplication", () => {

src/node/services/compactionHandler.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider";
88
import { collectUsageHistory } from "@/common/utils/tokens/displayUsage";
99
import { sumUsageHistory } from "@/common/utils/tokens/usageAggregator";
1010
import { createMuxMessage, type MuxMessage } from "@/common/types/message";
11+
import type { StatusSetToolResult } from "@/common/types/tools";
12+
import { isDynamicToolPart } from "@/common/types/toolParts";
1113

1214
interface CompactionHandlerOptions {
1315
workspaceId: string;
@@ -80,15 +82,54 @@ export class CompactionHandler {
8082
return true;
8183
}
8284

85+
/**
86+
* Extract the last status URL from messages by scanning all status_set tool results.
87+
* Returns undefined if no URL was ever set.
88+
*/
89+
private extractLastStatusUrl(messages: MuxMessage[]): string | undefined {
90+
let lastUrl: string | undefined;
91+
92+
// Sort by historySequence to ensure chronological order
93+
const sortedMessages = [...messages].sort(
94+
(a, b) => (a.metadata?.historySequence ?? 0) - (b.metadata?.historySequence ?? 0)
95+
);
96+
97+
for (const message of sortedMessages) {
98+
// Check if a previous compaction preserved a lastStatusUrl
99+
if (message.metadata?.lastStatusUrl) {
100+
lastUrl = message.metadata.lastStatusUrl;
101+
}
102+
103+
// Then check tool calls for status_set with URL
104+
if (message.role === "assistant") {
105+
for (const part of message.parts) {
106+
if (
107+
isDynamicToolPart(part) &&
108+
part.state === "output-available" &&
109+
part.toolName === "status_set"
110+
) {
111+
const output = part.output as StatusSetToolResult | undefined;
112+
if (output && "success" in output && output.success && output.url) {
113+
lastUrl = output.url;
114+
}
115+
}
116+
}
117+
}
118+
}
119+
120+
return lastUrl;
121+
}
122+
83123
/**
84124
* Perform history compaction by replacing all messages with a summary
85125
*
86126
* Steps:
87127
* 1. Calculate cumulative usage from all messages (for historicalUsage field)
88-
* 2. Clear entire history and get deleted sequence numbers
89-
* 3. Append summary message with metadata
90-
* 4. Emit delete event for old messages
91-
* 5. Emit summary message to frontend
128+
* 2. Extract last status URL from messages (for lastStatusUrl field)
129+
* 3. Clear entire history and get deleted sequence numbers
130+
* 4. Append summary message with metadata
131+
* 5. Emit delete event for old messages
132+
* 6. Emit summary message to frontend
92133
*/
93134
private async performCompaction(
94135
summary: string,
@@ -102,9 +143,11 @@ export class CompactionHandler {
102143
}
103144
): Promise<Result<void, string>> {
104145
const usageHistory = collectUsageHistory(messages, undefined);
105-
106146
const historicalUsage = usageHistory.length > 0 ? sumUsageHistory(usageHistory) : undefined;
107147

148+
// Extract last status URL to preserve through compaction
149+
const lastStatusUrl = this.extractLastStatusUrl(messages);
150+
108151
// Clear entire history and get deleted sequences
109152
const clearResult = await this.historyService.clearHistory(this.workspaceId);
110153
if (!clearResult.success) {
@@ -126,6 +169,7 @@ export class CompactionHandler {
126169
model: metadata.model,
127170
usage: metadata.usage,
128171
historicalUsage,
172+
lastStatusUrl,
129173
duration: metadata.duration,
130174
systemMessageTokens: metadata.systemMessageTokens,
131175
muxMetadata: { type: "normal" },

0 commit comments

Comments
 (0)