Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions packages/opencode/src/altimate/tool-label.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, string> = {
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 → "<name> <kind>" 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()]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Non-dbt files under common directories like src/models can be shown as dbt objects because friendlyTarget() applies the dbt noun to any path segment named models, tests, or macros, regardless of the target file type. That makes labels such as Reading User.tsx model possible outside dbt projects; consider only applying the dbt noun when the target looks like a dbt resource (for example known dbt file extensions) and otherwise falling back to the basename.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/tool-label.ts, line 50:

<comment>Non-dbt files under common directories like `src/models` can be shown as dbt objects because `friendlyTarget()` applies the dbt noun to any path segment named `models`, `tests`, or `macros`, regardless of the target file type. That makes labels such as `Reading User.tsx model` possible outside dbt projects; consider only applying the dbt noun when the target looks like a dbt resource (for example known dbt file extensions) and otherwise falling back to the basename.</comment>

<file context>
@@ -0,0 +1,89 @@
+  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, "")
</file context>

Comment on lines +49 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 MEDIUM] Iterating left-to-right (from the root down) can incorrectly match an outer directory that coincidentally shares a name with a dbt folder, rather than the intended specific directory (e.g., an absolute path like /Users/user/models/my_project/macros/utils.sql would match models instead of macros, returning utils model instead of utils macro).

Consider iterating right-to-left (from the file upwards) to match the most specific parent directory.

Suggested change:

Suggested change
for (const segment of segments.slice(0, -1)) {
const kind = DBT_DIR_KIND[segment.toLowerCase()]
for (let i = segments.length - 2; i >= 0; i--) {
const segment = segments[i]
const kind = DBT_DIR_KIND[segment.toLowerCase()]

if (kind) {
const name = base.replace(/\.(sql|ya?ml|csv)$/i, "")
return `${name} ${kind}`
}
Comment on lines +51 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 MEDIUM] The regex misses .py (for Python models) and .md (for dbt documentation files), which are both common in dbt projects. Without these, a Python model would render as model.py model rather than model model. Consider adding them to the regex.

Suggested change:

Suggested change
if (kind) {
const name = base.replace(/\.(sql|ya?ml|csv)$/i, "")
return `${name} ${kind}`
}
if (kind) {
const name = base.replace(/\.(sql|ya?ml|csv|py|md)$/i, "")
return `${name} ${kind}`
}

}
return base
}
Comment on lines +46 to +57

/** Extract the display target for a given file tool from its input args. */
function fileTarget(tool: string, input: Record<string, unknown>): 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<string, unknown>)
if (target) return `${verb} ${target}`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When list targets the worktree root (no path argument or empty relative path), fileTarget() returns undefined and asString(rawTitle) also returns undefined (since path.relative(worktree, worktree) is ""). The ?? fallback in tool.ts then yields the original empty-string title, producing a blank UI label. Consider producing a fallback like "Listing ." when a file tool has a verb but neither a usable target nor a non-empty raw title.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/tool-label.ts, line 85:

<comment>When `list` targets the worktree root (no path argument or empty relative path), `fileTarget()` returns `undefined` and `asString(rawTitle)` also returns `undefined` (since `path.relative(worktree, worktree)` is `""`). The `??` fallback in `tool.ts` then yields the original empty-string title, producing a blank UI label. Consider producing a fallback like `"Listing ."` when a file tool has a verb but neither a usable target nor a non-empty raw title.</comment>

<file context>
@@ -0,0 +1,89 @@
+  const verb = FILE_TOOL_VERBS[tool]
+  if (verb && input && typeof input === "object") {
+    const target = fileTarget(tool, input as Record<string, unknown>)
+    if (target) return `${verb} ${target}`
+  }
+  // Non-file / rich-title tools: keep the title the tool already emitted.
</file context>

}
// Non-file / rich-title tools: keep the title the tool already emitted.
return fallback
}
Comment on lines +80 to +89
87 changes: 87 additions & 0 deletions packages/opencode/src/altimate/tool-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Authoritative classification of a tool call's origin, stamped onto the tool
* part's `state.metadata.source` so clients (chat webview, ...) render the right
* badge without re-deriving it from tool-name prefixes.
*
* - "builtin" — native opencode tools (read/glob/bash/...)
* - "altimate" — Altimate-provided tools (sql_*, schema_*, finops_*, ...) AND
* tools from the Datamates MCP server (Altimate-owned, just
* delivered over MCP)
* - "mcp" — third-party MCP tools
*
* Registry tools and MCP tools are resolved in separate loops (see
* `session/prompt.ts` resolveTools), so each has its own classifier. The native
* `skill` tool is a further special case: it loads skills of varying origin, so
* its badge is classified per-call from the loaded skill's origin (see
* `skillToolSource`) rather than from the tool id.
*/
export type ToolSource = "builtin" | "altimate" | "mcp"

