Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ const layer = Layer.effect(
status: "error",
input: match.part.state.input,
error: errorMessage(error),
// Keep metadata streamed while running so failures retain progress detail (e.g. execute's child calls).
metadata: match.part.state.metadata,
time: { start: match.part.state.time.start, end: Date.now() },
},
})
Expand Down
48 changes: 25 additions & 23 deletions packages/opencode/src/tool/code-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,35 +287,37 @@ export const CodeModeTool = Tool.define(

const result = yield* Effect.raceFirst(runtime.execute(params.code), abort.pipe(Effect.map(cancelled)))
const logs = result.logs ?? []
const attached = attachments.length > 0 ? { attachments } : {}
const hints = result.ok
? []
: (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
const metadata: Metadata = result.ok ? { toolCalls: calls } : { toolCalls: calls, error: true }
let output: string
if (result.ok) {
if (typeof result.value === "string") output = result.value
else if (result.value === undefined) output = "undefined"
else {
try {
output = JSON.stringify(result.value, null, 2) ?? String(result.value)
} catch {
output = String(result.value)
}
const withLogs = (text: string) => {
if (logs.length === 0) return text
return text.length > 0 ? `${text}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}`
}

if (!result.ok) {
if (ctx.abort.aborted) {
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: calls, error: true },
output: "Execution cancelled.",
} satisfies Tool.ExecuteResult<Metadata>
}
} else {
output = [result.error.message, ...hints].join("\n")
const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
return yield* Effect.fail(new Error(withLogs([result.error.message, ...hints].join("\n"))))
}
if (logs.length > 0)
output = output.length > 0 ? `${output}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}`

// The interpreter validates returned values as plain JSON, so stringify cannot throw;
// it yields undefined only for a program that returns undefined.
const output =
typeof result.value === "string"
? result.value
: (JSON.stringify(result.value, null, 2) ?? String(result.value))

return {
title: CODE_MODE_TOOL,
metadata,
output,
...attached,
metadata: { toolCalls: calls },
output: withLogs(output),
...(attachments.length > 0 ? { attachments } : {}),
} satisfies Tool.ExecuteResult<Metadata>
}),
}, Effect.orDie),
}
return init
}),
Expand Down
20 changes: 12 additions & 8 deletions packages/opencode/test/tool/code-mode-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
ListToolsRequestSchema,
type Tool as MCPToolDef,
} from "@modelcontextprotocol/sdk/types.js"
import { Effect, Layer } from "effect"
import { Cause, Effect, Exit, Layer } from "effect"

const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="

Expand Down Expand Up @@ -160,6 +160,12 @@ async function buildTool() {
}

const run = (code: string) => Effect.runPromise(tool.execute({ code }, ctx))
// Program failures die at the tool boundary; recover the defect for message assertions.
const runFailed = async (code: string) => {
const exit = await Effect.runPromise(tool.execute({ code }, ctx).pipe(Effect.exit))
if (Exit.isSuccess(exit)) throw new Error("expected the tool to fail")
return Cause.squash(exit.cause) as Error
}

