Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions src/stores/useSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,85 @@ function createEmptySlot(): SessionSlot {
}

/**
* Compute merged messages: server + realtime, deduped by id.
* Compute merged messages: server + realtime, deduped by stable message identity.
* Server messages take priority (they're the persisted source of truth).
* Realtime messages that aren't yet in server stay (in-flight streaming).
*/
function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] {
if (realtime.length === 0) return server;
if (server.length === 0) return realtime;
const serverIds = new Set(server.map(m => m.id));
const extra = realtime.filter(m => !serverIds.has(m.id));
const extra = realtime.filter(m => !server.some(existing => isEquivalentNormalizedMessage(existing, m)));
if (extra.length === 0) return server;
return [...server, ...extra];
}

function isEquivalentNormalizedMessage(a: NormalizedMessage, b: NormalizedMessage): boolean {
if (a.id === b.id) return true;

if (
a.rowid !== undefined &&
b.rowid !== undefined &&
a.rowid === b.rowid &&
a.kind === b.kind
) {
return a.sequence === undefined || b.sequence === undefined || a.sequence === b.sequence;
}

if (
a.sequence !== undefined &&
b.sequence !== undefined &&
a.sequence === b.sequence &&
a.kind === b.kind
) {
return true;
}

if (a.requestId && b.requestId && a.requestId === b.requestId && a.kind === b.kind) {
return true;
}

if (a.toolId && b.toolId && a.toolId === b.toolId && a.kind === b.kind) {
return true;
}
Comment on lines +139 to +145
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

requestId is too broad to outrank toolId here.

Line 139 matches on requestId before Line 143 gets a chance to disambiguate by toolId. That can collapse multiple same-kind messages from one request—especially repeated tool_use / tool_result events—into a single entry. src/components/chat/utils/messageKeys.ts:10-24 points the same way: it treats toolId, rowid, and sequence as intrinsic identity, but never uses requestId.

🔧 Narrow fix
-  if (a.requestId && b.requestId && a.requestId === b.requestId && a.kind === b.kind) {
-    return true;
-  }
-
   if (a.toolId && b.toolId && a.toolId === b.toolId && a.kind === b.kind) {
     return true;
   }
+
+  if (
+    a.requestId &&
+    b.requestId &&
+    a.requestId === b.requestId &&
+    a.kind === b.kind &&
+    (a.toolId === undefined || b.toolId === undefined || a.toolId === b.toolId)
+  ) {
+    return true;
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/useSessionStore.ts` around lines 139 - 145, The current equality
logic in useSessionStore (the comparisons using a.requestId, b.requestId,
a.toolId, b.toolId and a.kind) considers requestId before toolId which collapses
multiple same-kind messages from the same request; change the comparator to
disambiguate by tool identity first (check toolId equality with a.kind) or
require both toolId and requestId to match before returning true, and if
applicable include the same identity fields used in
src/components/chat/utils/messageKeys.ts (toolId, rowid, sequence) when
determining message identity so that repeated tool_use/tool_result events are
not merged incorrectly.


if (
a.kind === 'session_created' &&
b.kind === 'session_created' &&
a.newSessionId &&
a.newSessionId === b.newSessionId
) {
return true;
}

if (a.role === 'user' || b.role === 'user') {
return false;
}

return (
a.kind === b.kind &&
a.timestamp === b.timestamp &&
a.role === b.role &&
a.content === b.content &&
a.text === b.text &&
a.toolName === b.toolName
);
}

function upsertRealtimeMessage(messages: NormalizedMessage[], msg: NormalizedMessage): NormalizedMessage[] {
const existingIndex = messages.findIndex(existing => isEquivalentNormalizedMessage(existing, msg));

if (existingIndex === -1) {
return [...messages, msg];
}

const next = [...messages];
next[existingIndex] = {
...messages[existingIndex],
...msg,
};
return next;
}

/**
* Recompute slot.merged only when the input arrays have actually changed
* (by reference). Returns true if merged was recomputed.
Expand Down Expand Up @@ -273,7 +339,7 @@ export function useSessionStore() {
*/
const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => {
const slot = getSlot(sessionId);
let updated = [...slot.realtimeMessages, msg];
let updated = upsertRealtimeMessage(slot.realtimeMessages, msg);
if (updated.length > MAX_REALTIME_MESSAGES) {
updated = updated.slice(-MAX_REALTIME_MESSAGES);
}
Expand All @@ -288,7 +354,10 @@ export function useSessionStore() {
const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => {
if (msgs.length === 0) return;
const slot = getSlot(sessionId);
let updated = [...slot.realtimeMessages, ...msgs];
let updated = slot.realtimeMessages;
for (const msg of msgs) {
updated = upsertRealtimeMessage(updated, msg);
}
if (updated.length > MAX_REALTIME_MESSAGES) {
updated = updated.slice(-MAX_REALTIME_MESSAGES);
}
Expand Down