mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +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>
183 lines
6.0 KiB
TypeScript
183 lines
6.0 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { buildAgentTrace, splitFinalAnswer } from './agentTrace'
|
|
import { parseAgentErrorMessages } from './aiAgentResult'
|
|
import type { AgentMessage } from './aiAgentResult'
|
|
|
|
// The worker splits one tool call across two messages: the assistant message
|
|
// carries the arguments and no action tag, the `tool` message answering it
|
|
// carries the result and the `tool_call` tag naming the job. Joining them on
|
|
// `tool_call_id` is the whole contract, and reading it off the wrong message
|
|
// yields a row with no parameters.
|
|
const messages: AgentMessage[] = [
|
|
{ role: 'system', content: 'You are an SRE assistant.' },
|
|
{ role: 'user', content: 'Which region is broken?' },
|
|
{
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_1',
|
|
type: 'function',
|
|
function: { name: 'query_metrics', arguments: '{"w":"30m"}' }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
role: 'tool',
|
|
tool_call_id: 'call_1',
|
|
content: '{"eu-central-1":0.184}',
|
|
agent_action: {
|
|
type: 'tool_call',
|
|
job_id: '0199-job',
|
|
module_id: 'b',
|
|
function_name: 'query_metrics'
|
|
}
|
|
},
|
|
{ role: 'assistant', content: 'eu-central-1 is down.', agent_action: { type: 'message' } }
|
|
]
|
|
|
|
describe('buildAgentTrace', () => {
|
|
it('joins a tool call to the arguments on the message that requested it', () => {
|
|
expect(buildAgentTrace(messages)).toEqual([
|
|
{
|
|
kind: 'tool',
|
|
name: 'query_metrics',
|
|
args: '{"w":"30m"}',
|
|
result: '{"eu-central-1":0.184}',
|
|
jobId: '0199-job'
|
|
},
|
|
{ kind: 'assistant', content: 'eu-central-1 is down.', sources: undefined }
|
|
])
|
|
})
|
|
|
|
it('keeps an MCP call, whose arguments live on the action itself', () => {
|
|
const entries = buildAgentTrace([
|
|
{
|
|
role: 'tool',
|
|
content: 'sunny',
|
|
agent_action: {
|
|
type: 'mcp_tool_call',
|
|
call_id: 'c1',
|
|
function_name: 'get_weather',
|
|
resource_path: 'f/mcp/weather',
|
|
arguments: { city: 'Paris' }
|
|
}
|
|
}
|
|
])
|
|
expect(entries).toEqual([
|
|
{
|
|
kind: 'tool',
|
|
name: 'get_weather',
|
|
args: '{\n "city": "Paris"\n}',
|
|
result: 'sunny',
|
|
resourcePath: 'f/mcp/weather'
|
|
}
|
|
])
|
|
})
|
|
|
|
// The worker splits a search the same way: a `tool` message tagged web_search
|
|
// carrying a constant sentence, then the assistant turn that carries the
|
|
// citations. The search row therefore has nothing of its own to show.
|
|
it('records the search and puts its citations on the turn that follows', () => {
|
|
const entries = buildAgentTrace([
|
|
{
|
|
role: 'tool',
|
|
content: 'Used websearch tool successfully',
|
|
agent_action: { type: 'web_search' }
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: 'Postgres 17 changed the default.',
|
|
annotations: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }],
|
|
agent_action: { type: 'message' }
|
|
}
|
|
])
|
|
expect(entries).toEqual([
|
|
{ kind: 'search' },
|
|
{
|
|
kind: 'assistant',
|
|
content: 'Postgres 17 changed the default.',
|
|
sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }]
|
|
}
|
|
])
|
|
})
|
|
|
|
// The prompt and the question are the step's inputs, shown as inputs. A replayed
|
|
// turn comes back from memory without its tag, and crediting this run with an
|
|
// answer a previous one gave would be a lie about what happened.
|
|
it('traces only what this run did', () => {
|
|
expect(
|
|
buildAgentTrace([
|
|
{ role: 'system', content: 'You are an SRE assistant.' },
|
|
{ role: 'user', content: 'Which region is broken?' },
|
|
{ role: 'assistant', content: 'Answered in an earlier turn, replayed from memory.' },
|
|
{ role: 'assistant', tool_calls: [{ id: 'c1', function: { name: 'x', arguments: '{}' } }] },
|
|
{ role: 'assistant', content: '', agent_action: { type: 'message' } }
|
|
])
|
|
).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('splitFinalAnswer', () => {
|
|
it('moves the answering turn out of the trace, with its citations', () => {
|
|
const entries = buildAgentTrace([
|
|
{ role: 'tool', content: 'Used websearch tool', agent_action: { type: 'web_search' } },
|
|
{
|
|
role: 'assistant',
|
|
content: 'Postgres 17 changed the default.',
|
|
annotations: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }],
|
|
agent_action: { type: 'message' }
|
|
}
|
|
])
|
|
expect(splitFinalAnswer(entries, 'Postgres 17 changed the default.')).toEqual({
|
|
trace: [{ kind: 'search' }],
|
|
sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }]
|
|
})
|
|
})
|
|
|
|
// A run whose last turn returned a tool call and no text leaves its answer
|
|
// mid-trace. Looking only at the final entry finds nothing to move and prints
|
|
// that answer as a row and again under the output.
|
|
it('finds the answering turn even when it is not the last one', () => {
|
|
const entries = buildAgentTrace([
|
|
{ role: 'assistant', content: 'Let me check.', agent_action: { type: 'message' } },
|
|
{
|
|
role: 'tool',
|
|
tool_call_id: 'call_1',
|
|
content: '{}',
|
|
agent_action: {
|
|
type: 'tool_call',
|
|
job_id: '0199-job',
|
|
module_id: 'b',
|
|
function_name: 'query_metrics'
|
|
}
|
|
}
|
|
])
|
|
expect(splitFinalAnswer(entries, 'Let me check.').trace).toEqual([entries[1]])
|
|
})
|
|
|
|
it('leaves the trace whole when the output is not a turn of its own', () => {
|
|
const entries = buildAgentTrace(messages)
|
|
expect(splitFinalAnswer(entries, { rows: 3 })).toEqual({ trace: entries })
|
|
})
|
|
})
|
|
|
|
// A run stopped by max_iterations serializes its partial messages itself rather
|
|
// than reusing the success envelope's writer. `agent_action` is `skip_serializing`
|
|
// on `OpenAIMessage`, so if that path ever stops wrapping them the tags vanish and
|
|
// this trace silently empties — which is the one run worth reading.
|
|
describe('the max-iterations path', () => {
|
|
it('traces the partial messages the error carries', () => {
|
|
const partial = parseAgentErrorMessages({
|
|
error: {
|
|
name: 'ExecutionErr',
|
|
message: 'AI agent reached max iterations (10)',
|
|
step_id: 'd',
|
|
result: { messages }
|
|
}
|
|
})
|
|
// The trace itself is `buildAgentTrace`'s, pinned above; what this path can
|
|
// lose is the tags it reads, and an untagged conversation traces to nothing.
|
|
expect(buildAgentTrace(partial ?? [])).toHaveLength(2)
|
|
})
|
|
})
|