beforeAll(async () => {
const built = await buildTool()
Expand Down Expand Up @@ -248,9 +254,8 @@ describe("code mode integration (real MCP server)", () => {
})

test("an uncaught MCP error surfaces as a failed execution", async () => {
const out = await run("await tools.fixtures.boom({}); return 'unreachable'")
expect(out.metadata.error).toBe(true)
expect(out.output).toContain("kaboom")
const error = await runFailed("await tools.fixtures.boom({}); return 'unreachable'")
expect(error.message).toContain("kaboom")
})

test("console output is captured and appended as a Logs section after the result", async () => {
Expand All @@ -265,14 +270,13 @@ describe("code mode integration (real MCP server)", () => {
})

test("console output is preserved on the error path", async () => {
const out = await run(`
const error = await runFailed(`
console.log("before the throw")
await tools.fixtures.boom({})
return "unreachable"
`)
expect(out.metadata.error).toBe(true)
expect(out.output).toContain("kaboom")
expect(out.output).toContain("Logs:\nbefore the throw")
expect(error.message).toContain("kaboom")
expect(error.message).toContain("Logs:\nbefore the throw")
})

test("a program that logs nothing gets no Logs section", async () => {
Expand Down
43 changes: 21 additions & 22 deletions packages/opencode/test/tool/code-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Session } from "@/session/session"
import { Tool } from "@/tool/tool"
import * as Truncate from "@/tool/truncate"
import { MessageID, SessionID } from "@/session/schema"
import { Effect, Layer, Schema } from "effect"
import { Cause, Effect, Exit, Layer, Schema } from "effect"

const ctx: Tool.Context = {
sessionID: SessionID.make("ses_code-mode"),
Expand Down Expand Up @@ -86,6 +86,13 @@ function describeFor(mcpTools: Record<string, MCP.McpTool>, servers?: string[],
return describeCatalog(Permission.visibleTools(mcpTools, permission), serverNames(mcpTools, servers))
}

// Program failures die at the tool boundary; recover the defect for message assertions.
async function failure(effect: Effect.Effect<unknown>) {
const exit = await Effect.runPromise(effect.pipe(Effect.exit))
if (Exit.isSuccess(exit)) throw new Error("expected the tool to fail")
return Cause.squash(exit.cause) as Error
}

describe("code mode execute", () => {
test("defines execute input with an Effect schema", async () => {
const decode = Schema.decodeUnknownEffect(Parameters)
Expand Down Expand Up @@ -313,18 +320,16 @@ describe("code mode execute", () => {
expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true)
})

test("returns a readable error when the program throws", async () => {
test("a program failure fails the tool with a readable error", async () => {
const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "throw new Error('boom')" }, ctx))
expect(output.output).toBe("Uncaught: boom")
expect(output.metadata.error).toBe(true)
const error = await failure(tool.execute({ code: "throw new Error('boom')" }, ctx))
expect(error.message).toBe("Uncaught: boom")
})

test("reports an unknown tool as a failed execution", async () => {
const tool = await build({ known_tool: mcpTool("tool", () => "ok") })
const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
expect(output.metadata.error).toBe(true)
expect(output.output).toContain("Unknown tool 'known.missing'")
const error = await failure(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
expect(error.message).toContain("Unknown tool 'known.missing'")
})

test("propagates an MCP tool error into the program as a catchable failure", async () => {
Expand Down Expand Up @@ -576,8 +581,8 @@ describe("code mode execute", () => {

test("isolates the sandbox from host globals", async () => {
const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "return process.env" }, ctx))
expect(output.metadata.error).toBe(true)
const error = await failure(tool.execute({ code: "return process.env" }, ctx))
expect(error.message).toContain("process")
})

test("cancelling via ctx.abort interrupts the running program", async () => {
Expand Down Expand Up @@ -627,12 +632,9 @@ describe("code mode execute", () => {
)
expect(ok.output).toBe("done\n\nLogs:\nstep one\n[warn] careful")

const err = await Effect.runPromise(
tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx),
)
expect(err.metadata.error).toBe(true)
expect(err.output).toContain("Uncaught: boom")
expect(err.output).toContain("Logs:\nbefore the throw")
const error = await failure(tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx))
expect(error.message).toContain("Uncaught: boom")
expect(error.message).toContain("Logs:\nbefore the throw")
})
})

Expand Down Expand Up @@ -677,12 +679,9 @@ describe("code mode permission visibility", () => {
[deny("github_create_issue")],
)

const denied = await Effect.runPromise(
tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx),
)
expect(denied.metadata.error).toBe(true)
expect(denied.output).toContain("Unknown tool 'github.create_issue'")
expect(denied.output).not.toContain("permission")
const denied = await failure(tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx))
expect(denied.message).toContain("Unknown tool 'github.create_issue'")
expect(denied.message).not.toContain("permission")
expect(called).toEqual([])

const allowed = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, ctx))
Expand Down
Loading