From b12a4737e7ade69388b6fdc05ca073290d79b46e Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" <79431566+ralphstodomingo@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:20:54 +0800 Subject: [PATCH 1/2] feat: [AI-7392] humanize tool-call titles at the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite each tool's state.title in the execute() wrapper so any client (chat webview, TUI, ...) can render a readable label straight from state.title — e.g. "Reading customers model" for a dbt model read, "Searching **/*.sql" for a glob. File-acting tools get a gerund verb plus a dbt-aware target (model/seed/macro, degrading to the filename off-dbt); every other tool keeps the rich title it already emits. --- packages/opencode/src/altimate/tool-label.ts | 89 +++++++++++++++++++ packages/opencode/src/tool/tool.ts | 7 ++ .../opencode/test/altimate/tool-label.test.ts | 52 +++++++++++ 3 files changed, 148 insertions(+) create mode 100644 packages/opencode/src/altimate/tool-label.ts create mode 100644 packages/opencode/test/altimate/tool-label.test.ts diff --git a/packages/opencode/src/altimate/tool-label.ts b/packages/opencode/src/altimate/tool-label.ts new file mode 100644 index 000000000..05379da66 --- /dev/null +++ b/packages/opencode/src/altimate/tool-label.ts @@ -0,0 +1,89 @@ +/** + * Produces a readable, dbt-aware title for a tool call — e.g. "Reading customers + * model" instead of a bare file path — so any client (chat webview, TUI, ...) can + * render a descriptive label straight from the tool part's `state.title`. + * + * This is the source of truth for tool-call labels: it runs inside the tool + * execute() wrapper (see `tool/tool.ts`) and rewrites the title every tool + * returns. Only file-acting tools (whose native title is a bare path) are + * rewritten; every other tool keeps the rich title it already emits. + * + * dbt naming ("model"/"seed"/...) is applied only when the path sits under the + * matching directory, so it degrades to the plain filename off-dbt. + */ + +/** File-acting tools whose native title is a bare path → gerund verb. */ +const FILE_TOOL_VERBS: Record = { + read: "Reading", + write: "Writing", + edit: "Editing", + multiedit: "Editing", + glob: "Searching", + grep: "Searching", + list: "Listing", +} + +/** dbt directory → singular noun used in the label. */ +const DBT_DIR_KIND: Record = { + models: "model", + seeds: "seed", + macros: "macro", + snapshots: "snapshot", + tests: "test", + analyses: "analysis", + analysis: "analysis", +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined +} + +/** + * Turn a file path into a friendly target: + * - under a known dbt dir → " " with the sql/yaml/csv extension stripped + * - otherwise → the basename, extension kept (e.g. "dbt_project.yml", "index.ts") + */ +function friendlyTarget(rawPath: string): string { + const segments = rawPath.replace(/\\/g, "/").replace(/^\.\//, "").split("/").filter(Boolean) + const base = segments[segments.length - 1] ?? rawPath + for (const segment of segments.slice(0, -1)) { + const kind = DBT_DIR_KIND[segment.toLowerCase()] + if (kind) { + const name = base.replace(/\.(sql|ya?ml|csv)$/i, "") + return `${name} ${kind}` + } + } + return base +} + +/** Extract the display target for a given file tool from its input args. */ +function fileTarget(tool: string, input: Record): string | undefined { + if (tool === "glob" || tool === "grep") { + return asString(input["pattern"]) + } + if (tool === "list") { + const path = asString(input["path"]) + return path ? friendlyTarget(path) : undefined + } + // read / write / edit / multiedit + const filePath = asString(input["filePath"]) ?? asString(input["path"]) + return filePath ? friendlyTarget(filePath) : undefined +} + +/** + * @param tool the tool id (e.g. "read", "sql_analyze") + * @param input the tool's input args + * @param rawTitle the title the tool itself returned (a bare path for file tools, + * already human-readable for everything else) + * @returns a humanized label for file tools, otherwise the tool's own title. + */ +export function describeToolCall(tool: string, input: unknown, rawTitle?: string): string | undefined { + const fallback = asString(rawTitle) + const verb = FILE_TOOL_VERBS[tool] + if (verb && input && typeof input === "object") { + const target = fileTarget(tool, input as Record) + if (target) return `${verb} ${target}` + } + // Non-file / rich-title tools: keep the title the tool already emitted. + return fallback +} diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 5daa021eb..ab59e75ef 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -7,6 +7,9 @@ import { Truncate } from "./truncation" // altimate_change start — telemetry instrumentation for tool execution import { Telemetry } from "../altimate/telemetry" // altimate_change end +// altimate_change start — humanize tool-call titles at the source +import { describeToolCall } from "../altimate/tool-label" +// altimate_change end export namespace Tool { interface Metadata { @@ -121,6 +124,10 @@ export namespace Tool { } throw error } + // altimate_change start — humanize the tool-call title at the source so any + // client (chat webview, TUI, ...) can render a readable label from state.title. + result = { ...result, title: describeToolCall(id, args, result.title) ?? result.title } + // altimate_change end // Telemetry runs after execute() succeeds — wrapped so it never breaks the tool try { const isSoftFailure = result.metadata?.success === false diff --git a/packages/opencode/test/altimate/tool-label.test.ts b/packages/opencode/test/altimate/tool-label.test.ts new file mode 100644 index 000000000..a8c68f0af --- /dev/null +++ b/packages/opencode/test/altimate/tool-label.test.ts @@ -0,0 +1,52 @@ +import { describe, test, expect } from "bun:test" +import { describeToolCall } from "../../src/altimate/tool-label" + +describe("describeToolCall", () => { + test("humanizes reads of a dbt model into 'Reading model'", () => { + expect(describeToolCall("read", { filePath: "models/customers.sql" }, "models/customers.sql")).toBe( + "Reading customers model", + ) + expect( + describeToolCall("read", { filePath: "models/staging/stg_customers.sql" }, "models/staging/stg_customers.sql"), + ).toBe("Reading stg_customers model") + }) + + test("maps other dbt directories to their noun", () => { + expect(describeToolCall("edit", { filePath: "macros/cents_to_dollars.sql" }, "macros/cents_to_dollars.sql")).toBe( + "Editing cents_to_dollars macro", + ) + expect(describeToolCall("write", { filePath: "analyses/rollup.sql" }, "analyses/rollup.sql")).toBe( + "Writing rollup analysis", + ) + expect(describeToolCall("read", { filePath: "seeds/raw_customers.csv" }, "seeds/raw_customers.csv")).toBe( + "Reading raw_customers seed", + ) + }) + + test("falls back to the filename for non-dbt paths (never a false 'model')", () => { + expect(describeToolCall("read", { filePath: "dbt_project.yml" }, "dbt_project.yml")).toBe("Reading dbt_project.yml") + expect(describeToolCall("read", { filePath: "src/index.ts" }, "src/index.ts")).toBe("Reading index.ts") + }) + + test("labels glob / grep / list by their target", () => { + expect(describeToolCall("glob", { pattern: "**/*.sql" }, "12 matches")).toBe("Searching **/*.sql") + expect(describeToolCall("grep", { pattern: "customer_id" }, "3 matches")).toBe("Searching customer_id") + expect(describeToolCall("list", { path: "models" }, "models/")).toBe("Listing models") + }) + + test("keeps the tool's own title for non-file / rich-title tools", () => { + expect( + describeToolCall("sql_analyze", { filePath: "models/customers.sql" }, "Analyze: 2 issues [high]"), + ).toBe("Analyze: 2 issues [high]") + expect(describeToolCall("bash", { command: "dbt build" }, "Run full dbt build")).toBe("Run full dbt build") + // apply_patch carries a diff, not a path, so it keeps its own per-file title. + expect(describeToolCall("apply_patch", { patch: "*** Update File: models/x.sql" }, "# Patched x.sql")).toBe( + "# Patched x.sql", + ) + }) + + test("falls back to the raw title when a file tool has no usable path", () => { + expect(describeToolCall("read", {}, "some title")).toBe("some title") + expect(describeToolCall("read", undefined, "some title")).toBe("some title") + }) +}) From 9e1853e98a3a769c3c4b21c9e4f3b78d80fa08c0 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" <79431566+ralphstodomingo@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:26:25 +0800 Subject: [PATCH 2/2] test: [AI-7392] update write-tool title test for humanized label The execute() wrapper now humanizes file-tool titles at the source, so the write tool's title is "Writing " rather than the raw relative path. Update the assertion accordingly. --- packages/opencode/test/tool/write.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 97939c105..0d89f251e 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -328,7 +328,7 @@ describe("tool.write", () => { }) describe("title generation", () => { - test("returns relative path as title", async () => { + test("humanizes the title to a readable label", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "src", "components", "Button.tsx") await fs.mkdir(path.dirname(filepath), { recursive: true }) @@ -345,7 +345,10 @@ describe("tool.write", () => { ctx, ) - expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) + // The execute() wrapper humanizes file-tool titles at the source + // (see src/altimate/tool-label.ts) — a non-dbt path degrades to the + // filename, so the title is a readable "Writing " label. + expect(result.title).toBe("Writing Button.tsx") }, }) })