fix: scope a structured answer's claim to its own turn and document the row fields as stored

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-16 19:04:28 +02:00
co-authored by Claude Fable 5.1
parent 985b0a0b26
commit 6692e9d0c0
4 changed files with 62 additions and 13 deletions
+7 -6
View File
@@ -28371,16 +28371,17 @@ components:
type: string
nullable: true
description: >-
The call, for a tool that runs inside the agent's job and so has none of its
own: an MCP tool, or a provider-native one such as web search. A Windmill tool
is a script or flow with its own job, and its call is read from there instead.
The arguments of an MCP tool call, which runs inside the agent's job and so has
none of its own. Null for a provider-native web search, whose query the provider
does not return, and for a Windmill tool, a script or flow whose own job holds
its call.
tool_result:
type: string
nullable: true
description: >-
What that same call returned, including the citations of a provider-native web
search, and what it failed with when it failed — the row's own text names the
tool rather than the reason. Null for a tool whose job holds the answer.
An MCP tool's result, a provider-native web search's citations, and for any
failed tool what it failed with — the row's own text names the tool rather than
the reason. Null for a successful Windmill tool, whose own job holds the result.
reasoning:
type: string
nullable: true
+3 -2
View File
@@ -40,10 +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. */
/** An MCP tool call's arguments; null for a web search and for a Windmill tool, whose own job holds its call. */
tool_arguments?: string | null
/** An MCP tool's result, a web search's citations, and what any failed tool failed with. */
tool_result?: string | null
/** The thinking behind an answer, which is streamed and stored nowhere else. */
/** The thinking of the iteration that produced this row; the agent job keeps the turn's thinking as one string. */
reasoning?: string | null
}
+9 -5
View File
@@ -622,12 +622,16 @@ class ChatImpl implements Chat {
(m.content === row.content || (row.tool !== undefined && m.tool?.name === row.tool.name))
)
// A structured answer streams as thinking alone, its text arriving as a tool call
// the stream never turns into a message, so its row claims the pending message
// that holds that thinking and nothing else.
// the stream never turns into a message, so its row claims the message that holds
// that thinking and nothing else. Only past the newest user message: a turn stopped
// before its rows landed leaves such a message behind, and it is not this answer's.
if (i < 0 && row.role === 'assistant' && row.reasoning !== undefined) {
i = messages.findIndex(
(m) => m.seq === undefined && m.role === 'assistant' && m.content === '' && m.reasoning !== undefined
)
let turnStart = messages.length - 1
while (turnStart >= 0 && messages[turnStart].role !== 'user') turnStart--
const j = messages
.slice(turnStart + 1)
.findIndex((m) => m.seq === undefined && m.role === 'assistant' && m.content === '' && m.reasoning !== undefined)
if (j >= 0) i = turnStart + 1 + j
}
if (i >= 0) {
const m = messages[i]
+43
View File
@@ -666,6 +666,49 @@ describe('createChat with server history', () => {
])
})
test('a structured answer row leaves a stopped earlier turn its thinking', async () => {
let jobs = 0
const { fetch } = fetchMock(
(c) => (c.method === 'POST' && c.url.pathname.includes('/jobs/run/f/') ? text(`job-${++jobs}`) : undefined),
(c) =>
c.url.pathname.endsWith('/getupdate_sse/job-1')
? sse([{ type: 'update', new_result_stream: ndjson({ type: 'reasoning_token_delta', content: 'first thoughts' }), stream_offset: 1 }])
: undefined,
(c) =>
c.url.pathname.endsWith('/getupdate_sse/job-2')
? sse([
{
type: 'update',
new_result_stream: ndjson({ type: 'reasoning_token_delta', content: 'hmm' }),
stream_offset: 1,
completed: true,
only_result: { output: { n: 1 }, messages: [] }
}
])
: undefined,
(c) => (c.url.pathname.includes('/queue/cancel/') ? text('ok') : undefined),
(c) =>
c.url.pathname.endsWith('/messages')
? json([messageRow(41, 'user', 'first'), messageRow(42, 'user', 'again'), messageRow(43, 'assistant', '{"n":1}', { job_id: 'step-2', reasoning: 'hmm' })])
: undefined,
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({}, fetch))
const first = chat.sendMessage('first')
await new Promise((r) => setTimeout(r, 50))
const stopped = chat.stop()
await first
const second = chat.sendMessage('again')
await stopped
await second
expect(chat.getState().messages.map((m) => [m.role, m.content, m.reasoning])).toEqual([
['user', 'first', undefined],
['assistant', '', 'first thoughts'],
['user', 'again', undefined],
['assistant', '{"n":1}', 'hmm']
])
})
test('the stream asks for a server poll interval only when one is set', async () => {
const answer: Route = (c) =>
c.url.pathname === streamPath ? sse([{ type: 'update', completed: true, only_result: 'ok' }]) : undefined