Files
windmill/frontend/src/lib/components/aiAgentResult.test.ts
T
hugocasaandClaude Opus 5 a08992834d feat: render an AI agent result as its answer, not as raw JSON (#11051)
* feat: render an AI agent result as its answer, not as raw JSON

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: sanitize agent markdown through the shared plugin chain

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: fold agent stream events incrementally per poll

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: separate the agent meta line from the result toggle group

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: add a transcript view of an agent run's conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: retire AIAgentLogViewer in favour of the transcript

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: show the partial transcript a max-iterations failure carries

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: move the agent meta line and system prompt below the conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: show an agent run as what it did, not as a conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: name the agent run breakdown a trace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): label the save-as-agent form fields per the guidelines

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): reuse the resource form's path and description fields

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): keep the action tags on a max-iterations failure

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): keep offline replay inert and the streamed answer to one turn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): reset the streamed answer on providers that skip tool_call

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: carry the event type narrowing through the stream parser

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: survive a malformed message rather than take the viewer down

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: coerce agent messages once at the parse boundary

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: show an agent run as one scroll ending in its output

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep every streamed turn instead of dropping the narration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: share the chat divider and drop the unsafe run auto-scroll

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: give the labelled divider a border colour and the standard pretty icon

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: pad the agent run below its badges as well as above

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: follow a streaming run's pane without moving the page

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: share the chat's stick-to-bottom mechanics with the agent run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the answer's citations and end a turn's reasoning with the turn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: read the agent stream through the windmill-chat sdk parser

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show an agent run's thinking instead of falling back to raw json

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop a stream the fold cannot use from claiming the run pane

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: identify an agent run by more than the job id the replay withholds

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: drop the tests and comment lines that were not earning their place

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop a streamed turn's text shifting when a tool call closes it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:38:46 +02:00

