mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix: keep every streamed turn instead of dropping the narration
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8343f92efe
commit
3029d74905
@@ -41,7 +41,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col w-full pt-1">
|
||||
<div class="flex flex-col w-full pt-3">
|
||||
{#if trace.length > 0}
|
||||
<AgentTrace entries={trace} {workspaceId} />
|
||||
{/if}
|
||||
|
||||
@@ -36,45 +36,49 @@
|
||||
|
||||
let anchor: HTMLElement | undefined = $state()
|
||||
$effect(() => {
|
||||
// Follow the text as it is written, the same way the finished run opens on
|
||||
// its output: both put what you came for at the bottom.
|
||||
stream.answer
|
||||
// Follow the text as it is written, the same way a finished run opens on its
|
||||
// output: both put what you came for at the bottom.
|
||||
stream.current
|
||||
stream.reasoning
|
||||
stream.tools.length
|
||||
stream.entries.length
|
||||
scrollPaneToEnd(anchor)
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Deliberately the same order the finished run uses — what it did, then what it
|
||||
produced — so the view does not rearrange itself when the result lands. -->
|
||||
<div class="flex flex-col w-full pt-1">
|
||||
{#each stream.tools as tool (tool.callId)}
|
||||
<ChatCollapsibleCard
|
||||
label={tool.name}
|
||||
expanded={false}
|
||||
toggleable={false}
|
||||
shimmer={tool.running}
|
||||
onToggle={() => {}}
|
||||
labelClass={tool.success === false ? 'text-red-500' : ''}
|
||||
/>
|
||||
<!-- The same order a finished run uses — what it did, then what it is saying — so
|
||||
nothing moves when the result lands. The text at the bottom is deliberately
|
||||
unlabelled: a turn that goes on to call a tool was narration, and only the end
|
||||
of the run settles which this one is. -->
|
||||
<div class="flex flex-col w-full pt-3">
|
||||
{#each stream.entries as entry, index (entry.kind === 'tool' ? entry.callId : index)}
|
||||
{#if entry.kind === 'tool'}
|
||||
<ChatCollapsibleCard
|
||||
label={entry.name}
|
||||
expanded={false}
|
||||
toggleable={false}
|
||||
shimmer={entry.running}
|
||||
onToggle={() => {}}
|
||||
labelClass={entry.success === false ? 'text-red-500' : ''}
|
||||
/>
|
||||
{:else}
|
||||
<div class="mb-1">
|
||||
<GfmMarkdown md={entry.content} noPadding />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<div class={stream.tools.length > 0 ? 'mt-4 pt-3 border-t border-border-light' : ''}>
|
||||
<span class="text-2xs text-hint">Output</span>
|
||||
<div class="mt-1">
|
||||
<!-- Same sanitizing chain as the finished output: a partial answer is
|
||||
written by the same model and is no more trusted for arriving in
|
||||
pieces. -->
|
||||
{#if stream.answer !== ''}
|
||||
<GfmMarkdown md={stream.answer} noPadding />
|
||||
{:else if stream.reasoning !== ''}
|
||||
<!-- Reasoning arrives before the answer, so on its own it means the model
|
||||
is still thinking rather than that this run has no answer. -->
|
||||
<div class="text-secondary">
|
||||
<GfmMarkdown md={stream.reasoning} prose="xs" noPadding />
|
||||
</div>
|
||||
{/if}
|
||||
{#if stream.current !== ''}
|
||||
<div class="mt-2">
|
||||
<!-- Same sanitizing chain as a finished output: a partial answer is written
|
||||
by the same model and is no more trusted for arriving in pieces. -->
|
||||
<GfmMarkdown md={stream.current} noPadding />
|
||||
</div>
|
||||
</div>
|
||||
{:else if stream.reasoning !== ''}
|
||||
<!-- Reasoning arrives before the text, so on its own it means the model is
|
||||
still thinking rather than that this run has nothing to say. -->
|
||||
<div class="text-secondary mt-2">
|
||||
<GfmMarkdown md={stream.reasoning} prose="xs" noPadding />
|
||||
</div>
|
||||
{/if}
|
||||
<div bind:this={anchor}></div>
|
||||
</div>
|
||||
|
||||
@@ -831,7 +831,7 @@
|
||||
{#if ['table-col', 'table-row', 'table-row-object'].includes(resultKind ?? '')}
|
||||
<ToggleButton size="sm" value="table" label="Table" icon={Table2} {item} />
|
||||
{:else if resultKind === 'aiagent'}
|
||||
<ToggleButton size="sm" value="pretty" label="Run" icon={Bot} {item} />
|
||||
<ToggleButton size="sm" value="pretty" label="Pretty" icon={Bot} {item} />
|
||||
{:else}
|
||||
<ToggleButton size="sm" value="pretty" label="Pretty" icon={Highlighter} {item} />
|
||||
{/if}
|
||||
|
||||
@@ -82,10 +82,10 @@ describe('agent stream', () => {
|
||||
|
||||
it('folds the token deltas into the answer so far', () => {
|
||||
const { stream } = advanceAgentStream(events, emptyAgentStreamProgress())
|
||||
expect(stream.answer).toBe('eu-central-1 is down')
|
||||
expect(stream.current).toBe('eu-central-1 is down')
|
||||
expect(stream.reasoning).toBe('checking')
|
||||
expect(stream.tools).toEqual([
|
||||
{ callId: 'c1', name: 'query_metrics', running: false, success: true }
|
||||
expect(stream.entries).toEqual([
|
||||
{ kind: 'tool', callId: 'c1', name: 'query_metrics', running: false, success: true }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -95,22 +95,22 @@ describe('agent stream', () => {
|
||||
const firstPoll = advanceAgentStream(lines.slice(0, 3).join('\n') + '\n', emptyAgentStreamProgress())
|
||||
const secondPoll = advanceAgentStream(events, firstPoll)
|
||||
expect(secondPoll.consumed).toBe(events.length)
|
||||
expect(secondPoll.stream.answer).toBe('eu-central-1 is down')
|
||||
expect(secondPoll.stream.current).toBe('eu-central-1 is down')
|
||||
expect(secondPoll.stream.reasoning).toBe('checking')
|
||||
})
|
||||
|
||||
it('leaves a half-written trailing line for the next poll', () => {
|
||||
const partial = advanceAgentStream(`${events}{"type":"token_de`, emptyAgentStreamProgress())
|
||||
expect(partial.stream.answer).toBe('eu-central-1 is down')
|
||||
expect(partial.stream.current).toBe('eu-central-1 is down')
|
||||
const completed = advanceAgentStream(`${events}{"type":"token_delta","content":"!"}\n`, partial)
|
||||
expect(completed.stream.answer).toBe('eu-central-1 is down!')
|
||||
expect(completed.stream.current).toBe('eu-central-1 is down!')
|
||||
})
|
||||
|
||||
it('marks a tool still running, and a failed one', () => {
|
||||
const started = '{"type":"tool_execution","call_id":"c1","function_name":"fetch"}\n'
|
||||
const running = advanceAgentStream(started, emptyAgentStreamProgress())
|
||||
expect(running.stream.tools).toEqual([
|
||||
{ callId: 'c1', name: 'fetch', running: true, success: undefined }
|
||||
expect(running.stream.entries).toEqual([
|
||||
{ kind: 'tool', callId: 'c1', name: 'fetch', running: true, success: undefined }
|
||||
])
|
||||
const failed = advanceAgentStream(
|
||||
started +
|
||||
@@ -118,8 +118,8 @@ describe('agent stream', () => {
|
||||
running
|
||||
)
|
||||
// One row for the call, not one per event about it.
|
||||
expect(failed.stream.tools).toEqual([
|
||||
{ callId: 'c1', name: 'fetch', running: false, success: false }
|
||||
expect(failed.stream.entries).toEqual([
|
||||
{ kind: 'tool', callId: 'c1', name: 'fetch', running: false, success: false }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -144,20 +144,22 @@ describe('a stream that narrates before calling a tool', () => {
|
||||
'{"type":"token_delta","content":"eu-central-1 is down."}',
|
||||
''
|
||||
].join('\n')
|
||||
expect(advanceAgentStream(raw, emptyAgentStreamProgress()).stream.answer).toBe(
|
||||
'eu-central-1 is down.'
|
||||
)
|
||||
const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress())
|
||||
expect(stream.current).toBe('eu-central-1 is down.')
|
||||
// The narration became a row rather than disappearing.
|
||||
expect(stream.entries[0]).toEqual({ kind: 'assistant', content: 'Let me check the metrics.' })
|
||||
})
|
||||
|
||||
it('drops the narration at the boundary even across polls', () => {
|
||||
const first = '{"type":"token_delta","content":"Let me check."}\n'
|
||||
const afterCall = first + '{"type":"tool_call","call_id":"c1","function_name":"q"}\n'
|
||||
const poll1 = advanceAgentStream(first, emptyAgentStreamProgress())
|
||||
expect(poll1.stream.answer).toBe('Let me check.')
|
||||
expect(poll1.stream.current).toBe('Let me check.')
|
||||
const poll2 = advanceAgentStream(afterCall, poll1)
|
||||
expect(poll2.stream.answer).toBe('')
|
||||
expect(poll2.stream.current).toBe('')
|
||||
expect(poll2.stream.entries[0]).toEqual({ kind: 'assistant', content: 'Let me check.' })
|
||||
const poll3 = advanceAgentStream(afterCall + '{"type":"token_delta","content":"Done."}\n', poll2)
|
||||
expect(poll3.stream.answer).toBe('Done.')
|
||||
expect(poll3.stream.current).toBe('Done.')
|
||||
})
|
||||
|
||||
// Bedrock has its own streaming implementation rather than the shared SSE
|
||||
@@ -173,9 +175,10 @@ describe('a stream that narrates before calling a tool', () => {
|
||||
'{"type":"token_delta","content":"eu-central-1 is down."}',
|
||||
''
|
||||
].join('\n')
|
||||
expect(advanceAgentStream(raw, emptyAgentStreamProgress()).stream.answer).toBe(
|
||||
'eu-central-1 is down.'
|
||||
)
|
||||
const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress())
|
||||
expect(stream.current).toBe('eu-central-1 is down.')
|
||||
// The narration became a row rather than disappearing.
|
||||
expect(stream.entries[0]).toEqual({ kind: 'assistant', content: 'Let me check the metrics.' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -187,21 +187,30 @@ export function summarizeAgentResult(result: AgentResult): AgentResultSummary {
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentStreamTool = { callId: string; name: string; running: boolean; success?: boolean }
|
||||
export type AgentStreamEntry =
|
||||
| { kind: 'assistant'; content: string }
|
||||
| { kind: 'tool'; callId: string; name: string; running: boolean; success?: boolean }
|
||||
|
||||
export type AgentStream = {
|
||||
answer: string
|
||||
/**
|
||||
* What the run has finished doing, in order — the same rows the completed
|
||||
* trace will show, so nothing on screen moves when the result lands.
|
||||
*/
|
||||
entries: AgentStreamEntry[]
|
||||
/**
|
||||
* The text of the turn being written. It is not yet the output: a turn that
|
||||
* goes on to call a tool was narration, and only the run ending decides which
|
||||
* this was. So it stays unlabelled here and becomes one or the other.
|
||||
*/
|
||||
current: string
|
||||
reasoning: string
|
||||
/** Every tool the run has touched, in order, so a stream shows the same rows
|
||||
* the finished trace will. */
|
||||
tools: AgentStreamTool[]
|
||||
}
|
||||
|
||||
/** How much of the stream has been folded in, so the next poll starts there. */
|
||||
export type AgentStreamProgress = { consumed: number; stream: AgentStream }
|
||||
|
||||
export function emptyAgentStreamProgress(): AgentStreamProgress {
|
||||
return { consumed: 0, stream: { answer: '', reasoning: '', tools: [] } }
|
||||
return { consumed: 0, stream: { entries: [], current: '', reasoning: '' } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,7 +285,7 @@ export function advanceAgentStream(
|
||||
if (complete <= previous.consumed) {
|
||||
return previous
|
||||
}
|
||||
const stream: AgentStream = { ...previous.stream, tools: [...previous.stream.tools] }
|
||||
const stream: AgentStream = { ...previous.stream, entries: [...previous.stream.entries] }
|
||||
for (const line of raw.slice(previous.consumed, complete).split('\n')) {
|
||||
if (line.trim() === '') {
|
||||
continue
|
||||
@@ -286,28 +295,33 @@ export function advanceAgentStream(
|
||||
continue
|
||||
}
|
||||
if (event.type === 'token_delta' && typeof event.content === 'string') {
|
||||
stream.answer += event.content
|
||||
stream.current += event.content
|
||||
} else if (event.type === 'reasoning_token_delta' && typeof event.content === 'string') {
|
||||
stream.reasoning += event.content
|
||||
} else if (typeof event.function_name === 'string') {
|
||||
if (TOOL_TURN_STARTED.includes(event.type)) {
|
||||
// A model can narrate and request a tool in the same turn, and the loop
|
||||
// then runs again. That narration is not part of the answer — the
|
||||
// finished result keeps the text of the last turn that produced any — so
|
||||
// a starting call resets rather than appending to what came before.
|
||||
stream.answer = ''
|
||||
if (TOOL_TURN_STARTED.includes(event.type) && stream.current !== '') {
|
||||
// A model can narrate and request a tool in the same turn. The call
|
||||
// settles what that text was: narration, not the output. It becomes a
|
||||
// row rather than being dropped, so nothing vanishes from the screen
|
||||
// only to reappear when the result lands.
|
||||
stream.entries.push({ kind: 'assistant', content: stream.current })
|
||||
stream.current = ''
|
||||
stream.reasoning = ''
|
||||
}
|
||||
// The same call is announced, then argued, then executed, then answered.
|
||||
// Keyed on `call_id` so those four events are one row rather than four.
|
||||
const callId = typeof event.call_id === 'string' ? event.call_id : event.function_name
|
||||
const existing = stream.tools.find((t) => t.callId === callId)
|
||||
const existing = stream.entries.find(
|
||||
(e): e is Extract<AgentStreamEntry, { kind: 'tool' }> =>
|
||||
e.kind === 'tool' && e.callId === callId
|
||||
)
|
||||
const settled = event.type === 'tool_result'
|
||||
if (existing) {
|
||||
existing.running = !settled
|
||||
existing.success = settled ? event.success === true : existing.success
|
||||
} else {
|
||||
stream.tools.push({
|
||||
stream.entries.push({
|
||||
kind: 'tool',
|
||||
callId,
|
||||
name: event.function_name,
|
||||
running: !settled,
|
||||
|
||||
Reference in New Issue
Block a user