diff --git a/packages/opencode/src/altimate/tool-label.ts b/packages/opencode/src/altimate/tool-label.ts new file mode 100644 index 000000000..30213c2b5 --- /dev/null +++ b/packages/opencode/src/altimate/tool-label.ts @@ -0,0 +1,98 @@ +/** + * 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 +} + +/** 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: + * - 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 + 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 +} + +/** 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..e26bad8ce --- /dev/null +++ b/packages/opencode/src/altimate/tool-source.ts @@ -0,0 +1,176 @@ +/** + * 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 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 + * with no per-tool maintenance here. + */ +const NATIVE_TOOL_IDS = new Set([ + "invalid", + "question", + "bash", + "batch", + "read", + "glob", + "grep", + "list", + "edit", + "write", + "multiedit", + "task", + "webfetch", + "todowrite", + "todoread", + "websearch", + "codesearch", + "skill", + "apply_patch", + "lsp", + "plan_exit", + "plan_enter", + "StructuredOutput", +]) + +/** 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. 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 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(clientName: string): ToolSource { + const client = clientName.toLowerCase() + return ALTIMATE_MCP_PREFIXES.some((p) => client === p || client === `${p}s` || client.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" +} + +/** 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 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, 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 a953653cd..289bd8a74 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -71,6 +71,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 { stampRegistryToolSource, describeMcpTool } from "../altimate/tool-source" // altimate_change end import { Telemetry } from "@/telemetry" // altimate_change — session telemetry @@ -1607,6 +1609,9 @@ export namespace SessionPrompt { messageID: input.processor.message.id, })), } + // altimate_change — stamp authoritative tool source so clients render the right badge. + // 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", { @@ -1615,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 @@ -1700,14 +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 }), + source: described.source, } return { - title: "", + // altimate_change — MCP tools have no native title; give a readable label + 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/skill.ts b/packages/opencode/src/tool/skill.ts index 78ea4d645..53a1dc481 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -17,10 +17,22 @@ 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. + // 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) || + normalized.includes(".altimate/builtin") + ) + return "builtin" + if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global" return "project" } // altimate_change end @@ -147,6 +159,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 +171,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 +204,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 001ae084a..e6bd4c56b 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -17,6 +17,9 @@ import { type LegacyInitFn, } from "../altimate/tool-zod-compat" // altimate_change end +// altimate_change start — humanize tool-call titles at the source +import { describeToolCall } from "../altimate/tool-label" +// altimate_change end interface Metadata { [key: string]: any @@ -92,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 @@ -276,11 +284,14 @@ function wrap, Result extends Metadat ) // altimate_change start — telemetry instrumentation for tool execution const startTime = Date.now() - const result = yield* execute(decoded as Schema.Schema.Type, ctx).pipe( + const rawResult = yield* execute(decoded as Schema.Schema.Type, ctx).pipe( Effect.onError((cause) => Effect.sync(() => altimateTrackError(id, decoded, ctx, startTime, Cause.squash(cause))), ), ) + // humanize the tool-call title at the source so any client (chat webview, + // TUI, ...) can render a readable label from state.title. + const result = { ...rawResult, title: describeToolCall(id, decoded, rawResult.title) ?? rawResult.title } altimateTrackSuccess(id, decoded, ctx, startTime, result) // altimate_change end if (result.metadata.truncated !== undefined) { 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..6774e5bc0 --- /dev/null +++ b/packages/opencode/test/altimate/tool-label.test.ts @@ -0,0 +1,73 @@ +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("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]"), + ).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..a6a16ef5f --- /dev/null +++ b/packages/opencode/test/altimate/tool-source.test.ts @@ -0,0 +1,136 @@ +import { describe, test, expect } from "bun:test" +import { + registryToolSource, + mcpToolSource, + humanizeMcpTitle, + skillToolSource, + stampRegistryToolSource, + describeMcpTool, +} from "../../src/altimate/tool-source" + +describe("registryToolSource", () => { + 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("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", () => { + 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 client → altimate", () => { + expect(mcpToolSource("datamates")).toBe("altimate") + expect(mcpToolSource("datamate")).toBe("altimate") + expect(mcpToolSource("Datamates")).toBe("altimate") + }) + + 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", () => { + expect(mcpToolSource("datamatex")).toBe("mcp") + expect(mcpToolSource("datamateworks")).toBe("mcp") + }) + + 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 (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 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..3ede7dcf7 --- /dev/null +++ b/packages/opencode/test/tool/skill-source.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect } from "bun:test" +import path from "path" +import os from "os" +import { classifySkillSource } from "../../src/tool/skill" +import { skillToolSource } from "../../src/altimate/tool-source" + +// The end-to-end "load a skill through the tool and read metadata.skillOrigin" +// path is exercised by the serve E2E. These unit tests pin the pure decision +// logic that drives the source badge: classifySkillSource (location → origin) +// and skillToolSource (origin → badge). + +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("a third-party skill under some other node_modules is NOT tagged Altimate", () => { + expect(classifySkillSource("/work/repo/node_modules/some-other-pkg/skills/x/SKILL.md")).toBe("project") + }) + + 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 origin → source badge (skillToolSource ∘ classifySkillSource)", () => { + test("an Altimate-shipped skill wears the Altimate mark", () => { + expect(skillToolSource(classifySkillSource("/home/x/.altimate/builtin/pii-audit/SKILL.md"))).toBe("altimate") + expect(skillToolSource(classifySkillSource("builtin:data-viz/SKILL.md"))).toBe("altimate") + }) + + test("a project / user-authored skill stays neutral", () => { + expect(skillToolSource(classifySkillSource("/work/repo/.claude/skills/jaffle-glossary/SKILL.md"))).toBe("builtin") + expect(skillToolSource(classifySkillSource(path.join(os.homedir(), ".claude", "skills", "mine", "SKILL.md")))).toBe( + "builtin", + ) + }) +}) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 48da3aae0..47f5741d0 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -306,14 +306,16 @@ describe("tool.write", () => { }) describe("title generation", () => { - it.instance("returns relative path as title", () => + it.instance("humanizes the title to a readable label", () => Effect.gen(function* () { const test = yield* TestInstance const filepath = path.join(test.directory, "src", "components", "Button.tsx") yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) - 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. + expect(result.title).toBe("Writing Button.tsx") }), ) })