summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-09 10:03:26 -0400
committerGitHub <[email protected]>2026-04-09 10:03:26 -0400
commitc29392d0857f11208753bd95be76c6069c070289 (patch)
tree08d086d3405a3923f578dac4550cf7ffeef48e43
parent46f243fea71c65464471fcf1f5a807dd860c0f8f (diff)
downloadopencode-c29392d0857f11208753bd95be76c6069c070289.tar.gz
opencode-c29392d0857f11208753bd95be76c6069c070289.zip
fix: preserve interrupted bash output in tool results (#21598)
-rw-r--r--packages/opencode/src/session/llm.ts8
-rw-r--r--packages/opencode/src/session/message-v2.ts36
-rw-r--r--packages/opencode/src/session/processor.ts11
-rw-r--r--packages/opencode/src/session/prompt.ts2
-rw-r--r--packages/opencode/test/session/message-v2.test.ts75
-rw-r--r--packages/opencode/test/session/processor-effect.test.ts1
6 files changed, 116 insertions, 17 deletions
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index d55424f91..22fc9b1d5 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -234,7 +234,11 @@ export namespace LLM {
// from the workflow service are executed via opencode's tool system
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
- const workflowModel = language
+ const workflowModel = language as GitLabWorkflowLanguageModel & {
+ sessionID?: string
+ sessionPreapprovedTools?: string[]
+ approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
+ }
workflowModel.sessionID = input.sessionID
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
@@ -301,7 +305,7 @@ export namespace LLM {
ruleset: [],
})
for (const name of uniqueNames) approvedToolsForSession.add(name)
- workflowModel.sessionPreapprovedTools = [...workflowModel.sessionPreapprovedTools, ...uniqueNames]
+ workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
return { approved: true }
} catch {
return { approved: false }
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 78604fbf7..61c159646 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -751,16 +751,32 @@ export namespace MessageV2 {
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
}
- if (part.state.status === "error")
- assistantMessage.parts.push({
- type: ("tool-" + part.tool) as `tool-${string}`,
- state: "output-error",
- toolCallId: part.callID,
- input: part.state.input,
- errorText: part.state.error,
- ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
- ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
- })
+ if (part.state.status === "error") {
+ const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined
+ if (typeof output === "string") {
+ assistantMessage.parts.push({
+ type: ("tool-" + part.tool) as `tool-${string}`,
+ state: "output-available",
+ toolCallId: part.callID,
+ input: part.state.input,
+ output,
+ ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
+ ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
+ })
+ } else {
+ assistantMessage.parts.push({
+ type: ("tool-" + part.tool) as `tool-${string}`,
+ state: "output-error",
+ toolCallId: part.callID,
+ input: part.state.input,
+ errorText: part.state.error,
+ ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
+ ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
+ })
+ }
+ }
+ // Handle pending/running tool calls to prevent dangling tool_use blocks
+ // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
if (part.state.status === "pending" || part.state.status === "running")
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 8e4225fed..d66e77490 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -18,6 +18,7 @@ import { SessionStatus } from "./status"
import { SessionSummary } from "./summary"
import type { Provider } from "@/provider/provider"
import { Question } from "@/question"
+import { isRecord } from "@/util/record"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -398,19 +399,21 @@ export namespace SessionProcessor {
}
ctx.reasoningMap = {}
- const parts = MessageV2.parts(ctx.assistantMessage.id)
- for (const part of parts) {
- if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue
+ for (const part of Object.values(ctx.toolcalls)) {
+ const end = Date.now()
+ const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
yield* session.updatePart({
...part,
state: {
...part.state,
status: "error",
error: "Tool execution aborted",
- time: { start: Date.now(), end: Date.now() },
+ metadata: { ...metadata, interrupted: true },
+ time: { start: "time" in part.state ? part.state.time.start : end, end },
},
})
}
+ ctx.toolcalls = {}
ctx.assistantMessage.time.completed = Date.now()
yield* session.updateMessage(ctx.assistantMessage)
})
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index e9bd5bcd5..dc75efcdc 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -1507,7 +1507,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Effect.promise(() => SystemPrompt.skills(agent)),
Effect.promise(() => SystemPrompt.environment(model)),
instruction.system().pipe(Effect.orDie),
- Effect.promise(() => MessageV2.toModelMessages(msgs, model)),
+ MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [...env, ...(skills ? [skills] : []), ...instructions]
const format = lastUser.format ?? { type: "text" as const }
diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts
index 3634d6fb7..64a5d3e4b 100644
--- a/packages/opencode/test/session/message-v2.test.ts
+++ b/packages/opencode/test/session/message-v2.test.ts
@@ -570,6 +570,81 @@ describe("session.message-v2.toModelMessage", () => {
])
})
+ test("forwards partial bash output for aborted tool calls", async () => {
+ const userID = "m-user"
+ const assistantID = "m-assistant"
+ const output = [
+ "31403",
+ "12179",
+ "4575",
+ "",
+ "<bash_metadata>",
+ "User aborted the command",
+ "</bash_metadata>",
+ ].join("\n")
+
+ const input: MessageV2.WithParts[] = [
+ {
+ info: userInfo(userID),
+ parts: [
+ {
+ ...basePart(userID, "u1"),
+ type: "text",
+ text: "run tool",
+ },
+ ] as MessageV2.Part[],
+ },
+ {
+ info: assistantInfo(assistantID, userID),
+ parts: [
+ {
+ ...basePart(assistantID, "a1"),
+ type: "tool",
+ callID: "call-1",
+ tool: "bash",
+ state: {
+ status: "error",
+ input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
+ error: "Tool execution aborted",
+ metadata: { interrupted: true, output },
+ time: { start: 0, end: 1 },
+ },
+ },
+ ] as MessageV2.Part[],
+ },
+ ]
+
+ expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
+ {
+ role: "user",
+ content: [{ type: "text", text: "run tool" }],
+ },
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "call-1",
+ toolName: "bash",
+ input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
+ providerExecuted: undefined,
+ },
+ ],
+ },
+ {
+ role: "tool",
+ content: [
+ {
+ type: "tool-result",
+ toolCallId: "call-1",
+ toolName: "bash",
+ output: { type: "text", value: output },
+ },
+ ],
+ },
+ ])
+ })
+
test("filters assistant messages with non-abort errors", async () => {
const assistantID = "m-assistant"
diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts
index 86149272b..f3a65b3ba 100644
--- a/packages/opencode/test/session/processor-effect.test.ts
+++ b/packages/opencode/test/session/processor-effect.test.ts
@@ -604,6 +604,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
expect(call?.state.status).toBe("error")
if (call?.state.status === "error") {
expect(call.state.error).toBe("Tool execution aborted")
+ expect(call.state.metadata?.interrupted).toBe(true)
expect(call.state.time.end).toBeDefined()
}
}),