258 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { buildAgentTrace } from './agentTrace'
import {
advanceAgentStream,
emptyAgentStreamProgress,
formatTokenCount,
isAgentStream,
parseAgentResult,
summarizeAgentResult
} from './aiAgentResult'
const envelope = {
output: 'the answer',
messages: [
{ role: 'user', content: 'ask' },
{ role: 'assistant', content: 'the answer', agent_action: { type: 'message' } }
],
usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }
}
describe('parseAgentResult', () => {
it('accepts the envelope with and without its optional keys', () => {
expect(parseAgentResult(envelope)?.output).toBe('the answer')
expect(parseAgentResult({ output: 1, messages: [{ role: 'user' }] })?.messages).toHaveLength(1)
// A key the worker serializes and the signature does not know costs every run
// carrying it the pretty display, so each one is pinned here.
expect(parseAgentResult({ ...envelope, reasoning: 'let me think' })?.reasoning).toBe(
'let me think'
)
})
// The signature is the only thing separating an agent result from any other
// object, so each of these near-misses has to stay a miss.
it.each([
['a key outside the envelope', { ...envelope, retries: 2 }],
['no output', { messages: envelope.messages }],
['messages that are not a list', { output: 'a', messages: { role: 'user' } }],
['no messages at all', { output: 'a', messages: [] }],
['a message without a role', { output: 'a', messages: [{ content: 'ask' }] }],
['null', null]
])('rejects %s', (_label, value) => {
expect(parseAgentResult(value)).toBeUndefined()
})
})
describe('summarizeAgentResult', () => {
it('counts the actions and falls back to the parts when no total is reported', () => {
const summary = summarizeAgentResult({
output: '',
messages: [
{ role: 'user' },
{ role: 'assistant', agent_action: { type: 'tool_call' } as any },
{ role: 'assistant', agent_action: { type: 'mcp_tool_call' } as any },
{ role: 'assistant', agent_action: { type: 'web_search' } },
{ role: 'assistant', agent_action: { type: 'message' } }
],
usage: { input_tokens: 8421, output_tokens: 512, cache_read_input_tokens: 6144 }
})
expect(summary).toEqual({
toolCalls: 2,
webSearches: 1,
tokens: 8933,
cachedTokens: 6144
})
})
})
describe('agent stream', () => {
const lines = [
'{"type":"tool_call","call_id":"c1","function_name":"query_metrics"}',
'{"type":"tool_result","call_id":"c1","function_name":"query_metrics","result":"{}","success":true}',
'{"type":"reasoning_token_delta","content":"checking"}',
'{"type":"token_delta","content":"eu-central-1"}',
'{"type":"token_delta","content":" is down"}'
]
const events = lines.join('\n') + '\n'
it('recognises an agent stream from its first line only', () => {
expect(isAgentStream(events)).toBe(true)
expect(isAgentStream('processing row 1\nprocessing row 2\n')).toBe(false)
expect(isAgentStream('{"level":"info","msg":"hello"}\n')).toBe(false)
// No newline yet, so the first line may still be half-written.
expect(isAgentStream('{"type":"token_delta","content":"a"}')).toBe(false)
// A provider that does not filter its empty deltas opens with one, and the
// run is an agent's all the same.
expect(isAgentStream('{"type":"token_delta","content":""}\n')).toBe(true)
// What the fold cannot use must not claim the pane either, or the script's
// own output is replaced by a view with nothing to draw.
expect(isAgentStream('{"type":"tool_call","function_name":"q"}\n')).toBe(false)
})
// The stream only grows, so each poll must fold in the new lines and re-read
// none of the old ones — the reason this is incremental at all.
it('resumes where the previous poll stopped', () => {
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.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.current).toBe('eu-central-1 is down')
const completed = advanceAgentStream(`${events}{"type":"token_delta","content":"!"}\n`, partial)
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.entries).toEqual([
{ kind: 'tool', callId: 'c1', name: 'fetch', running: true, success: undefined }
])
const failed = advanceAgentStream(
started +
'{"type":"tool_result","call_id":"c1","function_name":"fetch","result":"boom","success":false}\n',
running
)
// One row for the call, not one per event about it.
expect(failed.stream.entries).toEqual([
{ kind: 'tool', callId: 'c1', name: 'fetch', running: false, success: false }
])
})
})
describe('formatTokenCount', () => {
it('keeps small counts exact and rounds the rest', () => {
expect(formatTokenCount(940)).toBe('940')
expect(formatTokenCount(8933)).toBe('8.9k')
expect(formatTokenCount(48211)).toBe('48k')
})
})
// The worker streams every iteration's text, and a turn may narrate and call a
// tool at once. Appending across that boundary makes the live answer read
// "I'll checkThe issue is..." and never converge on the finished output.
describe('a stream that narrates before calling a tool', () => {
it('turns the narration into a row and starts the answer fresh', () => {
const raw = [
'{"type":"token_delta","content":"Let me check the metrics."}',
'{"type":"tool_call","call_id":"c1","function_name":"query_metrics"}',
'{"type":"tool_result","call_id":"c1","function_name":"query_metrics","result":"{}","success":true}',
'{"type":"token_delta","content":"eu-central-1 is down."}',
''
].join('\n')
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('closes the turn at the boundary even when polls split it', () => {
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.current).toBe('Let me check.')
const poll2 = advanceAgentStream(afterCall, poll1)
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.current).toBe('Done.')
})
// Extended thinking emits reasoning with no narration before the tool call, so
// the turn boundary is the only thing that can end it.
it('ends a turn that thought without narrating', () => {
const raw = [
'{"type":"reasoning_token_delta","content":"The user wants the metrics."}',
'{"type":"tool_call","call_id":"c1","function_name":"q"}',
'{"type":"tool_result","call_id":"c1","function_name":"q","result":"{}","success":true}',
'{"type":"reasoning_token_delta","content":"eu-central-1 looks down."}',
''
].join('\n')
const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress())
expect(stream.reasoning).toBe('eu-central-1 looks down.')
})
// Bedrock has its own streaming implementation rather than the shared SSE
// parsers, and never announces `tool_call` — only the arguments, then the
// worker's `tool_execution`. Keying the reset on `tool_call` alone leaves the
// narration in place for that provider.
it('resets on a provider that never announces the call itself', () => {
const raw = [
'{"type":"token_delta","content":"Let me check the metrics."}',
'{"type":"tool_call_arguments","call_id":"c1","function_name":"q","arguments":"{}"}',
'{"type":"tool_execution","call_id":"c1","function_name":"q"}',
'{"type":"tool_result","call_id":"c1","function_name":"q","result":"{}","success":true}',
'{"type":"token_delta","content":"eu-central-1 is down."}',
''
].join('\n')
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.' })
})
// A script can write anything to `result_stream`, so an event is not guaranteed
// the fields its type declares.
it('ignores an event with nothing to key a row by, or no text to add', () => {
const raw =
'{"type":"token_delta","content":"Hi."}\n{"type":"token_delta"}\n{"type":"tool_call"}\n'
const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress())
expect(stream.entries).toEqual([])
expect(stream.current).toBe('Hi.')
})
})
// A result is whatever a script returned, so a message that has a `role` still has
// arbitrary anything underneath. Coercing once here is what lets every reader
// treat `AgentMessage` as true; a bad value reaching them throws mid-render and
// takes the result viewer down, including the plain error it usually rides on.
describe('coercing messages at the boundary', () => {
function messagesOf(raw: unknown) {
return parseAgentResult({ output: '', messages: raw })?.messages
}
it.each([
['tool_calls that are not a list', { role: 'assistant', tool_calls: {} }],
['a null entry inside tool_calls', { role: 'assistant', tool_calls: [null] }],
[
'a tool call whose function is a string',
{ role: 'assistant', tool_calls: [{ id: 'c1', function: 'q' }] }
],
[
'non-string arguments',
{ role: 'assistant', tool_calls: [{ id: 'c1', function: { arguments: 3 } }] }
],
['annotations that are not a list', { role: 'assistant', annotations: 'abc' }],
['a null entry inside annotations', { role: 'assistant', annotations: [null] }],
['an annotation with no url', { role: 'assistant', annotations: [{ title: 'x' }] }],
['an agent_action that is not an object', { role: 'tool', agent_action: 'tool_call' }],
['an agent_action with no type', { role: 'tool', agent_action: {} }]
])('survives %s', (_label, message) => {
const parsed = messagesOf([message])
expect(parsed).toHaveLength(1)
expect(() => buildAgentTrace(parsed!)).not.toThrow()
})
it('keeps a well-formed call intact', () => {
const parsed = messagesOf([
{
role: 'assistant',
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'q', arguments: '{}' } }]
}
])
expect(parsed?.[0].tool_calls).toEqual([
{ id: 'c1', type: 'function', function: { name: 'q', arguments: '{}' } }
])
})
})