mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): settle a tool run whose calls never resolved
A run inferred "still working" from a missing result alone, so a call
left unresolved — by an interrupted turn, or an agent that moved on —
pinned its run as live forever. That stranded the summary in the present
tense ("Exploring 2 files" on a finished turn) and, because a live run
withholds its toggle so approvals can't hide, left the run permanently
expanded with no way to collapse it.
Qualify liveness the way ToolEntry already qualifies a row's: a missing
result only means pending while the run is the tail of a running
message. Liveness is now passed into summarizeToolRun rather than read
off the calls, since it isn't a property of the calls.
This commit is contained in:
parent
f7d6c1be8f
commit
f96997c36f
4 changed files with 143 additions and 26 deletions
|
|
@ -849,16 +849,28 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
|
|||
const cache = useRef<null | { signature: string; value: ToolRunState }>(null)
|
||||
|
||||
return useAuiState(state => {
|
||||
const tools = state.message.parts.slice(Math.max(0, startIndex), endIndex + 1).filter(isToolCallPart)
|
||||
const signature = tools.map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`).join('|')
|
||||
const parts = state.message.parts
|
||||
const tools = parts.slice(Math.max(0, startIndex), endIndex + 1).filter(isToolCallPart)
|
||||
// A missing result only means "still working" while this run is the tail of
|
||||
// a running message — the same qualification ToolEntry puts on a row's
|
||||
// pending state. A turn that ends, or an agent that moves on to later
|
||||
// parts, can leave a call unresolved forever; treating that as live would
|
||||
// strand the run in the present tense AND leave it uncollapsible, since a
|
||||
// live run deliberately withholds its toggle.
|
||||
const live =
|
||||
selectMessageRunning(state) && endIndex >= parts.length - 1 && tools.some(tool => tool.result === undefined)
|
||||
const signature = tools
|
||||
.map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`)
|
||||
.concat(String(live))
|
||||
.join('|')
|
||||
|
||||
if (cache.current?.signature !== signature) {
|
||||
cache.current = {
|
||||
signature,
|
||||
value: {
|
||||
key: tools[0]?.toolCallId ?? '',
|
||||
live: tools.some(tool => tool.result === undefined),
|
||||
summary: summarizeToolRun(tools)
|
||||
live,
|
||||
summary: summarizeToolRun(tools, live)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,54 +10,57 @@ const edited = (path: string, diff = '') => tool('write_file', { path }, { path,
|
|||
const read = (path: string) => tool('read_file', { path }, { content: '' })
|
||||
const ran = (command: string) => tool('terminal', { command }, { exit_code: 0 })
|
||||
|
||||
const settled = (tools: ToolCallLike[]) => summarizeToolRun(tools, false)
|
||||
const running = (tools: ToolCallLike[]) => summarizeToolRun(tools, true)
|
||||
|
||||
describe('summarizeToolRun', () => {
|
||||
it('names a lone edit and counts the rest', () => {
|
||||
expect(
|
||||
summarizeToolRun([edited('src/use-preview-routing.ts'), read('a.ts'), read('b.ts'), read('c.ts')]).text
|
||||
).toBe('Edited use-preview-routing.ts, explored 3 files')
|
||||
expect(settled([edited('src/use-preview-routing.ts'), read('a.ts'), read('b.ts'), read('c.ts')]).text).toBe(
|
||||
'Edited use-preview-routing.ts, explored 3 files'
|
||||
)
|
||||
})
|
||||
|
||||
it('orders clauses edit, explore, run regardless of call order', () => {
|
||||
expect(
|
||||
summarizeToolRun([ran('ls'), read('a.ts'), edited('src/attachments.tsx'), read('b.ts'), ran('pwd'), ran('id')])
|
||||
.text
|
||||
settled([ran('ls'), read('a.ts'), edited('src/attachments.tsx'), read('b.ts'), ran('pwd'), ran('id')]).text
|
||||
).toBe('Edited attachments.tsx, explored 2 files, ran 3 commands')
|
||||
})
|
||||
|
||||
it('counts commands rather than naming them once they have run', () => {
|
||||
expect(summarizeToolRun([ran('git status')]).text).toBe('Ran 1 command')
|
||||
expect(summarizeToolRun([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')]).text).toBe(
|
||||
expect(settled([ran('git status')]).text).toBe('Ran 1 command')
|
||||
expect(settled([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')]).text).toBe(
|
||||
'Explored status.ts, ran 5 commands'
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a multi-file edit', () => {
|
||||
expect(summarizeToolRun([edited('a.tsx'), edited('b.tsx'), read('c.ts')]).text).toBe(
|
||||
'Edited 2 files, explored c.ts'
|
||||
)
|
||||
expect(settled([edited('a.tsx'), edited('b.tsx'), read('c.ts')]).text).toBe('Edited 2 files, explored c.ts')
|
||||
})
|
||||
|
||||
it('puts the running category in the present tense and leaves the rest past', () => {
|
||||
const live = [edited('a.tsx'), tool('write_file', { path: 'b.tsx' }), ran('x'), ran('y')]
|
||||
|
||||
expect(summarizeToolRun(live).text).toBe('Editing 2 files, ran 2 commands')
|
||||
expect(running([edited('a.tsx'), tool('write_file', { path: 'b.tsx' }), ran('x'), ran('y')]).text).toBe(
|
||||
'Editing 2 files, ran 2 commands'
|
||||
)
|
||||
})
|
||||
|
||||
it('names the command that is still running', () => {
|
||||
expect(summarizeToolRun([tool('terminal', { command: 'npm run typecheck' })]).text).toMatch(/^Running /)
|
||||
expect(running([tool('terminal', { command: 'npm run typecheck' })]).text).toMatch(/^Running /)
|
||||
})
|
||||
|
||||
// A turn can end — or the agent can simply move on — with a call that never
|
||||
// got a result. The run is history at that point and has to read as history,
|
||||
// or it narrates work that stopped happening and never offers its toggle.
|
||||
it('reads a run the turn left unresolved as finished', () => {
|
||||
expect(settled([read('a.ts'), tool('search_files', { query: 'toolRuns' })]).text).toBe('Explored 2 files')
|
||||
})
|
||||
|
||||
it('sums diff stats across the edits in the run', () => {
|
||||
const summary = summarizeToolRun([
|
||||
edited('a.tsx', '--- a\n+++ b\n+one\n+two\n-old'),
|
||||
edited('b.tsx', '+three'),
|
||||
ran('ls')
|
||||
])
|
||||
const summary = settled([edited('a.tsx', '--- a\n+++ b\n+one\n+two\n-old'), edited('b.tsx', '+three'), ran('ls')])
|
||||
|
||||
expect(summary).toMatchObject({ added: 3, removed: 1 })
|
||||
})
|
||||
|
||||
it('reports no diff stats for a run that changed nothing', () => {
|
||||
expect(summarizeToolRun([read('a.ts'), ran('ls')])).toMatchObject({ added: 0, removed: 0 })
|
||||
expect(settled([read('a.ts'), ran('ls')])).toMatchObject({ added: 0, removed: 0 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -137,9 +137,14 @@ function lowerFirst(text: string): string {
|
|||
* — "Edited wiring.tsx, explored 3 files, ran 5 commands". The category holding
|
||||
* the still-running tool speaks in the present tense so a live run reads as
|
||||
* work in progress rather than work already done.
|
||||
*
|
||||
* Whether the run is `live` is the caller's to say, not something readable off
|
||||
* the calls: a call can be left without a result by a turn that ended or an
|
||||
* agent that moved on, and a run like that has to read as finished rather than
|
||||
* narrate work that stopped happening.
|
||||
*/
|
||||
export function summarizeToolRun(tools: readonly ToolCallLike[]): RunSummary {
|
||||
const running = tools.find(isPending)
|
||||
export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): RunSummary {
|
||||
const running = live ? tools.find(isPending) : undefined
|
||||
const liveCategory = running ? toolCategory(running.toolName) : null
|
||||
|
||||
const byCategory = new Map<RunCategory, ToolCallLike[]>()
|
||||
|
|
|
|||
|
|
@ -220,6 +220,83 @@ function settledRunMessage(): ThreadMessage {
|
|||
} as ThreadMessage
|
||||
}
|
||||
|
||||
// A finished turn that left a call without a result — interrupted, or the
|
||||
// result landed elsewhere. The run is history and has to behave like it.
|
||||
function abandonedRunMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-abandoned-run',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-3',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/repo/src/status.tsx' },
|
||||
argsText: JSON.stringify({ path: '/repo/src/status.tsx' }),
|
||||
result: { content: 'export const Status = () => null' }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'search-1',
|
||||
toolName: 'search_files',
|
||||
args: { query: 'toolRuns' },
|
||||
argsText: JSON.stringify({ query: 'toolRuns' })
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
// Still streaming, but the agent has moved past its first run and left both of
|
||||
// its calls unresolved. Only the run at the tail is still live.
|
||||
function movedOnMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-moved-on',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-4',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/repo/src/status.tsx' },
|
||||
argsText: JSON.stringify({ path: '/repo/src/status.tsx' })
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'search-2',
|
||||
toolName: 'search_files',
|
||||
args: { query: 'toolRuns' },
|
||||
argsText: JSON.stringify({ query: 'toolRuns' })
|
||||
},
|
||||
{ type: 'text', text: 'Let me read the rest of the file.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-2',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'ls' },
|
||||
argsText: JSON.stringify({ command: 'ls' })
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function GroupHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
|
|
@ -297,6 +374,26 @@ describe('live tool run', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// A run whose calls never resolved used to read as live forever, which stranded
|
||||
// it in the present tense and — because a live run withholds its toggle — left
|
||||
// it permanently expanded with no way to collapse it.
|
||||
describe('tool run left unresolved', () => {
|
||||
it('settles with the turn rather than narrating work that stopped', async () => {
|
||||
const { container } = render(<GroupHarness message={abandonedRunMessage()} />)
|
||||
|
||||
expect(await screen.findByText('Explored 2 files')).toBeTruthy()
|
||||
expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0)
|
||||
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('settles once the agent moves on, even mid-turn', async () => {
|
||||
const { container } = render(<GroupHarness message={movedOnMessage()} />)
|
||||
|
||||
expect(await screen.findByText('Explored 2 files')).toBeTruthy()
|
||||
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('flat tool list approval surfacing', () => {
|
||||
it('renders no inline approval bar when there is no live approval', async () => {
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue