Skip to content

Commit fdf9905

Browse files
authored
Merge pull request #525 from outsourc-e/fix/505-506-chat-compaction-salvage
fix(chat): prevent prompt duplication and response loss after compaction
2 parents a47846d + a2d0bba commit fdf9905

3 files changed

Lines changed: 49 additions & 2 deletions

File tree

src/screens/chat/chat-screen.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,10 @@ export function ChatScreen({
11221122
(message) => ({
11231123
...message,
11241124
status: 'sent',
1125+
// Clear __optimisticId so isOptimisticUserMessage returns false.
1126+
// Without this the message keeps being treated as pending and
1127+
// gets re-persisted, causing transcript duplication. Fixes #506.
1128+
__optimisticId: undefined,
11251129
runId: runId ?? message.runId,
11261130
}),
11271131
)

src/screens/chat/hooks/use-chat-history.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,14 @@ function getAttachmentSignature(message: ChatMessage): string {
181181
function isOptimisticUserMessage(message: ChatMessage): boolean {
182182
if (message.role !== 'user') return false
183183
const raw = message as Record<string, unknown>
184+
const status = normalizeMessageValue(raw.status)
185+
// Once the server confirms (status 'sent' or 'done'), the message is no
186+
// longer optimistic — stop re-persisting it as pending. Fixes #506 where
187+
// __optimisticId was never cleared, causing confirmed messages to keep
188+
// being treated as pending and duplicated in the transcript.
189+
if (status === 'sent' || status === 'done') return false
184190
return (
185-
normalizeMessageValue(raw.status) === 'sending' ||
191+
status === 'sending' ||
186192
normalizeMessageValue(raw.__optimisticId).length > 0
187193
)
188194
}

src/screens/chat/hooks/use-realtime-chat-history.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,12 +376,49 @@ export function useRealtimeChatHistory({
376376
}
377377
}
378378

379+
// Capture the just-completed assistant message from the realtime
380+
// buffer BEFORE clearing it. After compaction the refetched history
381+
// may be shorter and miss this message entirely. Fixes #505.
382+
const completedAssistant =
383+
realtimeMessages.length > 0
384+
? (() => {
385+
const last = realtimeMessages[realtimeMessages.length - 1] as
386+
| Record<string, unknown>
387+
| undefined
388+
return last?.role === 'assistant' ? last : null
389+
})()
390+
: null
391+
379392
// Clear realtime buffer immediately — no more stale data in render
380393
store.clearRealtimeBuffer(effectiveSessionKey)
381394
clearCompletedStreaming()
382395

383396
// Background refetch for long-term consistency — doesn't block render
384-
queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
397+
queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }).then(() => {
398+
// Re-inject the completed assistant message if compaction dropped it
399+
if (completedAssistant) {
400+
const refetchData =
401+
queryClient.getQueryData<Record<string, unknown>>(key)
402+
const refetchedMessages =
403+
(refetchData?.messages as Array<Record<string, unknown>>) ?? []
404+
const assistantTail = (completedAssistant.content ?? completedAssistant.text ?? '')
405+
.toString()
406+
.slice(-64)
407+
const alreadyPresent = refetchedMessages.some(
408+
(m) =>
409+
m.role === 'assistant' &&
410+
((m.content ?? m.text ?? '') as string).toString().slice(-64) === assistantTail,
411+
)
412+
if (!alreadyPresent) {
413+
appendHistoryMessage(
414+
queryClient,
415+
effectiveFriendlyId,
416+
effectiveSessionKey,
417+
completedAssistant as unknown as import('@/types/chat').ChatMessage,
418+
)
419+
}
420+
}
421+
})
385422

386423
// Check for compaction — significant message count drop
387424
const newData =

0 commit comments

Comments
 (0)