Skip to content

Commit 01b72d5

Browse files
committed
🤖 refactor: Shared tool result processing for live & historical
## Problem Status (and TODOs) weren't persisting across page reloads/workspace switches because loadHistoricalMessages() didn't process tool results to reconstruct derived state. ## Solution Extracted processToolResult() as single source of truth for updating derived state from tool results. Called from: 1. handleToolCallEnd() - live streaming events 2. loadHistoricalMessages() - historical message loading This ensures agentStatus and currentTodos are reconstructed uniformly whether processing live events or historical snapshots. ## Implementation - Extracted 23-line processToolResult() method - Updated handleToolCallEnd() to call shared method (-12 LoC) - Updated loadHistoricalMessages() to process tool parts (+6 LoC) - Added 3 tests for historical message reconstruction ## Benefits - Single source of truth for tool result processing - No special reconstruction logic needed - Easier to extend with new derived state - Leverages existing tool part architecture ## Testing - Added 3 new tests for historical message scenarios - All 842 tests pass - Typecheck and lint pass Net: ~17 LoC
1 parent 56ccbf6 commit 01b72d5

File tree

2 files changed

+156
-14
lines changed

2 files changed

+156
-14
lines changed

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

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,4 +376,122 @@ describe("StreamingMessageAggregator - Agent Status", () => {
376376
expect(status?.emoji).toBe("🔍");
377377
expect(status?.message).toBe("Analyzing code");
378378
});
379+
380+
it("should reconstruct agentStatus when loading historical messages", () => {
381+
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
382+
383+
// Create historical messages with a completed status_set tool call
384+
const historicalMessages = [
385+
{
386+
id: "msg1",
387+
role: "user" as const,
388+
parts: [{ type: "text" as const, text: "Hello" }],
389+
metadata: { timestamp: Date.now(), historySequence: 1 },
390+
},
391+
{
392+
id: "msg2",
393+
role: "assistant" as const,
394+
parts: [
395+
{ type: "text" as const, text: "Working on it..." },
396+
{
397+
type: "dynamic-tool" as const,
398+
toolCallId: "tool1",
399+
toolName: "status_set",
400+
state: "output-available" as const,
401+
input: { emoji: "🔍", message: "Analyzing code" },
402+
output: { success: true, emoji: "🔍", message: "Analyzing code" },
403+
timestamp: Date.now(),
404+
},
405+
],
406+
metadata: { timestamp: Date.now(), historySequence: 2 },
407+
},
408+
];
409+
410+
// Load historical messages
411+
aggregator.loadHistoricalMessages(historicalMessages);
412+
413+
// Status should be reconstructed from the historical tool call
414+
const status = aggregator.getAgentStatus();
415+
expect(status).toBeDefined();
416+
expect(status?.emoji).toBe("🔍");
417+
expect(status?.message).toBe("Analyzing code");
418+
});
419+
420+
it("should use most recent status_set when loading multiple historical messages", () => {
421+
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
422+
423+
// Create historical messages with multiple status_set calls
424+
const historicalMessages = [
425+
{
426+
id: "msg1",
427+
role: "assistant" as const,
428+
parts: [
429+
{
430+
type: "dynamic-tool" as const,
431+
toolCallId: "tool1",
432+
toolName: "status_set",
433+
state: "output-available" as const,
434+
input: { emoji: "🔍", message: "First status" },
435+
output: { success: true, emoji: "🔍", message: "First status" },
436+
timestamp: Date.now(),
437+
},
438+
],
439+
metadata: { timestamp: Date.now(), historySequence: 1 },
440+
},
441+
{
442+
id: "msg2",
443+
role: "assistant" as const,
444+
parts: [
445+
{
446+
type: "dynamic-tool" as const,
447+
toolCallId: "tool2",
448+
toolName: "status_set",
449+
state: "output-available" as const,
450+
input: { emoji: "📝", message: "Second status" },
451+
output: { success: true, emoji: "📝", message: "Second status" },
452+
timestamp: Date.now(),
453+
},
454+
],
455+
metadata: { timestamp: Date.now(), historySequence: 2 },
456+
},
457+
];
458+
459+
// Load historical messages
460+
aggregator.loadHistoricalMessages(historicalMessages);
461+
462+
// Should use the most recent (last processed) status
463+
const status = aggregator.getAgentStatus();
464+
expect(status?.emoji).toBe("📝");
465+
expect(status?.message).toBe("Second status");
466+
});
467+
468+
it("should not reconstruct status from failed status_set in historical messages", () => {
469+
const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z");
470+
471+
// Create historical message with failed status_set
472+
const historicalMessages = [
473+
{
474+
id: "msg1",
475+
role: "assistant" as const,
476+
parts: [
477+
{
478+
type: "dynamic-tool" as const,
479+
toolCallId: "tool1",
480+
toolName: "status_set",
481+
state: "output-available" as const,
482+
input: { emoji: "not-emoji", message: "test" },
483+
output: { success: false, error: "emoji must be a single emoji character" },
484+
timestamp: Date.now(),
485+
},
486+
],
487+
metadata: { timestamp: Date.now(), historySequence: 1 },
488+
},
489+
];
490+
491+
// Load historical messages
492+
aggregator.loadHistoricalMessages(historicalMessages);
493+
494+
// Status should remain undefined (failed validation)
495+
expect(aggregator.getAgentStatus()).toBeUndefined();
496+
});
379497
});

