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:
1 parent
274b50f0c8
commit
7b09055193
4 files changed
+35
-8
No files matched your search
+9
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user