fix: preserve recovered opencode results

This commit is contained in:
lofyer
2026-08-11 20:58:10 +08:00
parent d8f1badad6
commit 6942bef567
2 changed files with 110 additions and 7 deletions
+66
View File
@@ -1928,6 +1928,72 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await runtime.dispose()
})
it('keeps a completed response when an earlier tool attempt failed', async () => {
const { client, session } = runClient([
{
id: 'event-tool-error',
type: 'message.part.updated',
properties: {
sessionID: 'session-1',
part: {
id: 'part-1',
callID: 'call-1',
type: 'tool',
tool: 'read',
state: {
status: 'error',
error: 'Cannot read binary file'
}
}
}
},
completedToolEvent('call-2', 'write'),
{
id: 'event-text',
type: 'message.part.delta',
properties: {
sessionID: 'session-1',
messageID: 'message-1',
partID: 'part-text',
field: 'text',
delta: 'PPT 已生成并保存。'
}
},
{
id: 'event-idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(client)
const events = await collectRun(runtime, 'execute')
expect(
events.filter(
(event) =>
event.type === 'tool' && event.callId === 'call-1'
)
).toEqual([
expect.objectContaining({
state: 'failed',
error: 'Cannot read binary file'
}),
expect.objectContaining({
state: 'recoverable',
error: 'Cannot read binary file'
})
])
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: 'PPT 已生成并保存。'
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(session.abort).not.toHaveBeenCalled()
await runtime.dispose()
})
it('surfaces a rejected async prompt instead of reporting success', async () => {
const { client, session } = runClient([
{
+44 -7
View File
@@ -1190,6 +1190,7 @@ export class OpenCodeRuntime implements AgentRuntime {
>()
const reasoningPartIds = new Set<string>()
const reportedQuestionIds = new Set<string>()
let hasResponseTextAfterFailure = false
try {
const promptText =
session.created && request.history?.length
@@ -1262,6 +1263,15 @@ export class OpenCodeRuntime implements AgentRuntime {
'thinking'
].includes(event.properties.field)
if (reasoning || event.properties.field === 'text') {
if (
!reasoning &&
/\S/u.test(event.properties.delta) &&
[...toolStates.values()].some(
(tool) => tool.state === 'failed'
)
) {
hasResponseTextAfterFailure = true
}
yield {
requestId: request.requestId,
type: reasoning ? 'reasoning' : 'text',
@@ -1291,6 +1301,9 @@ export class OpenCodeRuntime implements AgentRuntime {
}
const state =
part.state.status === 'error' ? 'failed' : part.state.status
if (state === 'failed') {
hasResponseTextAfterFailure = false
}
const error =
part.state.status === 'error'
? safeToolErrorDetail(part.state.error)
@@ -1498,17 +1511,41 @@ export class OpenCodeRuntime implements AgentRuntime {
)
)
}
const unsuccessfulTool = [...toolStates.entries()].find(
([, tool]) => tool.state !== 'completed'
const incompleteTool = [...toolStates.entries()].find(
([, tool]) =>
tool.state === 'pending' || tool.state === 'running'
)
if (unsuccessfulTool) {
const [callId, tool] = unsuccessfulTool
if (incompleteTool) {
const [callId] = incompleteTool
throw new Error(
tool.state === 'failed'
? `OpenCode 工具执行失败(${callId.slice(0, 128)}${tool.error ? `${tool.error}` : ''}`
: `OpenCode 工具未完成(${callId.slice(0, 128)}`
`OpenCode 工具未完成(${callId.slice(0, 128)}`
)
}
const failedTools = [...toolStates.entries()].filter(
([, tool]) => tool.state === 'failed'
)
if (
failedTools.length > 0 &&
!hasResponseTextAfterFailure
) {
const [callId, tool] = failedTools[0]!
throw new Error(
`OpenCode 工具执行失败(${callId.slice(0, 128)}${tool.error ? `${tool.error}` : ''}`
)
}
for (const [callId, tool] of failedTools) {
yield {
requestId: request.requestId,
type: 'tool',
callId,
name: tool.name,
state: 'recoverable',
summary: `OpenCode 已在后续响应中处理工具失败:${tool.name}`,
...(tool.input ? { input: tool.input } : {}),
...(tool.output ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {})
}
}
yield {
requestId: request.requestId,
type: 'done',