-
Notifications
You must be signed in to change notification settings - Fork 3.5k
fix(transcript): lossless transcript restore — full-fidelity cold-boot resume, per-turn thinking hydration, seq envelope #5077
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
senamakel
merged 6 commits into
tinyhumansai:main
from
senamakel:fix/transcript-restore-fidelity
Jul 21, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5cd9839
fix(chat): restore per-turn transcripts, interrupted partials, subage…
senamakel bf247f2
test(chat): cover restore fidelity — past-turn transcripts, interrupt…
senamakel 13156db
fix(transcript-restore): cold-boot web-chat resume prefers full-fidel…
senamakel 0c81c42
feat(turn-state): persist subagent transcript fidelity + seq envelope
senamakel 8f73e5a
chore(format): rustfmt reflow over resume-fidelity changes
senamakel 242a133
chore(chat): declare optional seq wire field on core progress socket …
senamakel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
30 changes: 30 additions & 0 deletions
30
app/src/features/conversations/components/InterruptedAnswer.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { render, screen } from '@testing-library/react'; | ||
| import { Provider } from 'react-redux'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { store } from '../../../store'; | ||
| import { InterruptedAnswer } from './InterruptedAnswer'; | ||
|
|
||
| function renderInStore(ui: React.ReactNode) { | ||
| return render(<Provider store={store}>{ui}</Provider>); | ||
| } | ||
|
|
||
| describe('InterruptedAnswer', () => { | ||
| it('surfaces the partial reply with an interrupted marker', () => { | ||
| renderInStore( | ||
| <InterruptedAnswer content="Here is the partial answer" thinking="was reasoning about it" /> | ||
| ); | ||
|
|
||
| const block = screen.getByTestId('interrupted-answer'); | ||
| expect(block.textContent).toContain('Here is the partial answer'); | ||
| // The reasoning it had streamed is kept in a collapsed block. | ||
| expect(block.textContent).toContain('was reasoning about it'); | ||
| // Marked interrupted rather than presented as a finished answer. | ||
| expect(screen.getByTestId('interrupted-answer-marker')).toBeTruthy(); | ||
| }); | ||
|
|
||
| it('renders nothing when neither content nor thinking has any text', () => { | ||
| const { container } = renderInStore(<InterruptedAnswer content=" " thinking="" />); | ||
| expect(container.querySelector('[data-testid="interrupted-answer"]')).toBeNull(); | ||
| }); | ||
| }); |
59 changes: 59 additions & 0 deletions
59
app/src/features/conversations/components/InterruptedAnswer.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { useT } from '../../../lib/i18n/I18nContext'; | ||
| import { BubbleMarkdown } from './AgentMessageBubble'; | ||
|
|
||
| /** | ||
| * The partial assistant reply left behind by an INTERRUPTED turn — the core | ||
| * process that was streaming it exited before `chat_done`, so no final message | ||
| * was ever committed to the durable thread log (restore-fidelity fix 2). | ||
| * | ||
| * Unlike the live streaming preview, this is a SETTLED buffer: it renders as a | ||
| * static agent bubble (no pulsing cursor, full Markdown like a finished answer) | ||
| * carrying an "Interrupted" marker, plus the hidden reasoning it had streamed in | ||
| * a collapsed block. It is deliberately NOT written into the durable message | ||
| * list — it is a restore-time surfacing of what the agent had produced, so the | ||
| * user sees the partial work instead of a blank turn. | ||
| */ | ||
| export function InterruptedAnswer({ | ||
| content, | ||
| thinking, | ||
| }: { | ||
| content: string; | ||
| thinking: string; | ||
| }) { | ||
| const { t } = useT(); | ||
| const trimmedContent = content.trim(); | ||
| const trimmedThinking = thinking.trim(); | ||
| // Nothing persisted to show — render nothing rather than an empty marked bubble. | ||
| if (!trimmedContent && !trimmedThinking) return null; | ||
|
|
||
| return ( | ||
| <div className="flex justify-start" data-testid="interrupted-answer"> | ||
| <div className="relative w-fit max-w-[75%] space-y-1"> | ||
| {trimmedThinking ? ( | ||
| <details className="mb-0.5 rounded-lg bg-surface-subtle px-3 py-1.5 text-xs text-content-secondary dark:bg-surface-muted"> | ||
| <summary className="flex cursor-pointer items-center gap-1.5 select-none"> | ||
| <span aria-hidden className="text-[10px] leading-none"> | ||
| 💭 | ||
| </span> | ||
| <span>{t('chat.thinking')}</span> | ||
| </summary> | ||
| <pre className="mt-1.5 font-sans text-[11px] break-words whitespace-pre-wrap text-content-muted"> | ||
| {trimmedThinking} | ||
| </pre> | ||
| </details> | ||
| ) : null} | ||
| <div className="rounded-2xl rounded-bl-md border-l-2 border-amber-400/70 bg-surface-strong/80 px-3 py-2 text-content dark:bg-surface-muted"> | ||
| <div | ||
| className="mb-1 flex items-center gap-1.5 text-[10px] font-medium tracking-wide text-amber-600 uppercase dark:text-amber-300" | ||
| data-testid="interrupted-answer-marker"> | ||
| <span aria-hidden className="text-[11px] leading-none"> | ||
| ⚠ | ||
| </span> | ||
| <span>{t('intelligence.agentWork.status.interrupted')}</span> | ||
| </div> | ||
| {trimmedContent ? <BubbleMarkdown content={trimmedContent} /> : null} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
70 changes: 70 additions & 0 deletions
70
app/src/features/conversations/components/PastTurnInsights.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { render, screen } from '@testing-library/react'; | ||
| import { Provider } from 'react-redux'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { store } from '../../../store'; | ||
| import type { ProcessingTranscriptItem, ToolTimelineEntry } from '../../../store/chatRuntimeSlice'; | ||
| import { PastTurnInsights } from './PastTurnInsights'; | ||
|
|
||
| function renderInStore(ui: React.ReactNode) { | ||
| return render(<Provider store={store}>{ui}</Provider>); | ||
| } | ||
|
|
||
| describe('PastTurnInsights', () => { | ||
| it('replays a restored turn as interleaved thoughts + tool rows, not just tool cards', () => { | ||
| // A reopened past turn carries both its reasoning trail and its tools. | ||
| const entries: ToolTimelineEntry[] = [ | ||
| { id: 'c1', name: 'read_file', round: 0, seq: 0, status: 'success' }, | ||
| ]; | ||
| const transcript: ProcessingTranscriptItem[] = [ | ||
| { kind: 'thinking', round: 0, seq: 1, text: 'planning the search' }, | ||
| { kind: 'toolCall', round: 0, seq: 2, callId: 'c1' }, | ||
| ]; | ||
|
|
||
| renderInStore(<PastTurnInsights entries={entries} transcript={transcript} />); | ||
|
|
||
| // The hidden reasoning replays (fix 1) — a restored turn is no longer | ||
| // tool-cards-only. | ||
| expect(screen.getByTestId('processing-thinking').textContent).toContain('planning the search'); | ||
| // And its tool step still renders in the interleaved view. | ||
| expect(screen.getAllByTestId('processing-tool-row').length).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| it('renders restored sub-agent transcripts beneath the trail', () => { | ||
| const entries: ToolTimelineEntry[] = [ | ||
| { | ||
| id: 'subagent:task-y', | ||
| name: 'subagent:researcher', | ||
| round: 0, | ||
| seq: 0, | ||
| status: 'success', | ||
| subagent: { | ||
| taskId: 'task-y', | ||
| agentId: 'researcher', | ||
| toolCalls: [], | ||
| transcript: [{ kind: 'thinking', iteration: 1, text: 'child reasoning trail' }], | ||
| }, | ||
| }, | ||
| ]; | ||
| const transcript: ProcessingTranscriptItem[] = [ | ||
| { kind: 'narration', round: 0, seq: 0, text: 'delegating to a researcher' }, | ||
| ]; | ||
|
|
||
| renderInStore(<PastTurnInsights entries={entries} transcript={transcript} />); | ||
|
|
||
| const subagents = screen.getByTestId('past-turn-subagents'); | ||
| expect(subagents.textContent).toContain('child reasoning trail'); | ||
| }); | ||
|
|
||
| it('falls back to the tool-only timeline for a legacy turn with no transcript', () => { | ||
| const entries: ToolTimelineEntry[] = [ | ||
| { id: 'c1', name: 'read_file', round: 0, seq: 0, status: 'success' }, | ||
| ]; | ||
|
|
||
| renderInStore(<PastTurnInsights entries={entries} transcript={[]} />); | ||
|
|
||
| // The interleaved transcript view is absent; the tool-only block renders. | ||
| expect(screen.queryByTestId('processing-transcript')).toBeNull(); | ||
| expect(screen.getByTestId('agent-task-insights')).toBeTruthy(); | ||
| }); | ||
| }); |
60 changes: 60 additions & 0 deletions
60
app/src/features/conversations/components/PastTurnInsights.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import type { ProcessingTranscriptItem, ToolTimelineEntry } from '../../../store/chatRuntimeSlice'; | ||
| import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting'; | ||
| import { ProcessingTranscriptView } from './ProcessingTranscriptView'; | ||
| import { SubagentActivityBlock, ToolTimelineBlock } from './ToolTimelineBlock'; | ||
|
|
||
| /** | ||
| * The collapsed process trail rendered above a PAST (settled) turn's answer on a | ||
| * reopened thread — restore-fidelity fix 1. | ||
| * | ||
| * A restored turn must show the *same* interleaved thoughts + tools view a live | ||
| * turn does, not just its tool cards: the persisted `transcript` | ||
| * (narration/thinking/tool pointers, hydrated into | ||
| * `turnTranscriptsByThread`) drives {@link ProcessingTranscriptView}, so the | ||
| * reasoning and narration replay inline exactly where they streamed. Restored | ||
| * sub-agent transcripts (fix 4) render beneath as their own activity blocks, | ||
| * mirroring the whole-run {@link AgentProcessSourcePanel} body. | ||
| * | ||
| * Legacy turns persisted before the transcript field existed have no | ||
| * `transcript` — they fall back to the tool-only {@link ToolTimelineBlock}, so | ||
| * older threads keep rendering unchanged. | ||
| */ | ||
| export function PastTurnInsights({ | ||
| entries, | ||
| transcript, | ||
| }: { | ||
| entries: ToolTimelineEntry[]; | ||
| transcript: ProcessingTranscriptItem[]; | ||
| }) { | ||
| // No reasoning/narration trail persisted (legacy snapshot): render the | ||
| // tool-only timeline, which already nests each sub-agent's activity inline. | ||
| if (transcript.length === 0) { | ||
| return <ToolTimelineBlock entries={entries} />; | ||
| } | ||
|
|
||
| const subagentEntries = entries.filter(entry => entry.subagent); | ||
|
|
||
| return ( | ||
| <div className="space-y-3"> | ||
| {/* Interleaved narration + hidden reasoning + grouped tool steps. */} | ||
| <ProcessingTranscriptView transcript={transcript} entries={entries} /> | ||
|
|
||
| {/* Sub-agents — each delegated agent's restored transcript (thoughts + | ||
| tool rows), so a reopened turn keeps its sub-agent reasoning, not just | ||
| a flat tool row. `ProcessingTranscriptView` shows the spawn as a tool | ||
| row but does not nest the child's activity, so surface it here. */} | ||
| {subagentEntries.length > 0 ? ( | ||
| <div className="space-y-3" data-testid="past-turn-subagents"> | ||
| {subagentEntries.map(entry => ( | ||
| <div key={entry.id}> | ||
| <p className="text-[12px] font-medium text-content-secondary"> | ||
| {formatTimelineEntry(entry).title} | ||
| </p> | ||
| <SubagentActivityBlock subagent={entry.subagent!} /> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a restored interrupted thread is used again, this only hides the old partial while
isSendingis true; the live socket path clears processing/streaming oninference_start/chat_donebut never clears the newinterruptedAssistantByThreadentry. After the replacement turn completes,isSendingbecomes false and the stale interrupted bubble can reappear below the new answer until some later snapshot hydration happens to clear it, so the buffer should be cleared when a fresh live turn starts (or when that turn settles).Useful? React with 👍 / 👎.