/**
* Native opencode tool ids. This set is small and stable; every other tool in
* the registry is Altimate-provided, so new Altimate tools classify correctly
* with no per-tool maintenance here.
*/
const NATIVE_TOOL_IDS = new Set<string>([

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: The native batch tool would be misclassified as altimate

batch is a native opencode tool (Tool.define("batch", ...) in src/tool/batch.ts, registered in ToolRegistry.all() behind experimental.batch_tool), but it is absent from NATIVE_TOOL_IDS. As a result registryToolSource("batch") returns "altimate", contradicting the comment above the set that claims every non-listed registry tool is Altimate-provided. Adding "batch" to this set keeps its source badge correct.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"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 `<client>_<tool>` 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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: MCP source badging can misclassify some third-party tools as Altimate because mcpToolSource matches datamate against the start of the entire key instead of the parsed <client> segment. Using a client-segment check (with _ boundary) would avoid false Altimate badges for similarly named external MCP clients.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/tool-source.ts, line 58:

<comment>MCP source badging can misclassify some third-party tools as Altimate because `mcpToolSource` matches `datamate` against the start of the entire key instead of the parsed `<client>` segment. Using a client-segment check (with `_` boundary) would avoid false Altimate badges for similarly named external MCP clients.</comment>

<file context>
@@ -0,0 +1,72 @@
+/** Classify an MCP tool by its `<client>_<tool>` 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"
+}
+
</file context>

}

/**
* 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 `<client>_<tool>`
* 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(" ")
}
15 changes: 14 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ import { registerAltimateValidators } from "../altimate/validators"
registerAltimateValidators()
import { Config } from "../config/config"
import { Tracer } from "../altimate/observability/tracing"
// altimate_change — stamp an authoritative tool source + humanized MCP title
import { registryToolSource, mcpToolSource, humanizeMcpTitle, skillToolSource } from "../altimate/tool-source"
// altimate_change end
import { Telemetry } from "@/telemetry" // altimate_change — session telemetry

Expand Down Expand Up @@ -1564,6 +1566,14 @@ export namespace SessionPrompt {
messageID: input.processor.message.id,
})),
}
// altimate_change — stamp authoritative tool source so clients render the right badge.
// The `skill` tool loads skills of varying origin, so classify it per-call from the
// origin it reports (Altimate-shipped → altimate mark, user global/project → neutral).
const metadata = output.metadata ?? {}
output.metadata = {
...metadata,
source: item.id === "skill" ? skillToolSource(metadata.skillOrigin) : registryToolSource(item.id),
}
await Plugin.trigger(
"tool.execute.after",
{
Expand Down Expand Up @@ -1655,10 +1665,13 @@ export namespace SessionPrompt {
...(result.metadata ?? {}),
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
// altimate_change — authoritative source so the chat can badge Datamates MCP tools
source: mcpToolSource(key),
}

return {
title: "",
// altimate_change — MCP tools have no native title; give a readable label
title: humanizeMcpTitle(key),
metadata,
output: truncated.content,
attachments: attachments.map((attachment) => ({
Expand Down
21 changes: 16 additions & 5 deletions packages/opencode/src/tool/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>/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"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The normalized.includes("node_modules") check over-classifies third-party npm packages as Altimate-shipped skills. Any skill whose path traverses a node_modules directory — even one installed from a completely unrelated npm package — would be classified as "builtin" and subsequently earn the Altimate badge. Scoping this to node_modules/@altimateai/ would match the intent shown by the test case while preventing misrepresentation of third-party skill provenance.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tool/skill.ts, line 26:

<comment>The `normalized.includes("node_modules")` check over-classifies third-party npm packages as Altimate-shipped skills. Any skill whose path traverses a `node_modules` directory — even one installed from a completely unrelated npm package — would be classified as `"builtin"` and subsequently earn the Altimate badge. Scoping this to `node_modules/@altimateai/` would match the intent shown by the test case while preventing misrepresentation of third-party skill provenance.</comment>

<file context>
@@ -17,10 +17,15 @@ import os from "os"
+  const normalized = location.replace(/\\/g, "/")
+  // Embedded skills load with a `builtin:<name>/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"
</file context>
Suggested change
if (normalized.startsWith("builtin:") || normalized.includes("node_modules") || normalized.includes(".altimate/builtin"))
if (normalized.startsWith("builtin:") || normalized.includes("node_modules/@altimateai/") || normalized.includes(".altimate/builtin"))

return "builtin"
if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new home-directory classification uses startsWith(...) without a directory boundary check, so project paths that only share a home prefix can be reported as global. This can skew skill_source telemetry and make origin classification less reliable; checking home equality or home + '/' would avoid collisions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tool/skill.ts, line 28:

<comment>The new home-directory classification uses `startsWith(...)` without a directory boundary check, so project paths that only share a home prefix can be reported as `global`. This can skew `skill_source` telemetry and make origin classification less reliable; checking `home` equality or `home + '/'` would avoid collisions.</comment>

<file context>
@@ -17,10 +17,15 @@ import os from "os"
+  // 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"
 }
</file context>
Suggested change
if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global"
if (
normalized === os.homedir().replace(/\\/g, "/").replace(/\/+$/, "") ||
normalized.startsWith(`${os.homedir().replace(/\\/g, "/").replace(/\/+$/, "")}/`)
)
return "global"

return "project"
}
Comment on lines +20 to 30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Unscoped node_modules substring check over-classifies third-party skills as Altimate-shipped.

normalized.includes("node_modules") matches any path containing node_modules, not specifically Altimate's own packages. A user who installs an unrelated third-party skill package (e.g., via npm) that happens to live under any node_modules directory will be misclassified as "builtin" and subsequently earn the Altimate badge via skillToolSource, misrepresenting the skill's provenance to the user. The companion test (/app/node_modules/@altimateai/pkg/skills/x/SKILL.md) implies the intent is scoped to Altimate's own npm scope, but the implementation doesn't enforce that scope.

🐛 Proposed fix to scope the node_modules check
-  if (normalized.startsWith("builtin:") || normalized.includes("node_modules") || normalized.includes(".altimate/builtin"))
+  if (
+    normalized.startsWith("builtin:") ||
+    normalized.includes("node_modules/@altimateai/") ||
+    normalized.includes(".altimate/builtin")
+  )
     return "builtin"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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:<name>/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 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:<name>/SKILL.md` location and Altimate's
// bundled skills land under `~/.altimate/builtin/` — both are Altimate-shipped.
if (
normalized.startsWith("builtin:") ||
normalized.includes("node_modules/@altimateai/") ||
normalized.includes(".altimate/builtin")
)
return "builtin"
if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global"
return "project"
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/tool/skill.ts` around lines 20 - 30, The
classifySkillSource helper is over-classifying any path containing node_modules
as builtin, which can mislabel third-party skills as Altimate-shipped. Tighten
the check in classifySkillSource so only Altimate-owned npm packages are treated
as builtin, using the existing builtin:/ and .altimate/builtin logic plus a
scoped package-path match (for example, the Altimate namespace used by the
tests). Keep the global and project fallbacks unchanged, and make sure
skillToolSource continues to rely on the corrected classification.

// altimate_change end
Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/test/altimate/tool-label.test.ts
Original file line number Diff line number Diff line change
@@ -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 <name> 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")
})
})
57 changes: 57 additions & 0 deletions packages/opencode/test/altimate/tool-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, test, expect } from "bun:test"
import { registryToolSource, mcpToolSource, humanizeMcpTitle, skillToolSource } from "../../src/altimate/tool-source"

describe("registryToolSource", () => {
test("native opencode tools → builtin", () => {
for (const id of ["read", "write", "edit", "glob", "grep", "list", "bash", "task", "skill", "apply_patch"]) {
expect(registryToolSource(id)).toBe("builtin")
}
})

test("any non-native registry tool → altimate (incl. tools not enumerated here)", () => {
for (const id of ["sql_analyze", "schema_inspect", "finops_query_history", "altimate_core_check", "data_diff", "some_new_altimate_tool"]) {
expect(registryToolSource(id)).toBe("altimate")
}
})
})

describe("skillToolSource", () => {
test("Altimate-shipped (builtin origin) skills → altimate", () => {
expect(skillToolSource("builtin")).toBe("altimate")
})

test("user-authored global/project skills → builtin (neutral)", () => {
expect(skillToolSource("global")).toBe("builtin")
expect(skillToolSource("project")).toBe("builtin")
})

test("missing or unexpected origin → builtin (neutral, never over-claims)", () => {
expect(skillToolSource(undefined)).toBe("builtin")
expect(skillToolSource(null)).toBe("builtin")
expect(skillToolSource("")).toBe("builtin")
expect(skillToolSource(42)).toBe("builtin")
})
})

describe("mcpToolSource", () => {
test("Datamates MCP tools → altimate", () => {
expect(mcpToolSource("datamates_jira_get_issue")).toBe("altimate")
expect(mcpToolSource("datamate_snowflake_query")).toBe("altimate")
})

test("third-party MCP tools → mcp", () => {
expect(mcpToolSource("github_search_issues")).toBe("mcp")
expect(mcpToolSource("linear_create_issue")).toBe("mcp")
})
})

describe("humanizeMcpTitle", () => {
test("strips the client segment and Title-Cases the rest", () => {
expect(humanizeMcpTitle("datamates_jira_get_issue")).toBe("Jira Get Issue")
expect(humanizeMcpTitle("github_search_issues")).toBe("Search Issues")
})

test("falls back gracefully for single-segment keys", () => {
expect(humanizeMcpTitle("ping")).toBe("Ping")
})
})
Loading
Loading