mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): keep a run live between sequential calls
A run counted as live only while one of its calls was unresolved, which is false for the instant between one sequential call finishing and the next arriving — and for a string of commands that instant is most of the run. It settled and re-opened between every call, so the ticker unmounted and came back at the top of its reel instead of scrolling, and the summary flipped to past tense while work was still going. Live is now "the turn is working and nothing follows this run", with the tail bound still settling a run the agent has moved past. The summary takes its present-tense clause from the most recent call when none is pending — the same call the ticker is showing. The stall spinner stays out of the way while a run narrates, rather than stacking a second timer under it.
This commit is contained in:
parent
0b0e53cee1
commit
46df6ca705
5 changed files with 80 additions and 14 deletions
|
|
@ -202,6 +202,11 @@ export const StreamStallIndicator: FC = () => {
|
|||
const { awaitingInput, compacting, drafting, turnTimerKey } = useThreadSessionStatus()
|
||||
const hint = useStatusHint(compacting, drafting)
|
||||
|
||||
// A tool run at the tail already narrates the wait — its summary counts the
|
||||
// calls, its ticker names the current one, and it carries its own timer. A
|
||||
// second spinner under that adds a line and says nothing new.
|
||||
const toolNarrating = useAuiState(s => s.message.content.at(-1)?.type === 'tool-call')
|
||||
|
||||
useEffect(() => {
|
||||
setQuietSince(undefined)
|
||||
const seenAt = Date.now()
|
||||
|
|
@ -213,7 +218,7 @@ export const StreamStallIndicator: FC = () => {
|
|||
// A named wait doesn't have to earn the stall threshold first — we already
|
||||
// know what the turn is doing, so say it as soon as the label is ready rather
|
||||
// than leaving the transcript silent for STREAM_STALL_S.
|
||||
const active = (quietSince !== undefined || Boolean(hint)) && !awaitingInput
|
||||
const active = (quietSince !== undefined || Boolean(hint)) && !awaitingInput && !toolNarrating
|
||||
|
||||
// Compaction owns the whole turn, so it keeps counting from the turn's start;
|
||||
// anything else counts from the moment the stream went quiet — the stall's own
|
||||
|
|
|
|||
|
|
@ -825,14 +825,15 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
|
|||
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)
|
||||
// Live means the turn is still working and nothing has come after this run
|
||||
// — not that some call is unresolved. Those differ in the gap between one
|
||||
// call finishing and the next arriving, which for sequential calls is most
|
||||
// of the run: it fell back to past tense there, unmounting the ticker and
|
||||
// dropping its reel to the top instead of scrolling.
|
||||
//
|
||||
// The tail bound is what keeps this honest — a turn that ends, or an agent
|
||||
// that moves on to later parts, leaves the run settled and collapsible.
|
||||
const live = selectMessageRunning(state) && endIndex >= parts.length - 1
|
||||
|
||||
const signature = tools
|
||||
.map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,13 @@ describe('summarizeToolRun', () => {
|
|||
expect(running([tool('terminal', { command: 'npm run typecheck' })])).toMatch(/^Running /)
|
||||
})
|
||||
|
||||
// Sequential calls leave a gap where the run is still going but nothing is
|
||||
// pending. Falling back to past tense there contradicted the ticker still
|
||||
// scrolling underneath, so the most recent call carries the present tense.
|
||||
it('stays in the present tense between two sequential calls', () => {
|
||||
expect(running([read('a.ts'), ran('x'), ran('y')])).toBe('Explored a.ts, running 2 commands')
|
||||
})
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -112,9 +112,9 @@ function lowerFirst(text: string): string {
|
|||
|
||||
/**
|
||||
* Collapse a run of tool calls into the single grey line that stands in for it
|
||||
* — "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.
|
||||
* — "Explored 3 files, ran 5 commands". While the run is live, the category
|
||||
* holding its most recent call speaks in the present tense so the line 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
|
||||
|
|
@ -126,8 +126,12 @@ function lowerFirst(text: string): string {
|
|||
* diff to report here; each edit carries its own +N/−M on its card.
|
||||
*/
|
||||
export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): string {
|
||||
const running = live ? tools.find(isPending) : undefined
|
||||
const liveCategory = running ? toolCategory(running.toolName) : null
|
||||
// Which clause narrates in the present tense: normally the outstanding call,
|
||||
// but sequential calls leave gaps where the run is still going and nothing is
|
||||
// pending. The most recent call covers those, and it's the one the ticker is
|
||||
// showing anyway.
|
||||
const narrating = live ? (tools.find(isPending) ?? tools.at(-1)) : undefined
|
||||
const liveCategory = narrating ? toolCategory(narrating.toolName) : null
|
||||
|
||||
const byCategory = new Map<RunCategory, ToolCallLike[]>()
|
||||
|
||||
|
|
|
|||
|
|
@ -316,6 +316,43 @@ function abandonedRunMessage(): ThreadMessage {
|
|||
} as ThreadMessage
|
||||
}
|
||||
|
||||
// The gap between one sequential call finishing and the next arriving: the
|
||||
// turn is still running, the run is still the tail, but for this instant every
|
||||
// call has a result.
|
||||
function betweenSequentialCallsMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-between-calls',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-a',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'sleep 2; echo alpha' },
|
||||
argsText: JSON.stringify({ command: 'sleep 2; echo alpha' }),
|
||||
result: { exit_code: 0, stdout: 'alpha' }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-b',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'sleep 2; echo bravo' },
|
||||
argsText: JSON.stringify({ command: 'sleep 2; echo bravo' }),
|
||||
result: { exit_code: 0, stdout: 'bravo' }
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
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 {
|
||||
|
|
@ -457,6 +494,18 @@ describe('live tool run', () => {
|
|||
|
||||
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull()
|
||||
})
|
||||
|
||||
// Liveness used to also require an unresolved call, which is false for the
|
||||
// instant between one sequential call finishing and the next arriving — so a
|
||||
// string of commands settled and re-opened between every one, unmounting the
|
||||
// ticker and dropping its reel back to the first row instead of scrolling.
|
||||
it('stays live in the gap between two sequential calls', async () => {
|
||||
const { container } = render(<GroupHarness message={betweenSequentialCallsMessage()} />)
|
||||
|
||||
expect(await screen.findByText('Running 2 commands')).toBeTruthy()
|
||||
expect(container.querySelector('[data-tool-ticker]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// A run whose calls never resolved used to read as live forever, which stranded
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue