fix(tui): anchor details to stream timeline
This commit is contained in:
@@ -82,15 +82,13 @@ describe('createGatewayEventHandler', () => {
|
|||||||
type: 'message.complete'
|
type: 'message.complete'
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
expect(appended).toHaveLength(1)
|
expect(appended).toHaveLength(3)
|
||||||
expect(appended[0]).toMatchObject({
|
expect(appended[0]).toMatchObject({ kind: 'trail', role: 'system', text: '', thinking: 'mapped the page' })
|
||||||
role: 'assistant',
|
expect(appended[1]).toMatchObject({ kind: 'trail', role: 'system', text: '' })
|
||||||
text: 'final answer',
|
expect(appended[1]?.tools).toHaveLength(1)
|
||||||
thinking: 'mapped the page'
|
expect(appended[1]?.tools?.[0]).toContain('hero cards')
|
||||||
})
|
expect(appended[1]?.toolTokens).toBeGreaterThan(0)
|
||||||
expect(appended[0]?.tools).toHaveLength(1)
|
expect(appended[2]).toMatchObject({ role: 'assistant', text: 'final answer' })
|
||||||
expect(appended[0]?.tools?.[0]).toContain('hero cards')
|
|
||||||
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps tool tokens across handler recreation mid-turn', () => {
|
it('keeps tool tokens across handler recreation mid-turn', () => {
|
||||||
@@ -118,9 +116,10 @@ describe('createGatewayEventHandler', () => {
|
|||||||
type: 'message.complete'
|
type: 'message.complete'
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
expect(appended).toHaveLength(1)
|
expect(appended).toHaveLength(3)
|
||||||
expect(appended[0]?.tools).toHaveLength(1)
|
expect(appended[1]?.tools).toHaveLength(1)
|
||||||
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
|
expect(appended[1]?.toolTokens).toBeGreaterThan(0)
|
||||||
|
expect(appended[2]).toMatchObject({ role: 'assistant', text: 'final answer' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('streams legacy thinking.delta into visible reasoning state', () => {
|
it('streams legacy thinking.delta into visible reasoning state', () => {
|
||||||
@@ -148,9 +147,10 @@ describe('createGatewayEventHandler', () => {
|
|||||||
onEvent({ payload: { text: fallback }, type: 'reasoning.available' } as any)
|
onEvent({ payload: { text: fallback }, type: 'reasoning.available' } as any)
|
||||||
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
|
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
|
||||||
|
|
||||||
expect(appended).toHaveLength(1)
|
expect(appended).toHaveLength(2)
|
||||||
expect(appended[0]?.thinking).toBe(streamed)
|
expect(appended[0]?.thinking).toBe(streamed)
|
||||||
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(streamed))
|
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(streamed))
|
||||||
|
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses message.complete reasoning when no streamed reasoning ref', () => {
|
it('uses message.complete reasoning when no streamed reasoning ref', () => {
|
||||||
@@ -161,9 +161,10 @@ describe('createGatewayEventHandler', () => {
|
|||||||
|
|
||||||
onEvent({ payload: { reasoning: fromServer, text: 'final answer' }, type: 'message.complete' } as any)
|
onEvent({ payload: { reasoning: fromServer, text: 'final answer' }, type: 'message.complete' } as any)
|
||||||
|
|
||||||
expect(appended).toHaveLength(1)
|
expect(appended).toHaveLength(2)
|
||||||
expect(appended[0]?.thinking).toBe(fromServer)
|
expect(appended[0]?.thinking).toBe(fromServer)
|
||||||
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
|
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
|
||||||
|
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('anchors inline_diff as its own segment where the edit happened', () => {
|
it('anchors inline_diff as its own segment where the edit happened', () => {
|
||||||
@@ -184,21 +185,19 @@ describe('createGatewayEventHandler', () => {
|
|||||||
expect(appended).toHaveLength(0)
|
expect(appended).toHaveLength(0)
|
||||||
expect(turnController.segmentMessages).toEqual([
|
expect(turnController.segmentMessages).toEqual([
|
||||||
{ role: 'assistant', text: 'Editing the file' },
|
{ role: 'assistant', text: 'Editing the file' },
|
||||||
|
{ kind: 'trail', role: 'system', text: '', tools: ['Patch("foo.ts") ✓'] },
|
||||||
{ kind: 'diff', role: 'assistant', text: block }
|
{ kind: 'diff', role: 'assistant', text: block }
|
||||||
])
|
])
|
||||||
|
|
||||||
onEvent({ payload: { text: 'patch applied' }, type: 'message.complete' } as any)
|
onEvent({ payload: { text: 'patch applied' }, type: 'message.complete' } as any)
|
||||||
|
|
||||||
// Four transcript messages: pre-tool narration → tool trail → diff
|
expect(appended).toHaveLength(5)
|
||||||
// (kind='diff', so MessageLine gives it blank-line breathing room) →
|
|
||||||
// post-tool narration. The final message does NOT contain a diff.
|
|
||||||
expect(appended).toHaveLength(4)
|
|
||||||
expect(appended[0]?.text).toBe('Editing the file')
|
expect(appended[0]?.text).toBe('Editing the file')
|
||||||
expect(appended[1]).toMatchObject({ kind: 'trail' })
|
expect(appended[1]).toMatchObject({ kind: 'trail' })
|
||||||
expect(appended[1]?.tools?.[0]).toContain('Patch')
|
expect(appended[1]?.tools?.[0]).toContain('Patch')
|
||||||
expect(appended[2]).toMatchObject({ kind: 'diff', text: block })
|
expect(appended[2]).toMatchObject({ kind: 'diff', text: block })
|
||||||
expect(appended[3]?.text).toBe('patch applied')
|
expect(appended[4]?.text).toBe('patch applied')
|
||||||
expect(appended[3]?.text).not.toContain('```diff')
|
expect(appended[4]?.text).not.toContain('```diff')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('drops the diff segment when the final assistant text narrates the same diff', () => {
|
it('drops the diff segment when the final assistant text narrates the same diff', () => {
|
||||||
@@ -212,9 +211,10 @@ describe('createGatewayEventHandler', () => {
|
|||||||
|
|
||||||
// Only the final message — diff-only segment dropped so we don't
|
// Only the final message — diff-only segment dropped so we don't
|
||||||
// render two stacked copies of the same patch.
|
// render two stacked copies of the same patch.
|
||||||
expect(appended).toHaveLength(1)
|
expect(appended).toHaveLength(2)
|
||||||
expect(appended[0]?.text).toBe(assistantText)
|
expect(appended[0]).toMatchObject({ kind: 'trail' })
|
||||||
expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
|
expect(appended[1]?.text).toBe(assistantText)
|
||||||
|
expect((appended[1]?.text.match(/```diff/g) ?? []).length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('strips the CLI "┊ review diff" header from inline diff segments', () => {
|
it('strips the CLI "┊ review diff" header from inline diff segments', () => {
|
||||||
@@ -246,9 +246,10 @@ describe('createGatewayEventHandler', () => {
|
|||||||
} as any)
|
} as any)
|
||||||
onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)
|
onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)
|
||||||
|
|
||||||
expect(appended).toHaveLength(1)
|
expect(appended).toHaveLength(2)
|
||||||
expect(appended[0]?.text).toBe(assistantText)
|
expect(appended[0]).toMatchObject({ kind: 'trail' })
|
||||||
expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
|
expect(appended[1]?.text).toBe(assistantText)
|
||||||
|
expect((appended[1]?.text.match(/```diff/g) ?? []).length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps tool trail terse when inline_diff is present', () => {
|
it('keeps tool trail terse when inline_diff is present', () => {
|
||||||
|
|||||||
@@ -379,6 +379,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||||||
const inlineDiffText =
|
const inlineDiffText =
|
||||||
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''
|
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''
|
||||||
|
|
||||||
|
if (inlineDiffText) {
|
||||||
|
turnController.flushStreamingSegment()
|
||||||
|
}
|
||||||
|
|
||||||
turnController.recordToolComplete(
|
turnController.recordToolComplete(
|
||||||
ev.payload.tool_id,
|
ev.payload.tool_id,
|
||||||
ev.payload.name,
|
ev.payload.name,
|
||||||
@@ -386,17 +390,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||||||
inlineDiffText ? '' : ev.payload.summary
|
inlineDiffText ? '' : ev.payload.summary
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!inlineDiffText) {
|
if (inlineDiffText) {
|
||||||
return
|
turnController.pushInlineDiffSegment(inlineDiffText)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anchor the diff to where the edit happened in the turn — between
|
|
||||||
// the narration that preceded the tool call and whatever the agent
|
|
||||||
// streams afterwards. The previous end-merge put the diff at the
|
|
||||||
// bottom of the final message even when the edit fired mid-turn,
|
|
||||||
// which read as "the agent wrote this after saying that".
|
|
||||||
turnController.pushInlineDiffSegment(inlineDiffText)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,7 @@ const diffSegmentBody = (msg: Msg): null | string => {
|
|||||||
return m ? m[1]! : null
|
return m ? m[1]! : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertBeforeFirstDiff = (segments: Msg[], msg: Msg): Msg[] => {
|
const hasDetails = (msg: Msg): boolean => Boolean(msg.thinking || msg.tools?.length || msg.toolTokens)
|
||||||
const index = segments.findIndex(segment => segment.kind === 'diff')
|
|
||||||
|
|
||||||
return index < 0 ? [...segments, msg] : [...segments.slice(0, index), msg, ...segments.slice(index)]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InterruptDeps {
|
export interface InterruptDeps {
|
||||||
appendMessage: (msg: Msg) => void
|
appendMessage: (msg: Msg) => void
|
||||||
@@ -69,6 +65,7 @@ class TurnController {
|
|||||||
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
|
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
|
||||||
protocolWarned = false
|
protocolWarned = false
|
||||||
reasoningText = ''
|
reasoningText = ''
|
||||||
|
reasoningSegmentOffset = 0
|
||||||
segmentMessages: Msg[] = []
|
segmentMessages: Msg[] = []
|
||||||
pendingSegmentTools: string[] = []
|
pendingSegmentTools: string[] = []
|
||||||
statusTimer: Timer = null
|
statusTimer: Timer = null
|
||||||
@@ -94,6 +91,7 @@ class TurnController {
|
|||||||
clearReasoning() {
|
clearReasoning() {
|
||||||
this.reasoningTimer = clear(this.reasoningTimer)
|
this.reasoningTimer = clear(this.reasoningTimer)
|
||||||
this.reasoningText = ''
|
this.reasoningText = ''
|
||||||
|
this.reasoningSegmentOffset = 0
|
||||||
this.toolTokenAcc = 0
|
this.toolTokenAcc = 0
|
||||||
patchTurnState({ reasoning: '', reasoningTokens: 0, toolTokens: 0 })
|
patchTurnState({ reasoning: '', reasoningTokens: 0, toolTokens: 0 })
|
||||||
}
|
}
|
||||||
@@ -181,29 +179,33 @@ class TurnController {
|
|||||||
|
|
||||||
flushStreamingSegment() {
|
flushStreamingSegment() {
|
||||||
const raw = this.bufRef.trimStart()
|
const raw = this.bufRef.trimStart()
|
||||||
|
const split = raw ? (hasReasoningTag(raw) ? splitReasoning(raw) : { reasoning: '', text: raw }) : { reasoning: '', text: '' }
|
||||||
if (!raw) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const split = hasReasoningTag(raw) ? splitReasoning(raw) : { reasoning: '', text: raw }
|
|
||||||
|
|
||||||
if (split.reasoning && !this.reasoningText.trim()) {
|
if (split.reasoning && !this.reasoningText.trim()) {
|
||||||
this.reasoningText = split.reasoning
|
this.reasoningText = split.reasoning
|
||||||
patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) })
|
patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = split.text
|
const thinking = this.reasoningText.slice(this.reasoningSegmentOffset).trim()
|
||||||
|
const msg: Msg = {
|
||||||
|
role: split.text ? 'assistant' : 'system',
|
||||||
|
text: split.text,
|
||||||
|
...(!split.text && { kind: 'trail' as const }),
|
||||||
|
...(thinking && {
|
||||||
|
thinking,
|
||||||
|
thinkingTokens: estimateTokensRough(thinking)
|
||||||
|
}),
|
||||||
|
...(this.pendingSegmentTools.length && { tools: this.pendingSegmentTools })
|
||||||
|
}
|
||||||
|
|
||||||
this.streamTimer = clear(this.streamTimer)
|
this.streamTimer = clear(this.streamTimer)
|
||||||
|
|
||||||
if (text) {
|
if (split.text || hasDetails(msg)) {
|
||||||
const tools = this.pendingSegmentTools
|
this.segmentMessages = [...this.segmentMessages, msg]
|
||||||
|
|
||||||
this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }]
|
|
||||||
this.pendingSegmentTools = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.reasoningSegmentOffset = this.reasoningText.length
|
||||||
|
this.pendingSegmentTools = []
|
||||||
this.bufRef = ''
|
this.bufRef = ''
|
||||||
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' })
|
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' })
|
||||||
}
|
}
|
||||||
@@ -295,7 +297,6 @@ class TurnController {
|
|||||||
const finalText = split.text
|
const finalText = split.text
|
||||||
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
||||||
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
|
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
|
||||||
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
|
|
||||||
const savedToolTokens = this.toolTokenAcc
|
const savedToolTokens = this.toolTokenAcc
|
||||||
const tools = this.pendingSegmentTools
|
const tools = this.pendingSegmentTools
|
||||||
|
|
||||||
@@ -312,32 +313,20 @@ class TurnController {
|
|||||||
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
|
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasDiffSegment = segments.some(msg => msg.kind === 'diff')
|
const finalThinking = savedReasoning.slice(this.reasoningSegmentOffset).trim()
|
||||||
const detailsBelongBeforeDiff = hasDiffSegment && (tools.length > 0 || Boolean(savedReasoning))
|
const finalDetails: Msg = {
|
||||||
|
kind: 'trail',
|
||||||
const finalMessages = detailsBelongBeforeDiff
|
role: 'system',
|
||||||
? insertBeforeFirstDiff(segments, {
|
text: '',
|
||||||
kind: 'trail',
|
thinking: finalThinking || undefined,
|
||||||
role: 'system',
|
thinkingTokens: finalThinking ? estimateTokensRough(finalThinking) : undefined,
|
||||||
text: '',
|
toolTokens: savedToolTokens || undefined,
|
||||||
thinking: savedReasoning || undefined,
|
...(tools.length && { tools })
|
||||||
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
|
}
|
||||||
toolTokens: savedToolTokens || undefined,
|
const finalMessages = hasDetails(finalDetails) ? [...segments, finalDetails] : [...segments]
|
||||||
...(tools.length && { tools })
|
|
||||||
})
|
|
||||||
: [...segments]
|
|
||||||
|
|
||||||
if (finalText) {
|
if (finalText) {
|
||||||
finalMessages.push({
|
finalMessages.push({ role: 'assistant', text: finalText })
|
||||||
role: 'assistant',
|
|
||||||
text: finalText,
|
|
||||||
...(!detailsBelongBeforeDiff && {
|
|
||||||
thinking: savedReasoning || undefined,
|
|
||||||
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
|
|
||||||
toolTokens: savedToolTokens || undefined,
|
|
||||||
...(tools.length && { tools })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasInterrupted = this.interrupted
|
const wasInterrupted = this.interrupted
|
||||||
|
|||||||
Reference in New Issue
Block a user