mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 01:31:41 +00:00
fix(tui): anchor inline_diff to the segment where the edit happened
Revisits #13729. That PR buffered each `tool.complete`'s inline_diff and merged them into the final assistant message body as a fenced ```diff block. The merge-at-end placement reads as "the agent wrote this after the summary", even when the edit fired mid-turn — which is both misleading and (per blitz feedback) feels like noise tacked onto the end of every task. Segment-anchored placement instead: - On tool.complete with inline_diff, `pushInlineDiffSegment` calls `flushStreamingSegment` first (so any in-progress narration lands as its own segment), then pushes the ```diff block as its own segment into segmentMessages. The diff is now anchored BETWEEN the narration that preceded the edit and whatever the agent streams afterwards, which is where the edit actually happened. - `recordMessageComplete` no longer merges buffered diffs. The only remaining dedupe is "drop diff-only segments whose body the final assistant text narrates verbatim (or whose diff fence the final text already contains)" — same tradeoff as before, kept so an agent that narrates its own diff doesn't render two stacked copies. - Drops `pendingInlineDiffs` and `queueInlineDiff` — buffer + end- merge machinery is gone; segmentMessages is now the only source of truth. Side benefit: Ctrl+C interrupt (`interruptTurn`) iterates segmentMessages, so diff segments are now preserved in the transcript when the user cancels after an edit. Previously the pending buffer was silently dropped on interrupt. Reported by Teknium during blitz usage: "no diffs are ever at the end because it didn't make this file edit after the final message".
This commit is contained in:
parent
c95c6bdb7c
commit
11b2942f16
3 changed files with 105 additions and 131 deletions
|
|
@ -19,6 +19,21 @@ const INTERRUPT_COOLDOWN_MS = 1500
|
|||
const ACTIVITY_LIMIT = 8
|
||||
const TRAIL_LIMIT = 8
|
||||
|
||||
// Matches segments produced by pushInlineDiffSegment — a bare ```diff fence
|
||||
// wrapping the raw patch, no surrounding prose. Used at message.complete to
|
||||
// dedupe against final assistant text that narrates the same patch.
|
||||
const DIFF_SEGMENT_RE = /^```diff\n([\s\S]*?)\n```$/
|
||||
|
||||
const diffSegmentBody = (msg: Msg): null | string => {
|
||||
if (msg.role !== 'assistant' || msg.tools?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const m = msg.text.match(DIFF_SEGMENT_RE)
|
||||
|
||||
return m ? m[1]! : null
|
||||
}
|
||||
|
||||
export interface InterruptDeps {
|
||||
appendMessage: (msg: Msg) => void
|
||||
gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
|
||||
|
|
@ -40,7 +55,6 @@ class TurnController {
|
|||
bufRef = ''
|
||||
interrupted = false
|
||||
lastStatusNote = ''
|
||||
pendingInlineDiffs: string[] = []
|
||||
persistedToolLabels = new Set<string>()
|
||||
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
|
||||
protocolWarned = false
|
||||
|
|
@ -79,7 +93,6 @@ class TurnController {
|
|||
this.activeTools = []
|
||||
this.streamTimer = clear(this.streamTimer)
|
||||
this.bufRef = ''
|
||||
this.pendingInlineDiffs = []
|
||||
this.pendingSegmentTools = []
|
||||
this.segmentMessages = []
|
||||
|
||||
|
|
@ -186,18 +199,35 @@ class TurnController {
|
|||
}, REASONING_PULSE_MS)
|
||||
}
|
||||
|
||||
queueInlineDiff(diffText: string) {
|
||||
pushInlineDiffSegment(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()
|
||||
const stripped = diffText.replace(/^\s*┊[^\n]*\n?/, '').trim()
|
||||
|
||||
if (!text || this.pendingInlineDiffs.includes(text)) {
|
||||
if (!stripped) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingInlineDiffs = [...this.pendingInlineDiffs, text]
|
||||
// Flush any in-progress streaming text as its own segment first, so the
|
||||
// diff lands BETWEEN the assistant narration that preceded the edit and
|
||||
// whatever the agent streams afterwards — not glued onto the final
|
||||
// message. This is the whole point of segment-anchored diffs: the diff
|
||||
// renders where the edit actually happened.
|
||||
this.flushStreamingSegment()
|
||||
|
||||
const block = `\`\`\`diff\n${stripped}\n\`\`\``
|
||||
|
||||
// Skip consecutive duplicates (same tool firing tool.complete twice, or
|
||||
// two edits producing the same patch). Keeping this cheap — deeper
|
||||
// dedupe against the final assistant text happens at message.complete.
|
||||
if (this.segmentMessages.at(-1)?.text === block) {
|
||||
return
|
||||
}
|
||||
|
||||
this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text: block }]
|
||||
patchTurnState({ streamSegments: this.segmentMessages })
|
||||
}
|
||||
|
||||
pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) {
|
||||
|
|
@ -234,7 +264,6 @@ class TurnController {
|
|||
this.idle()
|
||||
this.clearReasoning()
|
||||
this.clearStatusTimer()
|
||||
this.pendingInlineDiffs = []
|
||||
this.pendingSegmentTools = []
|
||||
this.segmentMessages = []
|
||||
this.turnTools = []
|
||||
|
|
@ -245,31 +274,35 @@ 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
|
||||
const savedToolTokens = this.toolTokenAcc
|
||||
const tools = this.pendingSegmentTools
|
||||
const finalMessages = [...this.segmentMessages]
|
||||
|
||||
if (mergedText) {
|
||||
// Drop diff-only segments the agent is about to narrate in the final
|
||||
// reply. Without this, a closing "here's the diff …" message would
|
||||
// render two stacked copies of the same patch. Only touches segments
|
||||
// whose entire body is a ```diff``` fence emitted by pushInlineDiff-
|
||||
// Segment — real assistant narration stays put.
|
||||
const finalHasOwnDiffFence = /```(?:diff|patch)\b/i.test(finalText)
|
||||
|
||||
const segments = this.segmentMessages.filter(msg => {
|
||||
const body = diffSegmentBody(msg)
|
||||
|
||||
if (body === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
return !finalHasOwnDiffFence && !finalText.includes(body)
|
||||
})
|
||||
|
||||
const finalMessages = [...segments]
|
||||
|
||||
if (finalText) {
|
||||
finalMessages.push({
|
||||
role: 'assistant',
|
||||
text: mergedText,
|
||||
text: finalText,
|
||||
thinking: savedReasoning || undefined,
|
||||
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
|
||||
toolTokens: savedToolTokens || undefined,
|
||||
|
|
@ -300,7 +333,7 @@ class TurnController {
|
|||
this.bufRef = ''
|
||||
patchTurnState({ activity: [], outcome: '' })
|
||||
|
||||
return { finalMessages, finalText: mergedText, wasInterrupted }
|
||||
return { finalMessages, finalText, wasInterrupted }
|
||||
}
|
||||
|
||||
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
|
||||
|
|
@ -406,7 +439,6 @@ class TurnController {
|
|||
this.bufRef = ''
|
||||
this.interrupted = false
|
||||
this.lastStatusNote = ''
|
||||
this.pendingInlineDiffs = []
|
||||
this.pendingSegmentTools = []
|
||||
this.protocolWarned = false
|
||||
this.segmentMessages = []
|
||||
|
|
@ -452,7 +484,6 @@ 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