diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 719d815acc..41bacdb9c1 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -24,8 +24,12 @@ export const Parameters = Schema.Struct({ }), }) +/** One child tool call, surfaced live so the UI can render a per-call line that + * updates as the program runs. `tool` is the dotted path (e.g. `github.create_issue`). */ +export type CallEntry = { tool: string; status: "running" | "completed" | "error" } + type Metadata = { - toolCalls: string[] + toolCalls: CallEntry[] error?: boolean } @@ -477,24 +481,42 @@ export function define( description: describe(groups), parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { - const calls: string[] = [] + const calls: CallEntry[] = [] + // Stream the current call list to the UI. Sent on every status change so the + // tool part shows each child call appearing and resolving while the program runs. + const publish = () => + ctx.metadata({ title: "Code mode", metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) + const mark = (index: number, status: CallEntry["status"]) => + Effect.suspend(() => { + calls[index] = { ...calls[index]!, status } + return publish() + }) // One host function per MCP tool: gate on permission, dispatch to the native // MCP tool, and coerce the result into the { result, attachments? } envelope. // A failure (e.g. an MCP isError) fails the Effect, which the interpreter // surfaces as a catchable in-program error. - const callTool = (key: string, tool: AITool) => (input: unknown) => + const callTool = (entry: CatalogEntry) => (input: unknown) => Effect.gen(function* () { - yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - calls.push(key) - const result = yield* Effect.tryPromise({ + yield* ctx.ask({ permission: entry.key, metadata: {}, patterns: ["*"], always: ["*"] }) + const index = calls.length + calls.push({ tool: entry.path, status: "running" }) + yield* publish() + return yield* Effect.tryPromise({ try: () => Promise.resolve( - tool.execute!(input ?? {}, { toolCallId: ctx.callID ?? key, abortSignal: ctx.abort, messages: [] }), + entry.tool.execute!(input ?? {}, { + toolCallId: ctx.callID ?? entry.key, + abortSignal: ctx.abort, + messages: [], + }), ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }) - return toEnvelope(result) + }).pipe( + Effect.tap(() => mark(index, "completed")), + Effect.tapError(() => mark(index, "error")), + Effect.map(toEnvelope), + ) }) // The Rune host-tool tree: per-server namespaces (`tools..`) @@ -514,7 +536,7 @@ export function define( namespace = {} tools[entry.server] = namespace } - namespace[entry.local] = callTool(entry.key, entry.tool) + namespace[entry.local] = callTool(entry) } const result = yield* Rune.execute({ diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/session/code-mode-integration.test.ts index 01823cb960..58f0dd976d 100644 --- a/packages/opencode/test/session/code-mode-integration.test.ts +++ b/packages/opencode/test/session/code-mode-integration.test.ts @@ -138,7 +138,7 @@ describe("code mode integration (real MCP server)", () => { test("calls a text tool and unwraps the result envelope", async () => { const out = await run("const r = await tools.fixtures.get_text({ name: 'world' }); return r.result") expect(out.output).toBe("hello world") - expect(out.metadata.toolCalls).toEqual(["fixtures_get_text"]) + expect(out.metadata.toolCalls).toEqual([{ tool: "fixtures.get_text", status: "completed" }]) expect(out.attachments).toBeUndefined() }) @@ -154,7 +154,10 @@ describe("code mode integration (real MCP server)", () => { return { total: second.result.sum } `) expect(JSON.parse(out.output)).toEqual({ total: 13 }) - expect(out.metadata.toolCalls).toEqual(["fixtures_add", "fixtures_add"]) + expect(out.metadata.toolCalls).toEqual([ + { tool: "fixtures.add", status: "completed" }, + { tool: "fixtures.add", status: "completed" }, + ]) }) test("forwards an image as an attachment when the whole result is returned", async () => { @@ -191,7 +194,7 @@ describe("code mode integration (real MCP server)", () => { `) expect(out.output).toBe("two shots") expect(out.attachments).toHaveLength(2) - expect(out.metadata.toolCalls.sort()).toEqual(["fixtures_screenshot", "fixtures_screenshot"]) + expect(out.metadata.toolCalls.map((c) => c.tool)).toEqual(["fixtures.screenshot", "fixtures.screenshot"]) }) test("propagates an MCP isError into the program as a catchable error", async () => { diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index ac6c212f4b..93d04300cc 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -174,7 +174,7 @@ describe("code mode execute", () => { expect(seen).toEqual([{ name: "world" }]) expect(output.output).toBe("HELLO WORLD") - expect(output.metadata.toolCalls).toEqual(["greeter_hello"]) + expect(output.metadata.toolCalls).toEqual([{ tool: "greeter.hello", status: "completed" }]) }) test("exposes structured content as data and composes multiple calls", async () => { @@ -199,7 +199,10 @@ describe("code mode execute", () => { ) expect(JSON.parse(output.output)).toEqual({ total: 13 }) - expect(output.metadata.toolCalls).toEqual(["math_add", "math_add"]) + expect(output.metadata.toolCalls).toEqual([ + { tool: "math.add", status: "completed" }, + { tool: "math.add", status: "completed" }, + ]) }) test("runs tool calls in parallel with Promise.all", async () => { @@ -216,7 +219,8 @@ describe("code mode execute", () => { ) expect(output.output).toBe("12") - expect(output.metadata.toolCalls.sort()).toEqual(["echo_one", "echo_two"]) + expect(output.metadata.toolCalls.map((c) => c.tool).sort()).toEqual(["echo.one", "echo.two"]) + expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true) }) test("returns a readable error when the program throws", async () => { @@ -260,6 +264,38 @@ describe("code mode execute", () => { expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"]) }) + test("streams live per-call metadata as a call starts and finishes", async () => { + const snapshots: Array<{ toolCalls: { tool: string; status: string }[] }> = [] + const recordingCtx: Tool.Context = { + ...ctx, + metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), + } + const tool = await build({ greeter_hello: mcpTool("hello", () => ({ content: [{ type: "text", text: "hi" }] })) }) + + await Effect.runPromise(tool.execute({ code: "await tools.greeter.hello({}); return 'done'" }, recordingCtx)) + + // The UI sees the call appear as running, then resolve to completed. + expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "running" }] }) + expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "completed" }] }) + }) + + test("marks a failed child call as error in the live metadata", async () => { + const snapshots: Array<{ toolCalls: { tool: string; status: string }[] }> = [] + const recordingCtx: Tool.Context = { + ...ctx, + metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), + } + const tool = await build({ + bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "boom" }] })), + }) + + await Effect.runPromise( + tool.execute({ code: "try { await tools.bad.tool({}) } catch (e) { return 'caught' }" }, recordingCtx), + ) + + expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error" }] }) + }) + test("unit: toEnvelope wraps result and extracts media as attachments", () => { expect(toEnvelope({ structuredContent: { x: 1 }, content: [] })).toEqual({ result: { x: 1 } }) expect(toEnvelope({ content: [{ type: "text", text: "hi" }] })).toEqual({ result: "hi" }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f832174aa4..b56a4d54a7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1758,6 +1758,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess + + + @@ -2322,6 +2325,75 @@ export function formatCompletedSubagentDetail(toolcalls: number, duration: strin return `${formatSubagentToolcalls(toolcalls)} · ${duration}` } +type CodeCall = { tool: string; status: "running" | "completed" | "error" } + +function codeCalls(value: unknown): CodeCall[] { + if (!Array.isArray(value)) return [] + return value.filter( + (call): call is CodeCall => + !!call && + typeof call === "object" && + typeof (call as CodeCall).tool === "string" && + ["running", "completed", "error"].includes((call as CodeCall).status), + ) +} + +// The code-mode `execute` tool: a header with the run status, a live `↳` line per +// child tool call (sourced from streamed metadata, not a child session like Task), +// and the program source on demand. +function Execute(props: ToolProps) { + const { theme, syntax } = useTheme() + const isRunning = createMemo(() => props.part.state.status === "running") + const calls = createMemo(() => codeCalls(props.metadata.toolCalls)) + const code = createMemo(() => stringValue(props.input.code) ?? "") + const [expanded, setExpanded] = createSignal(false) + + const summary = createMemo(() => { + const count = calls().length + if (count === 0) return "Execute" + return `Execute · ${count} tool call${count === 1 ? "" : "s"}` + }) + + return ( + <> + setExpanded((value) => !value) : undefined} + > + {summary()} + + + {(call) => ( + + + ↳ {call.tool} + {call.status === "running" ? " …" : call.status === "error" ? " (failed)" : ""} + + + )} + + + + + ↳ {expanded() ? "Hide code" : "View code"} + + + + + + + + + + + + ) +} + function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() @@ -2578,6 +2650,7 @@ const toolDisplays = new Set([ "todowrite", "question", "skill", + "execute", ]) export function toolDisplay(tool: string) {