mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 08:03:33 +00:00
* 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>
258 lines
11 KiB
TypeScript
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: '{}' } }
|
|
])
|
|
})
|
|
})
|