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/7] 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/7] 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") }, }) }) From 14f5cbd9511b2413a3bb33cab29e54890d25e777 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" <79431566+ralphstodomingo@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:13:08 +0800 Subject: [PATCH 3/7] feat: [AI-7392] authoritative tool source + humanized MCP titles Classify every tool call's origin server-side and stamp it on the tool part's state.metadata.source (builtin | altimate | mcp), so clients render the source badge without re-deriving it from tool-name prefixes: - tool-source.ts: native-set inversion (any non-native registry tool is Altimate, so new Altimate tools classify with no maintenance); Datamates MCP folded into "altimate"; humanizeMcpTitle for readable MCP labels. - resolveTools: stamp metadata.source on registry tools; on MCP tools set both the source and a humanized title (they previously had title: ""). --- packages/opencode/src/altimate/tool-source.ts | 72 +++++++++++++++++++ packages/opencode/src/session/prompt.ts | 9 ++- .../test/altimate/tool-source.test.ts | 39 ++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/altimate/tool-source.ts create mode 100644 packages/opencode/test/altimate/tool-source.test.ts diff --git a/packages/opencode/src/altimate/tool-source.ts b/packages/opencode/src/altimate/tool-source.ts new file mode 100644 index 000000000..26a56a4f3 --- /dev/null +++ b/packages/opencode/src/altimate/tool-source.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ +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" +} + +/** + * 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..35b925a2a 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 } from "../altimate/tool-source" // altimate_change end import { Telemetry } from "@/telemetry" // altimate_change — session telemetry @@ -1564,6 +1566,8 @@ export namespace SessionPrompt { messageID: input.processor.message.id, })), } + // altimate_change — stamp authoritative tool source so clients render the right badge + output.metadata = { ...(output.metadata ?? {}), source: registryToolSource(item.id) } await Plugin.trigger( "tool.execute.after", { @@ -1655,10 +1659,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/test/altimate/tool-source.test.ts b/packages/opencode/test/altimate/tool-source.test.ts new file mode 100644 index 000000000..2bc4af487 --- /dev/null +++ b/packages/opencode/test/altimate/tool-source.test.ts @@ -0,0 +1,39 @@ +import { describe, test, expect } from "bun:test" +import { registryToolSource, mcpToolSource, humanizeMcpTitle } 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("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") + }) +}) From 0d359d898b957a1d6ee76ee9881cceb35527921f Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" <79431566+ralphstodomingo@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:40:32 +0800 Subject: [PATCH 4/7] feat: [AI-7392][AI-7394] badge Altimate-shipped skills by origin Skill loads always classified as `builtin` (terminal badge) because `skill` is a native tool id. Classify skill loads per-call from the loaded skill's origin instead: Altimate-shipped skills (bundled under `~/.altimate/builtin` or binary-embedded `builtin:` skills) carry the `altimate` source badge; user global/project skills stay neutral so the badge never over-claims a personal or third-party skill. - `tool-source.ts`: add `skillToolSource(origin)` badge policy - `skill.ts`: export `classifySkillSource`, stamp `skillOrigin` on the tool metadata, and match `builtin:`/`.altimate/builtin` locations (normalizing Windows separators) - `prompt.ts`: stamp the skill source from the reported origin - tests: `skillToolSource` + `classifySkillSource` units, plus real `SkillTool.execute` integration covering project (neutral) and builtin (Altimate) origins --- packages/opencode/src/altimate/tool-source.ts | 17 ++- packages/opencode/src/session/prompt.ts | 12 ++- packages/opencode/src/tool/skill.ts | 21 +++- .../test/altimate/tool-source.test.ts | 20 +++- .../opencode/test/tool/skill-source.test.ts | 102 ++++++++++++++++++ 5 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/test/tool/skill-source.test.ts diff --git a/packages/opencode/src/altimate/tool-source.ts b/packages/opencode/src/altimate/tool-source.ts index 26a56a4f3..a06e5742c 100644 --- a/packages/opencode/src/altimate/tool-source.ts +++ b/packages/opencode/src/altimate/tool-source.ts @@ -10,7 +10,10 @@ * - "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. + * `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" @@ -58,6 +61,18 @@ export function mcpToolSource(key: string): ToolSource { 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 diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 35b925a2a..44fcb4015 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -66,7 +66,7 @@ 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 } from "../altimate/tool-source" +import { registryToolSource, mcpToolSource, humanizeMcpTitle, skillToolSource } from "../altimate/tool-source" // altimate_change end import { Telemetry } from "@/telemetry" // altimate_change — session telemetry @@ -1566,8 +1566,14 @@ export namespace SessionPrompt { messageID: input.processor.message.id, })), } - // altimate_change — stamp authoritative tool source so clients render the right badge - output.metadata = { ...(output.metadata ?? {}), source: registryToolSource(item.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", { 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/test/altimate/tool-source.test.ts b/packages/opencode/test/altimate/tool-source.test.ts index 2bc4af487..e9d2d2d08 100644 --- a/packages/opencode/test/altimate/tool-source.test.ts +++ b/packages/opencode/test/altimate/tool-source.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { registryToolSource, mcpToolSource, humanizeMcpTitle } from "../../src/altimate/tool-source" +import { registryToolSource, mcpToolSource, humanizeMcpTitle, skillToolSource } from "../../src/altimate/tool-source" describe("registryToolSource", () => { test("native opencode tools → builtin", () => { @@ -15,6 +15,24 @@ describe("registryToolSource", () => { }) }) +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") 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 + } + }) +}) From 31f297d06bdd404b98306e06c48957ebb4073cd1 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 9 Jul 2026 07:07:44 +0800 Subject: [PATCH 5/7] fix: [AI-7392] address review feedback on tool-call titles/source - tool-label: gate the dbt-kind rename on a recognized dbt file extension so a directory target (e.g. `list models/staging`) is no longer mislabeled as a single "staging model"; match the nearest dbt ancestor by scanning right-to-left so a coincidental outer dir doesn't win; add .py/.md files. - tool-source: classify the native `batch` tool as builtin (was falling through to "altimate"). - skill: scope the node_modules origin check to @altimateai/ and altimate-code so third-party skills under other node_modules aren't tagged Altimate-shipped. - tests: nested-dir list, nearest-ancestor, and py/md coverage. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0153bSPGZy5quJ4ipk3nFBjW --- packages/opencode/src/altimate/tool-label.ts | 23 +++++++++++++------ packages/opencode/src/altimate/tool-source.ts | 1 + packages/opencode/src/tool/skill.ts | 8 ++++++- .../opencode/test/altimate/tool-label.test.ts | 21 +++++++++++++++++ 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/altimate/tool-label.ts b/packages/opencode/src/altimate/tool-label.ts index 05379da66..30213c2b5 100644 --- a/packages/opencode/src/altimate/tool-label.ts +++ b/packages/opencode/src/altimate/tool-label.ts @@ -38,19 +38,28 @@ function asString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined } +/** dbt file extensions that mark a target as a single dbt node (model/seed/...). */ +const DBT_FILE_EXT = /\.(sql|ya?ml|csv|py|md)$/i + /** * 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") + * - a dbt *file* under a known dbt dir → " " with the extension stripped + * - otherwise → the basename (e.g. "dbt_project.yml", "index.ts", or a directory name) + * + * The dbt-kind rewrite is gated on a recognized dbt file extension so directory + * targets (e.g. `list models/staging`) aren't mislabeled as a single model, and + * the nearest dbt ancestor is matched by scanning right-to-left so a coincidental + * outer directory name (e.g. a repo called `models/`) doesn't win over the real one. */ 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}` + if (DBT_FILE_EXT.test(base)) { + for (let i = segments.length - 2; i >= 0; i--) { + const kind = DBT_DIR_KIND[segments[i].toLowerCase()] + if (kind) { + return `${base.replace(DBT_FILE_EXT, "")} ${kind}` + } } } return base diff --git a/packages/opencode/src/altimate/tool-source.ts b/packages/opencode/src/altimate/tool-source.ts index a06e5742c..3a04d7c8f 100644 --- a/packages/opencode/src/altimate/tool-source.ts +++ b/packages/opencode/src/altimate/tool-source.ts @@ -26,6 +26,7 @@ const NATIVE_TOOL_IDS = new Set([ "invalid", "question", "bash", + "batch", "read", "glob", "grep", diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 5b5ab5ed2..208297d3e 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -23,7 +23,13 @@ export function classifySkillSource(location: string): "builtin" | "global" | "p 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")) + // The node_modules match is scoped to the altimate-code package so a third-party + // skill installed under some other `node_modules/` isn't tagged as Altimate. + if ( + normalized.startsWith("builtin:") || + /\/node_modules\/(@altimateai\/|altimate-code\/)/.test(normalized) || + normalized.includes(".altimate/builtin") + ) return "builtin" if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global" return "project" diff --git a/packages/opencode/test/altimate/tool-label.test.ts b/packages/opencode/test/altimate/tool-label.test.ts index a8c68f0af..6774e5bc0 100644 --- a/packages/opencode/test/altimate/tool-label.test.ts +++ b/packages/opencode/test/altimate/tool-label.test.ts @@ -34,6 +34,27 @@ describe("describeToolCall", () => { expect(describeToolCall("list", { path: "models" }, "models/")).toBe("Listing models") }) + test("list on a nested dbt subdirectory keeps the dir name (not a false ' model')", () => { + // A directory listing under models/ is a folder of many models, not one model. + expect(describeToolCall("list", { path: "models/staging" }, "models/staging/")).toBe("Listing staging") + }) + + test("matches the nearest dbt ancestor, not a coincidental outer directory", () => { + // `models` appears higher in the path but the file lives under `macros`. + expect( + describeToolCall("read", { filePath: "models/project/macros/util.sql" }, "models/project/macros/util.sql"), + ).toBe("Reading util macro") + }) + + test("humanizes python and markdown dbt files", () => { + expect(describeToolCall("read", { filePath: "models/py_model.py" }, "models/py_model.py")).toBe( + "Reading py_model model", + ) + expect(describeToolCall("read", { filePath: "models/customers.md" }, "models/customers.md")).toBe( + "Reading customers model", + ) + }) + 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]"), From 2b89566f82ff6b4c94d80f8984af507c926cb1a3 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 10 Jul 2026 13:40:28 +0800 Subject: [PATCH 6/7] fix: [AI-7392] scope MCP source badge to the parsed client segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address remaining review feedback: - mcpToolSource: match the parsed segment (before the first `_`), not the whole key, so a third-party client that merely starts with "datamate" (e.g. `datamatex_foo`) isn't mislabeled Altimate. Accepts the exact name, its plural, and a `datamate-…` prefixed client. (cubic, Copilot) - skill.ts: fix the comment to match the code — the node_modules match is scoped to Altimate-owned packages (@altimateai/* and altimate-code). (Copilot) - tests: third-party `datamate`-prefixed client → mcp; `datamate-…` → altimate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0153bSPGZy5quJ4ipk3nFBjW --- packages/opencode/src/altimate/tool-source.ts | 17 +++++++++++++---- packages/opencode/src/tool/skill.ts | 5 +++-- .../opencode/test/altimate/tool-source.test.ts | 10 ++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/altimate/tool-source.ts b/packages/opencode/src/altimate/tool-source.ts index 3a04d7c8f..0a3faf41d 100644 --- a/packages/opencode/src/altimate/tool-source.ts +++ b/packages/opencode/src/altimate/tool-source.ts @@ -48,7 +48,7 @@ const NATIVE_TOOL_IDS = new Set([ "StructuredOutput", ]) -/** MCP client-name prefixes that are Altimate-owned (Datamates as an MCP server). */ +/** MCP client names 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. */ @@ -56,10 +56,19 @@ 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. */ +/** + * Classify an MCP tool by its `_` key: Altimate (Datamates) vs third-party. + * Matches the parsed `` segment (before the first `_`), not the whole key, so a + * third-party client that merely starts with "datamate" (e.g. `datamatex_foo`) isn't + * mislabeled Altimate. Accepts the exact name, its plural (`datamates`), and a + * `datamate-…` prefixed client. + */ export function mcpToolSource(key: string): ToolSource { - const lower = key.toLowerCase() - return ALTIMATE_MCP_PREFIXES.some((p) => lower.startsWith(p)) ? "altimate" : "mcp" + const underscore = key.indexOf("_") + const client = (underscore === -1 ? key : key.slice(0, underscore)).toLowerCase() + return ALTIMATE_MCP_PREFIXES.some((p) => client === p || client === `${p}s` || client.startsWith(`${p}-`)) + ? "altimate" + : "mcp" } /** diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 208297d3e..53a1dc481 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -23,8 +23,9 @@ export function classifySkillSource(location: string): "builtin" | "global" | "p 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. - // The node_modules match is scoped to the altimate-code package so a third-party - // skill installed under some other `node_modules/` isn't tagged as Altimate. + // The node_modules match is scoped to Altimate-owned packages (`@altimateai/*` and + // the `altimate-code` package) so a third-party skill installed under some other + // `node_modules/` isn't tagged as Altimate. if ( normalized.startsWith("builtin:") || /\/node_modules\/(@altimateai\/|altimate-code\/)/.test(normalized) || diff --git a/packages/opencode/test/altimate/tool-source.test.ts b/packages/opencode/test/altimate/tool-source.test.ts index e9d2d2d08..987d7dc09 100644 --- a/packages/opencode/test/altimate/tool-source.test.ts +++ b/packages/opencode/test/altimate/tool-source.test.ts @@ -43,6 +43,16 @@ describe("mcpToolSource", () => { expect(mcpToolSource("github_search_issues")).toBe("mcp") expect(mcpToolSource("linear_create_issue")).toBe("mcp") }) + + test("a third-party client that merely starts with 'datamate' is NOT Altimate", () => { + // Only the parsed segment counts — not a whole-key prefix match. + expect(mcpToolSource("datamatex_do_thing")).toBe("mcp") + expect(mcpToolSource("datamateworks_run")).toBe("mcp") + }) + + test("a datamate-prefixed client variant is Altimate", () => { + expect(mcpToolSource("datamate-prod_query")).toBe("altimate") + }) }) describe("humanizeMcpTitle", () => { From 518ecd1bd83a0e349768b43c5c46324a320ac0fd Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 15 Jul 2026 01:55:49 +0800 Subject: [PATCH 7/7] fix: [AI-7392][AI-7394] classify tool source from origin, not id/key heuristics Address review findings on the authoritative tool-source badge: - Custom/plugin tools no longer mislabeled Altimate. `registryToolSource` inferred "altimate" for any non-native id, so user-authored custom tools and third-party plugin tools (both built via `fromPlugin`) got the Altimate mark. Tools now carry a declared `registrySource`; `fromPlugin` stamps "external" so those classify neutral. Native/Altimate tools omit it and keep the id fallback. - Third-party MCP servers that sanitize into a `datamate_`-prefixed key are no longer mislabeled Altimate. `mcpToolSource`/`humanizeMcpTitle` now classify from the original (pre-sanitize) client name, threaded onto `MCP.tools()` entries alongside `prompts`/`resources`, instead of re-parsing the flattened `_` key (e.g. client `datamate.ai` -> key `datamate_ai_query`). - Both tool resolvers share the stamping. `prompt.ts` resolveTools and the Effect-based `SessionTools.resolve` now stamp via the shared `stampRegistryToolSource` / `describeMcpTool` helpers, so the dormant resolver can't silently drift (it previously applied no source/title at all). Tests: unit coverage for the new signatures and regressions (external -> neutral, datamate.ai -> mcp), plus an end-to-end registry test proving `registrySource` propagates through `ToolRegistry.Service.tools()` and drives the badge. Typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011rvaJenj74MCacJY9RGEEw --- packages/opencode/src/altimate/tool-source.ts | 113 +++++++++++++++--- packages/opencode/src/mcp/index.ts | 10 +- packages/opencode/src/session/prompt.ts | 28 ++--- packages/opencode/src/session/tools.ts | 21 +++- packages/opencode/src/tool/registry.ts | 6 + packages/opencode/src/tool/tool.ts | 5 + .../test/altimate/tool-source.test.ts | 101 +++++++++++++--- packages/opencode/test/tool/registry.test.ts | 51 ++++++++ 8 files changed, 280 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/altimate/tool-source.ts b/packages/opencode/src/altimate/tool-source.ts index 0a3faf41d..e26bad8ce 100644 --- a/packages/opencode/src/altimate/tool-source.ts +++ b/packages/opencode/src/altimate/tool-source.ts @@ -10,13 +10,30 @@ * - "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 + * `session/prompt.ts` resolveTools and `session/tools.ts` SessionTools.resolve), + * so each has its own classifier — but both loops stamp via the shared + * `stampRegistryToolSource` / `describeMcpTool` helpers below so they can't drift. + * 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" +/** + * Where a registry tool comes from, declared at registration time so we don't + * have to infer ownership from the tool id: + * - "native" — a native opencode tool (read/glob/bash/...) + * - "altimate" — an Altimate-shipped tool registered directly in the registry + * - "external" — a user-authored custom tool or a third-party plugin tool + * + * Only "external" strictly needs to be declared: without it, the id-based + * fallback in `registryToolSource` treats every non-native id as Altimate, which + * over-claims ownership of custom/plugin tools (see `fromPlugin` in + * `tool/registry.ts`). Native and Altimate tools may omit it and rely on the id + * fallback. + */ +export type RegistryToolOrigin = "native" | "altimate" | "external" + /** * 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 @@ -51,21 +68,36 @@ const NATIVE_TOOL_IDS = new Set([ /** MCP client names 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 { +/** + * Classify a registry tool (never an MCP tool) as builtin vs Altimate. Prefers + * the origin declared at registration; only falls back to the id when a tool + * doesn't declare one (native + Altimate tools registered directly). "external" + * (user custom / third-party plugin) maps to the neutral "builtin" badge so the + * Altimate mark never over-claims tools we don't own. + */ +export function registryToolSource(id: string, origin?: RegistryToolOrigin): ToolSource { + switch (origin) { + case "external": + return "builtin" + case "altimate": + return "altimate" + case "native": + return "builtin" + } return NATIVE_TOOL_IDS.has(id) ? "builtin" : "altimate" } /** - * Classify an MCP tool by its `_` key: Altimate (Datamates) vs third-party. - * Matches the parsed `` segment (before the first `_`), not the whole key, so a - * third-party client that merely starts with "datamate" (e.g. `datamatex_foo`) isn't - * mislabeled Altimate. Accepts the exact name, its plural (`datamates`), and a + * Classify an MCP tool by its original (pre-sanitize) client name: Altimate + * (Datamates) vs third-party. Classifying from the real client name — rather than + * re-parsing the flattened `_` key — avoids mislabeling a + * third-party server whose name sanitizes into a `datamate…`-prefixed key (e.g. + * client `datamate.ai` → key `datamate_ai_query`, whose first segment is + * `datamate`). Accepts the exact name, its plural (`datamates`), and a * `datamate-…` prefixed client. */ -export function mcpToolSource(key: string): ToolSource { - const underscore = key.indexOf("_") - const client = (underscore === -1 ? key : key.slice(0, underscore)).toLowerCase() +export function mcpToolSource(clientName: string): ToolSource { + const client = clientName.toLowerCase() return ALTIMATE_MCP_PREFIXES.some((p) => client === p || client === `${p}s` || client.startsWith(`${p}-`)) ? "altimate" : "mcp" @@ -83,15 +115,62 @@ export function skillToolSource(origin: unknown): ToolSource { return origin === "builtin" ? "altimate" : "builtin" } +/** Mirror of `sanitize()` in `mcp/catalog.ts` — MCP keys are built from sanitized names. */ +const sanitizeMcpName = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_") + /** * 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.) + * key — e.g. "datamates_jira_get_issue" → "Jira Get Issue". Strips the client + * segment and Title-Cases the rest. When the original client name is known the + * segment is stripped by its exact sanitized length (robust to client names that + * themselves contain underscores, e.g. `datamate.ai` → `datamate_ai`); otherwise + * it falls back to splitting on the first `_`. (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 +export function humanizeMcpTitle(key: string, clientName?: string): string { + const prefix = clientName ? `${sanitizeMcpName(clientName)}_` : "" + const withoutClient = + prefix && key.startsWith(prefix) + ? key.slice(prefix.length) + : 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(" ") } + +/** + * A resolved registry tool, as seen by the two tool resolvers (`prompt.ts` + * resolveTools and `session/tools.ts` SessionTools.resolve). + */ +interface RegistryToolLike { + id: string + registrySource?: RegistryToolOrigin +} + +/** + * Stamp the authoritative `source` badge onto a registry tool's output metadata. + * Shared by both tool resolvers so their per-call stamping cannot drift. The + * native `skill` tool is classified per-call from the loaded skill's origin + * (`state.metadata.skillOrigin`); every other tool from its declared/inferred + * origin. + */ +export function stampRegistryToolSource }>( + output: T, + item: RegistryToolLike, +): T & { metadata: Record & { source: ToolSource } } { + const metadata = output.metadata ?? {} + const source = + item.id === "skill" ? skillToolSource(metadata.skillOrigin) : registryToolSource(item.id, item.registrySource) + return { ...output, metadata: { ...metadata, source } } +} + +/** + * Authoritative `source` badge + readable `title` for an MCP tool call, derived + * from the original client name and the flattened key. Shared by both tool + * resolvers so MCP stamping cannot drift. + */ +export function describeMcpTool(key: string, clientName: string): { source: ToolSource; title: string } { + return { source: mcpToolSource(clientName), title: humanizeMcpTitle(key, clientName) } +} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index bd795650a..8f5ececa9 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -277,7 +277,9 @@ interface State { export interface Interface { readonly status: () => Effect.Effect> readonly clients: () => Effect.Effect> - readonly tools: () => Effect.Effect> + // altimate_change — carry the original (pre-sanitize) client name so tool-source classification + // works from the real name, not the flattened `_` key (see altimate/tool-source). + readonly tools: () => Effect.Effect> readonly prompts: () => Effect.Effect> readonly resources: () => Effect.Effect> readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record | Status }> @@ -1010,7 +1012,8 @@ export const layer = Layer.effect( } const tools = Effect.fn("MCP.tools")(function* () { - const result: Record = {} + // altimate_change — values carry the original client name (see Interface.tools). + const result: Record = {} const s = yield* InstanceState.get(state) const cfg = yield* cfgSvc.get() @@ -1028,7 +1031,8 @@ export const layer = Layer.effect( const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) for (const mcpTool of listed) { const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name) - result[key] = McpCatalog.convertTool(mcpTool, client, timeout) + // altimate_change — attach the original client name for source classification downstream. + result[key] = Object.assign(McpCatalog.convertTool(mcpTool, client, timeout), { client: clientName }) } } return result diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 435d4f299..289bd8a74 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -72,7 +72,7 @@ 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" +import { stampRegistryToolSource, describeMcpTool } from "../altimate/tool-source" // altimate_change end import { Telemetry } from "@/telemetry" // altimate_change — session telemetry @@ -1610,13 +1610,8 @@ export namespace SessionPrompt { })), } // 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), - } + // Shared with SessionTools.resolve (session/tools.ts) so the two resolvers can't drift. + const stamped = stampRegistryToolSource(output, item) await Plugin.trigger( "tool.execute.after", { @@ -1625,14 +1620,17 @@ export namespace SessionPrompt { callID: ctx.callID, args, }, - output, + stamped, ) - return output + return stamped }, }) } - for (const [key, item] of Object.entries(await MCP.tools())) { + for (const [key, entry] of Object.entries(await MCP.tools())) { + // altimate_change — split the original client name off the model-facing tool object so it's + // used only for source classification and never leaks into the tool schema sent to the model. + const { client: clientName, ...item } = entry const execute = item.execute if (!execute) continue @@ -1710,17 +1708,19 @@ export namespace SessionPrompt { } const truncated = await Truncate.output(textParts.join("\n\n"), {}, input.agent) + // altimate_change — authoritative source + readable title from the original client name, + // shared with SessionTools.resolve (session/tools.ts) so the two resolvers can't drift. + const described = describeMcpTool(key, clientName) const metadata = { ...(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), + source: described.source, } return { // altimate_change — MCP tools have no native title; give a readable label - title: humanizeMcpTitle(key), + title: described.title, metadata, output: truncated.content, attachments: attachments.map((attachment) => ({ diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 0b8fd1c5b..907d8eb79 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -18,6 +18,8 @@ import { Session } from "./session" import { SessionProcessor } from "./processor" import { PartID } from "./schema" import { EffectBridge } from "@/effect/bridge" +// altimate_change — shared tool-source stamping so this resolver can't drift from prompt.ts +import { stampRegistryToolSource, describeMcpTool } from "@/altimate/tool-source" // altimate_change start — upstream_fix: ToolRegistry expects fork-branded model ids here import { ModelID } from "@/provider/schema" // altimate_change end @@ -102,22 +104,27 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { messageID: input.processor.message.id, })), } + // altimate_change — stamp authoritative tool source (shared with prompt.ts resolveTools) + const stamped = stampRegistryToolSource(output, item) yield* plugin.trigger( "tool.execute.after", { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, - output, + stamped, ) if (options.abortSignal?.aborted) { - yield* input.processor.completeToolCall(options.toolCallId, output) + yield* input.processor.completeToolCall(options.toolCallId, stamped) } - return output + return stamped }), ) }, }) } - for (const [key, item] of Object.entries(yield* mcp.tools())) { + for (const [key, entry] of Object.entries(yield* mcp.tools())) { + // altimate_change — split the original client name off the model-facing tool object so it's + // used only for source classification and never leaks into the tool schema sent to the model. + const { client: clientName, ...item } = entry const execute = item.execute if (!execute) continue @@ -177,14 +184,18 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent) + // altimate_change — authoritative source + readable title from the original client name, + // shared with prompt.ts resolveTools so the two resolvers can't drift. + const described = describeMcpTool(key, clientName) const metadata = { ...result.metadata, truncated: truncated.truncated, ...(truncated.truncated && { outputPath: truncated.outputPath }), + source: described.source, } const output = { - title: "", + title: described.title, metadata, output: truncated.content, attachments: attachments.map((attachment) => ({ diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index d88d64b53..17236cb39 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -240,6 +240,10 @@ export namespace ToolRegistry { function fromPlugin(id: string, def: ToolDefinition): Tool.Info { return { id, + // altimate_change — user custom tools (file-scanned) and third-party plugin tools both flow + // through here; mark them "external" so the tool-source badge stays neutral and never + // over-claims them as Altimate-owned. + registrySource: "external", init: () => legacyToInit({ // altimate_change start — tolerate JSON-Schema-shaped legacy args (see argsToZodShape) @@ -555,6 +559,8 @@ export namespace ToolRegistry { await Plugin.trigger("tool.definition", { toolID: t.id }, output) return { id: t.id, + // altimate_change — carry declared origin to the resolvers' source-badge stamping. + registrySource: t.registrySource, // altimate_change start — upstream_fix: hide disabled runtime-gated tool schema fields. ...applyRuntimeToolSchemaFlags(t.id, tool, runtimeFlags), // altimate_change end diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index e547f40b9..e6bd4c56b 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -95,6 +95,11 @@ export interface Info< M extends Metadata = Metadata, > { id: string + // altimate_change start — declared origin so the tool-source badge classifier doesn't have to + // infer ownership from the id. Only external (user custom / third-party plugin) tools set it; + // native + Altimate tools omit it and rely on the id fallback. See altimate/tool-source. + registrySource?: import("../altimate/tool-source").RegistryToolOrigin + // altimate_change end // altimate_change start — upstream_fix: restore caller context for deferred tool init. init: (ctx?: InitContext) => Effect.Effect> // altimate_change end diff --git a/packages/opencode/test/altimate/tool-source.test.ts b/packages/opencode/test/altimate/tool-source.test.ts index 987d7dc09..a6a16ef5f 100644 --- a/packages/opencode/test/altimate/tool-source.test.ts +++ b/packages/opencode/test/altimate/tool-source.test.ts @@ -1,18 +1,37 @@ import { describe, test, expect } from "bun:test" -import { registryToolSource, mcpToolSource, humanizeMcpTitle, skillToolSource } from "../../src/altimate/tool-source" +import { + registryToolSource, + mcpToolSource, + humanizeMcpTitle, + skillToolSource, + stampRegistryToolSource, + describeMcpTool, +} from "../../src/altimate/tool-source" describe("registryToolSource", () => { - test("native opencode tools → builtin", () => { + test("native opencode tools → builtin (id fallback)", () => { 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"]) { + test("non-native registry tool with no declared origin → altimate (id fallback)", () => { + for (const id of ["sql_analyze", "schema_inspect", "finops_query_history", "altimate_core_check", "data_diff"]) { expect(registryToolSource(id)).toBe("altimate") } }) + + test("declared origin wins over the id fallback", () => { + expect(registryToolSource("some_new_altimate_tool", "altimate")).toBe("altimate") + expect(registryToolSource("read", "native")).toBe("builtin") + }) + + test("external (user custom / third-party plugin) tools → builtin, never altimate", () => { + // Regression: a custom/plugin tool id looks non-native, so the id fallback would call it + // "altimate". The declared "external" origin keeps it neutral. + expect(registryToolSource("my_custom_tool", "external")).toBe("builtin") + expect(registryToolSource("acme_plugin_deploy", "external")).toBe("builtin") + }) }) describe("skillToolSource", () => { @@ -34,34 +53,84 @@ describe("skillToolSource", () => { }) describe("mcpToolSource", () => { - test("Datamates MCP tools → altimate", () => { - expect(mcpToolSource("datamates_jira_get_issue")).toBe("altimate") - expect(mcpToolSource("datamate_snowflake_query")).toBe("altimate") + test("Datamates MCP client → altimate", () => { + expect(mcpToolSource("datamates")).toBe("altimate") + expect(mcpToolSource("datamate")).toBe("altimate") + expect(mcpToolSource("Datamates")).toBe("altimate") }) - test("third-party MCP tools → mcp", () => { - expect(mcpToolSource("github_search_issues")).toBe("mcp") - expect(mcpToolSource("linear_create_issue")).toBe("mcp") + test("a datamate-prefixed client variant is Altimate", () => { + expect(mcpToolSource("datamate-prod")).toBe("altimate") + }) + + test("third-party MCP clients → mcp", () => { + expect(mcpToolSource("github")).toBe("mcp") + expect(mcpToolSource("linear")).toBe("mcp") }) test("a third-party client that merely starts with 'datamate' is NOT Altimate", () => { - // Only the parsed segment counts — not a whole-key prefix match. - expect(mcpToolSource("datamatex_do_thing")).toBe("mcp") - expect(mcpToolSource("datamateworks_run")).toBe("mcp") + expect(mcpToolSource("datamatex")).toBe("mcp") + expect(mcpToolSource("datamateworks")).toBe("mcp") }) - test("a datamate-prefixed client variant is Altimate", () => { - expect(mcpToolSource("datamate-prod_query")).toBe("altimate") + test("regression: a third-party client that sanitizes into a datamate_ key is NOT Altimate", () => { + // Client `datamate.ai` sanitizes to `datamate_ai`, producing key `datamate_ai_query` whose + // first segment is `datamate`. Classifying from the real client name avoids the false positive. + expect(mcpToolSource("datamate.ai")).toBe("mcp") }) }) describe("humanizeMcpTitle", () => { - test("strips the client segment and Title-Cases the rest", () => { + test("strips the client segment (via first '_') and Title-Cases the rest", () => { expect(humanizeMcpTitle("datamates_jira_get_issue")).toBe("Jira Get Issue") expect(humanizeMcpTitle("github_search_issues")).toBe("Search Issues") }) + test("strips the client segment by exact sanitized length when the client name is known", () => { + // Client `datamate.ai` → sanitized prefix `datamate_ai_`. Without the known client name a + // first-'_' split would wrongly yield "Ai Query". + expect(humanizeMcpTitle("datamate_ai_query", "datamate.ai")).toBe("Query") + expect(humanizeMcpTitle("github_search_issues", "github")).toBe("Search Issues") + }) + test("falls back gracefully for single-segment keys", () => { expect(humanizeMcpTitle("ping")).toBe("Ping") }) }) + +describe("stampRegistryToolSource", () => { + test("stamps altimate for a non-native tool with no declared origin", () => { + const out = stampRegistryToolSource({ metadata: { foo: 1 } }, { id: "sql_analyze" }) + expect(out.metadata.source).toBe("altimate") + expect(out.metadata.foo).toBe(1) // preserves existing metadata + }) + + test("stamps builtin (neutral) for an external tool", () => { + const out = stampRegistryToolSource({ metadata: {} }, { id: "my_custom_tool", registrySource: "external" }) + expect(out.metadata.source).toBe("builtin") + }) + + test("classifies the skill tool from its loaded skill origin", () => { + expect(stampRegistryToolSource({ metadata: { skillOrigin: "builtin" } }, { id: "skill" }).metadata.source).toBe( + "altimate", + ) + expect(stampRegistryToolSource({ metadata: { skillOrigin: "project" } }, { id: "skill" }).metadata.source).toBe( + "builtin", + ) + }) + + test("tolerates missing metadata", () => { + const out = stampRegistryToolSource({}, { id: "read" }) + expect(out.metadata.source).toBe("builtin") + }) +}) + +describe("describeMcpTool", () => { + test("returns source + humanized title from the original client name", () => { + expect(describeMcpTool("datamates_jira_get_issue", "datamates")).toEqual({ + source: "altimate", + title: "Jira Get Issue", + }) + expect(describeMcpTool("datamate_ai_query", "datamate.ai")).toEqual({ source: "mcp", title: "Query" }) + }) +}) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 7aa424291..ec6dcfa60 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -6,6 +6,7 @@ import { Effect, Layer, Result, Schema } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ToolRegistry } from "@/tool/registry" import { Tool } from "@/tool/tool" +import { registryToolSource, stampRegistryToolSource } from "@/altimate/tool-source" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" @@ -142,6 +143,56 @@ describe("tool.registry", () => { }), ) + // altimate_change — end-to-end: a resolved tool's `registrySource` drives the source badge. + // A file-scanned custom tool must classify neutral ("builtin"), never "altimate" — the id-based + // fallback alone would wrongly call any non-native id "altimate" (reviewer finding on #980). + it.instance("resolved custom tools carry registrySource=external and classify neutral", () => + Effect.gen(function* () { + const test = yield* TestInstance + const tool = path.join(test.directory, ".opencode", "tool") + yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tool, "deploy.ts"), + [ + "export default {", + " description: 'a user custom tool',", + " args: {},", + " execute: async () => 'ok',", + "}", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const agent = yield* Agent.Service + const resolved = yield* registry.tools({ + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + agent: yield* agent.defaultInfo(), + }) + + const custom = resolved.find((t) => t.id === "deploy") + expect(custom?.registrySource).toBe("external") + expect(registryToolSource(custom!.id, custom!.registrySource)).toBe("builtin") + + // A native tool declares no origin and classifies builtin via the id fallback. + const read = resolved.find((t) => t.id === "read") + expect(read?.registrySource).toBeUndefined() + expect(registryToolSource(read!.id, read!.registrySource)).toBe("builtin") + + // An Altimate tool declares no origin and classifies altimate via the id fallback. + const altimate = resolved.find((t) => t.id === "sql_analyze") + expect(altimate?.registrySource).toBeUndefined() + expect(registryToolSource(altimate!.id, altimate!.registrySource)).toBe("altimate") + + // The shared stamping helper matches, end-to-end. + expect(stampRegistryToolSource({ metadata: {} }, custom!).metadata.source).toBe("builtin") + expect(stampRegistryToolSource({ metadata: {} }, altimate!).metadata.source).toBe("altimate") + }), + ) + it.instance("ignores non-tool exports in .opencode/tool files", () => Effect.gen(function* () { const test = yield* TestInstance