fix(host): read the V4 tool-result error mark

V4 lifted `isError` from the tool-result content wrapper onto the message
and made the event-level `data.error` identity optional, so a failing
result could lose its error flag. The fold now reads the mark from BOTH
spellings — surface nodes and file-op rows keep it on every generation.
This commit is contained in:
bowenliang123 committed 2026-09-22 16:27:55 +08:00
1 parent 274b50f0c8
commit 7b09055193
4 files changed
+35 -8

No files matched your search

+9 -2
View File
@@ -437,6 +437,13 @@ interface MessageLike {
content?: ContentBlock[]
source?: MessageSource
error?: boolean
/**
* The V4 tool-result error mark (`tool/result.message.isError`): V3 carried
* it inside the `tool-result` content wrapper block, V4 lifted it onto the
* message and made the event-level `data.error` identity optional — so the
* flag is read from BOTH spellings.
*/
isError?: unknown
}
/**
@@ -551,7 +558,7 @@ function applySurface(
}
st.callNames = kept
}
if (data?.error) node.err = true
if (data?.error || message?.isError === true) node.err = true
} else if (source?.kind === 'skill-invocation') {
node.skill = typeof source.name === 'string' ? source.name : '?'
} else if (source?.kind === 'plugin') {
@@ -1103,7 +1110,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
tool: pendingEntry.name,
argsRaw: pendingEntry.argsRaw,
meta: data?.meta,
err: Boolean(data?.error) || firstBlock?.isError === true,
err: Boolean(data?.error) || toolMsg?.isError === true || firstBlock?.isError === true,
})
pushFileOps(s, ops)
}
+5 -2
View File
@@ -67,7 +67,7 @@ describe('the file-op log — call/result pairing', () => {
assert.equal(state.callNames.c1, undefined, 'the pending entry consumed at the result')
})
test('the error flag reads the envelope error object or the block isError', () => {
test('the error flag reads the envelope error object, the block isError, or the V4 message isError', () => {
const { state } = driveTimeline([
toolCall(1, { callId: 'c1', name: 'read', arguments: JSON.stringify({ file_path: 'a.ts' }) }),
toolResult(2, { callId: 'c1', content: [{ type: 'text', text: 'no' }], error: true }),
@@ -80,8 +80,11 @@ describe('the file-op log — call/result pairing', () => {
message: { content: [{ type: 'tool-result', toolCallId: 'c2', isError: true, content: [] }], source: { kind: 'tool', callId: 'c2' } },
},
} as unknown as TimelineEvent,
toolCall(5, { callId: 'c3', name: 'read', arguments: JSON.stringify({ file_path: 'c.ts' }) }),
// The V4 spelling: the mark lifted onto the message, no envelope error.
toolResult(6, { callId: 'c3', content: [{ type: 'text', text: 'v4' }], error: true, v4: true }),
])
assert.deepEqual(state.fileOps.map(o => o.err), [true, true])
assert.deepEqual(state.fileOps.map(o => o.err), [true, true, true])
})
test('a call whose raw arguments are not a string still pairs by name (ops degrade to nothing)', () => {
+10
View File
@@ -106,6 +106,16 @@ describe('applySurface tool/result branch', () => {
])
assert.equal(state.surface.at(-1)?.err, true)
})
test('the V4 message isError flags the node (no envelope error object)', () => {
const { state } = driveTimeline([
toolCall(1, { callId: 'c1', name: 'bash' }),
toolResult(2, { callId: 'c1', content: text('boom'), error: true, v4: true }),
])
const node = state.surface.at(-1)
assert.equal(node?.err, true)
assert.equal(node?.tool, 'bash', 'the V4 source callId still pairs the node')
})
})
describe('applySurface user-message previews', () => {
+11 -4
View File
@@ -79,14 +79,21 @@ export function toolResult(seq: number, opts: {
/** The bounded presentation meta (search matches / read window) on the durable event. */
meta?: unknown
time?: number
/**
* The V4 spelling: `role: 'tool'`, the lifted message `toolCallId`/`isError`,
* direct content (no wrapper block), and no envelope `error` object — the
* mark rides the message alone.
*/
v4?: boolean
}): TimelineEvent {
const message: Record<string, unknown> = {
content: [{ type: 'tool-result', toolCallId: opts.callId, content: opts.content }],
}
const message: Record<string, unknown> = opts.v4 === true
? { role: 'tool', toolCallId: opts.callId, content: opts.content }
: { content: [{ type: 'tool-result', toolCallId: opts.callId, content: opts.content }] }
if (opts.noSource !== true) message.source = { kind: 'tool', callId: opts.callId }
if (opts.v4 === true && opts.error === true) message.isError = true
const data: Record<string, unknown> = { message }
if (opts.noEnvelopeId !== true) data.callId = opts.callId
if (opts.error === true) data.error = true
if (opts.v4 !== true && opts.error === true) data.error = true
if (opts.meta !== undefined) data.meta = opts.meta
return { type: 'tool/result', seq, time: at(opts.time), data, surfaceOp: 'append' }
}