diff --git a/packages/cli/test/run/noninteractive.test.ts b/packages/cli/test/run/noninteractive.test.ts index 904ced74743..f0a680061f2 100644 --- a/packages/cli/test/run/noninteractive.test.ts +++ b/packages/cli/test/run/noninteractive.test.ts @@ -157,6 +157,53 @@ function failedTool(inputID: string): V2Event[] { ] } +function successfulGrep(inputID: string): V2Event[] { + const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle" + return [ + prompted(inputID), + { + id: "evt_grep_input", + created: 1, + type: "session.tool.input.started", + durable: { aggregateID: "ses_1", seq: 1, version: 1 }, + data: { + sessionID: "ses_1", + assistantMessageID: "msg_grep", + callID: "call_grep", + name: "grep", + }, + }, + { + id: "evt_grep_called", + created: 2, + type: "session.tool.called", + durable: { aggregateID: "ses_1", seq: 2, version: 1 }, + data: { + sessionID: "ses_1", + assistantMessageID: "msg_grep", + callID: "call_grep", + input: { pattern: "needle" }, + executed: true, + }, + }, + { + id: "evt_grep_success", + created: 3, + type: "session.tool.success", + durable: { aggregateID: "ses_1", seq: 3, version: 1 }, + data: { + sessionID: "ses_1", + assistantMessageID: "msg_grep", + callID: "call_grep", + structured: { matches: 2 }, + content: [{ type: "text", text }], + executed: false, + }, + }, + settled(), + ] +} + // Runs one non-interactive prompt against a mocked SDK. `turn` produces the // live events the prompt admission triggers, keyed by the generated message ID. async function run(input: { @@ -269,6 +316,32 @@ afterEach(() => { }) describe("runNonInteractivePrompt", () => { + test("keeps formatted tool output and compact structured metadata in JSON", async () => { + const output = await capture({ format: "json", turn: successfulGrep }) + const events = output.stdout + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + type: "tool_use", + part: { + tool: "grep", + state: { + status: "completed", + output: expect.stringContaining("Found 2 matches"), + metadata: { + structured: { matches: 2 }, + content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }], + }, + }, + }, + }) + expect(events[0].part.state.metadata.structured).toEqual({ matches: 2 }) + expect(events[0].part.state.metadata.result).toBeUndefined() + }) + test("uses session.wait then reconciles projected output without a terminal event", async () => { const idle = Promise.withResolvers() let done = false diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index e231ec7c0ba..27a7d26a010 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -8,7 +8,7 @@ import { FileSystem } from "../filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "../location" import { Ripgrep } from "../ripgrep" -import { RelativePath } from "../schema" +import { NonNegativeInt, RelativePath } from "../schema" import { PermissionV2 } from "../permission" import { Tool } from "./tool" @@ -25,6 +25,9 @@ export const Input = Schema.Struct({ }) export const Output = Schema.Array(FileSystem.Entry) +const StructuredOutput = Schema.Struct({ + count: NonNegativeInt, +}) type ModelOutput = typeof Output.Encoded /** Format raw search results into the concise line-oriented output models expect. */ @@ -51,6 +54,8 @@ export const Plugin = { "Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ count: output.length }), toModelOutput: ({ output }) => [ { type: "text", diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index a972c958659..56cb004ddf8 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "../location" import { PermissionV2 } from "../permission" import { Ripgrep } from "../ripgrep" -import { RelativePath } from "../schema" +import { NonNegativeInt, RelativePath } from "../schema" import { Tool } from "./tool" export const name = "grep" @@ -30,6 +30,9 @@ export const Input = Schema.Struct({ }) export const Output = Schema.Array(FileSystem.Match) +const StructuredOutput = Schema.Struct({ + matches: NonNegativeInt, +}) type ModelOutput = typeof Output.Encoded /** Format raw search matches into the familiar concise model output. */ @@ -65,6 +68,8 @@ export const Plugin = { "Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ matches: output.length }), toModelOutput: ({ output }) => [ { type: "text", diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 21fdef69c0b..dcf0e8bf70e 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -21,6 +21,10 @@ export const Output = Schema.Struct({ directory: Schema.String, output: Schema.String, }) +const StructuredOutput = Schema.Struct({ + name: Output.fields.name, + directory: Output.fields.directory, +}) export const description = [ "Load a specialized skill when the task at hand matches one of the available skills in the instructions.", @@ -66,6 +70,8 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ name: output.name, directory: output.directory }), toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 347a3998852..1f2f2f6c8bd 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -31,6 +31,10 @@ export const Output = Schema.Struct({ status: Schema.Literals(["completed", "running"]), output: Schema.String, }) +const StructuredOutput = Schema.Struct({ + sessionID: Output.fields.sessionID, + status: Output.fields.status, +}) export const description = [ "Spawn a subagent: a child session running a configured agent with fresh context.", @@ -115,6 +119,8 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ sessionID: output.sessionID, status: output.status }), toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index e7a372e4b44..0ce32509950 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -37,6 +37,9 @@ const Output = Schema.Struct({ format: Input.fields.format, output: Schema.String, }) +const StructuredOutput = Schema.Struct({ + contentType: Output.fields.contentType, +}) type Format = (typeof Input.Type)["format"] @@ -126,6 +129,8 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ contentType: output.contentType }), toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index c74a5d642bd..49b059c9dff 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -190,6 +190,9 @@ const Output = Schema.Struct({ provider: Provider, text: Schema.String, }) +const StructuredOutput = Schema.Struct({ + provider: Output.fields.provider, +}) export const Plugin = { id: "opencode.tool.websearch", @@ -206,6 +209,8 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ provider: output.provider }), toModelOutput: ({ output }) => [{ type: "text", text: output.text }], execute: (input, context) => { const provider = selectProvider(context.sessionID, config, config.provider) diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 3152c3f4e1e..41e25953c41 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -86,8 +86,12 @@ describe("search tools", () => { const glob = yield* settleTool(registry, call("glob", { pattern: "*" })) const grep = yield* settleTool(registry, call("grep", { pattern: "needle" })) - expect(glob.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) - expect(grep.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) + expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT }) + expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT }) + expect(glob.output?.content).toEqual([{ type: "text", text: glob.result.value }]) + expect(grep.output?.content).toEqual([{ type: "text", text: grep.result.value }]) + expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) + expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`) }), ) }), diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index d6745b44d25..72f5565deec 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -119,9 +119,12 @@ describe("SkillTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } }, }), - ).toMatchObject({ + ).toEqual({ result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) }, - output: { structured: { name: "Effect" } }, + output: { + structured: { name: "Effect", directory }, + content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }], + }, }) expect(assertions).toMatchObject([ { sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index bf17b9092c0..8c70525a9ab 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -35,7 +35,8 @@ const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") }) const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } -const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID +const outputSessionID = (value: unknown) => + Schema.decodeUnknownSync(Schema.Struct({ sessionID: SessionV2.ID }))(value).sessionID const executionNode = makeGlobalNode({ service: SessionExecution.Service, @@ -229,7 +230,17 @@ describe("SubagentTool", () => { }, }) - expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) + expect(settled).toMatchObject({ + result: { type: "text", value: childText }, + output: { + structured: { status: "completed" }, + content: [{ type: "text", text: childText }], + }, + }) + expect(settled.output?.structured).toEqual({ + sessionID: outputSessionID(settled.output?.structured), + status: "completed", + }) expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id) }), ), @@ -264,8 +275,15 @@ describe("SubagentTool", () => { }, }) - expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) + expect(settled).toMatchObject({ + result: { type: "text", value: childText }, + output: { + structured: { status: "completed" }, + content: [{ type: "text", text: childText }], + }, + }) const child = yield* sessions.get(outputSessionID(settled.output?.structured)) + expect(settled.output?.structured).toEqual({ sessionID: child.id, status: "completed" }) expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" }) expect(child).toMatchObject({ parentID: parent.id, @@ -361,8 +379,10 @@ describe("SubagentTool", () => { const childID = outputSessionID(settled.output?.structured) expect(settled.output?.structured).toMatchObject({ status: "running", - output: expect.stringContaining(`id: ${childID}`), }) + expect(settled.output?.structured).toEqual({ sessionID: childID, status: "running" }) + expect(settled.result).toEqual({ type: "text", value: expect.stringContaining(`id: ${childID}`) }) + expect(settled.output?.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }]) const admission = Array.from(yield* Fiber.join(admitted))[0] expect(admission?.data.input.data.text).toContain(` { expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({ result: { type: "text", value: "hello" }, output: { - structured: { url, contentType: "text/plain", format: "text", output: "hello" }, + structured: { contentType: "text/plain" }, content: [{ type: "text", text: "hello" }], }, }) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index f8468285a5f..37d3960ab7c 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -244,7 +244,7 @@ describe("WebSearchTool registration", () => { expect(settled).toEqual({ result: { type: "text", value: "parallel results" }, output: { - structured: { provider: "parallel", text: "parallel results" }, + structured: { provider: "parallel" }, content: [{ type: "text", text: "parallel results" }], }, }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 5a4fd04e828..ca776bb2281 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2669,8 +2669,7 @@ function WebFetch(props: ToolProps) { function WebSearch(props: ToolProps) { return ( - {webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"{" "} - ({finiteNumber(props.metadata.numResults)} results) + {webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}" ) } diff --git a/packages/tui/test/mini/tool.test.ts b/packages/tui/test/mini/tool.test.ts index 86c40d1492a..fb005cdf13c 100644 --- a/packages/tui/test/mini/tool.test.ts +++ b/packages/tui/test/mini/tool.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { normalizeTool, toolInlineInfo, toolOutputText, toolPath, toolScroll } from "../../src/mini/tool" +import { canonicalToolPart } from "./fixture/tool-part" describe("Mini tool presentation", () => { test("uses V2 shell output without the model-facing status", () => { @@ -105,6 +106,29 @@ describe("Mini tool presentation", () => { ).toBe('→ Skill "effect"') }) + test("renders compact search metadata", () => { + expect( + toolInlineInfo( + canonicalToolPart("glob", { + status: "completed", + input: { pattern: "*.ts" }, + structured: { count: 3 }, + content: [], + }), + ).description, + ).toBe("3 matches") + expect( + toolInlineInfo( + canonicalToolPart("grep", { + status: "completed", + input: { pattern: "needle" }, + structured: { matches: 1 }, + content: [], + }), + ).description, + ).toBe("1 match") + }) + test("keeps segment-safe contained tool paths relative", () => { expect(toolPath("..cache/result.txt", { directory: "/work/project" })).toBe("..cache/result.txt") expect(toolPath("../shared/result.txt", { directory: "/work/project" })).toBe("/work/shared/result.txt")