fix(chat-sdk): read a tool's call and a model's thinking off the row

A conversation row carries three things the SDK did not declare and so
never read: what a tool was called with, what it returned, and the
thinking behind an answer. For a tool that runs inside the agent's job
the row is the only copy of the first two, and for a failed tool the
result is what it failed with — so a conversation read back through the
SDK showed a tool call with nothing to expand and a failure with no
reason.

The AI SDK adapter already preferred a tool's result over the row's text
when reporting an error, so it needed only the data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-16 13:02:35 +02:00
co-authored by Claude Opus 5
parent 680da0a916
commit d6c07ce66d
3 changed files with 39 additions and 7 deletions
+22 -6
View File
@@ -1,5 +1,10 @@
import type { ChatTransport, UIMessage, UIMessageChunk, UIMessagePart } from 'ai'
import { WindmillApiError, WindmillChatApi, type WindmillChatApiOptions } from './api'
import {
WindmillApiError,
WindmillChatApi,
type FlowConversationMessage,
type WindmillChatApiOptions
} from './api'
import { followJob } from './follow'
import type { AgentStreamEvent } from './stream'
import type { ChatMessage, Conversation } from './types'
@@ -101,7 +106,8 @@ export function createWindmillChatTransport<UI_MESSAGE extends UIMessage = UIMes
stepName: row.step_name ?? undefined,
pending: false,
seq: row.created_seq,
tool: toolFromRowContent(row.message_type, row.content, row.success ?? true)
reasoning: row.reasoning ?? undefined,
tool: toolFromRow(row)
}))
) as UI_MESSAGE[]
},
@@ -123,10 +129,20 @@ export function createWindmillChatTransport<UI_MESSAGE extends UIMessage = UIMes
}
}
function toolFromRowContent(role: string, content: string, success: boolean): ChatMessage['tool'] {
if (role !== 'tool') return undefined
const name = /^Used (.+) tool$/.exec(content)?.[1] ?? /^Error executing (.+)$/.exec(content)?.[1]
return name ? { name, status: success ? 'success' : 'error' } : undefined
/** The call a stored tool row carries: its tool, named by the sentence the worker words
* every tool row from, and the arguments and result the row keeps when its job cannot be
* asked for them — for a failed tool, the result is what it failed with. */
function toolFromRow(row: FlowConversationMessage): ChatMessage['tool'] {
if (row.message_type !== 'tool') return undefined
const name =
/^Used (.+) tool$/.exec(row.content)?.[1] ?? /^Error executing (.+)$/.exec(row.content)?.[1]
if (!name) return undefined
return {
name,
status: (row.success ?? true) ? 'success' : 'error',
arguments: row.tool_arguments ?? undefined,
result: row.tool_result ?? undefined
}
}
/** Streams a job's answer as AI SDK chunks; resumes from `entry.offset` when the job is already running. */
+5
View File
@@ -40,6 +40,11 @@ export interface FlowConversationMessage {
created_seq: number
step_name?: string | null
success?: boolean
/** The call a tool row carries itself, for a tool whose job cannot be asked for it. */
tool_arguments?: string | null
tool_result?: string | null
/** The thinking behind an answer, which is streamed and stored nowhere else. */
reasoning?: string | null
}
export type JobUpdateEvent =
+12 -1
View File
@@ -725,7 +725,18 @@ function fromRow(row: FlowConversationMessage): ChatMessage {
stepName: row.step_name ?? undefined,
pending: false,
seq: row.created_seq,
tool: toolName ? { name: toolName, status: success ? 'success' : 'error' } : undefined
reasoning: row.reasoning ?? undefined,
// The call the row carries, which is all there is of it for a tool that ran inside the
// agent's job or failed before it had one of its own. For a failed tool the result is
// what it failed with, and the row's text names the tool rather than the reason.
tool: toolName
? {
name: toolName,
status: success ? 'success' : 'error',
arguments: row.tool_arguments ?? undefined,
result: row.tool_result ?? undefined
}
: undefined
}
}