src/utils/messages/StreamingMessageAggregator.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,18 @@ export class StreamingMessageAggregator {
199199
loadHistoricalMessages(messages: CmuxMessage[]): void {
200200
for (const message of messages) {
201201
this.messages.set(message.id, message);
202+
203+
// Process completed tool calls to reconstruct derived state (todos, agentStatus)
204+
// This ensures state persists across page reloads and workspace switches
205+
if (message.role === "assistant") {
206+
for (const part of message.parts) {
207+
if (isDynamicToolPart(part) && part.state === "output-available") {
208+
this.processToolResult(part.toolName, part.input, part.output);
209+
}
210+
}
211+
}
202212
}
213+
203214
this.invalidateCache();
204215
}
205216

@@ -491,6 +502,31 @@ export class StreamingMessageAggregator {
491502
// Tool deltas are for display - args are in dynamic-tool part
492503
}
493504

505+
506+
/**
507+
* Process a completed tool call's result to update derived state.
508+
* Called for both live tool-call-end events and historical tool parts.
509+
*
510+
* This is the single source of truth for updating state from tool results,
511+
* ensuring consistency whether processing live events or historical messages.
512+
*/
513+
private processToolResult(toolName: string, input: unknown, output: unknown): void {
514+
// Update TODO state if this was a successful todo_write
515+
if (toolName === "todo_write" && hasSuccessResult(output)) {
516+
const args = input as { todos: TodoItem[] };
517+
// Only update if todos actually changed (prevents flickering from reference changes)
518+
if (!this.todosEqual(this.currentTodos, args.todos)) {
519+
this.currentTodos = args.todos;
520+
}
521+
}
522+
523+
// Update agent status if this was a successful status_set
524+
if (toolName === "status_set" && hasSuccessResult(output)) {
525+
const args = input as { emoji: string; message: string };
526+
this.agentStatus = { emoji: args.emoji, message: args.message };
527+
}
528+
}
529+
494530
handleToolCallEnd(data: ToolCallEndEvent): void {
495531
const message = this.messages.get(data.messageId);
496532
if (message) {
@@ -505,20 +541,8 @@ export class StreamingMessageAggregator {
505541
(toolPart as DynamicToolPartAvailable).state = "output-available";
506542
(toolPart as DynamicToolPartAvailable).output = data.result;
507543

508-
// Update TODO state if this was a successful todo_write
509-
if (data.toolName === "todo_write" && hasSuccessResult(data.result)) {
510-
const args = toolPart.input as { todos: TodoItem[] };
511-
// Only update if todos actually changed (prevents flickering from reference changes)
512-
if (!this.todosEqual(this.currentTodos, args.todos)) {
513-
this.currentTodos = args.todos;
514-
}
515-
}
516-
517-
// Update agent status if this was a successful status_set
518-
if (data.toolName === "status_set" && hasSuccessResult(data.result)) {
519-
const args = toolPart.input as { emoji: string; message: string };
520-
this.agentStatus = { emoji: args.emoji, message: args.message };
521-
}
544+
// Process tool result to update derived state (todos, agentStatus, etc.)
545+
this.processToolResult(data.toolName, toolPart.input, data.result);
522546
}
523547
this.invalidateCache();
524548
}

0 commit comments

Comments
 (0)