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/altimate/tool-source.ts b/packages/opencode/src/altimate/tool-source.ts new file mode 100644 index 000000000..a06e5742c --- /dev/null +++ b/packages/opencode/src/altimate/tool-source.ts @@ -0,0 +1,87 @@ +/** + * Authoritative classification of a tool call's origin, stamped onto the tool + * part's `state.metadata.source` so clients (chat webview, ...) render the right + * badge without re-deriving it from tool-name prefixes. + * + * - "builtin" — native opencode tools (read/glob/bash/...) + * - "altimate" — Altimate-provided tools (sql_*, schema_*, finops_*, ...) AND + * tools from the Datamates MCP server (Altimate-owned, just + * delivered over MCP) + * - "mcp" — third-party MCP tools + * + * Registry tools and MCP tools are resolved in separate loops (see + * `session/prompt.ts` resolveTools), so each has its own classifier. The native + * `skill` tool is a further special case: it loads skills of varying origin, so + * its badge is classified per-call from the loaded skill's origin (see + * `skillToolSource`) rather than from the tool id. + */ +export type ToolSource = "builtin" | "altimate" | "mcp" + +/** + * Native opencode tool ids. This set is small and stable; every other tool in + * the registry is Altimate-provided, so new Altimate tools classify correctly + * with no per-tool maintenance here. + */ +const NATIVE_TOOL_IDS = new Set([ + "invalid", + "question", + "bash", + "read", + "glob", + "grep", + "list", + "edit", + "write", + "multiedit", + "task", + "webfetch", + "todowrite", + "todoread", + "websearch", + "codesearch", + "skill", + "apply_patch", + "lsp", + "plan_exit", + "plan_enter", + "StructuredOutput", +]) + +/** MCP client-name prefixes that are Altimate-owned (Datamates as an MCP server). */ +const ALTIMATE_MCP_PREFIXES = ["datamate"] + +/** Classify a registry tool (never an MCP tool) as builtin vs Altimate. */ +export function registryToolSource(id: string): ToolSource { + return NATIVE_TOOL_IDS.has(id) ? "builtin" : "altimate" +} + +/** Classify an MCP tool by its `_` key: Altimate (Datamates) vs third-party. */ +export function mcpToolSource(key: string): ToolSource { + const lower = key.toLowerCase() + return ALTIMATE_MCP_PREFIXES.some((p) => lower.startsWith(p)) ? "altimate" : "mcp" +} + +/** + * Classify a skill-load (the native `skill` tool) by the loaded skill's origin, + * which the skill tool reports on its metadata (see `tool/skill.ts`). Altimate + * ships its skills bundled with the CLI ("builtin" origin) — those wear the + * Altimate mark. User-authored global/project skills stay neutral so the badge + * doesn't over-claim third-party or personal skills. Origin arrives as `unknown` + * (client metadata), so anything other than "builtin" is treated as neutral. + */ +export function skillToolSource(origin: unknown): ToolSource { + return origin === "builtin" ? "altimate" : "builtin" +} + +/** + * Best-effort readable title for an MCP tool call, from its `_` + * key — e.g. "datamates_jira_get_issue" → "Jira Get Issue". Strips the leading + * client segment and Title-Cases the rest. (Richer per-call titles are the MCP + * server's job; this is the fallback so MCP rows aren't a bare snake_case id.) + */ +export function humanizeMcpTitle(key: string): string { + const withoutClient = key.includes("_") ? key.slice(key.indexOf("_") + 1) : key + const words = (withoutClient || key).split(/[_-]+/).filter(Boolean) + if (words.length === 0) return key + return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ") +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 72027eedf..44fcb4015 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -65,6 +65,8 @@ import { registerAltimateValidators } from "../altimate/validators" registerAltimateValidators() import { Config } from "../config/config" import { Tracer } from "../altimate/observability/tracing" +// altimate_change — stamp an authoritative tool source + humanized MCP title +import { registryToolSource, mcpToolSource, humanizeMcpTitle, skillToolSource } from "../altimate/tool-source" // altimate_change end import { Telemetry } from "@/telemetry" // altimate_change — session telemetry @@ -1564,6 +1566,14 @@ export namespace SessionPrompt { messageID: input.processor.message.id, })), } + // altimate_change — stamp authoritative tool source so clients render the right badge. + // The `skill` tool loads skills of varying origin, so classify it per-call from the + // origin it reports (Altimate-shipped → altimate mark, user global/project → neutral). + const metadata = output.metadata ?? {} + output.metadata = { + ...metadata, + source: item.id === "skill" ? skillToolSource(metadata.skillOrigin) : registryToolSource(item.id), + } await Plugin.trigger( "tool.execute.after", { @@ -1655,10 +1665,13 @@ export namespace SessionPrompt { ...(result.metadata ?? {}), truncated: truncated.truncated, ...(truncated.truncated && { outputPath: truncated.outputPath }), + // altimate_change — authoritative source so the chat can badge Datamates MCP tools + source: mcpToolSource(key), } return { - title: "", + // altimate_change — MCP tools have no native title; give a readable label + title: humanizeMcpTitle(key), metadata, output: truncated.content, attachments: attachments.map((attachment) => ({ diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 78ea4d645..5b5ab5ed2 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -17,10 +17,15 @@ import os from "os" const MAX_DISPLAY_SKILLS = 50 -// altimate_change start — classifySkillSource helper for skill telemetry -function classifySkillSource(location: string): "builtin" | "global" | "project" { - if (location.includes("node_modules") || location.includes(".altimate/builtin")) return "builtin" - if (location.startsWith(os.homedir())) return "global" +// altimate_change start — classifySkillSource helper for skill telemetry + source badge +export function classifySkillSource(location: string): "builtin" | "global" | "project" { + // Normalize separators so `.altimate/builtin` / homedir prefix match on Windows too. + const normalized = location.replace(/\\/g, "/") + // Embedded skills load with a `builtin:/SKILL.md` location and Altimate's + // bundled skills land under `~/.altimate/builtin/` — both are Altimate-shipped. + if (normalized.startsWith("builtin:") || normalized.includes("node_modules") || normalized.includes(".altimate/builtin")) + return "builtin" + if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global" return "project" } // altimate_change end @@ -147,6 +152,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => { const followups = SkillFollowups.format(skill.name) // altimate_change end + // altimate_change start — classify origin once, reused for telemetry and the source badge + const skillOrigin = classifySkillSource(skill.location) + // altimate_change end + // altimate_change start — telemetry instrumentation for skill loading with trigger classification try { Telemetry.track({ @@ -155,7 +164,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => { session_id: ctx.sessionID, message_id: ctx.messageID, skill_name: skill.name, - skill_source: classifySkillSource(skill.location), + skill_source: skillOrigin, duration_ms: Date.now() - startTime, trigger: Telemetry.classifySkillTrigger(ctx.extra), has_followups: followups.length > 0, @@ -188,6 +197,8 @@ export const SkillTool = Tool.define("skill", async (ctx) => { metadata: { name: skill.name, dir, + // altimate_change — origin drives the source badge (see altimate/tool-source.ts skillToolSource) + skillOrigin, }, } // altimate_change end 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") + }) +}) diff --git a/packages/opencode/test/altimate/tool-source.test.ts b/packages/opencode/test/altimate/tool-source.test.ts new file mode 100644 index 000000000..e9d2d2d08 --- /dev/null +++ b/packages/opencode/test/altimate/tool-source.test.ts @@ -0,0 +1,57 @@ +import { describe, test, expect } from "bun:test" +import { registryToolSource, mcpToolSource, humanizeMcpTitle, skillToolSource } from "../../src/altimate/tool-source" + +describe("registryToolSource", () => { + test("native opencode tools → builtin", () => { + for (const id of ["read", "write", "edit", "glob", "grep", "list", "bash", "task", "skill", "apply_patch"]) { + expect(registryToolSource(id)).toBe("builtin") + } + }) + + test("any non-native registry tool → altimate (incl. tools not enumerated here)", () => { + for (const id of ["sql_analyze", "schema_inspect", "finops_query_history", "altimate_core_check", "data_diff", "some_new_altimate_tool"]) { + expect(registryToolSource(id)).toBe("altimate") + } + }) +}) + +describe("skillToolSource", () => { + test("Altimate-shipped (builtin origin) skills → altimate", () => { + expect(skillToolSource("builtin")).toBe("altimate") + }) + + test("user-authored global/project skills → builtin (neutral)", () => { + expect(skillToolSource("global")).toBe("builtin") + expect(skillToolSource("project")).toBe("builtin") + }) + + test("missing or unexpected origin → builtin (neutral, never over-claims)", () => { + expect(skillToolSource(undefined)).toBe("builtin") + expect(skillToolSource(null)).toBe("builtin") + expect(skillToolSource("")).toBe("builtin") + expect(skillToolSource(42)).toBe("builtin") + }) +}) + +describe("mcpToolSource", () => { + test("Datamates MCP tools → altimate", () => { + expect(mcpToolSource("datamates_jira_get_issue")).toBe("altimate") + expect(mcpToolSource("datamate_snowflake_query")).toBe("altimate") + }) + + test("third-party MCP tools → mcp", () => { + expect(mcpToolSource("github_search_issues")).toBe("mcp") + expect(mcpToolSource("linear_create_issue")).toBe("mcp") + }) +}) + +describe("humanizeMcpTitle", () => { + test("strips the client segment and Title-Cases the rest", () => { + expect(humanizeMcpTitle("datamates_jira_get_issue")).toBe("Jira Get Issue") + expect(humanizeMcpTitle("github_search_issues")).toBe("Search Issues") + }) + + test("falls back gracefully for single-segment keys", () => { + expect(humanizeMcpTitle("ping")).toBe("Ping") + }) +}) diff --git a/packages/opencode/test/tool/skill-source.test.ts b/packages/opencode/test/tool/skill-source.test.ts new file mode 100644 index 000000000..add296dc6 --- /dev/null +++ b/packages/opencode/test/tool/skill-source.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, test, expect } from "bun:test" +import path from "path" +import os from "os" +import fs from "fs/promises" +import { SkillTool, classifySkillSource } from "../../src/tool/skill" +import { skillToolSource } from "../../src/altimate/tool-source" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { SessionID, MessageID } from "../../src/session/schema" + +const ctx = { + sessionID: SessionID.make("ses_test-skill-source"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("classifySkillSource", () => { + test("Altimate-shipped locations → builtin", () => { + expect(classifySkillSource("builtin:data-viz/SKILL.md")).toBe("builtin") + expect(classifySkillSource("/home/x/.altimate/builtin/dbt-analyze/SKILL.md")).toBe("builtin") + expect(classifySkillSource("/app/node_modules/@altimateai/pkg/skills/x/SKILL.md")).toBe("builtin") + // Windows-style separators must still match the Altimate-builtin marker. + expect(classifySkillSource("C:\\Users\\x\\.altimate\\builtin\\dbt-analyze\\SKILL.md")).toBe("builtin") + }) + + test("skills under the user's home (but not Altimate builtin) → global", () => { + expect(classifySkillSource(path.join(os.homedir(), ".claude", "skills", "mine", "SKILL.md"))).toBe("global") + }) + + test("skills elsewhere (project checkout) → project", () => { + expect(classifySkillSource("/work/repo/.claude/skills/local/SKILL.md")).toBe("project") + }) +}) + +describe("skill tool stamps origin that drives the source badge", () => { + afterEach(async () => { + await Instance.disposeAll() + }) + + test("a real project skill load reports origin=project → neutral (builtin) badge", async () => { + await using tmp = await tmpdir() + // Lay down a project-level skill the way Claude Code / opencode discover them. + const skillDir = path.join(tmp.path, ".claude", "skills", "e2e-probe") + await fs.mkdir(skillDir, { recursive: true }) + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + ["---", "name: e2e-probe", "description: probe skill for source-badge test", "---", "", "Probe body."].join("\n"), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const skill = await SkillTool.init() + const result = await skill.execute({ name: "e2e-probe" }, ctx) + + // The tool now carries the loaded skill's origin on its metadata... + expect(result.metadata.skillOrigin).toBe("project") + // ...and the badge policy maps a user/project skill to the neutral badge. + expect(skillToolSource(result.metadata.skillOrigin)).toBe("builtin") + // The humanized title path is unaffected. + expect(result.title).toBe("Loaded skill: e2e-probe") + }, + }) + }) + + test("a real Altimate-shipped (~/.altimate/builtin) skill load → origin=builtin → altimate badge", async () => { + await using tmp = await tmpdir() + // Seed an Altimate-shipped skill in an isolated home's builtin dir, mirroring + // how postinstall lays bundled skills under ~/.altimate/builtin/. + const prevHome = process.env["OPENCODE_TEST_HOME"] + const home = path.join(tmp.path, "home") + const builtinSkillDir = path.join(home, ".altimate", "builtin", "e2e-builtin-probe") + await fs.mkdir(builtinSkillDir, { recursive: true }) + await fs.writeFile( + path.join(builtinSkillDir, "SKILL.md"), + ["---", "name: e2e-builtin-probe", "description: bundled probe skill", "---", "", "Bundled body."].join("\n"), + ) + process.env["OPENCODE_TEST_HOME"] = home + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const skill = await SkillTool.init() + const result = await skill.execute({ name: "e2e-builtin-probe" }, ctx) + + // A bundled skill is classified as builtin origin... + expect(result.metadata.skillOrigin).toBe("builtin") + // ...which the badge policy promotes to the Altimate mark. + expect(skillToolSource(result.metadata.skillOrigin)).toBe("altimate") + }, + }) + } finally { + if (prevHome === undefined) delete process.env["OPENCODE_TEST_HOME"] + else process.env["OPENCODE_TEST_HOME"] = prevHome + } + }) +}) 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") }, }) })