Skip to content

Commit 39d7df6

Browse files
Apply PR #21598: fix: preserve interrupted bash output in tool results
2 parents c50a97a + 49275c6 commit 39d7df6

File tree

5 files changed

+110
-15
lines changed

5 files changed

+110
-15
lines changed

packages/opencode/src/session/message-v2.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -751,16 +751,32 @@ export namespace MessageV2 {
751751
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
752752
})
753753
}
754-
if (part.state.status === "error")
755-
assistantMessage.parts.push({
756-
type: ("tool-" + part.tool) as `tool-${string}`,
757-
state: "output-error",
758-
toolCallId: part.callID,
759-
input: part.state.input,
760-
errorText: part.state.error,
761-
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
762-
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
763-
})
754+
if (part.state.status === "error") {
755+
const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined
756+
if (typeof output === "string") {
757+
assistantMessage.parts.push({
758+
type: ("tool-" + part.tool) as `tool-${string}`,
759+
state: "output-available",
760+
toolCallId: part.callID,
761+
input: part.state.input,
762+
output,
763+
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
764+
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
765+
})
766+
} else {
767+
assistantMessage.parts.push({
768+
type: ("tool-" + part.tool) as `tool-${string}`,
769+
state: "output-error",
770+
toolCallId: part.callID,
771+
input: part.state.input,
772+
errorText: part.state.error,
773+
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
774+
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
775+
})
776+
}
777+
}
778+
// Handle pending/running tool calls to prevent dangling tool_use blocks
779+
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
764780
if (part.state.status === "pending" || part.state.status === "running")
765781
assistantMessage.parts.push({
766782
type: ("tool-" + part.tool) as `tool-${string}`,

packages/opencode/src/session/processor.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { SessionStatus } from "./status"
1818
import { SessionSummary } from "./summary"
1919
import type { Provider } from "@/provider/provider"
2020
import { Question } from "@/question"
21+
import { isRecord } from "@/util/record"
2122

2223
export namespace SessionProcessor {
2324
const DOOM_LOOP_THRESHOLD = 3
@@ -398,19 +399,21 @@ export namespace SessionProcessor {
398399
}
399400
ctx.reasoningMap = {}
400401

401-
const parts = MessageV2.parts(ctx.assistantMessage.id)
402-
for (const part of parts) {
403-
if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue
402+
for (const part of Object.values(ctx.toolcalls)) {
403+
const end = Date.now()
404+
const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
404405
yield* session.updatePart({
405406
...part,
406407
state: {
407408
...part.state,
408409
status: "error",
409410
error: "Tool execution aborted",
410-
time: { start: Date.now(), end: Date.now() },
411+
metadata: { ...metadata, interrupted: true },
412+
time: { start: "time" in part.state ? part.state.time.start : end, end },
411413
},
412414
})
413415
}
416+
ctx.toolcalls = {}
414417
ctx.assistantMessage.time.completed = Date.now()
415418
yield* session.updateMessage(ctx.assistantMessage)
416419
})

packages/opencode/src/session/prompt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1505,7 +1505,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
15051505
Effect.promise(() => SystemPrompt.skills(agent)),
15061506
Effect.promise(() => SystemPrompt.environment(model)),
15071507
instruction.system().pipe(Effect.orDie),
1508-
Effect.promise(() => MessageV2.toModelMessages(msgs, model)),
1508+
MessageV2.toModelMessagesEffect(msgs, model),
15091509
])
15101510
const system = [...env, ...(skills ? [skills] : []), ...instructions]
15111511
const format = lastUser.format ?? { type: "text" as const }

packages/opencode/test/session/message-v2.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,81 @@ describe("session.message-v2.toModelMessage", () => {
570570
])
571571
})
572572

573+
test("forwards partial bash output for aborted tool calls", async () => {
574+
const userID = "m-user"
575+
const assistantID = "m-assistant"
576+
const output = [
577+
"31403",
578+
"12179",
579+
"4575",
580+
"",
581+
"<bash_metadata>",
582+
"User aborted the command",
583+
"</bash_metadata>",
584+
].join("\n")
585+
586+
const input: MessageV2.WithParts[] = [
587+
{
588+
info: userInfo(userID),
589+
parts: [
590+
{
591+
...basePart(userID, "u1"),
592+
type: "text",
593+
text: "run tool",
594+
},
595+
] as MessageV2.Part[],
596+
},
597+
{
598+
info: assistantInfo(assistantID, userID),
599+
parts: [
600+
{
601+
...basePart(assistantID, "a1"),
602+
type: "tool",
603+
callID: "call-1",
604+
tool: "bash",
605+
state: {
606+
status: "error",
607+
input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
608+
error: "Tool execution aborted",
609+
metadata: { interrupted: true, output },
610+
time: { start: 0, end: 1 },
611+
},
612+
},
613+
] as MessageV2.Part[],
614+
},
615+
]
616+
617+
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
618+
{
619+
role: "user",
620+
content: [{ type: "text", text: "run tool" }],
621+
},
622+
{
623+
role: "assistant",
624+
content: [
625+
{
626+
type: "tool-call",
627+
toolCallId: "call-1",
628+
toolName: "bash",
629+
input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
630+
providerExecuted: undefined,
631+
},
632+
],
633+
},
634+
{
635+
role: "tool",
636+
content: [
637+
{
638+
type: "tool-result",
639+
toolCallId: "call-1",
640+
toolName: "bash",
641+
output: { type: "text", value: output },
642+
},
643+
],
644+
},
645+
])
646+
})
647+
573648
test("filters assistant messages with non-abort errors", async () => {
574649
const assistantID = "m-assistant"
575650

packages/opencode/test/session/processor-effect.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
604604
expect(call?.state.status).toBe("error")
605605
if (call?.state.status === "error") {
606606
expect(call.state.error).toBe("Tool execution aborted")
607+
expect(call.state.metadata?.interrupted).toBe(true)
607608
expect(call.state.time.end).toBeDefined()
608609
}
609610
}),

0 commit comments

Comments
 (0)