Skip to content
Merged
Show file tree
Hide file tree
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
15 changes: 15 additions & 0 deletions ui/src/lib/chat-transcript-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ describe('chat-transcript-session', () => {
expect(session.transcript.messages[0].streaming).toBe(false);
});

it('replaces stale local transcript state when canonical replay starts', () => {
const session = createChatTranscriptSession('');
applyChatTranscriptUpdate(session, {
sessionUpdate: 'agent_message_chunk',
content: 'Based on the German residence law documents...',
});
finalizePromptStreaming(session);

noteReplayState(session, true);

expect(session.transcript.messages).toEqual([]);
expect(session.cacheHydrated).toBe(false);
expect(session.replaySawTranscriptUpdate).toBe(false);
});

it('drops stale cached state when replay had no transcript-bearing updates', () => {
const session = createChatTranscriptSession('');
session.cacheHydrated = true;
Expand Down
26 changes: 23 additions & 3 deletions ui/src/lib/chat-transcript-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,30 @@ export function replaceChatTranscriptSession(conversationId: string): ChatTransc
/**
* Resets replay bookkeeping when ACP begins replaying historical updates.
*/
export function noteReplayState(session: ChatTranscriptSession, replaying: boolean): void {
if (replaying) {
session.replaySawTranscriptUpdate = false;
export function noteReplayState(session: ChatTranscriptSession, replaying: boolean): ACPTranscript | null {
if (!replaying) {
return null;
}

session.replaySawTranscriptUpdate = false;

const shouldReplaceTranscript =
session.cacheHydrated ||
session.transcript.messages.length > 0 ||
session.transcript.thinkingChunks.length > 0 ||
session.transcript.availableCommands.length > 0 ||
session.transcript.currentMode !== '' ||
session.transcript.usage !== null;

if (!shouldReplaceTranscript) {
return null;
}

// Backend replay is the source of truth, so drop any cached/live transcript
// state before historical updates start arriving.
session.transcript = createTranscript();
session.cacheHydrated = false;
return session.transcript;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion ui/src/lib/use-chat-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,10 @@ export function useChatConnection({
},
onReplayStateChange: (replaying) => {
if (cancelled) return;
noteReplayState(transcriptSessionRef.current, replaying);
const nextTranscript = noteReplayState(transcriptSessionRef.current, replaying);
if (replaying && nextTranscript) {
syncTranscript(nextTranscript);
}
},
onUpdate: (update, updateOptions) => {
if (cancelled) return;
Expand Down
45 changes: 45 additions & 0 deletions ui/src/pages/chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,51 @@ describe('ChatPage draft persistence', () => {
});
});

it('replaces stale live assistant turns with canonical replay order on reconnect', async () => {
await renderChat('/c/covo/conv-1');

act(() => {
emitUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Based on the German residence law documents...' },
});
});

await waitFor(() => {
const messages = screen.getAllByTestId('chat-message').map((element) => element.textContent);
expect(messages).toEqual(['assistant:Based on the German residence law documents...']);
});

act(() => {
emitReplayState(true);
emitUpdate({
sessionUpdate: 'agent_message_chunk',
historyMessageId: 'assistant-1',
content: { type: 'text', text: 'I need to clarify: tc is the TextCortex CLI.' },
}, { historical: true });
emitUpdate({
sessionUpdate: 'agent_message_chunk',
historyMessageId: 'assistant-2',
content: { type: 'text', text: "You're right — `tc kb search` lets you search your own knowledge bases." },
}, { historical: true });
emitUpdate({
sessionUpdate: 'agent_message_chunk',
historyMessageId: 'assistant-3',
content: { type: 'text', text: 'Based on the German residence law documents...' },
}, { historical: true });
emitReplayState(false);
});

await waitFor(() => {
const messages = screen.getAllByTestId('chat-message').map((element) => element.textContent);
expect(messages).toEqual([
'assistant:I need to clarify: tc is the TextCortex CLI.',
"assistant:You're right — `tc kb search` lets you search your own knowledge bases.",
'assistant:Based on the German residence law documents...',
]);
});
});

it('restores the original conversation draft when send fails after switching chats', async () => {
const user = userEvent.setup();
const deferred = createDeferred<unknown>();
Expand Down
Loading