From 7b09055193ef9cd81e2d09f97022fc545a1aa5cf Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Tue, 22 Sep 2026 16:27:55 +0800 Subject: [PATCH] fix(host): read the V4 tool-result error mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/host/fold.ts | 11 +++++++++-- tests/host/fold-ops.spec.ts | 7 +++++-- tests/host/fold-surface.spec.ts | 10 ++++++++++ tests/host/helpers/events.ts | 15 +++++++++++---- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/host/fold.ts b/src/host/fold.ts index 2d37707..5b74f80 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -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) } diff --git a/tests/host/fold-ops.spec.ts b/tests/host/fold-ops.spec.ts index 2f29941..6b3990d 100644 --- a/tests/host/fold-ops.spec.ts +++ b/tests/host/fold-ops.spec.ts @@ -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)', () => { diff --git a/tests/host/fold-surface.spec.ts b/tests/host/fold-surface.spec.ts index cafb1e4..bae4b27 100644 --- a/tests/host/fold-surface.spec.ts +++ b/tests/host/fold-surface.spec.ts @@ -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', () => { diff --git a/tests/host/helpers/events.ts b/tests/host/helpers/events.ts index 2753d98..8130571 100644 --- a/tests/host/helpers/events.ts +++ b/tests/host/helpers/events.ts @@ -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 = { - content: [{ type: 'tool-result', toolCallId: opts.callId, content: opts.content }], - } + const message: Record = 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 = { 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' } }