mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 08:42:11 +00:00
Merge pull request #13729 from NousResearch/bb/tui-diff-inline-sequence
fix(tui): tool inline_diff renders inline with the active turn
This commit is contained in:
commit
1dfbfcfe74
3 changed files with 167 additions and 7 deletions
|
|
@ -143,6 +143,117 @@ describe('createGatewayEventHandler', () => {
|
|||
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
|
||||
})
|
||||
|
||||
it('attaches inline_diff to the assistant completion body', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
const diff = '\u001b[31m--- a/foo.ts\u001b[0m\n\u001b[32m+++ b/foo.ts\u001b[0m\n@@\n-old\n+new'
|
||||
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||
|
||||
onEvent({
|
||||
payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' },
|
||||
type: 'tool.start'
|
||||
} as any)
|
||||
onEvent({
|
||||
payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' },
|
||||
type: 'tool.complete'
|
||||
} as any)
|
||||
|
||||
// Diff is buffered for message.complete and sanitized (ANSI stripped).
|
||||
expect(appended).toHaveLength(0)
|
||||
expect(turnController.pendingInlineDiffs).toEqual([cleaned])
|
||||
|
||||
onEvent({
|
||||
payload: { text: 'patch applied' },
|
||||
type: 'message.complete'
|
||||
} as any)
|
||||
|
||||
// Diff is rendered in the same assistant message body as the completion.
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]).toMatchObject({ role: 'assistant' })
|
||||
expect(appended[0]?.text).toContain('patch applied')
|
||||
expect(appended[0]?.text).toContain('```diff')
|
||||
expect(appended[0]?.text).toContain(cleaned)
|
||||
})
|
||||
|
||||
it('does not append inline_diff twice when assistant text already contains it', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||
const assistantText = `Done. Here's the inline diff:\n\n\`\`\`diff\n${cleaned}\n\`\`\``
|
||||
|
||||
onEvent({
|
||||
payload: { inline_diff: cleaned, summary: 'patched', tool_id: 'tool-1' },
|
||||
type: 'tool.complete'
|
||||
} as any)
|
||||
onEvent({
|
||||
payload: { text: assistantText },
|
||||
type: 'message.complete'
|
||||
} as any)
|
||||
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]?.text).toBe(assistantText)
|
||||
expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
|
||||
})
|
||||
|
||||
it('strips the CLI "┊ review diff" header from queued inline diffs', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
const raw = ' \u001b[33m┊ review diff\u001b[0m\n--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||
|
||||
onEvent({
|
||||
payload: { inline_diff: raw, summary: 'patched', tool_id: 'tool-1' },
|
||||
type: 'tool.complete'
|
||||
} as any)
|
||||
onEvent({
|
||||
payload: { text: 'done' },
|
||||
type: 'message.complete'
|
||||
} as any)
|
||||
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]?.text).not.toContain('┊ review diff')
|
||||
expect(appended[0]?.text).toContain('--- a/foo.ts')
|
||||
})
|
||||
|
||||
it('suppresses inline_diff when assistant already wrote a diff fence', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||
const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```'
|
||||
|
||||
onEvent({
|
||||
payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' },
|
||||
type: 'tool.complete'
|
||||
} as any)
|
||||
onEvent({
|
||||
payload: { text: assistantText },
|
||||
type: 'message.complete'
|
||||
} as any)
|
||||
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]?.text).toBe(assistantText)
|
||||
expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps tool trail terse when inline_diff is present', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||
|
||||
onEvent({
|
||||
payload: { inline_diff: diff, name: 'review_diff', summary: diff, tool_id: 'tool-1' },
|
||||
type: 'tool.complete'
|
||||
} as any)
|
||||
onEvent({
|
||||
payload: { text: 'done' },
|
||||
type: 'message.complete'
|
||||
} as any)
|
||||
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]?.tools?.[0]).toContain('Review Diff')
|
||||
expect(appended[0]?.tools?.[0]).not.toContain('--- a/foo.ts')
|
||||
expect(appended[0]?.text).toContain('```diff')
|
||||
})
|
||||
|
||||
it('shows setup panel for missing provider startup error', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { STREAM_BATCH_MS } from '../config/timing.js'
|
|||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||
import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { formatToolCall } from '../lib/text.js'
|
||||
import { formatToolCall, stripAnsi } from '../lib/text.js'
|
||||
import { fromSkin } from '../theme.js'
|
||||
import type { Msg, SubagentProgress } from '../types.js'
|
||||
|
||||
|
|
@ -263,10 +263,27 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
return
|
||||
|
||||
case 'tool.complete':
|
||||
turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary)
|
||||
{
|
||||
const inlineDiffText =
|
||||
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''
|
||||
|
||||
if (ev.payload.inline_diff && getUiState().inlineDiffs) {
|
||||
sys(ev.payload.inline_diff)
|
||||
turnController.recordToolComplete(
|
||||
ev.payload.tool_id,
|
||||
ev.payload.name,
|
||||
ev.payload.error,
|
||||
inlineDiffText ? '' : ev.payload.summary
|
||||
)
|
||||
|
||||
if (!inlineDiffText) {
|
||||
return
|
||||
}
|
||||
|
||||
// Keep inline diffs attached to the assistant completion body so
|
||||
// they render in the same message flow, not as a standalone system
|
||||
// artifact that can look out-of-place around tool rows.
|
||||
turnController.queueInlineDiff(inlineDiffText)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class TurnController {
|
|||
bufRef = ''
|
||||
interrupted = false
|
||||
lastStatusNote = ''
|
||||
pendingInlineDiffs: string[] = []
|
||||
persistedToolLabels = new Set<string>()
|
||||
protocolWarned = false
|
||||
reasoningText = ''
|
||||
|
|
@ -76,6 +77,7 @@ class TurnController {
|
|||
this.activeTools = []
|
||||
this.streamTimer = clear(this.streamTimer)
|
||||
this.bufRef = ''
|
||||
this.pendingInlineDiffs = []
|
||||
this.pendingSegmentTools = []
|
||||
this.segmentMessages = []
|
||||
|
||||
|
|
@ -182,6 +184,22 @@ class TurnController {
|
|||
}, REASONING_PULSE_MS)
|
||||
}
|
||||
|
||||
queueInlineDiff(diffText: string) {
|
||||
// Strip CLI chrome the gateway emits before the unified diff (e.g. a
|
||||
// leading "┊ review diff" header written by `_emit_inline_diff` for the
|
||||
// terminal printer). That header only makes sense as stdout dressing,
|
||||
// not inside a markdown ```diff block.
|
||||
const text = diffText
|
||||
.replace(/^\s*┊[^\n]*\n?/, '')
|
||||
.trim()
|
||||
|
||||
if (!text || this.pendingInlineDiffs.includes(text)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingInlineDiffs = [...this.pendingInlineDiffs, text]
|
||||
}
|
||||
|
||||
pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) {
|
||||
patchTurnState(state => {
|
||||
const base = replaceLabel
|
||||
|
|
@ -216,6 +234,7 @@ class TurnController {
|
|||
this.idle()
|
||||
this.clearReasoning()
|
||||
this.clearStatusTimer()
|
||||
this.pendingInlineDiffs = []
|
||||
this.pendingSegmentTools = []
|
||||
this.segmentMessages = []
|
||||
this.turnTools = []
|
||||
|
|
@ -226,6 +245,17 @@ class TurnController {
|
|||
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
|
||||
const split = splitReasoning(rawText)
|
||||
const finalText = split.text
|
||||
// Skip appending if the assistant already narrated the diff inside a
|
||||
// markdown fence of its own — otherwise we render two stacked diff
|
||||
// blocks for the same edit.
|
||||
const assistantAlreadyHasDiff = /```(?:diff|patch)\b/i.test(finalText)
|
||||
const remainingInlineDiffs = assistantAlreadyHasDiff
|
||||
? []
|
||||
: this.pendingInlineDiffs.filter(diff => !finalText.includes(diff))
|
||||
const inlineDiffBlock = remainingInlineDiffs.length
|
||||
? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\``
|
||||
: ''
|
||||
const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n')
|
||||
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
||||
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
|
||||
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
|
||||
|
|
@ -233,10 +263,10 @@ class TurnController {
|
|||
const tools = this.pendingSegmentTools
|
||||
const finalMessages = [...this.segmentMessages]
|
||||
|
||||
if (finalText) {
|
||||
if (mergedText) {
|
||||
finalMessages.push({
|
||||
role: 'assistant',
|
||||
text: finalText,
|
||||
text: mergedText,
|
||||
thinking: savedReasoning || undefined,
|
||||
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
|
||||
toolTokens: savedToolTokens || undefined,
|
||||
|
|
@ -253,7 +283,7 @@ class TurnController {
|
|||
this.bufRef = ''
|
||||
patchTurnState({ activity: [], outcome: '' })
|
||||
|
||||
return { finalMessages, finalText, wasInterrupted }
|
||||
return { finalMessages, finalText: mergedText, wasInterrupted }
|
||||
}
|
||||
|
||||
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
|
||||
|
|
@ -359,6 +389,7 @@ class TurnController {
|
|||
this.bufRef = ''
|
||||
this.interrupted = false
|
||||
this.lastStatusNote = ''
|
||||
this.pendingInlineDiffs = []
|
||||
this.pendingSegmentTools = []
|
||||
this.protocolWarned = false
|
||||
this.segmentMessages = []
|
||||
|
|
@ -404,6 +435,7 @@ class TurnController {
|
|||
this.endReasoningPhase()
|
||||
this.clearReasoning()
|
||||
this.activeTools = []
|
||||
this.pendingInlineDiffs = []
|
||||
this.turnTools = []
|
||||
this.toolTokenAcc = 0
|
||||
this.persistedToolLabels.clear()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue