From fd13c9b35b3a512b2e6e23831615bd055e8436f1 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 12 Sep 2026 08:17:20 +0800 Subject: [PATCH 01/14] feat: read the engine's unfulfilled-keys report instead of diffing declared vs delivered The MCP catalog now keeps the `_meta` of a server's last tools/list page per client, exposed as `MCP.listMeta(name)`. On attach, the gaps come from the engine's `ai.altimate/unfulfilled` report, grouped by reason in the toast and headless line with the engine's detail (e.g. `spawn docker ENOENT`); `no-bridge` entries stay out of the missing set as before. The attached outcome carries the full report. `MIN_ENGINE_VERSION` moves to 0.7.2, the first engine that emits it; an engine that sends none claims no gaps rather than inventing them. Closes #1307 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../src/altimate/workspace/engine-overlay.ts | 35 ++++-- .../src/altimate/workspace/engine-seams.ts | 1 + .../src/altimate/workspace/engine-types.ts | 107 ++++++++++++++++-- packages/opencode/src/mcp/catalog.ts | 27 ++++- packages/opencode/src/mcp/index.ts | 22 ++++ .../workspace/engine-install-offer.test.ts | 1 + .../altimate/workspace/engine-overlay.test.ts | 99 ++++++++++++++-- .../altimate/workspace/engine-types.test.ts | 67 ++++++++++- .../test/mcp/catalog-list-meta.test.ts | 74 ++++++++++++ packages/opencode/test/session/prompt.test.ts | 1 + .../test/session/snapshot-tool-race.test.ts | 1 + 11 files changed, 400 insertions(+), 35 deletions(-) create mode 100644 packages/opencode/test/mcp/catalog-list-meta.test.ts diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 521ef8c2e..13d6ce12c 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -45,6 +45,8 @@ import { clearsFloor, describeExtensionServed, describeMissing, + parseUnfulfilled, + reportedMissing, describeRefusal, engineEntry, engineToolKeys, @@ -338,6 +340,7 @@ function mcp() { add: (name: string, cfg: LocalMcpConfig | McpEntry) => MCP.add(name, cfg as Parameters[1]), remove: (name: string) => MCP.remove(name), tools: () => MCP.tools() as Promise>, + listMeta: (name: string) => MCP.listMeta(name), } ) } @@ -645,12 +648,18 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS return } - const present = engineToolKeys(await mcp().tools()) - const missing = declared ? declared.keys.filter((k) => !present.has(k)) : undefined + const [tools, meta] = await Promise.all([mcp().tools(), mcp().listMeta(DATAMATE_KEY)]) + const present = engineToolKeys(tools) + // The gaps come from the engine's own report, with reasons; this client no + // longer diffs the allowlist against what arrived. No report (nothing at or + // above the floor omits it) means no gap is claimed, not that there is none. + const unfulfilled = parseUnfulfilled(meta) + const missingReport = unfulfilled === undefined ? undefined : reportedMissing(unfulfilled) + const missing = missingReport?.map((u) => u.key) // `available` is everything the engine serves under the key. The engine adds // tools beyond the allowlist (knowledge, memory) when the workspace enables // them, so the "N of M declared" line counts only the declared ones present. - const served = declared ? declared.keys.length - (missing?.length ?? 0) : present.size + const served = declared ? declared.keys.filter((k) => present.has(k)).length : present.size // Extension-declared tools appear in `present` only while the engine holds a // live IDE bridge; when they do they are real capability and the line names // them, but their absence is the normal no-IDE case, never `missing`. @@ -658,7 +667,9 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS const outcome: Outcome = { kind: "attached", available: present.size, - ...(declared ? { declared: declared.keys.length, missing } : {}), + ...(declared ? { declared: declared.keys.length } : {}), + ...(missing === undefined ? {} : { missing }), + ...(unfulfilled === undefined ? {} : { unfulfilled }), } const rec = record(sessionID, outcome) // Keyed on the workspace too: a re-link with an identical inventory is still @@ -666,22 +677,26 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // extServed is part of what the user hears, so it is part of the signature: // an equal-count tool swap that changes only the extension share must still // re-announce. (bot review) - const signature = `attached:${workspace.key}:${outcome.available}:${outcome.declared ?? "?"}:${(missing ?? []).join(",")}:${extServed}` + // A gap whose reason changed (a connection fixed, a binary still absent) + // is a new verdict too, so the reasons are in the signature. + const gaps = (missingReport ?? []).map((u) => `${u.key}=${u.reason}`).join(",") + const signature = `attached:${workspace.key}:${outcome.available}:${outcome.declared ?? "?"}:${gaps}:${extServed}` if (rec.announced === signature) return rec.announced = signature log.info("workspace engine attached", { workspaceId: workspace.id, available: outcome.available, declared: outcome.declared, - missing, + unfulfilled, }) if (isHeadless()) return + const headline = declared + ? `${served} of ${declared.keys.length} declared integration tools available.` + : `${outcome.available} integration tools available.` await notify({ title: `Workspace "${workspace.name}"`, - message: declared - ? `${served} of ${declared.keys.length} declared integration tools available.${describeMissing(missing ?? [])}${describeExtensionServed(extServed)}` - : `${outcome.available} integration tools available.`, - variant: missing && missing.length > 0 ? "warning" : "info", + message: `${headline}${describeMissing(missingReport ?? [])}${describeExtensionServed(extServed)}`, + variant: missingReport !== undefined && missingReport.length > 0 ? "warning" : "info", }) } diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 093a02b97..b499617e1 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -50,6 +50,7 @@ export const syncInternals: { add: (name: string, cfg: LocalMcpConfig | McpEntry) => Promise remove: (name: string) => Promise tools: () => Promise> + listMeta: (name: string) => Promise | undefined> } config?: { invalidate: () => Promise diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 070c26dc6..84e3336ed 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -14,9 +14,11 @@ import { DATAMATE_KEY } from "@/altimate/datamate-transport" * workspace promise rests on: integrations configured purely in the workspace * UI must produce working tools with no local files. It also passes the * resolved connection to MCP-type handlers, so their credential placeholders - * resolve. A 0.7.0 engine holds the pin but serves none of those tools, which - * is why the floor is 0.7.1. */ -export const MIN_ENGINE_VERSION = "0.7.1" + * resolve. A 0.7.0 engine holds the pin but serves none of those tools. 0.7.2 + * is the first that reports, on every tools/list, the allowlist keys it could + * not serve and why (`UNFULFILLED_META_KEY`); this client no longer diffs the + * allowlist itself, so below 0.7.2 it would announce no gaps at all. */ +export const MIN_ENGINE_VERSION = "0.7.2" export const ENGINE_PACKAGE = "@altimateai/datamate" export const ENGINE_BINARY = "datamate" export const INSTALL_COMMAND = `npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}` @@ -29,7 +31,16 @@ export const TOOL_PREFIX = `${DATAMATE_KEY}_` export type Outcome = | { kind: "disabled" } | { kind: "unbound" } - | { kind: "attached"; available: number; declared?: number; missing?: string[] } + | { + kind: "attached" + available: number + declared?: number + /** Keys of `unfulfilled` that count as gaps (see `reportedMissing`). */ + missing?: string[] + /** The engine's full report, `no-bridge` entries included; absent when + * the engine sent none. */ + unfulfilled?: Unfulfilled[] + } | { kind: "engine-missing"; declared?: number } /** `found` is null when the binary ran but printed nothing usable — broken * rather than old; the message says so. */ @@ -207,11 +218,91 @@ export function describeRefusal( ) } -export function describeMissing(missing: string[]): string { +/** Where the engine (0.7.2+) reports the allowlist keys it could not serve, + * on every tools/list response, so the client never diffs the allowlist + * against what arrived: a diff can name the keys, never the reason. */ +export const UNFULFILLED_META_KEY = "ai.altimate/unfulfilled" + +export type UnfulfilledReason = + | "catalog-missing" + | "invalid-connection" + | "spawn-failed" + | "no-bridge" + | "unknown-key" + | "exception" + +/** One declared key the engine did not serve, in the engine's own words. A + * reason outside the known set is kept verbatim: a newer engine may add one. */ +export type Unfulfilled = { + key: string + integrationId: string + reason: UnfulfilledReason | (string & {}) + detail?: string +} + +/** The engine's report out of a tools/list `_meta`. Undefined when there is + * none, or it is malformed: the caller then knows nothing about gaps, which + * is not the same as knowing there are none. */ +export function parseUnfulfilled(meta: Record | undefined): Unfulfilled[] | undefined { + const raw = meta?.[UNFULFILLED_META_KEY] + if (!Array.isArray(raw)) return undefined + const out: Unfulfilled[] = [] + for (const item of raw) { + if (typeof item !== "object" || item === null) return undefined + const { key, integrationId, reason, detail } = item as Record + if (typeof key !== "string" || typeof integrationId !== "string" || typeof reason !== "string") return undefined + out.push({ key, integrationId, reason, ...(typeof detail === "string" && detail !== "" ? { detail } : {}) }) + } + return out +} + +/** Absent extension tools without an IDE window are expected, not missing: + * `no-bridge` entries never join the "declared but not available" line. + * Everything else the engine reports is a real gap. */ +export function reportedMissing(unfulfilled: Unfulfilled[]): Unfulfilled[] { + return unfulfilled.filter((u) => u.reason !== "no-bridge") +} + +const REASON_PHRASE: Record = { + "invalid-connection": "no usable connection", + "spawn-failed": "server failed to start", + "catalog-missing": "no longer in the catalog", + "unknown-key": "not offered by the integration", + exception: "failed to load", + "no-bridge": "needs a VS Code window", +} + +const MISSING_SHOWN = 5 +const DETAIL_CHARS = 60 + +/** The gaps, grouped by reason in report order, at most `MISSING_SHOWN` keys + * across the groups; a group's first detail (the engine's error text, e.g. + * `spawn docker ENOENT`) stands for the group. */ +export function describeMissing(missing: Unfulfilled[]): string { if (missing.length === 0) return "" - const shown = missing.slice(0, 5).join(", ") - const more = missing.length > 5 ? ` (+${missing.length - 5} more)` : "" - return ` Declared but not available: ${shown}${more}.` + const groups = new Map() + for (const u of missing) { + const group = groups.get(u.reason) ?? { keys: [] } + group.keys.push(u.key) + if (group.detail === undefined && u.detail) group.detail = u.detail + groups.set(u.reason, group) + } + let budget = MISSING_SHOWN + const parts: string[] = [] + for (const [reason, group] of groups) { + if (budget <= 0) break + const shown = group.keys.slice(0, budget) + budget -= shown.length + const phrase = (REASON_PHRASE as Record)[reason] ?? reason + const detail = group.detail === undefined ? "" : ` (${truncate(group.detail, DETAIL_CHARS)})` + parts.push(`${phrase}${detail}: ${shown.join(", ")}`) + } + const more = missing.length > MISSING_SHOWN ? ` (+${missing.length - MISSING_SHOWN} more)` : "" + return ` Declared but not available — ${parts.join("; ")}${more}.` +} + +function truncate(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max - 1)}…` } /** Extension-declared tools a connected IDE bridge is actually serving. Zero diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index f7fbcf3ec..b26b369a9 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -15,6 +15,18 @@ import z from "zod/v4" const DEFAULT_TIMEOUT = 30_000 const MAX_LIST_PAGES = 1_000 +// altimate_change start — keep the `_meta` of a server's last tools/list page. +// `paginate` keeps only each page's items, so the result object — the sole +// carrier of `_meta` — is dropped. The workspace engine reports the allowlist +// keys it could not serve there (altimate/workspace/engine-types). Kept per +// client, cleared when a listing starts, set by any page that carries one. +const listMetaByClient = new WeakMap>() + +export function listMeta(client: Client): Record | undefined { + return listMetaByClient.get(client) +} +// altimate_change end + // altimate_change start — Microsoft Fabric Core MCP returns `null` (instead of // omitting the field) for `tool.annotations.{readOnlyHint,destructiveHint, // idempotentHint,openWorldHint}`, which the SDK's strict schema (boolean, @@ -150,8 +162,10 @@ export function resources(client: Client, timeout?: number) { function listTools(client: Client, timeout: number) { return Effect.tryPromise({ - try: () => - paginate( + try: () => { + // altimate_change — a fresh listing starts with no `_meta` (see listMeta). + listMetaByClient.delete(client) + return paginate( async (cursor) => { const params = cursor === undefined ? undefined : { cursor } try { @@ -169,8 +183,13 @@ function listTools(client: Client, timeout: number) { // altimate_change end } }, - (result) => result.tools, - ), + (result) => { + // altimate_change — remember this page's `_meta` (see listMeta). + if (result._meta !== undefined) listMetaByClient.set(client, result._meta as Record) + return result.tools + }, + ) + }, catch: (error) => (error instanceof Error ? error : new Error(String(error))), }) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index d5dbb65b9..08dadcd86 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -332,6 +332,11 @@ export interface Interface { // without re-deriving the merge. readonly entry: (name: string) => Effect.Effect // altimate_change end + // altimate_change start — the `_meta` of a connected server's last tools/list + // (undefined while not connected, or when the server sent none). The + // workspace engine reports the allowlist keys it could not serve there. + readonly listMeta: (name: string) => Effect.Effect | undefined> + // altimate_change end } export class Service extends Context.Service()("@opencode/MCP") {} @@ -915,6 +920,15 @@ export const layer = Layer.effect( return s.clients }) + // altimate_change start — see Interface.listMeta + const listMeta = Effect.fn("MCP.listMeta")(function* (name: string) { + const s = yield* InstanceState.get(state) + const client = s.clients[name] + if (!client || s.status[name]?.status !== "connected") return undefined + return McpCatalog.listMeta(client) + }) + // altimate_change end + const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) { const s = yield* InstanceState.get(state) const result = yield* create(name, mcp) @@ -1360,6 +1374,9 @@ export const layer = Layer.effect( return Service.of({ status, clients, + // altimate_change start + listMeta, + // altimate_change end tools, prompts, resources, @@ -1413,6 +1430,11 @@ export async function status() { export async function tools() { return runMcp((svc) => svc.tools()) } +// altimate_change start — see Interface.listMeta +export async function listMeta(name: string) { + return runMcp((svc) => svc.listMeta(name)) +} +// altimate_change end // altimate_change start — see Interface.entry export async function entry(name: string) { return runMcp((svc) => svc.entry(name)) diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts index 4ec56fd29..ad6b58a0d 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -100,6 +100,7 @@ function install(opts: { add: async () => {}, remove: async () => {}, tools: async () => ({}), + listMeta: async () => undefined, } return h } diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 3b4ee48d6..cc2f1ab1c 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -20,6 +20,7 @@ import { resetForTests, settledOutcome, syncInternals, + UNFULFILLED_META_KEY, trackedSessionsForTests, type Declared, type LocalMcpConfig, @@ -44,6 +45,7 @@ type Harness = { statusError?: string onAdd?: () => void tools: Record + meta: Record | null added: Array removes: number gets: number @@ -70,6 +72,8 @@ function install(opts: { statusError?: string onAdd?: () => void tools?: Record + /** The engine's tools/list `_meta`; `null` models an engine that sends none. */ + meta?: Record | null mcp?: Record noMcpKey?: boolean managed?: boolean @@ -78,11 +82,12 @@ function install(opts: { config: opts.noMcpKey ? {} : { mcp: opts.mcp ?? {} }, binding: opts.binding === undefined ? bound(42) : opts.binding, which: opts.which === undefined ? "/usr/local/bin/datamate" : opts.which, - version: opts.version === undefined ? "0.7.1" : opts.version, + version: opts.version === undefined ? "0.7.2" : opts.version, status: opts.status ?? "connected", statusError: opts.statusError, onAdd: opts.onAdd, tools: opts.tools ?? { datamate_dbt_build_model: {}, datamate_dbt_compile_model: {} }, + meta: opts.meta === undefined ? { [UNFULFILLED_META_KEY]: [] } : opts.meta, added: [], removes: 0, gets: 0, @@ -131,6 +136,7 @@ function install(opts: { h.removes += 1 }, tools: async () => h.tools, + listMeta: async () => h.meta ?? undefined, } // Models the real Config cache: `get` loads once and is then served from // cache until `invalidate`; a load rebuilds the config from its sources (so @@ -390,17 +396,19 @@ describe("beforeTurn — what a turn boundary does", () => { }) test("a connected engine settles attached with the inventory and announces it once", async () => { - const h = install({}) + const report = [{ key: "dbt_execute_sql", integrationId: "dbt", reason: "invalid-connection" }] + const h = install({ meta: { [UNFULFILLED_META_KEY]: report } }) await beforeTurn("s1") expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2, declared: 3, missing: ["dbt_execute_sql"], + unfulfilled: report, }) expect(h.toasts).toHaveLength(1) expect(h.toasts[0].message).toContain("2 of 3 declared integration tools available") - expect(h.toasts[0].message).toContain("dbt_execute_sql") + expect(h.toasts[0].message).toContain("no usable connection: dbt_execute_sql") // The engine was started by MCP bootstrap from the injected entry, not by the hook. expect(h.added).toEqual([]) await beforeTurn("s1") @@ -414,14 +422,14 @@ describe("beforeTurn — what a turn boundary does", () => { declared: { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: [] }, }) await beforeTurn("s1") - expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [] }) + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [], unfulfilled: [] }) expect(h.toasts[0].message).toBe("2 of 2 declared integration tools available.") }) test("attached without an allowlist reports only what is available", async () => { const h = install({ declared: null }) await beforeTurn("s1") - expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2 }) + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2, missing: [], unfulfilled: [] }) expect(h.toasts[0].message).toBe("2 integration tools available.") }) @@ -433,13 +441,90 @@ describe("beforeTurn — what a turn boundary does", () => { await beforeTurn("s1") // `run_model` is declared extension-type but no bridge serves it: that is // the normal no-IDE case, so the outcome stays clean and unwarned. - expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [] }) + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [], unfulfilled: [] }) expect(h.toasts[0].message).toBe( "2 of 2 declared integration tools available. Plus 1 extension tool via the connected VS Code window.", ) expect(h.toasts[0].variant).toBe("info") }) + test("gaps come from the engine's report, with reasons — not from a client-side diff", async () => { + // The report names a key the allowlist lookup never saw (`gh_list_prs`): + // it is still a gap, because the engine says so. + const report = [ + { key: "dbt_execute_sql", integrationId: "dbt", reason: "invalid-connection" }, + { key: "gh_list_prs", integrationId: "github-mcp", reason: "spawn-failed", detail: "spawn docker ENOENT" }, + { key: "gh_create_pr", integrationId: "github-mcp", reason: "spawn-failed", detail: "spawn docker ENOENT" }, + ] + const h = install({ meta: { [UNFULFILLED_META_KEY]: report } }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toMatchObject({ missing: ["dbt_execute_sql", "gh_list_prs", "gh_create_pr"] }) + expect(h.toasts[0].message).toBe( + "2 of 3 declared integration tools available. Declared but not available — no usable connection: dbt_execute_sql; " + + "server failed to start (spawn docker ENOENT): gh_list_prs, gh_create_pr.", + ) + expect(h.toasts[0].variant).toBe("warning") + }) + + test("no-bridge entries in the report are expected, never missing", async () => { + const report = [ + { key: "get_projects", integrationId: "vscode-power-user", reason: "no-bridge" }, + { key: "run_model", integrationId: "vscode-power-user", reason: "no-bridge" }, + ] + const h = install({ meta: { [UNFULFILLED_META_KEY]: report } }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ + kind: "attached", + available: 2, + declared: 3, + missing: [], + unfulfilled: report, + }) + expect(h.toasts[0].message).toBe("2 of 3 declared integration tools available.") + expect(h.toasts[0].variant).toBe("info") + }) + + test("an engine that sends no report is not read as having no gaps", async () => { + const h = install({ meta: null }) + await beforeTurn("s1") + // Two of three declared keys are present; without the engine's report + // the third is neither claimed missing nor claimed served. + expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2, declared: 3 }) + expect(h.toasts[0].message).toBe("2 of 3 declared integration tools available.") + expect(h.toasts[0].variant).toBe("info") + }) + + test("the report names gaps even when the allowlist lookup failed", async () => { + const report = [{ key: "jira_search_issues", integrationId: "jira", reason: "invalid-connection" }] + const h = install({ declared: null, meta: { [UNFULFILLED_META_KEY]: report } }) + await beforeTurn("s1") + expect(settledOutcome("s1")).toEqual({ + kind: "attached", + available: 2, + missing: ["jira_search_issues"], + unfulfilled: report, + }) + expect(h.toasts[0].message).toBe( + "2 integration tools available. Declared but not available — no usable connection: jira_search_issues.", + ) + expect(h.toasts[0].variant).toBe("warning") + }) + + test("a gap whose reason changed is announced again", async () => { + const h = install({ + meta: { [UNFULFILLED_META_KEY]: [{ key: "gh_list_prs", integrationId: "github-mcp", reason: "spawn-failed" }] }, + }) + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.toasts).toHaveLength(1) + h.meta = { + [UNFULFILLED_META_KEY]: [{ key: "gh_list_prs", integrationId: "github-mcp", reason: "invalid-connection" }], + } + await beforeTurn("s1") + expect(h.toasts).toHaveLength(2) + expect(h.toasts[1].message).toContain("no usable connection: gh_list_prs") + }) + test("the inventory is announced per session, not per process", async () => { const h = install({}) await beforeTurn("s1") @@ -801,7 +886,7 @@ describe("beforeTurn — what a turn boundary does", () => { test("a failed probe is repeated on its own after the TTL", async () => { const h = install({ version: "0.6.3" }) await beforeTurn("s1") - h.version = "0.7.1" + h.version = "0.7.2" h.clock += FAILED_PROBE_TTL_MS await beforeTurn("s1") expect(h.added).toHaveLength(1) diff --git a/packages/opencode/test/altimate/workspace/engine-types.test.ts b/packages/opencode/test/altimate/workspace/engine-types.test.ts index 091f39b03..60682422e 100644 --- a/packages/opencode/test/altimate/workspace/engine-types.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-types.test.ts @@ -13,6 +13,9 @@ import { clearsFloor, compareVersions, describeMissing, + parseUnfulfilled, + reportedMissing, + UNFULFILLED_META_KEY, describeRefusal, engineEntry, engineToolKeys, @@ -53,10 +56,11 @@ describe("clearsFloor", () => { expect(clearsFloor(null)).toBe(false) expect(clearsFloor("")).toBe(false) expect(clearsFloor(MIN_ENGINE_VERSION)).toBe(true) - expect(clearsFloor("0.7.1")).toBe(true) + expect(clearsFloor("0.7.2")).toBe(true) expect(clearsFloor("1.0.0")).toBe(true) expect(clearsFloor("0.6.9")).toBe(false) - expect(clearsFloor("0.7.0")).toBe(false) // the previous floor no longer clears + expect(clearsFloor("0.7.1")).toBe(false) // the previous floor no longer clears: no unfulfilled report + expect(clearsFloor("0.7.0")).toBe(false) expect(clearsFloor(`${MIN_ENGINE_VERSION}-beta.1`)).toBe(false) expect(clearsFloor("0.7rc.0")).toBe(false) }) @@ -153,11 +157,62 @@ describe("messages", () => { "Update with: npm i -g @altimateai/datamate@next", ) }) - test("the missing list is truncated after five", () => { + test("the missing line groups by reason, carries the engine's detail, and truncates after five", () => { + const u = (key: string, reason: string, detail?: string) => ({ + key, + integrationId: "i", + reason, + ...(detail ? { detail } : {}), + }) expect(describeMissing([])).toBe("") - expect(describeMissing(["a", "b"])).toBe(" Declared but not available: a, b.") - expect(describeMissing(["a", "b", "c", "d", "e", "f", "g"])).toBe( - " Declared but not available: a, b, c, d, e (+2 more).", + expect(describeMissing([u("a", "invalid-connection"), u("b", "invalid-connection")])).toBe( + " Declared but not available — no usable connection: a, b.", + ) + expect( + describeMissing([ + u("a", "spawn-failed", "spawn docker ENOENT"), + u("b", "spawn-failed", "spawn docker ENOENT"), + u("c", "catalog-missing"), + u("d", "unknown-key"), + u("e", "exception", "boom"), + ]), + ).toBe( + " Declared but not available — server failed to start (spawn docker ENOENT): a, b; no longer in the catalog: c; " + + "not offered by the integration: d; failed to load (boom): e.", ) + expect(describeMissing(["a", "b", "c", "d", "e", "f", "g"].map((k) => u(k, "invalid-connection")))).toBe( + " Declared but not available — no usable connection: a, b, c, d, e (+2 more).", + ) + // A reason this client does not know is shown verbatim rather than dropped. + expect(describeMissing([u("a", "quota-exceeded")])).toBe(" Declared but not available — quota-exceeded: a.") + // A long detail is cut so the toast stays a toast. + expect(describeMissing([u("a", "exception", "x".repeat(80))])).toContain(`(${"x".repeat(59)}…)`) + }) + + test("the engine's report is read out of tools/list _meta, and nothing is invented", () => { + const report = [ + { key: "a", integrationId: "jira", reason: "invalid-connection" }, + { key: "b", integrationId: "gh", reason: "spawn-failed", detail: "spawn docker ENOENT" }, + { key: "c", integrationId: "pu", reason: "no-bridge", detail: "" }, + ] + expect(parseUnfulfilled({ [UNFULFILLED_META_KEY]: report })).toEqual([ + report[0], + report[1], + { key: "c", integrationId: "pu", reason: "no-bridge" }, + ]) + expect(parseUnfulfilled({ [UNFULFILLED_META_KEY]: [] })).toEqual([]) + expect(parseUnfulfilled(undefined)).toBeUndefined() + expect(parseUnfulfilled({})).toBeUndefined() + expect(parseUnfulfilled({ [UNFULFILLED_META_KEY]: "nope" })).toBeUndefined() + expect(parseUnfulfilled({ [UNFULFILLED_META_KEY]: [{ key: "a" }] })).toBeUndefined() + }) + + test("no-bridge entries are the only ones kept out of the missing set", () => { + const report = [ + { key: "a", integrationId: "jira", reason: "invalid-connection" }, + { key: "b", integrationId: "pu", reason: "no-bridge" }, + { key: "c", integrationId: "pu", reason: "unknown-key" }, + ] + expect(reportedMissing(report).map((u) => u.key)).toEqual(["a", "c"]) }) }) diff --git a/packages/opencode/test/mcp/catalog-list-meta.test.ts b/packages/opencode/test/mcp/catalog-list-meta.test.ts new file mode 100644 index 000000000..d8ffe5a9f --- /dev/null +++ b/packages/opencode/test/mcp/catalog-list-meta.test.ts @@ -0,0 +1,74 @@ +// altimate_change - new file +// +// The MCP catalog keeps the `_meta` of a server's last tools/list page per +// client (the workspace engine reports unserved allowlist keys there), even +// though pagination keeps only the tools themselves. +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import * as McpCatalog from "../../src/mcp/catalog" + +const KEY = "ai.altimate/unfulfilled" + +async function connected(listTools: () => Record) { + const server = new Server({ name: "fake", version: "0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, async () => listTools() as never) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: "test", version: "0" }) + await client.connect(clientTransport) + return { client, close: () => Promise.all([client.close(), server.close()]) } +} + +const echo = { name: "echo", description: "", inputSchema: { type: "object", properties: {} } } + +describe("McpCatalog.listMeta", () => { + test("keeps the last tools/list page's _meta next to the listed tools", async () => { + const report = [{ key: "jira_search_issues", integrationId: "jira", reason: "invalid-connection" }] + const { client, close } = await connected(() => ({ tools: [echo], _meta: { [KEY]: report } })) + try { + expect(McpCatalog.listMeta(client)).toBeUndefined() + const defs = await Effect.runPromise(McpCatalog.defs(client)) + expect(defs?.map((t) => t.name)).toEqual(["echo"]) + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: report }) + } finally { + await close() + } + }) + + test("a listing that carries no _meta clears what an earlier one left", async () => { + let withMeta = true + const { client, close } = await connected(() => + withMeta ? { tools: [echo], _meta: { [KEY]: [] } } : { tools: [echo] }, + ) + try { + await Effect.runPromise(McpCatalog.defs(client)) + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: [] }) + withMeta = false + await Effect.runPromise(McpCatalog.defs(client)) + expect(McpCatalog.listMeta(client)).toBeUndefined() + } finally { + await close() + } + }) + + test("_meta survives the multi-page path", async () => { + let page = 0 + const { client, close } = await connected(() => { + page += 1 + return page === 1 + ? { tools: [echo], nextCursor: "p2" } + : { tools: [{ ...echo, name: "echo2" }], _meta: { [KEY]: [] } } + }) + try { + const defs = await Effect.runPromise(McpCatalog.defs(client)) + expect(defs?.map((t) => t.name)).toEqual(["echo", "echo2"]) + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: [] }) + } finally { + await close() + } + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index cf14e14c2..43d45bb27 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -125,6 +125,7 @@ const mcp = Layer.succeed( status: () => Effect.succeed({}), clients: () => Effect.succeed({}), tools: () => Effect.succeed({}), + listMeta: () => Effect.succeed(undefined), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index c39aa1b83..6a6176bdc 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -38,6 +38,7 @@ const mcp = Layer.succeed( status: () => Effect.succeed({}), clients: () => Effect.succeed({}), tools: () => Effect.succeed({}), + listMeta: () => Effect.succeed(undefined), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), From b92dab63e6b8c8e17f6d80552c47eca728aeb41b Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 12 Sep 2026 08:21:09 +0800 Subject: [PATCH 02/14] test: end-to-end through the MCP service against a real engine Env-guarded (`ALTIMATE_ENGINE_E2E_ROOT`), skipped otherwise: spawns a built engine over stdio the way the overlay does, against a fake Altimate API and a real second MCP server, and reads the `ai.altimate/unfulfilled` report through `MCP.listMeta` into the attach toast text. The engine is a node shebang script, so the test spawns node rather than the bun test runner. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../test/mcp/engine-unfulfilled.e2e.test.ts | 245 ++++++++++++++++++ .../test/mcp/fixtures/echo-mcp-server.mjs | 9 + 2 files changed, 254 insertions(+) create mode 100644 packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts create mode 100644 packages/opencode/test/mcp/fixtures/echo-mcp-server.mjs diff --git a/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts b/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts new file mode 100644 index 000000000..2ce9e2899 --- /dev/null +++ b/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts @@ -0,0 +1,245 @@ +// altimate_change - new file +// +// End to end through the real MCP service: a real `@altimateai/datamate` +// engine (0.7.2+) is spawned over stdio the way the workspace overlay spawns +// it, against a fake Altimate API, and its `ai.altimate/unfulfilled` report +// arrives through the catalog as `MCP.listMeta(...)`, ready for the toast. +// +// Needs an engine checkout with a built `dist/cli.js`; skipped otherwise: +// ALTIMATE_ENGINE_E2E_ROOT=/path/to/altimate-mcp-engine bun test test/mcp/engine-unfulfilled.e2e.test.ts +import http from "node:http" +import path from "node:path" +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" +import { testEffect } from "../lib/effect" +import { MCP } from "../../src/mcp/index" +import { + describeMissing, + parseUnfulfilled, + reportedMissing, + UNFULFILLED_META_KEY, +} from "../../src/altimate/workspace/engine-types" + +const root = process.env["ALTIMATE_ENGINE_E2E_ROOT"] +// The engine ships as a node shebang script; under `bun test` the test runner's own +// executable is bun, which the engine cannot run on. +const node = Bun.which("node") ?? "node" +const cli = root ? path.join(root, "dist/cli.js") : undefined +const runnable = !!cli && existsSync(cli) +const it = testEffect(MCP.defaultLayer) + +const DATAMATE_ID = "77" + +// The workspace declares five integrations; the engine can serve one of them. +const catalog = [ + { + id: "jira", + type: "tool", + name: "Jira", + description: "", + url: "", + supportsLocalConnectionTest: true, + supportsSaasConnectionTest: false, + config: [ + { key: "url", name: "URL", type: "string", required: true }, + { key: "email", name: "Email", type: "string", required: true }, + { key: "token", name: "Token", type: "string", required: true }, + ], + tools: [{ key: "jira_search_issues", name: "Search issues" }], + }, + { + id: "vscode-power-user", + type: "extension", + name: "Power User for dbt", + description: "", + url: "", + supportsLocalConnectionTest: false, + supportsSaasConnectionTest: false, + config: [], + tools: [{ key: "pu_lineage", name: "Lineage" }], + }, +] +const custom = [ + { + id: "mcp-ok", + type: "mcp", + name: "Echo MCP", + description: "", + url: "", + config: [], + toolConfig: [ + { key: "type", name: "type", type: "string", required: false, value: "stdio" }, + { key: "command", name: "command", type: "string", required: true, value: node }, + { + key: "arguments", + name: "arguments", + type: "array", + required: false, + value: [path.join(import.meta.dir, "fixtures/echo-mcp-server.mjs")], + }, + ], + tools: [{ key: "echo" }, { key: "ghost" }], + }, + { + id: "mcp-missing-binary", + type: "mcp", + name: "Missing MCP", + description: "", + url: "", + config: [], + toolConfig: [ + { key: "type", name: "type", type: "string", required: false, value: "stdio" }, + { key: "command", name: "command", type: "string", required: true, value: "altimate-e2e-missing-binary" }, + ], + tools: [{ key: "whatever" }], + }, +] +const datamate = { + id: DATAMATE_ID, + name: "e2e", + description: "", + privacy: "private", + memory_enabled: false, + knowledge_engine_enabled: false, + knowledge_bases: [], + integrations: [ + { id: "jira", type: "tool", name: "Jira", description: "", url: "", tools: [{ key: "jira_search_issues" }] }, + { + id: "vscode-power-user", + type: "extension", + name: "PU", + description: "", + url: "", + tools: [{ key: "pu_lineage" }], + }, + { + id: "mcp-ok", + type: "mcp", + name: "Echo MCP", + description: "", + url: "", + tools: [{ key: "echo" }, { key: "ghost" }], + }, + { + id: "mcp-missing-binary", + type: "mcp", + name: "Missing MCP", + description: "", + url: "", + tools: [{ key: "whatever" }], + }, + { + id: "retired-integration", + type: "tool", + name: "Retired", + description: "", + url: "", + tools: [{ key: "retired_tool" }], + }, + ], +} + +async function fakeAltimateApi() { + const unhandled: string[] = [] + const server = http.createServer((req, res) => { + const p = new URL(req.url ?? "/", "http://x").pathname + const json = (code: number, body?: unknown) => { + res.writeHead(code, { "content-type": "application/json" }) + res.end(body === undefined ? "" : JSON.stringify(body)) + } + if (p === "/dbt/v3/validate-credentials") return json(200, { ok: true }) + if (p === "/datamates") return json(200, { datamates: [datamate] }) + if (p === "/datamate_integrations") return json(200, catalog) + if (p === "/datamate_integrations/custom") return json(200, { items: custom }) + if (p === "/mask") return json(200, { mask_data: [] }) + if (p === "/connections") return json(200, { connections: [] }) + if (p === `/datamates/${DATAMATE_ID}/knowledge_bases`) return json(200, { knowledge_bases: [] }) + if (p === `/datamates/${DATAMATE_ID}/knowledge_engine_description`) return json(200, {}) + if (p === "/datamates/audit/create_batch") return json(204) + unhandled.push(p) + return json(404, { detail: `unhandled ${p}` }) + }) + await new Promise((r) => server.listen(0, "127.0.0.1", r)) + const address = server.address() as { port: number } + return { url: `http://127.0.0.1:${address.port}`, unhandled, close: () => server.close() } +} + +function isolatedHome(apiUrl: string) { + const home = mkdtempSync(path.join(tmpdir(), "engine-unfulfilled-e2e-")) + mkdirSync(path.join(home, ".altimate"), { recursive: true }) + writeFileSync( + path.join(home, ".altimate/altimate.json"), + JSON.stringify({ altimateUrl: apiUrl, altimateInstanceName: "e2e", altimateApiKey: "e2e-key" }), + ) + writeFileSync(path.join(home, ".altimate/settings.json"), "{}") + writeFileSync(path.join(home, ".altimate/connections.json"), "[]") + return home +} + +describe.skipIf(!runnable)("engine unfulfilled report through the MCP service", () => { + it.instance( + "the engine's report reaches MCP.listMeta and reads as the attach toast", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + const api = yield* Effect.promise(fakeAltimateApi) + try { + const home = isolatedHome(api.url) + yield* mcp.add("datamate", { + type: "local", + command: [node, cli!, "start-stdio", "--datamate", DATAMATE_ID], + environment: { HOME: home }, + cwd: root!, + }) + const status = yield* mcp.status() + if (status["datamate"]?.status !== "connected") { + const logDir = path.join(home, ".altimate/logs") + const logs = existsSync(logDir) ? readdirSync(logDir) : [] + const tail = logs.map((f) => readFileSync(path.join(logDir, f), "utf8").slice(-1500)).join("\n") + throw new Error( + `engine not connected: ${JSON.stringify(status["datamate"])}\n--- engine log tail ---\n${tail}`, + ) + } + + const tools = yield* mcp.tools() + expect(Object.keys(tools).filter((k) => k.startsWith("datamate_"))).toEqual(["datamate_echo"]) + + const meta = yield* mcp.listMeta("datamate") + const report = parseUnfulfilled(meta) + expect(report).toBeDefined() + const byKey = Object.fromEntries(report!.map((u) => [u.key, u])) + expect(byKey["jira_search_issues"]).toMatchObject({ integrationId: "jira", reason: "invalid-connection" }) + expect(byKey["pu_lineage"]).toMatchObject({ integrationId: "vscode-power-user", reason: "no-bridge" }) + expect(byKey["ghost"]).toMatchObject({ integrationId: "mcp-ok", reason: "unknown-key" }) + expect(byKey["whatever"]).toMatchObject({ integrationId: "mcp-missing-binary", reason: "spawn-failed" }) + expect(byKey["whatever"]?.detail).toMatch(/ENOENT/) + expect(byKey["retired_tool"]).toMatchObject({ + integrationId: "retired-integration", + reason: "catalog-missing", + }) + expect(report!.some((u) => `datamate_${u.key}` in tools)).toBe(false) + + // What the user would read on attach: every gap but the IDE one, with reasons. + expect(describeMissing(reportedMissing(report!))).toBe( + " Declared but not available — no usable connection: jira_search_issues; " + + "not offered by the integration: ghost; " + + "server failed to start (spawn altimate-e2e-missing-binary ENOENT): whatever; " + + "no longer in the catalog: retired_tool.", + ) + expect(api.unhandled).toEqual([]) + yield* mcp.remove("datamate") + expect(yield* mcp.listMeta("datamate")).toBeUndefined() + } finally { + api.close() + } + }), + ), + 60_000, + ) +}) + +// Referenced so the key is visibly the contract this test exercises. +void UNFULFILLED_META_KEY diff --git a/packages/opencode/test/mcp/fixtures/echo-mcp-server.mjs b/packages/opencode/test/mcp/fixtures/echo-mcp-server.mjs new file mode 100644 index 000000000..8f78dbe07 --- /dev/null +++ b/packages/opencode/test/mcp/fixtures/echo-mcp-server.mjs @@ -0,0 +1,9 @@ +// A real MCP server over stdio offering exactly one tool, "echo" (test fixture). +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { z } from "zod" +const server = new McpServer({ name: "e2e-echo", version: "0.0.1" }) +server.tool("echo", "Echoes its input", { text: z.string() }, async ({ text }) => ({ + content: [{ type: "text", text }], +})) +await server.connect(new StdioServerTransport()) From 93c879af87ab54cd3f60aee9ff99db7731ab2801 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 12 Sep 2026 08:24:55 +0800 Subject: [PATCH 03/14] chore: wrap the catalog _meta hunks in altimate_change markers Marker Guard flagged the changed lines in the upstream-shared catalog; the single-line marker comments did not count as a wrapped block. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- packages/opencode/src/mcp/catalog.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index b26b369a9..9fd1e974a 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -162,10 +162,11 @@ export function resources(client: Client, timeout?: number) { function listTools(client: Client, timeout: number) { return Effect.tryPromise({ + // altimate_change start — a fresh listing starts with no `_meta` (see listMeta). try: () => { - // altimate_change — a fresh listing starts with no `_meta` (see listMeta). listMetaByClient.delete(client) return paginate( + // altimate_change end async (cursor) => { const params = cursor === undefined ? undefined : { cursor } try { @@ -183,13 +184,14 @@ function listTools(client: Client, timeout: number) { // altimate_change end } }, + // altimate_change start — remember this page's `_meta` (see listMeta). (result) => { - // altimate_change — remember this page's `_meta` (see listMeta). if (result._meta !== undefined) listMetaByClient.set(client, result._meta as Record) return result.tools }, ) }, + // altimate_change end catch: (error) => (error instanceof Error ? error : new Error(String(error))), }) } From 82531540c718bf1b038044495dfa58b6e49b0c63 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 12 Sep 2026 22:19:31 +0800 Subject: [PATCH 04/14] fix: accept numeric integration ids in the engine report Custom (tenant-created) integrations carry numeric ids; the parser treated the whole report as malformed over that one field and the attach announced no gaps at all. Take the id as a string. Found by the engine-to-CLI run against a local backend with a custom MCP integration. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- packages/opencode/src/altimate/workspace/engine-types.ts | 6 ++++-- .../opencode/test/altimate/workspace/engine-types.test.ts | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 84e3336ed..34c024f1e 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -250,8 +250,10 @@ export function parseUnfulfilled(meta: Record | undefined): Unf for (const item of raw) { if (typeof item !== "object" || item === null) return undefined const { key, integrationId, reason, detail } = item as Record - if (typeof key !== "string" || typeof integrationId !== "string" || typeof reason !== "string") return undefined - out.push({ key, integrationId, reason, ...(typeof detail === "string" && detail !== "" ? { detail } : {}) }) + // Custom (tenant-created) integrations carry numeric ids; take them as strings. + const id = typeof integrationId === "number" ? String(integrationId) : integrationId + if (typeof key !== "string" || typeof id !== "string" || typeof reason !== "string") return undefined + out.push({ key, integrationId: id, reason, ...(typeof detail === "string" && detail !== "" ? { detail } : {}) }) } return out } diff --git a/packages/opencode/test/altimate/workspace/engine-types.test.ts b/packages/opencode/test/altimate/workspace/engine-types.test.ts index 60682422e..40dff310d 100644 --- a/packages/opencode/test/altimate/workspace/engine-types.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-types.test.ts @@ -201,6 +201,10 @@ describe("messages", () => { { key: "c", integrationId: "pu", reason: "no-bridge" }, ]) expect(parseUnfulfilled({ [UNFULFILLED_META_KEY]: [] })).toEqual([]) + // A custom integration's id arrives as a number from the engine; it is a string here. + expect( + parseUnfulfilled({ [UNFULFILLED_META_KEY]: [{ key: "demo_tool", integrationId: 7, reason: "spawn-failed" }] }), + ).toEqual([{ key: "demo_tool", integrationId: "7", reason: "spawn-failed" }]) expect(parseUnfulfilled(undefined)).toBeUndefined() expect(parseUnfulfilled({})).toBeUndefined() expect(parseUnfulfilled({ [UNFULFILLED_META_KEY]: "nope" })).toBeUndefined() From ceb212e3becb8329b914975307c75b3d894640c8 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 12 Sep 2026 22:11:57 +0800 Subject: [PATCH 05/14] feat(workspace): post a sanitized session attach report when the outcome settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an attach settles — attached, engine missing, engine too old, or the engine failed to start — the CLI posts what the session received to the backend: binding identity (the same remote or path the server row holds), CLI and engine versions, bridge state, declared and delivered keys, and the engine's unfulfilled report. Once per distinct report, fire-and-forget, never on the turn's path. Engine detail strings never leave the machine: each is reduced to a code plus, for spawn failures, the command's basename. Closes #1309 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- packages/opencode/src/altimate/api/client.ts | 6 + .../src/altimate/workspace/attach-report.ts | 143 ++++++++++++++++++ .../src/altimate/workspace/engine-overlay.ts | 84 +++++++++- .../src/altimate/workspace/engine-seams.ts | 8 +- .../altimate/workspace/attach-report.test.ts | 141 +++++++++++++++++ .../altimate/workspace/engine-overlay.test.ts | 98 ++++++++++++ 6 files changed, 472 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/attach-report.ts create mode 100644 packages/opencode/test/altimate/workspace/attach-report.test.ts diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 9d4caaae5..970930521 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -319,6 +319,12 @@ export namespace AltimateApi { await request(creds, "DELETE", `/datamates/${id}`) } + /** Post this session's attach report for a workspace (session attach report store). */ + export async function postAttachReport(datamateId: string, report: unknown): Promise { + const creds = await getCredentials() + await request(creds, "POST", `/datamates/${datamateId}/attach-reports`, report) + } + export async function listIntegrations() { const creds = await getCredentials() const data = await request(creds, "GET", "/datamate_integrations/") diff --git a/packages/opencode/src/altimate/workspace/attach-report.ts b/packages/opencode/src/altimate/workspace/attach-report.ts new file mode 100644 index 000000000..fb1b80d83 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/attach-report.ts @@ -0,0 +1,143 @@ +// altimate_change - new file +// +// The session attach report: what this session actually received from its +// workspace, posted to the backend when the outcome settles so the workspace +// page can show it. Pure shaping here; the one I/O function at the bottom +// goes through the API client and never throws. +import { AltimateApi } from "@/altimate/api/client" +import { log, syncInternals } from "./engine-seams" +import type { Declared, Outcome, Unfulfilled } from "./engine-types" + +/** What the backend accepts as an unserved key's detail: a code and, for + * spawn failures, the basename of the command. Never the engine's raw error + * text, which can name paths and hosts. */ +export type AttachReportDetail = { code: string; command?: string } + +export type AttachReportUnfulfilled = { + key: string + integration_id: string + reason: string + detail?: AttachReportDetail +} + +export type AttachReportOutcome = "attached" | "engine-missing" | "engine-too-old" | "connect-failed" + +export type AttachReport = { + binding_key: string + outcome: AttachReportOutcome + cli_version: string + engine_version: string | null + bridge_connected: boolean + declared_keys: string[] + delivered_keys: string[] + unfulfilled: AttachReportUnfulfilled[] + reported_at: string +} + +/** The identity the server binding row already carries: the git remote when + * the project has one, else its absolute path. Nothing new about the machine + * leaves it. */ +export function bindingKey(binding: { repoRemote: string | null; projectPath: string | null }): string | null { + return binding.repoRemote || binding.projectPath || null +} + +const CODES: Array<[RegExp, string]> = [ + [/\bENOENT\b/, "ENOENT"], + [/\bEACCES\b|\bEPERM\b/, "EACCES"], + [/\bETIMEDOUT\b|timed? ?out/i, "ETIMEDOUT"], + [/\bECONNREFUSED\b/, "ECONNREFUSED"], + [/invalid url/i, "invalid-url"], +] + +/** A code for an error string, never the string. */ +export function errorCode(text: string): string { + return CODES.find(([re]) => re.test(text))?.[1] ?? "other" +} + +/** Reduce an engine detail to what may leave the machine. For a spawn + * failure the spawned command's basename is kept (`spawn /Users/x/bin/docker + * ENOENT` → `docker`); the directory, and everything else, is dropped. */ +export function sanitizeDetail(detail: string | undefined, reason: string): AttachReportDetail | undefined { + if (!detail) return undefined + const code = errorCode(detail) + if (reason !== "spawn-failed") return { code } + const match = /\bspawn\s+(\S+)/.exec(detail) + const command = match ? match[1].split(/[\\/]/).pop() : undefined + return command ? { code, command } : { code } +} + +function sanitizeUnfulfilled(entries: Unfulfilled[]): AttachReportUnfulfilled[] { + return entries.map((u) => { + const detail = sanitizeDetail(u.detail, u.reason) + return { key: u.key, integration_id: u.integrationId, reason: u.reason, ...(detail ? { detail } : {}) } + }) +} + +export type AttachReportInput = { + outcome: Outcome + bindingKey: string + cliVersion: string + /** The probed engine version, when the engine ran at all. */ + engineVersion: string | null + declared: Declared | null + /** Keys the engine served under the workspace key (attached only). */ + present?: Set + bridgeConnected: boolean + reportedAt: string +} + +/** The report for a settled outcome, or null for outcomes that are not about + * the engine at all (disabled, unbound). */ +export function buildAttachReport(input: AttachReportInput): AttachReport | null { + const { outcome, declared } = input + const declaredKeys = declared ? [...declared.keys, ...declared.extensionKeys] : [] + const base = { + binding_key: input.bindingKey, + cli_version: input.cliVersion, + bridge_connected: input.bridgeConnected, + declared_keys: declaredKeys, + delivered_keys: [] as string[], + unfulfilled: [] as AttachReportUnfulfilled[], + reported_at: input.reportedAt, + } + switch (outcome.kind) { + case "attached": { + const present = input.present ?? new Set() + const delivered = declared ? declaredKeys.filter((k) => present.has(k)) : [...present] + return { + ...base, + outcome: "attached", + engine_version: input.engineVersion, + delivered_keys: delivered, + unfulfilled: sanitizeUnfulfilled(outcome.unfulfilled ?? []), + } + } + case "engine-missing": + return { ...base, outcome: "engine-missing", engine_version: null } + case "engine-too-old": + return { ...base, outcome: "engine-too-old", engine_version: outcome.found } + case "connect-failed": + return { ...base, outcome: "connect-failed", engine_version: input.engineVersion } + default: + return null + } +} + +/** Everything that would make the backend row different — so an identical + * re-attach does not post again, and a changed reason or version does. */ +export function attachReportSignature(report: AttachReport): string { + const { reported_at: _at, ...rest } = report + return JSON.stringify(rest) +} + +/** Post a report; fire-and-forget by contract. A failure is logged once at + * debug and never reaches the user or the turn. */ +export async function postAttachReport(datamateId: string, report: AttachReport): Promise { + try { + if (syncInternals.reportAttach) return await syncInternals.reportAttach(datamateId, report) + if (!(await AltimateApi.isConfigured())) return + await AltimateApi.postAttachReport(datamateId, report) + } catch (err) { + log.debug("attach report not posted", { datamateId, err: String(err) }) + } +} diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 13d6ce12c..895a3f39a 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -35,7 +35,18 @@ import { syncInternals, type ScopedBinding, } from "./engine-seams" -import { declaredBounded, fingerprint, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes" +import { + declaredBounded, + fingerprint, + liveBridge, + notify, + printLine, + resolveBinding, + versionOf, + which, +} from "./engine-probes" +import { attachReportSignature, bindingKey, buildAttachReport, postAttachReport } from "./attach-report" +import { Installation } from "@/installation" import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer" import { ENGINE_BINARY, @@ -136,6 +147,8 @@ type Overlay = { /** The derived entry, or null when the engine is unusable. */ entry: LocalMcpConfig | null refusal: Extract | null + /** The probed engine version when the engine ran; null when it is missing. */ + version: string | null } /** Per-directory state. Config and MCP state are per project instance, and one @@ -247,7 +260,7 @@ export async function overlay( const entry = engineEntry(workspace.id) config.mcp ??= {} config.mcp[DATAMATE_KEY] = entry - state.current = { directory, workspace, entry, refusal: null } + state.current = { directory, workspace, entry, refusal: null, version: probe.version } log.info("workspace engine overlay applied", { workspaceId: workspace.id, version: probe.version }) return } @@ -261,6 +274,7 @@ export async function overlay( workspace, entry: null, refusal: probe.kind === "missing" ? { kind: "engine-missing" } : { kind: "engine-too-old", found: probe.found }, + version: probe.kind === "missing" ? null : probe.found, } log.info("workspace engine overlay refused", { workspaceId: workspace.id, reason: probe.kind }) } catch (err) { @@ -303,7 +317,14 @@ export async function managedWorkspaceLoaded( /** `retried`: this session already spent its one re-add on a failed handshake. * Per session, so "start a new session to try again" is true. */ -type SessionRecord = { outcome: Outcome; announced?: string; announcedAt?: number; retried?: boolean } +type SessionRecord = { + outcome: Outcome + announced?: string + announcedAt?: number + retried?: boolean + /** Signature of the last attach report posted for this session. */ + reported?: string +} const sessions = new Map() const declaredCache = new Map() /** Verdict signatures a headless process has already printed to stderr. */ @@ -317,6 +338,7 @@ function record(sessionID: string, outcome: Outcome): SessionRecord { announced: previous?.announced, announcedAt: previous?.announcedAt, retried: previous?.retried, + reported: previous?.reported, } sessions.set(sessionID, next) while (sessions.size > MAX_TRACKED_SESSIONS) { @@ -329,6 +351,34 @@ function record(sessionID: string, outcome: Outcome): SessionRecord { /** The outcome a session settled at its last turn boundary. A pure read; * `undefined` before the first `beforeTurn` for that session. */ +/** Post the settled outcome as this session's attach report, once per + * distinct report. Never awaited by the turn: the post is fire-and-forget and + * swallows its own failures. */ +function reportOutcome( + sessionID: string, + binding: ScopedBinding, + extras: { engineVersion: string | null; declared: Declared | null; present?: Set; bridgeConnected: boolean }, +): void { + const rec = sessions.get(sessionID) + const key = bindingKey(binding) + if (!rec || !key) return + const report = buildAttachReport({ + outcome: rec.outcome, + bindingKey: key, + cliVersion: Installation.VERSION, + engineVersion: extras.engineVersion, + declared: extras.declared, + present: extras.present, + bridgeConnected: extras.bridgeConnected, + reportedAt: new Date(now()).toISOString(), + }) + if (!report) return + const signature = attachReportSignature(report) + if (rec.reported === signature) return + rec.reported = signature + void postAttachReport(String(binding.datamateId), report) +} + export function settledOutcome(sessionID: string): Outcome | undefined { return sessions.get(sessionID)?.outcome } @@ -369,7 +419,9 @@ async function refuseUnreadableLink(sessionID: string, state: DirectoryState, er record(sessionID, outcome) const kept = state.applied?.entry ? "the running engine is kept and " : "" await announceRefusal(sessionID, outcome, { - title: state.applied ? `Workspace "${state.applied.workspace.name}": link could not be read` : "Workspace link could not be read", + title: state.applied + ? `Workspace "${state.applied.workspace.name}": link could not be read` + : "Workspace link could not be read", message: `${outcome.error} (${error}); ${kept}it is read again next turn.`, variant: "warning", }) @@ -505,7 +557,9 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // probe memo bounds how often that is asked). let reload = state.current ? state.current.workspace.key !== boundKey - : state.linkUnreadable !== undefined || state.failedAt === undefined || now() - state.failedAt >= FAILED_PROBE_TTL_MS + : state.linkUnreadable !== undefined || + state.failedAt === undefined || + now() - state.failedAt >= FAILED_PROBE_TTL_MS if (!reload && state.current && !state.current.entry) { const probe = await probeEngine() reload = probe.kind === "ok" @@ -517,7 +571,8 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS } // The boundary read the binding but the reload could not: the link is // flapping, and the reload's verdict is the one the config now reflects. - if (!state.current && state.linkUnreadable !== undefined) return refuseUnreadableLink(sessionID, state, state.linkUnreadable) + if (!state.current && state.linkUnreadable !== undefined) + return refuseUnreadableLink(sessionID, state, state.linkUnreadable) // A transient overlay failure (its retry is throttled above) keeps what was // last applied for this same workspace: a running engine is not released @@ -540,6 +595,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // Say so, once, rather than settling a bound directory as unbound in silence. const outcome: Outcome = { kind: "connect-failed", error: "the workspace engine could not be checked" } record(sessionID, outcome) + reportOutcome(sessionID, binding, { engineVersion: null, declared: null, bridgeConnected: false }) await announceRefusal(sessionID, outcome, { title: `Workspace "${binding.datamateName}": engine unavailable`, message: `${outcome.error}; it is checked again shortly.`, @@ -575,6 +631,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS const outcome: Outcome = count === undefined ? { kind: "engine-missing" } : { kind: "engine-missing", declared: count } record(sessionID, outcome) + reportOutcome(sessionID, binding, { engineVersion: null, declared, bridgeConnected: false }) const what = count === undefined ? `Workspace "${workspace.name}" has integration tools that run on the local engine, which is not installed.` @@ -598,7 +655,13 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS return } record(sessionID, refusal) - const declared = (await declaredFor(workspace))?.keys.length + const declaredAll = await declaredFor(workspace) + const declared = declaredAll?.keys.length + reportOutcome(sessionID, binding, { + engineVersion: overlayNow.version, + declared: declaredAll, + bridgeConnected: false, + }) await announceRefusal( sessionID, refusal, @@ -640,6 +703,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS error: status?.error ?? `engine status: ${status?.status ?? "unknown"}`, } record(sessionID, outcome) + reportOutcome(sessionID, binding, { engineVersion: overlayNow.version, declared, bridgeConnected: false }) await announceRefusal(sessionID, outcome, { title: `Workspace "${workspace.name}": engine failed to start`, message: `${outcome.error}. Start a new session to try again.`, @@ -672,6 +736,12 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS ...(unfulfilled === undefined ? {} : { unfulfilled }), } const rec = record(sessionID, outcome) + reportOutcome(sessionID, binding, { + engineVersion: overlayNow.version, + declared, + present, + bridgeConnected: extServed > 0 || liveBridge(directory), + }) // Keyed on the workspace too: a re-link with an identical inventory is still // a new verdict the user should hear. // extServed is part of what the user hears, so it is part of the signature: diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index b499617e1..4ff4aaf15 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -7,6 +7,7 @@ import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" import type { CachedBinding } from "./state" import type { Declared, LocalMcpConfig, McpEntry, McpStatus, Toast } from "./engine-types" +import type { AttachReport } from "./attach-report" import type { EngineOffer, InstallResult } from "./engine-offer" export const log = Log.create({ service: "workspace-engine" }) @@ -19,7 +20,10 @@ export type ScopedBinding = CachedBinding & { scope?: string } /** What a binding read established. `failed` is not `unbound`: the link may * well exist, it could not be read, and nothing may be handed the key on the * strength of that. */ -export type BindingRead = { kind: "bound"; binding: ScopedBinding } | { kind: "unbound" } | { kind: "failed"; error: string } +export type BindingRead = + | { kind: "bound"; binding: ScopedBinding } + | { kind: "unbound" } + | { kind: "failed"; error: string } export const syncInternals: { resolveBinding?: (directory: string) => Promise @@ -29,6 +33,8 @@ export const syncInternals: { declared?: (workspaceId: string) => Promise liveBridge?: (cwd: string) => boolean notify?: (toast: Toast) => Promise + /** Attach-report sink (see attach-report.ts); production posts through the API client. */ + reportAttach?: (datamateId: string, report: AttachReport) => Promise printLine?: (line: string) => void /** Install-offer seams (see engine-offer.ts). */ offer?: (offer: EngineOffer) => boolean diff --git a/packages/opencode/test/altimate/workspace/attach-report.test.ts b/packages/opencode/test/altimate/workspace/attach-report.test.ts new file mode 100644 index 000000000..a04903821 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/attach-report.test.ts @@ -0,0 +1,141 @@ +// altimate_change - new file +import { describe, expect, test } from "bun:test" +import { + attachReportSignature, + bindingKey, + buildAttachReport, + errorCode, + sanitizeDetail, +} from "../../../src/altimate/workspace/attach-report" + +const declared = { keys: ["jira_search_issues", "echo", "ghost"], extensionKeys: ["pu_lineage"] } +const base = { + bindingKey: "ssh://git@github.com/acme/jaffle-shop", + cliVersion: "0.11.2", + engineVersion: "0.7.2", + declared, + bridgeConnected: false, + reportedAt: "2026-09-12T13:50:00.000Z", +} + +describe("bindingKey", () => { + test("prefers the git remote, falls back to the path, and is null with neither", () => { + expect(bindingKey({ repoRemote: "ssh://a", projectPath: "/p" })).toBe("ssh://a") + expect(bindingKey({ repoRemote: null, projectPath: "/p" })).toBe("/p") + expect(bindingKey({ repoRemote: "", projectPath: null })).toBeNull() + }) +}) + +describe("sanitizeDetail", () => { + test("keeps only a code and, for spawn failures, the command basename", () => { + expect(sanitizeDetail("spawn /Users/x/bin/docker ENOENT", "spawn-failed")).toEqual({ + code: "ENOENT", + command: "docker", + }) + expect(sanitizeDetail("spawn C:\\Users\\x\\tools\\gh.exe ENOENT", "spawn-failed")).toEqual({ + code: "ENOENT", + command: "gh.exe", + }) + expect(sanitizeDetail("spawn altimate-e2e-missing-binary ENOENT", "spawn-failed")).toEqual({ + code: "ENOENT", + command: "altimate-e2e-missing-binary", + }) + }) + test("never forwards free text: paths, hosts and messages collapse to a code", () => { + expect(sanitizeDetail("connect ECONNREFUSED 10.0.0.7:8443", "exception")).toEqual({ code: "ECONNREFUSED" }) + expect(sanitizeDetail("Invalid URL: http://[bad", "spawn-failed")).toEqual({ code: "invalid-url" }) + expect(sanitizeDetail("token expired for user@corp.example", "invalid-connection")).toEqual({ code: "other" }) + expect(sanitizeDetail(undefined, "spawn-failed")).toBeUndefined() + expect(sanitizeDetail("", "spawn-failed")).toBeUndefined() + }) + test("errorCode maps the recognised patterns", () => { + expect(errorCode("Request timed out")).toBe("ETIMEDOUT") + expect(errorCode("EACCES: permission denied")).toBe("EACCES") + expect(errorCode("boom")).toBe("other") + }) +}) + +describe("buildAttachReport", () => { + test("attached: declared vs delivered from the served set, unfulfilled sanitized", () => { + const report = buildAttachReport({ + ...base, + outcome: { + kind: "attached", + available: 1, + declared: 3, + missing: ["jira_search_issues", "ghost"], + unfulfilled: [ + { key: "jira_search_issues", integrationId: "jira", reason: "invalid-connection" }, + { key: "ghost", integrationId: "mcp-ok", reason: "unknown-key" }, + { key: "pu_lineage", integrationId: "vscode-power-user", reason: "no-bridge" }, + { + key: "whatever", + integrationId: "mcp-missing-binary", + reason: "spawn-failed", + detail: "spawn /opt/tools/altimate-e2e-missing-binary ENOENT", + }, + ], + }, + present: new Set(["echo", "altimate_knowledge_search"]), + }) + expect(report).toEqual({ + binding_key: base.bindingKey, + outcome: "attached", + cli_version: "0.11.2", + engine_version: "0.7.2", + bridge_connected: false, + declared_keys: ["jira_search_issues", "echo", "ghost", "pu_lineage"], + delivered_keys: ["echo"], + unfulfilled: [ + { key: "jira_search_issues", integration_id: "jira", reason: "invalid-connection" }, + { key: "ghost", integration_id: "mcp-ok", reason: "unknown-key" }, + { key: "pu_lineage", integration_id: "vscode-power-user", reason: "no-bridge" }, + { + key: "whatever", + integration_id: "mcp-missing-binary", + reason: "spawn-failed", + detail: { code: "ENOENT", command: "altimate-e2e-missing-binary" }, + }, + ], + reported_at: base.reportedAt, + }) + expect(JSON.stringify(report)).not.toContain("/opt/tools") + }) + test("attached without an allowlist reports what was served as delivered", () => { + const report = buildAttachReport({ + ...base, + declared: null, + outcome: { kind: "attached", available: 2 }, + present: new Set(["echo", "dbt_build_model"]), + }) + expect(report?.declared_keys).toEqual([]) + expect(report?.delivered_keys).toEqual(["echo", "dbt_build_model"]) + }) + test("failed outcomes carry the version the CLI saw, or null when the engine is missing", () => { + expect(buildAttachReport({ ...base, outcome: { kind: "engine-missing", declared: 3 } })).toMatchObject({ + outcome: "engine-missing", + engine_version: null, + declared_keys: declared.keys.concat(declared.extensionKeys), + delivered_keys: [], + }) + expect(buildAttachReport({ ...base, outcome: { kind: "engine-too-old", found: "0.7.1" } })).toMatchObject({ + outcome: "engine-too-old", + engine_version: "0.7.1", + }) + expect(buildAttachReport({ ...base, outcome: { kind: "connect-failed", error: "x" } })).toMatchObject({ + outcome: "connect-failed", + engine_version: "0.7.2", + }) + }) + test("outcomes that are not about the engine produce no report", () => { + expect(buildAttachReport({ ...base, outcome: { kind: "disabled" } })).toBeNull() + expect(buildAttachReport({ ...base, outcome: { kind: "unbound" } })).toBeNull() + }) + test("the signature ignores the timestamp and changes with the content", () => { + const a = buildAttachReport({ ...base, outcome: { kind: "engine-too-old", found: "0.7.1" } })! + const b = { ...a, reported_at: "2026-09-12T14:00:00.000Z" } + const c = { ...a, engine_version: "0.7.0" } + expect(attachReportSignature(a)).toBe(attachReportSignature(b)) + expect(attachReportSignature(a)).not.toBe(attachReportSignature(c)) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index cc2f1ab1c..bd5b4d694 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -28,6 +28,7 @@ import { type Toast, } from "../../../src/altimate/workspace/engine-overlay" import type { ScopedBinding } from "../../../src/altimate/workspace/engine-seams" +import type { AttachReport } from "../../../src/altimate/workspace/attach-report" import { DATAMATE_KEY } from "../../../src/altimate/datamate-transport" const DIR = "/tmp/analytics" @@ -52,6 +53,7 @@ type Harness = { invalidates: number probes: number toasts: Toast[] + reports: { datamateId: string; report: AttachReport }[] lines: string[] clock: number /** Whether MCP holds a client under the key — set when MCP "bootstraps" from @@ -94,6 +96,7 @@ function install(opts: { invalidates: 0, probes: 0, toasts: [], + reports: [], lines: [], clock: 1_000_000, fingerprint: "bin-1", @@ -115,6 +118,9 @@ function install(opts: { syncInternals.notify = async (toast) => { h.toasts.push(toast) } + syncInternals.reportAttach = async (datamateId, report) => { + h.reports.push({ datamateId, report }) + } syncInternals.printLine = (line) => { h.lines.push(line) } @@ -1016,3 +1022,95 @@ describe("beforeTurn — what a turn boundary does", () => { expect(managedWorkspace()).toEqual({ id: "42", name: "analytics" }) }) }) + +describe("attach reports — what the session posts when an outcome settles", () => { + test("an attached session posts one sanitized report for its binding", async () => { + const report = [ + { key: "dbt_execute_sql", integrationId: "dbt", reason: "invalid-connection" }, + { + key: "gh_list_prs", + integrationId: "github-mcp", + reason: "spawn-failed", + detail: "spawn /Users/ralph/.local/bin/docker ENOENT", + }, + ] + const h = install({ meta: { [UNFULFILLED_META_KEY]: report } }) + await beforeTurn("s1") + expect(h.reports).toHaveLength(1) + expect(h.reports[0].datamateId).toBe("42") + expect(h.reports[0].report).toMatchObject({ + binding_key: DIR, + outcome: "attached", + engine_version: "0.7.2", + bridge_connected: false, + declared_keys: ["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"], + delivered_keys: ["dbt_build_model", "dbt_compile_model"], + unfulfilled: [ + { key: "dbt_execute_sql", integration_id: "dbt", reason: "invalid-connection" }, + { + key: "gh_list_prs", + integration_id: "github-mcp", + reason: "spawn-failed", + detail: { code: "ENOENT", command: "docker" }, + }, + ], + }) + expect(JSON.stringify(h.reports[0].report)).not.toContain("/Users/ralph") + expect(typeof h.reports[0].report.cli_version).toBe("string") + expect(h.reports[0].report.reported_at).toBe(new Date(h.clock).toISOString()) + }) + + test("an unchanged outcome does not post again; a changed reason does", async () => { + const h = install({ + meta: { [UNFULFILLED_META_KEY]: [{ key: "gh_list_prs", integrationId: "github-mcp", reason: "spawn-failed" }] }, + }) + await beforeTurn("s1") + await beforeTurn("s1") + expect(h.reports).toHaveLength(1) + h.meta = { + [UNFULFILLED_META_KEY]: [{ key: "gh_list_prs", integrationId: "github-mcp", reason: "invalid-connection" }], + } + await beforeTurn("s1") + expect(h.reports).toHaveLength(2) + expect(h.reports[1].report.unfulfilled[0].reason).toBe("invalid-connection") + }) + + test("failed attaches post too: too old carries the found version, missing carries null", async () => { + const old = install({ version: "0.6.3" }) + await beforeTurn("s1") + expect(old.reports.map((r) => r.report)).toMatchObject([ + { + outcome: "engine-too-old", + engine_version: "0.6.3", + declared_keys: ["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"], + delivered_keys: [], + }, + ]) + const missing = install({ which: null }) + await beforeTurn("s2") + expect(missing.reports.map((r) => r.report)).toMatchObject([{ outcome: "engine-missing", engine_version: null }]) + }) + + test("an engine that fails to start posts connect-failed", async () => { + const h = install({ status: "failed", statusError: "spawn datamate ENOENT" }) + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("connect-failed") + expect(h.reports.map((r) => r.report.outcome)).toEqual(["connect-failed"]) + }) + + test("nothing is posted for an unbound directory", async () => { + const h = install({ binding: null }) + await beforeTurn("s1") + expect(h.reports).toHaveLength(0) + }) + + test("a failing sink never reaches the turn or the outcome", async () => { + const h = install({}) + syncInternals.reportAttach = async () => { + throw new Error("backend down") + } + await beforeTurn("s1") + expect(settledOutcome("s1")?.kind).toBe("attached") + expect(h.toasts).toHaveLength(1) + }) +}) From 39ba0e565eb1e425190560df05083154e422ef81 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Tue, 15 Sep 2026 01:09:27 +0800 Subject: [PATCH 06/14] feat(workspace): one-line attach toast, and a /workspace Status view for the detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attach toast carried the whole engine report — every undelivered key grouped by reason, with the engine's detail — and on a workspace with a few gaps it read as noise (review of the first cut, #1311). Numbers belong in the toast; the keys and reasons belong somewhere they can be read again. - The toast says `2 of 9 integration tools available · 7 need attention. Details: /workspace` and nothing more; extension tools a live bridge serves add `· N more via VS Code`. `describeMissing` / `describeExtensionServed` go; `reasonPhrase` keeps the wording for the view. - The overlay keeps an attach snapshot per directory — workspace, engine version, the allowlist, what the engine served, its report — in memory and in `altimate-attach-snapshots.json` under the state directory, because the TUI plugin runs in another process than the overlay (the same reason the binding cache is a file). Bounded to 64 directories; a test seam keeps the suite out of the real state directory. - `status-view.ts`, transport-agnostic like `manage.ts`: the snapshot joined to the workspace's selection and the catalog, one row per integration — served / partial / missing with reasons / idle for an extension without a window — attention first, keys beyond the allowlist as extras, and the headline the toast, the menu row and the sidebar share. - `/workspace` gains a Status row (its description is the headline, read from the snapshot so the menu opens without a network call) that opens the view: one row per integration with counts and the reason, the keys and reasons as the row's footer, and "Open on the web" / "Re-read" / "Done" as action rows. A snapshot from a workspace this project was since re-linked away from is ignored. - The sidebar's Workspace tile shows the headline under the name once a session has attached, with the same staleness guard. Tests: the snapshot file round-trip, cap and corrupt-file recovery; the status view (rows, ordering, name fallback, counts matching the toast, partial and bridged states, reports for integrations no longer selected, no allowlist); the overlay's toast wording, snapshot and persistence; the e2e engine report checked through `reasonPhrase`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../src/altimate/workspace/attach-snapshot.ts | 83 +++++++ .../src/altimate/workspace/engine-overlay.ts | 62 ++++- .../src/altimate/workspace/engine-seams.ts | 3 + .../src/altimate/workspace/engine-types.ts | 43 +--- .../src/altimate/workspace/status-view.ts | 211 ++++++++++++++++++ .../plugin/tui/altimate/workspace-sidebar.tsx | 94 +++++--- .../src/plugin/tui/altimate/workspace.tsx | 209 +++++++++++------ .../workspace/attach-snapshot.test.ts | 59 +++++ .../altimate/workspace/engine-overlay.test.ts | 45 ++-- .../altimate/workspace/engine-types.test.ts | 40 +--- .../altimate/workspace/status-view.test.ts | 98 ++++++++ .../test/mcp/engine-unfulfilled.e2e.test.ts | 16 +- 12 files changed, 764 insertions(+), 199 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/attach-snapshot.ts create mode 100644 packages/opencode/src/altimate/workspace/status-view.ts create mode 100644 packages/opencode/test/altimate/workspace/attach-snapshot.test.ts create mode 100644 packages/opencode/test/altimate/workspace/status-view.test.ts diff --git a/packages/opencode/src/altimate/workspace/attach-snapshot.ts b/packages/opencode/src/altimate/workspace/attach-snapshot.ts new file mode 100644 index 000000000..938713b12 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/attach-snapshot.ts @@ -0,0 +1,83 @@ +// altimate_change - new file +// +// What the last attach in a directory produced, on disk. The overlay settles +// an attach inside the server process; the TUI plugin (the `/workspace` +// menu, the sidebar tile) runs in another, so the memory the overlay keeps is +// invisible to it — the same reason the binding cache lives in a file. One +// small JSON under the state directory, keyed by project directory, latest +// attach per directory, bounded. +import path from "node:path" +import { chmodSync, existsSync, readFileSync } from "node:fs" +import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" +import { Log } from "@/altimate/util/log" +import type { Declared, Unfulfilled } from "./engine-types" + +const log = Log.create({ service: "altimate-workspace-attach-snapshot" }) + +export interface AttachSnapshot { + workspace: { id: string; name: string } + engineVersion: string | null + /** The allowlist the workspace declared, split like `Declared`; null when + * the lookup failed and the engine was taken at its word. */ + declared: Declared | null + /** Every key the engine served under the workspace key, allowlisted or not. */ + present: string[] + /** The engine's full report; undefined when it sent none. */ + unfulfilled: Unfulfilled[] | undefined + /** Extension-declared keys a live IDE bridge served. */ + extServed: number + at: number +} + +interface SnapshotFile { + version: 1 + snapshots: Record +} + +/** Enough for a machine's worth of projects; the oldest go first. */ +const MAX_SNAPSHOTS = 64 + +export function snapshotPath(): string { + return path.join(Global.Path.state, "altimate-attach-snapshots.json") +} + +function readFile(): SnapshotFile | null { + const p = snapshotPath() + if (!existsSync(p)) return null + try { + const raw = JSON.parse(readFileSync(p, "utf8")) as Partial | null + if (!raw || raw.version !== 1 || typeof raw.snapshots !== "object" || raw.snapshots === null) return null + return raw as SnapshotFile + } catch (err) { + log.warn("attach snapshot file is corrupt, discarding", { code: (err as NodeJS.ErrnoException)?.code }) + return null + } +} + +/** Best-effort, like every write to the state directory: a read-only home + * must not turn a successful attach into a failure. */ +export function writeAttachSnapshot(directory: string, snapshot: AttachSnapshot): void { + try { + const file = readFile() ?? { version: 1, snapshots: {} } + file.snapshots[path.resolve(directory)] = snapshot + const entries = Object.entries(file.snapshots) + if (entries.length > MAX_SNAPSHOTS) { + entries.sort((a, b) => a[1].at - b[1].at) + file.snapshots = Object.fromEntries(entries.slice(entries.length - MAX_SNAPSHOTS)) + } + const p = snapshotPath() + Filesystem.writeJsonAtomic(p, file) + try { + chmodSync(p, 0o600) + } catch { + // Umask permissions until the next write; the file holds tool keys, not credentials. + } + } catch (err) { + log.warn("could not write the attach snapshot", { err: String(err) }) + } +} + +export function readAttachSnapshot(directory: string): AttachSnapshot | undefined { + return readFile()?.snapshots[path.resolve(directory)] +} diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 895a3f39a..b6a99105c 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -54,8 +54,6 @@ import { REPAIRABLE, TOOL_PREFIX, clearsFloor, - describeExtensionServed, - describeMissing, parseUnfulfilled, reportedMissing, describeRefusal, @@ -69,6 +67,7 @@ import { type Outcome, type Toast, } from "./engine-types" +import { readAttachSnapshot, writeAttachSnapshot, type AttachSnapshot } from "./attach-snapshot" export * from "./engine-types" export * from "./engine-offer" @@ -330,6 +329,19 @@ const declaredCache = new Map() /** Verdict signatures a headless process has already printed to stderr. */ const headlessPrinted = new Set() +/** What the last attach in a directory produced, kept for the surfaces that + * describe it after the fact — the sidebar tile and the `/workspace` status + * view. In memory for this process, and on disk for the TUI process, which + * is where those surfaces run (see `attach-snapshot.ts`). */ +const lastAttach = new Map() + +/** The last attach snapshot for a directory: this process's, else the one on + * disk, else undefined before any session has settled there. */ +export function attachSnapshot(directory: string | null = currentDirectory()): AttachSnapshot | undefined { + if (directory === null) return undefined + return lastAttach.get(directory) ?? readAttachSnapshot(directory) +} + function record(sessionID: string, outcome: Outcome): SessionRecord { const previous = sessions.get(sessionID) sessions.delete(sessionID) @@ -736,6 +748,17 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS ...(unfulfilled === undefined ? {} : { unfulfilled }), } const rec = record(sessionID, outcome) + const snapshot: AttachSnapshot = { + workspace: { id: workspace.id, name: workspace.name }, + engineVersion: overlayNow.version, + declared, + present: [...present], + unfulfilled, + extServed, + at: now(), + } + lastAttach.set(directory, snapshot) + ;(syncInternals.persistSnapshot ?? writeAttachSnapshot)(directory, snapshot) reportOutcome(sessionID, binding, { engineVersion: overlayNow.version, declared, @@ -760,16 +783,42 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS unfulfilled, }) if (isHeadless()) return - const headline = declared - ? `${served} of ${declared.keys.length} declared integration tools available.` - : `${outcome.available} integration tools available.` + // Numbers only. The keys and their reasons live in the `/workspace` status + // view, which the toast points at; a toast that tried to carry them read as + // noise (review of the first cut). await notify({ title: `Workspace "${workspace.name}"`, - message: `${headline}${describeMissing(missingReport ?? [])}${describeExtensionServed(extServed)}`, + message: attachSummary({ + served, + declared: declared?.keys.length, + available: outcome.available, + gaps: missingReport?.length ?? 0, + extServed, + }), variant: missingReport !== undefined && missingReport.length > 0 ? "warning" : "info", }) } +/** The one line a settled attach is announced with: counts, then where the + * detail is. `declared` undefined means no allowlist was readable, so only + * what the engine serves can be counted. */ +export function attachSummary(input: { + served: number + declared: number | undefined + available: number + gaps: number + extServed: number +}): string { + const parts = [ + input.declared === undefined + ? `${input.available} integration tools available` + : `${input.served} of ${input.declared} integration tools available`, + ] + if (input.gaps > 0) parts.push(`${input.gaps} need attention`) + if (input.extServed > 0) parts.push(`${input.extServed} more via VS Code`) + return `${parts.join(" · ")}. Details: /workspace` +} + /** Tell the session about a refusal, once per unchanged verdict. * * The substitution point for the install offer: when installing would help @@ -839,6 +888,7 @@ export function isRepairable(outcome: Outcome | undefined): boolean { /** Test-only: forget everything this process learned. */ export function resetForTests(): void { + lastAttach.clear() directories.clear() probeMemo = null sessions.clear() diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 4ff4aaf15..8390fa4d2 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -6,6 +6,7 @@ import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { Instance } from "@/project/instance" import { Log } from "@/altimate/util/log" import type { CachedBinding } from "./state" +import type { AttachSnapshot } from "./attach-snapshot" import type { Declared, LocalMcpConfig, McpEntry, McpStatus, Toast } from "./engine-types" import type { AttachReport } from "./attach-report" import type { EngineOffer, InstallResult } from "./engine-offer" @@ -51,6 +52,8 @@ export const syncInternals: { headless?: () => boolean serve?: () => boolean now?: () => number + /** Tests keep the attach snapshot out of the real state directory. */ + persistSnapshot?: (directory: string, snapshot: AttachSnapshot) => void mcp?: { status: () => Promise add: (name: string, cfg: LocalMcpConfig | McpEntry) => Promise diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 34c024f1e..e3adfb522 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -274,45 +274,10 @@ const REASON_PHRASE: Record = { "no-bridge": "needs a VS Code window", } -const MISSING_SHOWN = 5 -const DETAIL_CHARS = 60 - -/** The gaps, grouped by reason in report order, at most `MISSING_SHOWN` keys - * across the groups; a group's first detail (the engine's error text, e.g. - * `spawn docker ENOENT`) stands for the group. */ -export function describeMissing(missing: Unfulfilled[]): string { - if (missing.length === 0) return "" - const groups = new Map() - for (const u of missing) { - const group = groups.get(u.reason) ?? { keys: [] } - group.keys.push(u.key) - if (group.detail === undefined && u.detail) group.detail = u.detail - groups.set(u.reason, group) - } - let budget = MISSING_SHOWN - const parts: string[] = [] - for (const [reason, group] of groups) { - if (budget <= 0) break - const shown = group.keys.slice(0, budget) - budget -= shown.length - const phrase = (REASON_PHRASE as Record)[reason] ?? reason - const detail = group.detail === undefined ? "" : ` (${truncate(group.detail, DETAIL_CHARS)})` - parts.push(`${phrase}${detail}: ${shown.join(", ")}`) - } - const more = missing.length > MISSING_SHOWN ? ` (+${missing.length - MISSING_SHOWN} more)` : "" - return ` Declared but not available — ${parts.join("; ")}${more}.` -} - -function truncate(text: string, max: number): string { - return text.length <= max ? text : `${text.slice(0, max - 1)}…` -} - -/** Extension-declared tools a connected IDE bridge is actually serving. Zero - * is the normal no-IDE case and says nothing — absent extension tools are - * expected, not missing, so they never join `describeMissing`. */ -export function describeExtensionServed(count: number): string { - if (count === 0) return "" - return ` Plus ${count} extension tool${count === 1 ? "" : "s"} via the connected VS Code window.` +/** A reason in the user's words. An unknown reason (a newer engine) is shown + * verbatim rather than dropped. */ +export function reasonPhrase(reason: string): string { + return (REASON_PHRASE as Record)[reason] ?? reason } /** What each outcome MEANS, as tables over the whole union: a new variant diff --git a/packages/opencode/src/altimate/workspace/status-view.ts b/packages/opencode/src/altimate/workspace/status-view.ts new file mode 100644 index 000000000..d9b10dbb4 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/status-view.ts @@ -0,0 +1,211 @@ +// altimate_change - new file +// +// What the last session got from its workspace, per integration — the view +// behind `/workspace` → Status and the sidebar's counts line. Built from the +// overlay's attach snapshot (what the engine served and what it reported it +// could not) joined to the workspace's own selection and the catalog (which +// integration each key belongs to, and its display name). +// +// TRANSPORT-AGNOSTIC, like `manage.ts`: plain data in, plain data out, no TUI +// or CLI imports, nothing printed. The dialog and the sidebar render it; a +// headless route could serve it as is. +import { AltimateApi } from "@/altimate/api/client" +import { Log } from "@/altimate/util/log" +import { attachSnapshot } from "./engine-overlay" +import type { AttachSnapshot } from "./attach-snapshot" +import { reasonPhrase, type Unfulfilled } from "./engine-types" + +const log = Log.create({ service: "altimate-workspace-status" }) + +export interface Gap { + key: string + reason: string + /** The reason in the user's words. */ + phrase: string + detail?: string +} + +/** One integration the workspace declared, and how much of it this session got. */ +export interface IntegrationRow { + id: string + name: string + /** `served`: every declared key present. `partial`: some. `missing`: none, + * with reasons. `idle`: an extension integration with no IDE bridge — expected + * without a VS Code window, not a gap. */ + state: "served" | "partial" | "missing" | "idle" + extension: boolean + declared: string[] + served: string[] + gaps: Gap[] +} + +export interface StatusView { + workspace: { id: string; name: string } + engineVersion: string | null + /** Declared keys present, over declared keys — the same pair the toast says. */ + served: number + declared: number | undefined + /** Gaps the engine reported, excluding the expected no-bridge case. */ + gaps: number + extServed: number + at: number + rows: IntegrationRow[] + /** Keys the engine served beyond the allowlist (knowledge, memory). */ + extras: string[] +} + +interface SelectionIntegration { + id: string + tools?: { key: string }[] +} +interface CatalogEntry { + id: string + name: string + type?: string +} + +/** Join the snapshot to the selection and the catalog. Pure. A key the engine + * reported for an integration the selection no longer lists still gets a row, + * named by its id, so a report is never silently dropped. */ +export function buildStatusView( + snapshot: AttachSnapshot, + selection: SelectionIntegration[], + catalog: CatalogEntry[], +): StatusView { + const byId = new Map(catalog.map((c) => [String(c.id), c])) + const present = new Set(snapshot.present) + const reported = new Map() + for (const u of snapshot.unfulfilled ?? []) { + const list = reported.get(u.integrationId) ?? [] + list.push(u) + reported.set(u.integrationId, list) + } + const rows: IntegrationRow[] = [] + const declaredKeys = new Set() + const seen = new Set() + for (const integration of selection) { + const id = String(integration.id) + seen.add(id) + const entry = byId.get(id) + const declared = (integration.tools ?? []).map((t) => t.key) + for (const k of declared) declaredKeys.add(k) + const served = declared.filter((k) => present.has(k)) + const gaps = toGaps(reported.get(id) ?? []) + const extension = entry?.type === "extension" + rows.push({ + id, + name: entry?.name ?? `Integration ${id}`, + extension, + declared, + served, + gaps, + state: rowState({ declared, served, gaps, extension }), + }) + } + // Reported for an integration the selection does not carry: keep it visible. + for (const [id, list] of reported) { + if (seen.has(id)) continue + const gaps = toGaps(list) + rows.push({ + id, + name: byId.get(id)?.name ?? `Integration ${id}`, + extension: byId.get(id)?.type === "extension", + declared: list.map((u) => u.key), + served: [], + gaps, + state: gaps.length > 0 ? "missing" : "idle", + }) + } + rows.sort(byAttention) + const extras = snapshot.present.filter((k) => !declaredKeys.has(k)).sort() + const declaredCount = snapshot.declared?.keys.length + const served = snapshot.declared ? snapshot.declared.keys.filter((k) => present.has(k)).length : present.size + const gapCount = (snapshot.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + return { + workspace: snapshot.workspace, + engineVersion: snapshot.engineVersion, + served, + declared: declaredCount, + gaps: gapCount, + extServed: snapshot.extServed, + at: snapshot.at, + rows, + extras, + } +} + +function toGaps(list: Unfulfilled[]): Gap[] { + return list + .filter((u) => u.reason !== "no-bridge") + .map((u) => ({ + key: u.key, + reason: u.reason, + phrase: reasonPhrase(u.reason), + ...(u.detail ? { detail: u.detail } : {}), + })) +} + +function rowState(row: { + declared: string[] + served: string[] + gaps: Gap[] + extension: boolean +}): IntegrationRow["state"] { + if (row.declared.length > 0 && row.served.length === row.declared.length) return "served" + if (row.served.length > 0) return "partial" + if (row.gaps.length > 0) return "missing" + // Nothing served and nothing reported wrong: an extension waiting for its + // window, or an integration the engine had nothing to say about. + return row.extension ? "idle" : row.declared.length === 0 ? "served" : "idle" +} + +/** Rows that need attention first, then partial, then served, then idle. */ +const ORDER: Record = { missing: 0, partial: 1, served: 2, idle: 3 } +function byAttention(a: IntegrationRow, b: IntegrationRow): number { + return ORDER[a.state] - ORDER[b.state] || a.name.localeCompare(b.name) +} + +/** The headline the dialog and the sidebar share: counts only. */ +export function statusHeadline(view: Pick): string { + const parts = [ + view.declared === undefined + ? `${view.served} integration tools available` + : `${view.served} of ${view.declared} integration tools available`, + ] + if (view.gaps > 0) parts.push(`${view.gaps} need attention`) + if (view.extServed > 0) parts.push(`${view.extServed} more via VS Code`) + return parts.join(" · ") +} + +/** One line for a row: counts and, when something is wrong, why. */ +export function rowLine(row: IntegrationRow): string { + const counts = row.declared.length > 0 ? `${row.served.length} of ${row.declared.length}` : `${row.served.length}` + if (row.state === "idle") return `${counts} · needs a VS Code window open on this project` + if (row.gaps.length === 0) return counts + const phrases = [...new Set(row.gaps.map((g) => g.phrase))] + const detail = row.gaps.find((g) => g.detail)?.detail + return `${counts} · ${phrases.join("; ")}${detail ? ` (${detail})` : ""}` +} + +/** Load the view for a directory: the snapshot from memory, the selection and + * the catalog from the API. Null when no session has attached there yet; the + * snapshot alone (rows named by id) when the API cannot be reached, so a + * network blip does not hide what the session already knows. */ +export async function loadStatusView(directory: string): Promise { + const snapshot = attachSnapshot(directory) + if (!snapshot) return null + try { + const [workspace, catalog] = await Promise.all([ + AltimateApi.getDatamate(snapshot.workspace.id), + AltimateApi.listIntegrations(), + ]) + return buildStatusView( + snapshot, + (workspace.integrations ?? []).map((i) => ({ id: String(i.id), tools: i.tools })), + catalog.map((c) => ({ id: String(c.id), name: c.name ?? `Integration ${c.id}`, type: c.type })), + ) + } catch (err) { + log.warn("could not load the workspace selection for the status view", { err: String(err) }) + return buildStatusView(snapshot, [], []) + } +} diff --git a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx index daf0f7ea6..3788b1dd2 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx @@ -15,6 +15,10 @@ import * as Manage from "@/altimate/workspace/manage" // altimate_change end import { buildManageUrl, resolveWorkspaceWebUrl } from "@/altimate/workspace/browser-handoff" import { getResolvedWorkspaceId } from "@/altimate/workspace/session-context" +// altimate_change start - counts from the last attach under the workspace name +import { attachSnapshot } from "@/altimate/workspace/engine-overlay" +import { statusHeadline } from "@/altimate/workspace/status-view" +// altimate_change end import { AltimateApi } from "@/altimate/api/client" import { openManageUrl } from "./workspace" @@ -80,6 +84,20 @@ function View(props: { api: TuiPluginApi }) { // altimate_change start - status lines const [detail, setDetail] = createSignal(null) // altimate_change end + // altimate_change start - what the last session got, in numbers + const [attachLine, setAttachLine] = createSignal(null) + const readAttachLine = (bound: CachedBinding | null) => { + const snapshot = attachSnapshot(props.api.state.path.directory) + // Only for the workspace this project is bound to now; a snapshot from a + // previous binding would describe the wrong workspace under this name. + if (!snapshot || !bound || snapshot.workspace.id !== String(bound.datamateId)) return setAttachLine(null) + const present = new Set(snapshot.present) + const declared = snapshot.declared?.keys.length + const served = snapshot.declared ? snapshot.declared.keys.filter((k) => present.has(k)).length : present.size + const gaps = (snapshot.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + setAttachLine(statusHeadline({ served, declared, gaps, extServed: snapshot.extServed, rows: [] })) + } + // altimate_change end let refreshInFlight = false let refreshQueued = false @@ -158,6 +176,9 @@ function View(props: { api: TuiPluginApi }) { setBinding(null) } const b = binding() + // altimate_change start - what the last session got, in numbers + readAttachLine(b ?? null) + // altimate_change end // No clear here: every path that reaches this with no binding has already // cleared the manage URL, or never set one. if (!b) return @@ -226,43 +247,30 @@ function View(props: { api: TuiPluginApi }) { {(b) => ( <> {/* Clicking the name (or the URL line below) opens the workspace - * in the browser — the manage URL is deterministic from tenant - * + id (see resolveManageBase above), so there's no extra - * round-trip before it's clickable. The whole line is the click - * target (mouse events only land on block-level ``/``, - * not inline ``/`` nodes), while only the name itself - * is styled to look like a link — matching the footer's docs/ - * community links (sidebar/footer.tsx), which use the same - * span-style + onMouseUp pair because raw `` hyperlink - * nodes crash in this JSX layer. ``onMouseUp`` is omitted - * entirely (not just a no-op) when there's no URL yet, so the - * name never advertises a click target that does nothing. The - * "pinned via --workspace" hint lives on its own line below - * (rather than appended inline here) so the click region - * doesn't extend over text that isn't part of the link — same - * reasoning as the URL line already being separate. (multi-model - * review, PR #1274.) */} - openManageUrl(props.api, manageUrl()!) : undefined}> + * in the browser — the manage URL is deterministic from tenant + * + id (see resolveManageBase above), so there's no extra + * round-trip before it's clickable. The whole line is the click + * target (mouse events only land on block-level ``/``, + * not inline ``/`` nodes), while only the name itself + * is styled to look like a link — matching the footer's docs/ + * community links (sidebar/footer.tsx), which use the same + * span-style + onMouseUp pair because raw `` hyperlink + * nodes crash in this JSX layer. ``onMouseUp`` is omitted + * entirely (not just a no-op) when there's no URL yet, so the + * name never advertises a click target that does nothing. The + * "pinned via --workspace" hint lives on its own line below + * (rather than appended inline here) so the click region + * doesn't extend over text that isn't part of the link — same + * reasoning as the URL line already being separate. (multi-model + * review, PR #1274.) */} + openManageUrl(props.api, manageUrl()!) : undefined} + > {(_u) => {b().datamateName}} - {/* ``pinned via --workspace`` means "this SESSION was launched - * with --workspace and it resolved to this id". It does NOT - * mean "the current binding was set by --workspace" — if the - * user relinks mid-session to a different workspace, the pin - * disappears (id mismatch); if they relink to the same id, - * the pin correctly stays because the launch fact is - * unchanged. Known imprecision: relink-to-same-id looks - * indistinguishable from "never relinked". Accepted per - * altimate-harness-bot round 8 (option b of the review). - * ``getResolvedWorkspaceId`` returns null when the launch - * had no --workspace flag or the flag failed to resolve, - * so the pin never falsely appears for a session that - * wasn't launched with the flag. */} - - (pinned via --workspace) - {/* altimate_change start - status lines: what has drifted, so the * reason to run `/workspace` is visible before you need it. */} @@ -277,6 +285,26 @@ function View(props: { api: TuiPluginApi }) { {(at) => {`skills synced ${describeAge(at())}`}} {/* altimate_change end */} + {/* ``pinned via --workspace`` means "this SESSION was launched + * with --workspace and it resolved to this id". It does NOT + * mean "the current binding was set by --workspace" — if the + * user relinks mid-session to a different workspace, the pin + * disappears (id mismatch); if they relink to the same id, + * the pin correctly stays because the launch fact is + * unchanged. Known imprecision: relink-to-same-id looks + * indistinguishable from "never relinked". Accepted per + * altimate-harness-bot round 8 (option b of the review). + * ``getResolvedWorkspaceId`` returns null when the launch + * had no --workspace flag or the flag failed to resolve, + * so the pin never falsely appears for a session that + * wasn't launched with the flag. */} + {/* altimate_change start - the toast's numbers, kept visible; + * the reasons are under /workspace → Status */} + {(line) => {line()} · /workspace} + {/* altimate_change end */} + + (pinned via --workspace) + {(u) => ( openManageUrl(props.api, u())}> diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 228a279aa..5d95993da 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -29,6 +29,8 @@ import open from "open" // altimate_change start - the /workspace action menu import * as Manage from "@/altimate/workspace/manage" import { inertWorkspaceName } from "@/altimate/workspace/workspace-name" +import { attachSnapshot } from "@/altimate/workspace/engine-overlay" +import { loadStatusView, rowLine, statusHeadline, type IntegrationRow } from "@/altimate/workspace/status-view" // altimate_change end import { createSignal, onCleanup, onMount } from "solid-js" import { @@ -48,11 +50,7 @@ import { resolveWorkspaceWebUrl, type HandoffResult, } from "@/altimate/workspace/browser-handoff" -import { - projectNameFromPath, - projectNameFromRemote, - resolveProjectIdentifier, -} from "@/altimate/workspace/detect" +import { projectNameFromPath, projectNameFromRemote, resolveProjectIdentifier } from "@/altimate/workspace/detect" import { readLocalBinding, recordApprovedBinding } from "@/altimate/workspace/state" import { describeOffer, @@ -124,12 +122,7 @@ function skipKey(id: ProjectIdentifier, scope: LatchScope | null): string { ) } -function isSkipActive( - api: TuiPluginApi, - id: ProjectIdentifier, - scope: LatchScope | null, - nowMs: number, -): boolean { +function isSkipActive(api: TuiPluginApi, id: ProjectIdentifier, scope: LatchScope | null, nowMs: number): boolean { const rec = api.kv.get<{ skippedAt: number }>(skipKey(id, scope)) if (!rec || typeof rec.skippedAt !== "number") return false // Reject records timestamped in the future — a system-clock rewind after @@ -142,12 +135,7 @@ function isSkipActive( return delta < SKIP_TTL_MS } -function recordSkip( - api: TuiPluginApi, - id: ProjectIdentifier, - scope: LatchScope | null, - nowMs: number, -): void { +function recordSkip(api: TuiPluginApi, id: ProjectIdentifier, scope: LatchScope | null, nowMs: number): void { api.kv.set(skipKey(id, scope), { skippedAt: nowMs }) } @@ -238,9 +226,7 @@ function OfferDialog(props: OfferProps) { return } // link → picker (fresh-project attach path) - props.api.ui.dialog.replace(() => ( - - )) + props.api.ui.dialog.replace(() => ) }} /> ) @@ -347,11 +333,7 @@ let activeHandoffAbort: AbortController | null = null * returned workspace via the existing ``POST /bind`` endpoint. Every failure * mode surfaces as a toast; the user can always fall back to another option * by re-invoking the dialog. */ -async function runBrowserHandoff( - api: TuiPluginApi, - identifier: ProjectIdentifier, - projectName: string, -): Promise { +async function runBrowserHandoff(api: TuiPluginApi, identifier: ProjectIdentifier, projectName: string): Promise { api.ui.dialog.clear() api.ui.toast({ variant: "info", @@ -383,10 +365,7 @@ async function runBrowserHandoff( // credentials we're about to bind under, and refuse if either drifted. try { const fresh = await AltimateApi.getCredentials() - if ( - fresh.altimateInstanceName !== result.credentials.tenant || - fresh.altimateUrl !== result.credentials.apiUrl - ) { + if (fresh.altimateInstanceName !== result.credentials.tenant || fresh.altimateUrl !== result.credentials.apiUrl) { api.ui.toast({ variant: "error", message: `Your Altimate credentials changed while the browser was open (was ${result.credentials.tenant}, now ${fresh.altimateInstanceName}). Re-run to link this project.`, @@ -445,7 +424,8 @@ function toastHandoffFailure(api: TuiPluginApi, result: Extract { // stored — the repo was renamed / remote swapped. The dialog surfaces // this so the user isn't silently attached to a stale binding. (M3) const boundIdent = - serverBinding.matchedBy === "remote" - ? serverBinding.binding.repo_remote - : serverBinding.binding.project_path - const currentIdent = - serverBinding.matchedBy === "remote" ? identifier.repoRemote : identifier.projectPath + serverBinding.matchedBy === "remote" ? serverBinding.binding.repo_remote : serverBinding.binding.project_path + const currentIdent = serverBinding.matchedBy === "remote" ? identifier.repoRemote : identifier.projectPath const hasDrift = boundIdent != null && currentIdent != null && boundIdent !== currentIdent // Resolved before the dialog renders — see AlreadyLinkedDialog's comment // on why this can't be fetched async inside the dialog itself. @@ -1193,8 +1160,7 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { // ordering as the server-side pre-check: remote first, path fallback. const cachedMatchedBy: MatchedIdentifier = local.repoRemote ? "remote" : "path" const cachedIdent = local.repoRemote ?? local.projectPath ?? "" - const currentIdent = - cachedMatchedBy === "remote" ? identifier.repoRemote : identifier.projectPath + const currentIdent = cachedMatchedBy === "remote" ? identifier.repoRemote : identifier.projectPath const hasDrift = cachedIdent !== "" && currentIdent != null && cachedIdent !== currentIdent const manageUrl = await resolveManageUrl(local.datamateId) api.ui.dialog.replace(() => ( @@ -1276,12 +1242,7 @@ async function awaitKvReady( /** Same clock-rewind handling as the post-scan latch; the TTL is the one the * attach side's announce dedupe expires on, so both agree on "7 days". */ -function isEngineSkipActive( - api: TuiPluginApi, - workspaceId: string, - scope: LatchScope | null, - nowMs: number, -): boolean { +function isEngineSkipActive(api: TuiPluginApi, workspaceId: string, scope: LatchScope | null, nowMs: number): boolean { const rec = api.kv.get<{ skippedAt: number }>(engineSkipKey(workspaceId, scope)) if (!rec || typeof rec.skippedAt !== "number") return false const delta = nowMs - rec.skippedAt @@ -1289,12 +1250,7 @@ function isEngineSkipActive( return delta < OFFER_SKIP_TTL_MS } -function recordEngineSkip( - api: TuiPluginApi, - workspaceId: string, - scope: LatchScope | null, - nowMs: number, -): void { +function recordEngineSkip(api: TuiPluginApi, workspaceId: string, scope: LatchScope | null, nowMs: number): void { api.kv.set(engineSkipKey(workspaceId, scope), { skippedAt: nowMs }) } @@ -1771,6 +1727,122 @@ function syncMessage(result: Manage.SyncReport): string { return parts.join(", ") + "." } +/** The description of the Status row: counts from the last attach, or why + * there are none yet. Read from memory, so the menu opens without waiting. */ +function statusRowDescription(directory: string, boundId: number): string { + const snapshot = attachSnapshot(directory) + // A snapshot from a workspace this project was since re-linked away from is + // about the wrong workspace; say nothing rather than something stale. + if (!snapshot || snapshot.workspace.id !== String(boundId)) { + return "No session has attached yet — send a message first." + } + const present = new Set(snapshot.present) + const declared = snapshot.declared?.keys.length + const served = snapshot.declared ? snapshot.declared.keys.filter((k) => present.has(k)).length : present.size + const gaps = (snapshot.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + return statusHeadline({ served, declared, gaps, extServed: snapshot.extServed, rows: [] }) +} + +const STATE_MARK: Record = { + served: "●", + partial: "◐", + missing: "○", + idle: "◌", +} + +/** `/workspace` → Status: what the last session got from each integration + * and why, the detail the attach toast now only points at. Rows are + * informational; the actions open the workspace on the web or re-read. */ +async function showWorkspaceStatus(api: TuiPluginApi, directory: string, boundId: number): Promise { + const view = await loadStatusView(directory) + if (!view || view.workspace.id !== String(boundId)) { + api.ui.dialog.replace(() => ( + api.ui.dialog.clear()} + /> + )) + return + } + const manageUrl = await resolveManageUrl(Number(view.workspace.id)) + const engine = view.engineVersion ? ` · engine ${view.engineVersion}` : "" + const title = `${view.workspace.name}${engine} · ${statusHeadline(view)}` + // The plugin's DialogSelect has rows and a footer per row, no action bar: + // the integrations are rows under one category, the actions rows under + // another, and a row's footer carries its keys and reasons. + const rows = view.rows.map((row) => ({ + title: `${STATE_MARK[row.state]} ${row.name}`, + value: `row:${row.id}`, + description: rowLine(row), + footer: rowDetails(row).join("\n"), + category: "Integrations", + })) + if (view.extras.length > 0) { + rows.push({ + title: `${STATE_MARK.served} Workspace extras`, + value: "row:extras", + description: `${view.extras.length} beyond the allowlist (knowledge, memory)`, + footer: view.extras.join(", "), + category: "Integrations", + }) + } + const actions = [ + ...(manageUrl + ? [ + { + title: "Open on the web", + value: "open", + description: "Connections and the selection live there.", + category: "Actions", + }, + ] + : []), + { + title: "Re-read", + value: "reread", + description: "Read the selection and the last attach again.", + category: "Actions", + }, + { title: "Done", value: "done", description: "Close this view.", category: "Actions" }, + ] + api.ui.dialog.replace(() => ( + { + if (option.value === "open" && manageUrl) { + api.ui.dialog.clear() + openManageUrl(api, manageUrl) + return + } + if (option.value === "reread") { + showWorkspaceStatus(api, directory, boundId).catch((err) => reportFlowFailure(api, err)) + return + } + api.ui.dialog.clear() + }} + /> + )) +} + +/** Served keys, then the gaps with their reason — the row's "details" lines. */ +function rowDetails(row: IntegrationRow): string[] { + const lines: string[] = [] + if (row.served.length > 0) lines.push(`available: ${row.served.join(", ")}`) + for (const gap of row.gaps) lines.push(`${gap.key} — ${gap.phrase}${gap.detail ? ` (${gap.detail})` : ""}`) + if (row.state === "idle" && row.declared.length > 0) lines.push(`via VS Code: ${row.declared.join(", ")}`) + return lines +} + /** The `/workspace` menu. */ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise { const report = await Manage.status(directory) @@ -1782,6 +1854,11 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise options={ linked ? [ + { + title: "Status", + value: "status", + description: statusRowDescription(directory, report.binding!.datamateId), + }, { title: "Refresh", value: "refresh", @@ -1805,8 +1882,12 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise }, ] } - current={linked ? "refresh" : "done"} + current={linked ? "status" : "done"} onSelect={(option) => { + if (option.value === "status") { + showWorkspaceStatus(api, directory, report.binding!.datamateId).catch((err) => reportFlowFailure(api, err)) + return + } if (option.value === "unlink") { confirmUnlink(api, directory, report.binding?.datamateName ?? "this workspace") return @@ -1911,9 +1992,7 @@ const tui: TuiPlugin = async (api) => { run() { // User-initiated → jump straight to picker (currently-linked marked, // "+ Create new" as the first row). No Skip funnel — they invoked. - runOnDemandPicker(api, api.state.path.directory).catch((err) => - reportFlowFailure(api, err), - ) + runOnDemandPicker(api, api.state.path.directory).catch((err) => reportFlowFailure(api, err)) }, }, ], diff --git a/packages/opencode/test/altimate/workspace/attach-snapshot.test.ts b/packages/opencode/test/altimate/workspace/attach-snapshot.test.ts new file mode 100644 index 000000000..07c5114c0 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/attach-snapshot.test.ts @@ -0,0 +1,59 @@ +// The attach snapshot file: what the overlay writes for the TUI process to +// read. Sandboxed state directory, like manage.test.ts. +import { afterAll, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" + +const SANDBOX = mkdtempSync(path.join(tmpdir(), "attach-snapshot-")) +const ORIGINAL_XDG_STATE_HOME = process.env.XDG_STATE_HOME +process.env.XDG_STATE_HOME = path.join(SANDBOX, "state") +afterAll(() => { + if (ORIGINAL_XDG_STATE_HOME === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = ORIGINAL_XDG_STATE_HOME + rmSync(SANDBOX, { recursive: true, force: true }) +}) + +const { readAttachSnapshot, writeAttachSnapshot, snapshotPath } = await import( + "../../../src/altimate/workspace/attach-snapshot" +) + +const snap = (at: number, id = "6") => ({ + workspace: { id, name: "e2e-demo-live" }, + engineVersion: "0.7.2", + declared: { keys: ["a", "b"], extensionKeys: [] }, + present: ["a"], + unfulfilled: [{ key: "b", integrationId: "jira", reason: "invalid-connection" }], + extServed: 0, + at, +}) + +describe("attach snapshot file", () => { + beforeEach(() => rmSync(snapshotPath(), { force: true })) + + test("round-trips per directory, latest attach wins, and a directory with none reads undefined", () => { + writeAttachSnapshot("/proj/a", snap(1)) + writeAttachSnapshot("/proj/b", snap(2, "7")) + writeAttachSnapshot("/proj/a", snap(3)) + expect(readAttachSnapshot("/proj/a")).toEqual(snap(3)) + expect(readAttachSnapshot("/proj/b")).toEqual(snap(2, "7")) + expect(readAttachSnapshot("/proj/c")).toBeUndefined() + }) + + test("keeps the newest 64 directories", () => { + for (let i = 0; i < 70; i++) writeAttachSnapshot(`/proj/${i}`, snap(i)) + expect(readAttachSnapshot("/proj/0")).toBeUndefined() + expect(readAttachSnapshot("/proj/5")).toBeUndefined() + expect(readAttachSnapshot("/proj/6")).toEqual(snap(6)) + expect(readAttachSnapshot("/proj/69")).toEqual(snap(69)) + }) + + test("a corrupt file reads as empty and is replaced by the next write", () => { + const { mkdirSync, writeFileSync } = require("node:fs") as typeof import("node:fs") + mkdirSync(path.dirname(snapshotPath()), { recursive: true }) + writeFileSync(snapshotPath(), "{not json") + expect(readAttachSnapshot("/proj/a")).toBeUndefined() + writeAttachSnapshot("/proj/a", snap(1)) + expect(readAttachSnapshot("/proj/a")).toEqual(snap(1)) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index bd5b4d694..6faad5b7b 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -26,8 +26,10 @@ import { type LocalMcpConfig, type McpEntry, type Toast, + attachSnapshot, } from "../../../src/altimate/workspace/engine-overlay" import type { ScopedBinding } from "../../../src/altimate/workspace/engine-seams" +import type { AttachSnapshot } from "../../../src/altimate/workspace/attach-snapshot" import type { AttachReport } from "../../../src/altimate/workspace/attach-report" import { DATAMATE_KEY } from "../../../src/altimate/datamate-transport" @@ -53,6 +55,7 @@ type Harness = { invalidates: number probes: number toasts: Toast[] + persisted: AttachSnapshot[] reports: { datamateId: string; report: AttachReport }[] lines: string[] clock: number @@ -96,6 +99,7 @@ function install(opts: { invalidates: 0, probes: 0, toasts: [], + persisted: [], reports: [], lines: [], clock: 1_000_000, @@ -115,6 +119,9 @@ function install(opts: { opts.declared === undefined ? { keys: ["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"], extensionKeys: [] } : opts.declared + syncInternals.persistSnapshot = (_dir, snap) => { + h.persisted.push(snap) + } syncInternals.notify = async (toast) => { h.toasts.push(toast) } @@ -413,8 +420,16 @@ describe("beforeTurn — what a turn boundary does", () => { unfulfilled: report, }) expect(h.toasts).toHaveLength(1) - expect(h.toasts[0].message).toContain("2 of 3 declared integration tools available") - expect(h.toasts[0].message).toContain("no usable connection: dbt_execute_sql") + expect(h.toasts[0].message).toBe("2 of 3 integration tools available · 1 need attention. Details: /workspace") + expect(h.toasts[0].variant).toBe("warning") + const snap = attachSnapshot(DIR)! + expect(snap.workspace).toEqual({ id: String(h.binding!.datamateId), name: h.binding!.datamateName }) + expect([...snap.present].sort()).toEqual(["dbt_build_model", "dbt_compile_model"]) + expect(snap.unfulfilled).toEqual(report) + expect(snap.extServed).toBe(0) + expect(snap.declared?.keys).toEqual(["dbt_build_model", "dbt_compile_model", "dbt_execute_sql"]) + // Persisted for the TUI process, which cannot see this one's memory. + expect(h.persisted).toEqual([snap]) // The engine was started by MCP bootstrap from the injected entry, not by the hook. expect(h.added).toEqual([]) await beforeTurn("s1") @@ -429,14 +444,14 @@ describe("beforeTurn — what a turn boundary does", () => { }) await beforeTurn("s1") expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [], unfulfilled: [] }) - expect(h.toasts[0].message).toBe("2 of 2 declared integration tools available.") + expect(h.toasts[0].message).toBe("2 of 2 integration tools available. Details: /workspace") }) test("attached without an allowlist reports only what is available", async () => { const h = install({ declared: null }) await beforeTurn("s1") expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2, missing: [], unfulfilled: [] }) - expect(h.toasts[0].message).toBe("2 integration tools available.") + expect(h.toasts[0].message).toBe("2 integration tools available. Details: /workspace") }) test("extension tools a live bridge serves are announced; absent ones are expected, not missing", async () => { @@ -448,9 +463,7 @@ describe("beforeTurn — what a turn boundary does", () => { // `run_model` is declared extension-type but no bridge serves it: that is // the normal no-IDE case, so the outcome stays clean and unwarned. expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 3, declared: 2, missing: [], unfulfilled: [] }) - expect(h.toasts[0].message).toBe( - "2 of 2 declared integration tools available. Plus 1 extension tool via the connected VS Code window.", - ) + expect(h.toasts[0].message).toBe("2 of 2 integration tools available · 1 more via VS Code. Details: /workspace") expect(h.toasts[0].variant).toBe("info") }) @@ -465,10 +478,7 @@ describe("beforeTurn — what a turn boundary does", () => { const h = install({ meta: { [UNFULFILLED_META_KEY]: report } }) await beforeTurn("s1") expect(settledOutcome("s1")).toMatchObject({ missing: ["dbt_execute_sql", "gh_list_prs", "gh_create_pr"] }) - expect(h.toasts[0].message).toBe( - "2 of 3 declared integration tools available. Declared but not available — no usable connection: dbt_execute_sql; " + - "server failed to start (spawn docker ENOENT): gh_list_prs, gh_create_pr.", - ) + expect(h.toasts[0].message).toBe("2 of 3 integration tools available · 3 need attention. Details: /workspace") expect(h.toasts[0].variant).toBe("warning") }) @@ -486,7 +496,7 @@ describe("beforeTurn — what a turn boundary does", () => { missing: [], unfulfilled: report, }) - expect(h.toasts[0].message).toBe("2 of 3 declared integration tools available.") + expect(h.toasts[0].message).toBe("2 of 3 integration tools available. Details: /workspace") expect(h.toasts[0].variant).toBe("info") }) @@ -496,7 +506,7 @@ describe("beforeTurn — what a turn boundary does", () => { // Two of three declared keys are present; without the engine's report // the third is neither claimed missing nor claimed served. expect(settledOutcome("s1")).toEqual({ kind: "attached", available: 2, declared: 3 }) - expect(h.toasts[0].message).toBe("2 of 3 declared integration tools available.") + expect(h.toasts[0].message).toBe("2 of 3 integration tools available. Details: /workspace") expect(h.toasts[0].variant).toBe("info") }) @@ -510,9 +520,7 @@ describe("beforeTurn — what a turn boundary does", () => { missing: ["jira_search_issues"], unfulfilled: report, }) - expect(h.toasts[0].message).toBe( - "2 integration tools available. Declared but not available — no usable connection: jira_search_issues.", - ) + expect(h.toasts[0].message).toBe("2 integration tools available · 1 need attention. Details: /workspace") expect(h.toasts[0].variant).toBe("warning") }) @@ -528,7 +536,10 @@ describe("beforeTurn — what a turn boundary does", () => { } await beforeTurn("s1") expect(h.toasts).toHaveLength(2) - expect(h.toasts[1].message).toContain("no usable connection: gh_list_prs") + // The toast carries numbers only; the changed reason is in the snapshot the + // status view reads. + expect(h.toasts[1].message).toBe("2 of 3 integration tools available · 1 need attention. Details: /workspace") + expect(attachSnapshot(DIR)?.unfulfilled?.map((u) => u.reason)).toEqual(["invalid-connection"]) }) test("the inventory is announced per session, not per process", async () => { diff --git a/packages/opencode/test/altimate/workspace/engine-types.test.ts b/packages/opencode/test/altimate/workspace/engine-types.test.ts index 40dff310d..4061a2ca5 100644 --- a/packages/opencode/test/altimate/workspace/engine-types.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-types.test.ts @@ -12,7 +12,6 @@ import { attributableEngine, clearsFloor, compareVersions, - describeMissing, parseUnfulfilled, reportedMissing, UNFULFILLED_META_KEY, @@ -23,6 +22,7 @@ import { installWouldHelp, pinnedWorkspace, type Outcome, + reasonPhrase, } from "../../../src/altimate/workspace/engine-types" describe("compareVersions", () => { @@ -157,36 +157,14 @@ describe("messages", () => { "Update with: npm i -g @altimateai/datamate@next", ) }) - test("the missing line groups by reason, carries the engine's detail, and truncates after five", () => { - const u = (key: string, reason: string, detail?: string) => ({ - key, - integrationId: "i", - reason, - ...(detail ? { detail } : {}), - }) - expect(describeMissing([])).toBe("") - expect(describeMissing([u("a", "invalid-connection"), u("b", "invalid-connection")])).toBe( - " Declared but not available — no usable connection: a, b.", - ) - expect( - describeMissing([ - u("a", "spawn-failed", "spawn docker ENOENT"), - u("b", "spawn-failed", "spawn docker ENOENT"), - u("c", "catalog-missing"), - u("d", "unknown-key"), - u("e", "exception", "boom"), - ]), - ).toBe( - " Declared but not available — server failed to start (spawn docker ENOENT): a, b; no longer in the catalog: c; " + - "not offered by the integration: d; failed to load (boom): e.", - ) - expect(describeMissing(["a", "b", "c", "d", "e", "f", "g"].map((k) => u(k, "invalid-connection")))).toBe( - " Declared but not available — no usable connection: a, b, c, d, e (+2 more).", - ) - // A reason this client does not know is shown verbatim rather than dropped. - expect(describeMissing([u("a", "quota-exceeded")])).toBe(" Declared but not available — quota-exceeded: a.") - // A long detail is cut so the toast stays a toast. - expect(describeMissing([u("a", "exception", "x".repeat(80))])).toContain(`(${"x".repeat(59)}…)`) + test("a reason is named in the user's words, and an unknown one is kept verbatim", () => { + expect(reasonPhrase("invalid-connection")).toBe("no usable connection") + expect(reasonPhrase("spawn-failed")).toBe("server failed to start") + expect(reasonPhrase("catalog-missing")).toBe("no longer in the catalog") + expect(reasonPhrase("unknown-key")).toBe("not offered by the integration") + expect(reasonPhrase("exception")).toBe("failed to load") + expect(reasonPhrase("no-bridge")).toBe("needs a VS Code window") + expect(reasonPhrase("quota-exceeded")).toBe("quota-exceeded") }) test("the engine's report is read out of tools/list _meta, and nothing is invented", () => { diff --git a/packages/opencode/test/altimate/workspace/status-view.test.ts b/packages/opencode/test/altimate/workspace/status-view.test.ts new file mode 100644 index 000000000..a5aacc5f4 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/status-view.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import type { AttachSnapshot } from "../../../src/altimate/workspace/attach-snapshot" +import { buildStatusView, rowLine, statusHeadline } from "../../../src/altimate/workspace/status-view" + +const snapshot = (over: Partial = {}): AttachSnapshot => ({ + workspace: { id: "6", name: "e2e-demo-live" }, + engineVersion: "0.7.2", + declared: { + keys: ["altimate_a", "altimate_b", "jira_search", "jira_create", "demo_tool"], + extensionKeys: ["get_projects"], + }, + present: ["altimate_a", "altimate_b", "altimate_knowledge_search"], + unfulfilled: [ + { key: "jira_search", integrationId: "jira", reason: "invalid-connection" }, + { key: "jira_create", integrationId: "jira", reason: "invalid-connection" }, + { key: "demo_tool", integrationId: "1", reason: "spawn-failed", detail: "altimate-demo-missing-mcp: ENOENT" }, + { key: "get_projects", integrationId: "power-user-for-dbt", reason: "no-bridge" }, + ], + extServed: 0, + at: 1, + ...over, +}) +const selection = [ + { id: "altimate", tools: [{ key: "altimate_a" }, { key: "altimate_b" }] }, + { id: "jira", tools: [{ key: "jira_search" }, { key: "jira_create" }] }, + { id: "power-user-for-dbt", tools: [{ key: "get_projects" }] }, + { id: "1", tools: [{ key: "demo_tool" }] }, +] +const catalog = [ + { id: "altimate", name: "Altimate", type: "tool" }, + { id: "jira", name: "Jira", type: "tool" }, + { id: "power-user-for-dbt", name: "Power User for dbt", type: "extension" }, +] + +describe("buildStatusView", () => { + test("one row per declared integration, attention first, named from the catalog with an id fallback", () => { + const view = buildStatusView(snapshot(), selection, catalog) + expect(view.rows.map((r) => [r.name, r.state])).toEqual([ + ["Integration 1", "missing"], + ["Jira", "missing"], + ["Altimate", "served"], + ["Power User for dbt", "idle"], + ]) + const jira = view.rows.find((r) => r.name === "Jira")! + expect(jira.gaps.map((g) => g.phrase)).toEqual(["no usable connection", "no usable connection"]) + expect(rowLine(jira)).toBe("0 of 2 · no usable connection") + expect(rowLine(view.rows[0]!)).toBe("0 of 1 · server failed to start (altimate-demo-missing-mcp: ENOENT)") + expect(rowLine(view.rows.find((r) => r.name === "Altimate")!)).toBe("2 of 2") + expect(rowLine(view.rows.find((r) => r.name === "Power User for dbt")!)).toBe( + "0 of 1 · needs a VS Code window open on this project", + ) + }) + + test("counts match the toast: declared keys present over declared, gaps without no-bridge, extras beyond the allowlist", () => { + const view = buildStatusView(snapshot(), selection, catalog) + expect(view.served).toBe(2) + expect(view.declared).toBe(5) + expect(view.gaps).toBe(3) + expect(view.extras).toEqual(["altimate_knowledge_search"]) + expect(statusHeadline(view)).toBe("2 of 5 integration tools available · 3 need attention") + }) + + test("a partially served integration and a live bridge read as such", () => { + const view = buildStatusView( + snapshot({ + present: ["altimate_a", "get_projects"], + unfulfilled: [{ key: "altimate_b", integrationId: "altimate", reason: "exception" }], + extServed: 1, + }), + selection, + catalog, + ) + const altimate = view.rows.find((r) => r.name === "Altimate")! + expect(altimate.state).toBe("partial") + expect(rowLine(altimate)).toBe("1 of 2 · failed to load") + expect(view.rows.find((r) => r.name === "Power User for dbt")!.state).toBe("served") + expect(statusHeadline(view)).toBe("1 of 5 integration tools available · 1 need attention · 1 more via VS Code") + }) + + test("a report for an integration the selection no longer lists still gets a row", () => { + const view = buildStatusView( + snapshot({ unfulfilled: [{ key: "old_tool", integrationId: "retired", reason: "catalog-missing" }] }), + [{ id: "altimate", tools: [{ key: "altimate_a" }] }], + catalog, + ) + expect(view.rows.map((r) => [r.name, r.state])).toEqual([ + ["Integration retired", "missing"], + ["Altimate", "served"], + ]) + }) + + test("without an allowlist the headline counts what the engine serves", () => { + const view = buildStatusView(snapshot({ declared: null, unfulfilled: undefined }), [], []) + expect(view.declared).toBeUndefined() + expect(statusHeadline(view)).toBe("3 integration tools available") + expect(view.rows).toEqual([]) + }) +}) diff --git a/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts b/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts index 2ce9e2899..4f7565511 100644 --- a/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts +++ b/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts @@ -17,7 +17,7 @@ import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" import { MCP } from "../../src/mcp/index" import { - describeMissing, + reasonPhrase, parseUnfulfilled, reportedMissing, UNFULFILLED_META_KEY, @@ -222,13 +222,13 @@ describe.skipIf(!runnable)("engine unfulfilled report through the MCP service", }) expect(report!.some((u) => `datamate_${u.key}` in tools)).toBe(false) - // What the user would read on attach: every gap but the IDE one, with reasons. - expect(describeMissing(reportedMissing(report!))).toBe( - " Declared but not available — no usable connection: jira_search_issues; " + - "not offered by the integration: ghost; " + - "server failed to start (spawn altimate-e2e-missing-binary ENOENT): whatever; " + - "no longer in the catalog: retired_tool.", - ) + // What the status view would list on attach: every gap but the IDE one, each with its reason. + expect(reportedMissing(report!).map((u) => `${u.key}: ${reasonPhrase(u.reason)}`)).toEqual([ + "jira_search_issues: no usable connection", + "ghost: not offered by the integration", + "whatever: server failed to start", + "retired_tool: no longer in the catalog", + ]) expect(api.unhandled).toEqual([]) yield* mcp.remove("datamate") expect(yield* mcp.listMeta("datamate")).toBeUndefined() From 7f6110e822da04ab11c65dd76449917eaf2ca011 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Tue, 15 Sep 2026 01:15:32 +0800 Subject: [PATCH 07/14] fix(workspace): key sub-rows the dialog will show, and a title that fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin's DialogSelect renders an option's footer inline with its title, which left three characters of "Altimate", and it filters `disabled` rows out entirely, so the keys under each integration never appeared. The keys are now ordinary indented rows — choosing one keeps the view open — and the engine version moves off the title (which wrapped) onto the Re-read row. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../src/plugin/tui/altimate/workspace.tsx | 71 +++++++++++++------ 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 5d95993da..4c3569952 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -1772,26 +1772,47 @@ async function showWorkspaceStatus(api: TuiPluginApi, directory: string, boundId return } const manageUrl = await resolveManageUrl(Number(view.workspace.id)) - const engine = view.engineVersion ? ` · engine ${view.engineVersion}` : "" - const title = `${view.workspace.name}${engine} · ${statusHeadline(view)}` - // The plugin's DialogSelect has rows and a footer per row, no action bar: - // the integrations are rows under one category, the actions rows under - // another, and a row's footer carries its keys and reasons. - const rows = view.rows.map((row) => ({ - title: `${STATE_MARK[row.state]} ${row.name}`, - value: `row:${row.id}`, - description: rowLine(row), - footer: rowDetails(row).join("\n"), - category: "Integrations", - })) + const title = `${view.workspace.name} · ${statusHeadline(view)}` + // The plugin's DialogSelect renders a row's footer inline with its title, + // which squeezes the title to a few characters, so the keys go on sub-rows + // under each integration instead — ordinary rows, since the dialog hides + // disabled ones: gaps with their reason first, then what is available, + // capped so a 40-tool integration stays readable. + const rows: { title: string; value: string; description?: string; category: string }[] = [] + for (const row of view.rows) { + rows.push({ + title: `${STATE_MARK[row.state]} ${row.name}`, + value: `row:${row.id}`, + description: rowLine(row), + category: "Integrations", + }) + for (const line of rowDetails(row)) { + rows.push({ + title: ` ${line.key}`, + value: `key:${row.id}:${line.key}`, + description: line.note, + category: "Integrations", + }) + } + } if (view.extras.length > 0) { rows.push({ title: `${STATE_MARK.served} Workspace extras`, value: "row:extras", description: `${view.extras.length} beyond the allowlist (knowledge, memory)`, - footer: view.extras.join(", "), category: "Integrations", }) + for (const line of capped( + view.extras.map((key) => ({ key, note: "available" })), + 4, + )) { + rows.push({ + title: ` ${line.key}`, + value: `key:extras:${line.key}`, + description: line.note, + category: "Integrations", + }) + } } const actions = [ ...(manageUrl @@ -1807,7 +1828,7 @@ async function showWorkspaceStatus(api: TuiPluginApi, directory: string, boundId { title: "Re-read", value: "reread", - description: "Read the selection and the last attach again.", + description: `Read the selection and the last attach again${view.engineVersion ? ` (engine ${view.engineVersion})` : ""}.`, category: "Actions", }, { title: "Done", value: "done", description: "Close this view.", category: "Actions" }, @@ -1828,19 +1849,27 @@ async function showWorkspaceStatus(api: TuiPluginApi, directory: string, boundId showWorkspaceStatus(api, directory, boundId).catch((err) => reportFlowFailure(api, err)) return } + // A key row is information, not an action: choosing it keeps the view open. + if (String(option.value).startsWith("key:")) return api.ui.dialog.clear() }} /> )) } -/** Served keys, then the gaps with their reason — the row's "details" lines. */ -function rowDetails(row: IntegrationRow): string[] { - const lines: string[] = [] - if (row.served.length > 0) lines.push(`available: ${row.served.join(", ")}`) - for (const gap of row.gaps) lines.push(`${gap.key} — ${gap.phrase}${gap.detail ? ` (${gap.detail})` : ""}`) - if (row.state === "idle" && row.declared.length > 0) lines.push(`via VS Code: ${row.declared.join(", ")}`) - return lines +/** The sub-rows under an integration: gaps with their reason, then what is + * available (or would be through a VS Code window), capped. */ +function rowDetails(row: IntegrationRow): { key: string; note: string }[] { + const gaps = row.gaps.map((gap) => ({ key: gap.key, note: `${gap.phrase}${gap.detail ? ` (${gap.detail})` : ""}` })) + const served = row.served.map((key) => ({ key, note: "available" })) + const idle = row.state === "idle" ? row.declared.map((key) => ({ key, note: "via VS Code" })) : [] + return [...capped(gaps, 6), ...capped(served, 4), ...capped(idle, 4)] +} + +/** The first `max` lines, then one line saying how many were left out. */ +function capped(lines: { key: string; note: string }[], max: number): { key: string; note: string }[] { + if (lines.length <= max) return lines + return [...lines.slice(0, max), { key: `+${lines.length - max} more`, note: "" }] } /** The `/workspace` menu. */ From a34dacaa1fee79157e8fe1b5c1f46bc7a5113a44 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Tue, 15 Sep 2026 04:00:14 +0800 Subject: [PATCH 08/14] feat(workspace): say so in the boot box when the CLI runs in workspace mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under "What is Altimate Code", three lines that only exist in workspace mode: the mode and the workspace this project is linked to, the slash commands the mode adds (/workspace, /skills), and what the last session got from the workspace — "attach on your first message" before one, the toast's numbers with a pointer at /workspace after. - A `welcome_extra` slot inside the boot box (medium and full variants); the panel asks for the plugin runtime without throwing, so its unit tests and any provider-less render simply omit the slot. - `welcome-lines.ts`: the three lines as a pure function of the binding and the attach snapshot (unlinked, linked-before-a-session, after a session, and a snapshot from another workspace ignored), tested. - `workspace-welcome.tsx`: the plugin that fills the slot, registered only under the workspace flag like the sidebar tile, reading the two cache files on a short poll so the integrations line follows the attach. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../src/altimate/workspace/welcome-lines.ts | 54 +++++++++++++++ .../opencode/src/plugin/tui/altimate/index.ts | 3 +- .../plugin/tui/altimate/workspace-welcome.tsx | 69 +++++++++++++++++++ .../altimate/workspace/welcome-lines.test.ts | 50 ++++++++++++++ packages/plugin/src/tui.ts | 3 + packages/tui/src/component/welcome-panel.tsx | 11 +++ packages/tui/src/plugin/runtime.tsx | 8 +++ 7 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/altimate/workspace/welcome-lines.ts create mode 100644 packages/opencode/src/plugin/tui/altimate/workspace-welcome.tsx create mode 100644 packages/opencode/test/altimate/workspace/welcome-lines.test.ts diff --git a/packages/opencode/src/altimate/workspace/welcome-lines.ts b/packages/opencode/src/altimate/workspace/welcome-lines.ts new file mode 100644 index 000000000..dbe4308fc --- /dev/null +++ b/packages/opencode/src/altimate/workspace/welcome-lines.ts @@ -0,0 +1,54 @@ +// altimate_change - new file +// +// The three lines the boot box shows under "What is Altimate Code" when the +// CLI runs in workspace mode: which mode and workspace, which slash commands +// the mode adds, and what the last session got from the workspace. Pure, so +// the plugin that renders them stays a thin view. +import type { AttachSnapshot } from "./attach-snapshot" +import type { CachedBinding } from "./state" +import { statusHeadline } from "./status-view" + +export interface WelcomeLines { + /** "Workspace mode · linked to …" or the unlinked variant. */ + mode: string + /** The slash commands workspace mode adds, with what each does. */ + commands: string + /** What the last session got, or what will happen on the first message. */ + integrations: string +} + +/** The commands workspace mode registers in the palette. Kept here rather + * than read from the palette so the line is stable and testable; the plugin + * that registers them is the same one that renders this. */ +export const WORKSPACE_COMMANDS = "/workspace — status, refresh, sync, unlink · /skills — the workspace's skills" + +export function welcomeLines(input: { + binding: CachedBinding | null + snapshot: AttachSnapshot | undefined +}): WelcomeLines { + const { binding, snapshot } = input + if (!binding) { + return { + mode: "Workspace mode · this project is not linked", + commands: "altimate-code link — bind this project to a workspace, then the commands below apply", + integrations: "Integrations: none until the project is linked", + } + } + const current = snapshot && snapshot.workspace.id === String(binding.datamateId) ? snapshot : undefined + if (!current) { + return { + mode: `Workspace mode · linked to ${binding.datamateName}`, + commands: WORKSPACE_COMMANDS, + integrations: "Integrations: attach on your first message", + } + } + const present = new Set(current.present) + const declared = current.declared?.keys.length + const served = current.declared ? current.declared.keys.filter((k) => present.has(k)).length : present.size + const gaps = (current.unfulfilled ?? []).filter((u) => u.reason !== "no-bridge").length + return { + mode: `Workspace mode · linked to ${binding.datamateName}`, + commands: WORKSPACE_COMMANDS, + integrations: `Integrations: ${statusHeadline({ served, declared, gaps, extServed: current.extServed, rows: [] })}${gaps > 0 ? " — /workspace for the reasons" : ""}`, + } +} diff --git a/packages/opencode/src/plugin/tui/altimate/index.ts b/packages/opencode/src/plugin/tui/altimate/index.ts index 8e90e364b..2ba576bbf 100644 --- a/packages/opencode/src/plugin/tui/altimate/index.ts +++ b/packages/opencode/src/plugin/tui/altimate/index.ts @@ -16,6 +16,7 @@ import SkillOps from "./skill-ops" import TraceViewer from "./trace-viewer" import Workspace from "./workspace" import WorkspaceSidebar from "./workspace-sidebar" +import WorkspaceWelcome from "./workspace-welcome" // Feature plugins are registered here as they are ported from the pre-merge sources on `main` // (see the ADR re-home plan). Each lives in its own file under this directory and default-exports @@ -32,6 +33,6 @@ export function altimateTuiPlugins(_flags: Pick props.api.theme.current + const [lines, setLines] = createSignal(null) + let inFlight = false + const refresh = async () => { + if (inFlight) return + inFlight = true + try { + const dir = props.api.state.path.directory + const binding: CachedBinding | null = await readLocalBinding(dir).catch(() => null) + setLines(welcomeLines({ binding, snapshot: attachSnapshot(dir) })) + } finally { + inFlight = false + } + } + onMount(() => { + void refresh() + const timer = setInterval(() => void refresh(), POLL_MS) + onCleanup(() => clearInterval(timer)) + }) + const current = () => lines() + return ( + + + {current()?.mode ?? "Workspace mode"} + + + {current()?.commands ?? ""} + + + {current()?.integrations ?? ""} + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 100, + slots: { + welcome_extra() { + return + }, + }, + }) +} + +export default { id, tui } satisfies BuiltinTuiPlugin diff --git a/packages/opencode/test/altimate/workspace/welcome-lines.test.ts b/packages/opencode/test/altimate/workspace/welcome-lines.test.ts new file mode 100644 index 000000000..c2e7f8c6b --- /dev/null +++ b/packages/opencode/test/altimate/workspace/welcome-lines.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { welcomeLines, WORKSPACE_COMMANDS } from "../../../src/altimate/workspace/welcome-lines" + +const binding = { + datamateId: 6, + datamateName: "e2e-demo-live", + repoRemote: null, + projectPath: "/proj", + linkedAt: 1, +} +const snapshot = (id = "6") => ({ + workspace: { id, name: "e2e-demo-live" }, + engineVersion: "0.7.2", + declared: { keys: ["a", "b", "c"], extensionKeys: ["x"] }, + present: ["a", "x"], + unfulfilled: [ + { key: "b", integrationId: "jira", reason: "invalid-connection" }, + { key: "c", integrationId: "jira", reason: "invalid-connection" }, + ], + extServed: 1, + at: 1, +}) + +describe("welcomeLines", () => { + test("unlinked: says so, and points at the link command rather than the menu", () => { + const lines = welcomeLines({ binding: null, snapshot: snapshot() }) + expect(lines.mode).toBe("Workspace mode · this project is not linked") + expect(lines.commands).toContain("altimate-code link") + expect(lines.integrations).toBe("Integrations: none until the project is linked") + }) + + test("linked before any session: names the workspace and promises the attach", () => { + const lines = welcomeLines({ binding, snapshot: undefined }) + expect(lines.mode).toBe("Workspace mode · linked to e2e-demo-live") + expect(lines.commands).toBe(WORKSPACE_COMMANDS) + expect(lines.integrations).toBe("Integrations: attach on your first message") + }) + + test("after a session: the toast's numbers, with a pointer when something needs attention", () => { + const lines = welcomeLines({ binding, snapshot: snapshot() }) + expect(lines.integrations).toBe( + "Integrations: 1 of 3 integration tools available · 2 need attention · 1 more via VS Code — /workspace for the reasons", + ) + }) + + test("a snapshot from another workspace is ignored", () => { + const lines = welcomeLines({ binding, snapshot: snapshot("9") }) + expect(lines.integrations).toBe("Integrations: attach on your first message") + }) +}) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 70c15b8f4..787740baa 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -462,6 +462,9 @@ export type TuiHostSlotMap = { app: {} app_bottom: {} home_logo: {} + // altimate_change start — a line block inside the boot box, under "What is Altimate Code" + welcome_extra: {} + // altimate_change end home_prompt: { ref?: (ref: TuiPromptRef | undefined) => void } diff --git a/packages/tui/src/component/welcome-panel.tsx b/packages/tui/src/component/welcome-panel.tsx index e42982d70..e126eae17 100644 --- a/packages/tui/src/component/welcome-panel.tsx +++ b/packages/tui/src/component/welcome-panel.tsx @@ -5,6 +5,9 @@ import { Logo } from "./logo" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { useReady } from "./altimate-onboarding" import { welcomePanelVariant } from "./welcome-panel-utils" +// altimate_change start — workspace-mode lines under "What is Altimate Code" (plugin slot) +import { usePluginRuntimeOptional } from "../plugin/runtime" +// altimate_change end const CONNECT_CTA = "Connect your AI model to start." @@ -40,6 +43,12 @@ export function WelcomePanel(props: { availableWidth: number; availableHeight: n // props are reactive getters, so reading them inside the memo tracks — the // variant recomputes when the caller's dimensions/sidebar change. const variant = createMemo(() => welcomePanelVariant(props.availableWidth, props.availableHeight)) + // altimate_change start — the workspace plugin fills `welcome_extra` in + // workspace mode (mode, the commands it adds, integration status); outside + // a plugin runtime (unit tests) the slot is simply absent. + const runtime = usePluginRuntimeOptional() + const extra = () => (runtime ? : null) + // altimate_change end const title = InstallationVersion === "local" ? " Altimate Code " : ` Altimate Code v${InstallationVersion} ` @@ -81,6 +90,7 @@ export function WelcomePanel(props: { availableWidth: number; availableHeight: n {CONNECT_CTA} + {extra()} @@ -133,6 +143,7 @@ export function WelcomePanel(props: { availableWidth: number; availableHeight: n + {extra()} diff --git a/packages/tui/src/plugin/runtime.tsx b/packages/tui/src/plugin/runtime.tsx index 4130ac9be..85cf27669 100644 --- a/packages/tui/src/plugin/runtime.tsx +++ b/packages/tui/src/plugin/runtime.tsx @@ -79,3 +79,11 @@ export function usePluginRuntime() { if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider") return runtime } + +// altimate_change start — a component that is also rendered without the +// provider (the boot box, in its unit tests) asks for the runtime without +// throwing, and simply omits its slot when there is none. +export function usePluginRuntimeOptional() { + return useContext(Context) +} +// altimate_change end From 45a8d02c8c689c83cc115c117fda9f38bf6decbd Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 15 Sep 2026 19:14:13 +0800 Subject: [PATCH 09/14] fix(workspace): commit the tools and their report together, and say what a gap is with its own detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the multi-model review of the unfulfilled report, client half. - the tools of a listing and its _meta are committed in one statement (State.meta beside State.defs) and read through one accessor, MCP.snapshot(name): a refresh that is pending or that failed leaves the last good pair standing, and the overlay can no longer pair one listing's tools with another's report - the catalog commits _meta when a listing completes — the last page that carries one wins, a listing with none clears it — instead of clearing at the start - served counts compare the declared keys in the catalog's sanitised key space, so the headline cannot undercount a served tool whose raw key the MCP layer renamed - the missing line groups by reason AND integration, so one integration's error is never printed as another's - spawn-failed reads 'server could not be started or reached', which is what the engine records under it Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../src/altimate/workspace/engine-overlay.ts | 15 +++- .../src/altimate/workspace/engine-seams.ts | 1 + .../src/altimate/workspace/engine-types.ts | 21 ++++-- packages/opencode/src/mcp/catalog.ts | 23 ++++-- packages/opencode/src/mcp/index.ts | 74 +++++++++++++++---- .../workspace/engine-install-offer.test.ts | 1 + .../altimate/workspace/engine-overlay.test.ts | 3 +- .../altimate/workspace/engine-types.test.ts | 18 ++++- .../test/mcp/catalog-list-meta.test.ts | 40 ++++++++++ .../test/mcp/engine-unfulfilled.e2e.test.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 1 + .../test/session/snapshot-tool-race.test.ts | 1 + 12 files changed, 163 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 13d6ce12c..403e65ab0 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -25,6 +25,7 @@ // that turn's start. import { DATAMATE_KEY } from "@/altimate/datamate-transport" import { MCP } from "@/mcp" +import { sanitize } from "@/mcp/catalog" import { Config } from "@/config/config" import { currentDirectory, @@ -341,6 +342,8 @@ function mcp() { remove: (name: string) => MCP.remove(name), tools: () => MCP.tools() as Promise>, listMeta: (name: string) => MCP.listMeta(name), + snapshot: (name: string) => + MCP.snapshot(name) as Promise<{ tools: Record; meta: Record | undefined }>, } ) } @@ -648,7 +651,9 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS return } - const [tools, meta] = await Promise.all([mcp().tools(), mcp().listMeta(DATAMATE_KEY)]) + // One read for both: a tools/list refresh that completes between two separate + // reads would pair one listing's tools with another's report. (multi-model review) + const { tools, meta } = await mcp().snapshot(DATAMATE_KEY) const present = engineToolKeys(tools) // The gaps come from the engine's own report, with reasons; this client no // longer diffs the allowlist against what arrived. No report (nothing at or @@ -659,11 +664,15 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // `available` is everything the engine serves under the key. The engine adds // tools beyond the allowlist (knowledge, memory) when the workspace enables // them, so the "N of M declared" line counts only the declared ones present. - const served = declared ? declared.keys.filter((k) => present.has(k)).length : present.size + // Compared in the catalog's key space: `present` holds tool names as the MCP + // layer sanitised them (`[a-zA-Z0-9_-]`), while the declaration carries the + // raw keys, so a raw key with any other character would never count as served + // and the headline would disagree with a report that names no gap. (multi-model review) + const served = declared ? declared.keys.filter((k) => present.has(sanitize(k))).length : present.size // Extension-declared tools appear in `present` only while the engine holds a // live IDE bridge; when they do they are real capability and the line names // them, but their absence is the normal no-IDE case, never `missing`. - const extServed = declared ? declared.extensionKeys.filter((k) => present.has(k)).length : 0 + const extServed = declared ? declared.extensionKeys.filter((k) => present.has(sanitize(k))).length : 0 const outcome: Outcome = { kind: "attached", available: present.size, diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index b499617e1..0952654e6 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -51,6 +51,7 @@ export const syncInternals: { remove: (name: string) => Promise tools: () => Promise> listMeta: (name: string) => Promise | undefined> + snapshot: (name: string) => Promise<{ tools: Record; meta: Record | undefined }> } config?: { invalidate: () => Promise diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 34c024f1e..6937122a3 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -267,7 +267,9 @@ export function reportedMissing(unfulfilled: Unfulfilled[]): Unfulfilled[] { const REASON_PHRASE: Record = { "invalid-connection": "no usable connection", - "spawn-failed": "server failed to start", + // The engine records transport construction, connect AND list failures under + // this one reason, so the phrase must not claim more than "could not be reached". + "spawn-failed": "server could not be started or reached", "catalog-missing": "no longer in the catalog", "unknown-key": "not offered by the integration", exception: "failed to load", @@ -277,21 +279,24 @@ const REASON_PHRASE: Record = { const MISSING_SHOWN = 5 const DETAIL_CHARS = 60 -/** The gaps, grouped by reason in report order, at most `MISSING_SHOWN` keys - * across the groups; a group's first detail (the engine's error text, e.g. - * `spawn docker ENOENT`) stands for the group. */ +/** The gaps, grouped by reason AND integration in report order, at most + * `MISSING_SHOWN` keys across the groups; a group's first detail (the engine's + * error text, e.g. `spawn docker ENOENT`) stands for the group. Grouped per + * integration so one integration's error is never printed as another's — two + * servers that both failed to start failed for their own reasons. (multi-model review) */ export function describeMissing(missing: Unfulfilled[]): string { if (missing.length === 0) return "" - const groups = new Map() + const groups = new Map() for (const u of missing) { - const group = groups.get(u.reason) ?? { keys: [] } + const id = `${u.reason}${u.integrationId}` + const group = groups.get(id) ?? { reason: u.reason, keys: [] } group.keys.push(u.key) if (group.detail === undefined && u.detail) group.detail = u.detail - groups.set(u.reason, group) + groups.set(id, group) } let budget = MISSING_SHOWN const parts: string[] = [] - for (const [reason, group] of groups) { + for (const { reason, ...group } of groups.values()) { if (budget <= 0) break const shown = group.keys.slice(0, budget) budget -= shown.length diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 9fd1e974a..715d025a0 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -15,11 +15,15 @@ import z from "zod/v4" const DEFAULT_TIMEOUT = 30_000 const MAX_LIST_PAGES = 1_000 -// altimate_change start — keep the `_meta` of a server's last tools/list page. +// altimate_change start — keep the `_meta` of a server's last tools/list. // `paginate` keeps only each page's items, so the result object — the sole // carrier of `_meta` — is dropped. The workspace engine reports the allowlist // keys it could not serve there (altimate/workspace/engine-types). Kept per -// client, cleared when a listing starts, set by any page that carries one. +// client and committed only when a listing COMPLETES: the last page that +// carries a `_meta` wins, a listing with none clears it, and a listing that +// is still pending or that failed leaves the previous value standing — so the +// tools and their report, which the caller commits together, never describe +// two different listings. (multi-model review) const listMetaByClient = new WeakMap>() export function listMeta(client: Client): Record | undefined { @@ -162,10 +166,10 @@ export function resources(client: Client, timeout?: number) { function listTools(client: Client, timeout: number) { return Effect.tryPromise({ - // altimate_change start — a fresh listing starts with no `_meta` (see listMeta). - try: () => { - listMetaByClient.delete(client) - return paginate( + // altimate_change start — `_meta` is committed with the completed listing (see listMeta). + try: async () => { + let meta: Record | undefined + const tools = await paginate( // altimate_change end async (cursor) => { const params = cursor === undefined ? undefined : { cursor } @@ -184,12 +188,15 @@ function listTools(client: Client, timeout: number) { // altimate_change end } }, - // altimate_change start — remember this page's `_meta` (see listMeta). + // altimate_change start — the last page that carries a `_meta` wins. (result) => { - if (result._meta !== undefined) listMetaByClient.set(client, result._meta as Record) + if (result._meta !== undefined) meta = result._meta as Record return result.tools }, ) + if (meta === undefined) listMetaByClient.delete(client) + else listMetaByClient.set(client, meta) + return tools }, // altimate_change end catch: (error) => (error instanceof Error ? error : new Error(String(error))), diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 08dadcd86..2fd4c0959 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -290,6 +290,11 @@ interface State { status: Record clients: Record defs: Record + // altimate_change start — the `_meta` of the listing `defs` came from, committed + // in the same statement as `defs` so a reader never pairs one listing's tools + // with another's report (see Interface.snapshot). + meta: Record | undefined> + // altimate_change end } export interface Interface { @@ -336,6 +341,13 @@ export interface Interface { // (undefined while not connected, or when the server sent none). The // workspace engine reports the allowlist keys it could not serve there. readonly listMeta: (name: string) => Effect.Effect | undefined> + // The tools of every connected server and one server's `_meta`, read in a + // single pass over the state so they come from the same listings: a refresh + // that lands between two separate reads cannot pair old tools with a new + // report, or the reverse. What the workspace overlay reconciles from. + readonly snapshot: ( + name: string, + ) => Effect.Effect<{ tools: Record; meta: Record | undefined }> // altimate_change end } @@ -722,6 +734,7 @@ export const layer = Layer.effect( if (s.clients[name] !== client) return delete s.clients[name] delete s.defs[name] + delete s.meta[name] s.status[name] = { status: "failed", error: "Connection closed" } bridge.fork( Effect.logWarning("MCP connection closed", { server: name }).pipe( @@ -745,7 +758,9 @@ export const layer = Layer.effect( if (!listed) return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + // altimate_change — tools and their report land in one statement. s.defs[name] = listed + s.meta[name] = McpCatalog.listMeta(client) await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) }) } @@ -778,6 +793,7 @@ export const layer = Layer.effect( status: {}, clients: {}, defs: {}, + meta: {}, } // altimate_change start — auto-discover MCP servers from external AI tool configs @@ -809,6 +825,7 @@ export const layer = Layer.effect( if (result.mcpClient) { s.clients[key] = result.mcpClient s.defs[key] = result.defs! + s.meta[key] = McpCatalog.listMeta(result.mcpClient) watch(s, key, result.mcpClient, bridge, mcp.timeout) } }), @@ -875,6 +892,7 @@ export const layer = Layer.effect( const client = s.clients[name] delete s.clients[name] delete s.defs[name] + delete s.meta[name] if (!client) return Effect.void return Effect.tryPromise(() => client.close()).pipe(Effect.ignore) } @@ -891,6 +909,7 @@ export const layer = Layer.effect( s.status[name] = { status: "connected" } s.clients[name] = client s.defs[name] = listed + s.meta[name] = McpCatalog.listMeta(client) watch(s, name, client, bridge, timeout) if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore) return s.status[name] @@ -920,12 +939,25 @@ export const layer = Layer.effect( return s.clients }) - // altimate_change start — see Interface.listMeta + // altimate_change start — see Interface.listMeta / Interface.snapshot const listMeta = Effect.fn("MCP.listMeta")(function* (name: string) { const s = yield* InstanceState.get(state) - const client = s.clients[name] - if (!client || s.status[name]?.status !== "connected") return undefined - return McpCatalog.listMeta(client) + if (!s.clients[name] || s.status[name]?.status !== "connected") return undefined + return s.meta[name] + }) + + const snapshot = Effect.fn("MCP.snapshot")(function* (name: string) { + // The config first: it is the one read that can suspend. What follows is + // one synchronous pass over the state, so a listing committed by another + // fiber lands either wholly before it or wholly after. + const cfg = yield* cfgSvc.get() + const s = yield* InstanceState.get(state) + const { result, missing } = toolsFrom(s, cfg) + for (const clientName of missing) { + yield* Effect.logWarning("missing cached tools for connected server", { clientName }) + } + const meta = s.clients[name] && s.status[name]?.status === "connected" ? s.meta[name] : undefined + return { tools: result, meta } }) // altimate_change end @@ -1058,13 +1090,12 @@ export const layer = Layer.effect( return s.config[name]?.timeout ?? staticTimeout ?? fallback } - const tools = Effect.fn("MCP.tools")(function* () { - // altimate_change start — values carry the original client name (see Interface.tools). + // altimate_change start — the synchronous half of `tools`, shared with `snapshot` + // so the two read the same state in one pass. Values carry the original client + // name (see Interface.tools). + function toolsFrom(s: State, cfg: Effect.Success>) { const result: Record = {} - // altimate_change end - const s = yield* InstanceState.get(state) - - const cfg = yield* cfgSvc.get() + const missing: string[] = [] const config = cfg.mcp ?? {} const defaultTimeout = cfg.experimental?.mcp_timeout @@ -1073,19 +1104,29 @@ export const layer = Layer.effect( const mcpConfig = config[clientName] const listed = s.defs[clientName] if (!listed) { - yield* Effect.logWarning("missing cached tools for connected server", { clientName }) + missing.push(clientName) continue } const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) for (const mcpTool of listed) { const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name) - // altimate_change start — attach the original client name for source classification downstream. + // attach the original client name for source classification downstream. result[key] = Object.assign(McpCatalog.convertTool(mcpTool, client, timeout), { client: clientName }) - // altimate_change end } } + return { result, missing } + } + + const tools = Effect.fn("MCP.tools")(function* () { + const s = yield* InstanceState.get(state) + const cfg = yield* cfgSvc.get() + const { result, missing } = toolsFrom(s, cfg) + for (const clientName of missing) { + yield* Effect.logWarning("missing cached tools for connected server", { clientName }) + } return result }) + // altimate_change end function collectFromConnected( s: State, @@ -1376,6 +1417,7 @@ export const layer = Layer.effect( clients, // altimate_change start listMeta, + snapshot, // altimate_change end tools, prompts, @@ -1430,7 +1472,11 @@ export async function status() { export async function tools() { return runMcp((svc) => svc.tools()) } -// altimate_change start — see Interface.listMeta +// altimate_change start — see Interface.listMeta / Interface.snapshot +export async function snapshot(name: string) { + return runMcp((svc) => svc.snapshot(name)) +} + export async function listMeta(name: string) { return runMcp((svc) => svc.listMeta(name)) } diff --git a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts index ad6b58a0d..a0b3ddd2a 100644 --- a/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-install-offer.test.ts @@ -101,6 +101,7 @@ function install(opts: { remove: async () => {}, tools: async () => ({}), listMeta: async () => undefined, + snapshot: async () => ({ tools: {}, meta: undefined }), } return h } diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index cc2f1ab1c..725846c23 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -137,6 +137,7 @@ function install(opts: { }, tools: async () => h.tools, listMeta: async () => h.meta ?? undefined, + snapshot: async () => ({ tools: h.tools, meta: h.meta ?? undefined }), } // Models the real Config cache: `get` loads once and is then served from // cache until `invalidate`; a load rebuilds the config from its sources (so @@ -461,7 +462,7 @@ describe("beforeTurn — what a turn boundary does", () => { expect(settledOutcome("s1")).toMatchObject({ missing: ["dbt_execute_sql", "gh_list_prs", "gh_create_pr"] }) expect(h.toasts[0].message).toBe( "2 of 3 declared integration tools available. Declared but not available — no usable connection: dbt_execute_sql; " + - "server failed to start (spawn docker ENOENT): gh_list_prs, gh_create_pr.", + "server could not be started or reached (spawn docker ENOENT): gh_list_prs, gh_create_pr.", ) expect(h.toasts[0].variant).toBe("warning") }) diff --git a/packages/opencode/test/altimate/workspace/engine-types.test.ts b/packages/opencode/test/altimate/workspace/engine-types.test.ts index 40dff310d..217e4dad5 100644 --- a/packages/opencode/test/altimate/workspace/engine-types.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-types.test.ts @@ -177,8 +177,8 @@ describe("messages", () => { u("e", "exception", "boom"), ]), ).toBe( - " Declared but not available — server failed to start (spawn docker ENOENT): a, b; no longer in the catalog: c; " + - "not offered by the integration: d; failed to load (boom): e.", + " Declared but not available — server could not be started or reached (spawn docker ENOENT): a, b; " + + "no longer in the catalog: c; not offered by the integration: d; failed to load (boom): e.", ) expect(describeMissing(["a", "b", "c", "d", "e", "f", "g"].map((k) => u(k, "invalid-connection")))).toBe( " Declared but not available — no usable connection: a, b, c, d, e (+2 more).", @@ -189,6 +189,20 @@ describe("messages", () => { expect(describeMissing([u("a", "exception", "x".repeat(80))])).toContain(`(${"x".repeat(59)}…)`) }) + test("two integrations that failed the same way keep their own details", () => { + // Grouped by reason alone, the first integration's error stood for both and + // the toast handed the user the wrong repair for the second. (multi-model review) + const out = describeMissing([ + { key: "gh_list_prs", integrationId: "github-mcp", reason: "spawn-failed", detail: "spawn docker ENOENT" }, + { key: "jira_search", integrationId: "jira-mcp", reason: "spawn-failed", detail: "spawn /opt/jira ENOENT" }, + { key: "gh_get_pr", integrationId: "github-mcp", reason: "spawn-failed" }, + ]) + expect(out).toBe( + " Declared but not available — server could not be started or reached (spawn docker ENOENT): gh_list_prs, gh_get_pr; " + + "server could not be started or reached (spawn /opt/jira ENOENT): jira_search.", + ) + }) + test("the engine's report is read out of tools/list _meta, and nothing is invented", () => { const report = [ { key: "a", integrationId: "jira", reason: "invalid-connection" }, diff --git a/packages/opencode/test/mcp/catalog-list-meta.test.ts b/packages/opencode/test/mcp/catalog-list-meta.test.ts index d8ffe5a9f..5245c6f99 100644 --- a/packages/opencode/test/mcp/catalog-list-meta.test.ts +++ b/packages/opencode/test/mcp/catalog-list-meta.test.ts @@ -71,4 +71,44 @@ describe("McpCatalog.listMeta", () => { await close() } }) + + test("a _meta on the first page is kept when the last page carries none", async () => { + // The rule is "the last page that carries one wins", stated so it is not + // mistaken for per-page clearing. (multi-model review) + let page = 0 + const { client, close } = await connected(() => { + page += 1 + return page === 1 + ? { tools: [echo], nextCursor: "p2", _meta: { [KEY]: [{ key: "x", integrationId: "i", reason: "unknown-key" }] } } + : { tools: [{ ...echo, name: "echo2" }] } + }) + try { + await Effect.runPromise(McpCatalog.defs(client)) + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: [{ key: "x", integrationId: "i", reason: "unknown-key" }] }) + } finally { + await close() + } + }) + + test("a listing that fails part-way leaves the previous _meta standing", async () => { + // Cleared at the start of a listing, a refresh that failed on its second + // page left the tools of the last good listing beside no report at all. + let attempt = 0 + let page = 0 + const { client, close } = await connected(() => { + if (attempt === 0) return { tools: [echo], _meta: { [KEY]: [] } } + page += 1 + if (page === 1) return { tools: [echo], nextCursor: "p2", _meta: { [KEY]: [{ key: "y", integrationId: "i", reason: "exception" }] } } + throw new Error("second page exploded") + }) + try { + await Effect.runPromise(McpCatalog.defs(client)) + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: [] }) + attempt = 1 + expect(await Effect.runPromise(McpCatalog.defs(client))).toBeUndefined() + expect(McpCatalog.listMeta(client)).toEqual({ [KEY]: [] }) + } finally { + await close() + } + }) }) diff --git a/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts b/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts index 2ce9e2899..679e6756e 100644 --- a/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts +++ b/packages/opencode/test/mcp/engine-unfulfilled.e2e.test.ts @@ -226,7 +226,7 @@ describe.skipIf(!runnable)("engine unfulfilled report through the MCP service", expect(describeMissing(reportedMissing(report!))).toBe( " Declared but not available — no usable connection: jira_search_issues; " + "not offered by the integration: ghost; " + - "server failed to start (spawn altimate-e2e-missing-binary ENOENT): whatever; " + + "server could not be started or reached (spawn altimate-e2e-missing-binary ENOENT): whatever; " + "no longer in the catalog: retired_tool.", ) expect(api.unhandled).toEqual([]) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 43d45bb27..b2208c392 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -126,6 +126,7 @@ const mcp = Layer.succeed( clients: () => Effect.succeed({}), tools: () => Effect.succeed({}), listMeta: () => Effect.succeed(undefined), + snapshot: () => Effect.succeed({ tools: {}, meta: undefined }), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 6a6176bdc..0ab4c0a69 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -39,6 +39,7 @@ const mcp = Layer.succeed( clients: () => Effect.succeed({}), tools: () => Effect.succeed({}), listMeta: () => Effect.succeed(undefined), + snapshot: () => Effect.succeed({ tools: {}, meta: undefined }), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), From 820147aee297dc711f9a74220457ede970dc581a Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 15 Sep 2026 19:53:48 +0800 Subject: [PATCH 10/14] fix(workspace): a listing carries its own _meta, and the headline never counts a reported key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the Codex round on the report fixes: - McpCatalog.defsWithMeta returns the listing and its _meta as one value, and every commit of a listing stores that pair — not a per-client value another refresh may have overwritten while this one was awaiting - served counts exclude keys the engine reports unfulfilled, so two raw keys that sanitise to one catalog name cannot both count as served - parseUnfulfilled rejects an entry whose detail is present but not a string, failing closed like the other fields Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../src/altimate/workspace/engine-overlay.ts | 12 +++++- .../src/altimate/workspace/engine-types.ts | 6 ++- packages/opencode/src/mcp/catalog.ts | 10 ++++- packages/opencode/src/mcp/index.ts | 43 +++++++++++-------- .../altimate/workspace/engine-overlay.test.ts | 15 +++++++ .../altimate/workspace/engine-types.test.ts | 14 ++++++ .../test/mcp/catalog-list-meta.test.ts | 14 ++++++ 7 files changed, 91 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 403e65ab0..2e30133e2 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -668,11 +668,19 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // layer sanitised them (`[a-zA-Z0-9_-]`), while the declaration carries the // raw keys, so a raw key with any other character would never count as served // and the headline would disagree with a report that names no gap. (multi-model review) - const served = declared ? declared.keys.filter((k) => present.has(sanitize(k))).length : present.size + // And never a key the engine itself reports as unfulfilled: two raw keys can + // sanitise to one catalog name, and the report is the authority on which of + // them the served tool stands for. (codex) + const reported = new Set((unfulfilled ?? []).map((u) => u.key)) + const served = declared + ? declared.keys.filter((k) => present.has(sanitize(k)) && !reported.has(k)).length + : present.size // Extension-declared tools appear in `present` only while the engine holds a // live IDE bridge; when they do they are real capability and the line names // them, but their absence is the normal no-IDE case, never `missing`. - const extServed = declared ? declared.extensionKeys.filter((k) => present.has(sanitize(k))).length : 0 + const extServed = declared + ? declared.extensionKeys.filter((k) => present.has(sanitize(k)) && !reported.has(k)).length + : 0 const outcome: Outcome = { kind: "attached", available: present.size, diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index 6937122a3..aaf13d9f7 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -253,7 +253,11 @@ export function parseUnfulfilled(meta: Record | undefined): Unf // Custom (tenant-created) integrations carry numeric ids; take them as strings. const id = typeof integrationId === "number" ? String(integrationId) : integrationId if (typeof key !== "string" || typeof id !== "string" || typeof reason !== "string") return undefined - out.push({ key, integrationId: id, reason, ...(typeof detail === "string" && detail !== "" ? { detail } : {}) }) + // A present `detail` must be a string: an entry with a malformed one is a + // malformed report, not a report with one field dropped. Fails closed like + // the fields above. (codex) + if (detail !== undefined && typeof detail !== "string") return undefined + out.push({ key, integrationId: id, reason, ...(detail ? { detail } : {}) }) } return out } diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 715d025a0..df50ad6f0 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -75,8 +75,16 @@ export async function paginate( } export function defs(client: Client, timeout?: number) { + return defsWithMeta(client, timeout).pipe(Effect.map((listing) => listing?.tools)) +} + +// altimate_change start — a listing and its own `_meta`, as one value. The +// caller commits the pair; reading the per-client `listMeta` after the fact +// could hand it another listing's `_meta` when two refreshes overlap. (codex) +export function defsWithMeta(client: Client, timeout?: number) { return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void)) } +// altimate_change end export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool { const inputSchema: JSONSchema7 = { @@ -196,7 +204,7 @@ function listTools(client: Client, timeout: number) { ) if (meta === undefined) listMetaByClient.delete(client) else listMetaByClient.set(client, meta) - return tools + return { tools, meta } }, // altimate_change end catch: (error) => (error instanceof Error ? error : new Error(String(error))), diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 2fd4c0959..37c09479e 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -272,6 +272,8 @@ interface CreateResult { mcpClient?: MCPClient status: Status defs?: MCPToolDef[] + // altimate_change — the `_meta` of the listing `defs` came from, committed with it + meta?: Record // altimate_change start — carry transport label for census telemetry transport?: TransportLabel // altimate_change end @@ -678,16 +680,16 @@ export const layer = Layer.effect( return yield* Effect.gen(function* () { // altimate_change — McpCatalog.defs() tolerates both outputSchema // reference errors and Fabric-style null annotation hints (#792). - const listed = mcpClient.getServerCapabilities()?.tools - ? yield* McpCatalog.defs(mcpClient, mcp.timeout) - : [] - if (!listed) { + const listing = mcpClient.getServerCapabilities()?.tools + ? yield* McpCatalog.defsWithMeta(mcpClient, mcp.timeout) + : { tools: [], meta: undefined } + if (!listing) { return yield* Effect.fail(new Error("Failed to get tools")) } // altimate_change start — fire-and-forget census telemetry once tools are listed - if (transport) trackCensus(key, transport, listed.length) + if (transport) trackCensus(key, transport, listing.tools.length) // altimate_change end - return { mcpClient, status, defs: listed, transport } satisfies CreateResult + return { mcpClient, status, defs: listing.tools, meta: listing.meta, transport } satisfies CreateResult }).pipe( Effect.catchCause((cause) => Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore, Effect.andThen(Effect.failCause(cause))), @@ -754,13 +756,15 @@ export const layer = Layer.effect( // altimate_change — matches create(): McpCatalog.defs() tolerates // annotation-null tools on a live tool-list refresh (#792). - const listed = await bridge.promise(McpCatalog.defs(client, timeout)) - if (!listed) return + const listing = await bridge.promise(McpCatalog.defsWithMeta(client, timeout)) + if (!listing) return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - // altimate_change — tools and their report land in one statement. - s.defs[name] = listed - s.meta[name] = McpCatalog.listMeta(client) + // altimate_change — tools and THEIR report land in one statement: the + // pair the listing returned, not a per-client value another refresh + // may have overwritten while this one was awaiting. (codex) + s.defs[name] = listing.tools + s.meta[name] = listing.meta await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) }) } @@ -825,7 +829,7 @@ export const layer = Layer.effect( if (result.mcpClient) { s.clients[key] = result.mcpClient s.defs[key] = result.defs! - s.meta[key] = McpCatalog.listMeta(result.mcpClient) + s.meta[key] = result.meta watch(s, key, result.mcpClient, bridge, mcp.timeout) } }), @@ -902,6 +906,7 @@ export const layer = Layer.effect( name: string, client: MCPClient, listed: MCPToolDef[], + meta: Record | undefined, timeout?: number, ) { const bridge = yield* EffectBridge.make() @@ -909,7 +914,7 @@ export const layer = Layer.effect( s.status[name] = { status: "connected" } s.clients[name] = client s.defs[name] = listed - s.meta[name] = McpCatalog.listMeta(client) + s.meta[name] = meta watch(s, name, client, bridge, timeout) if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore) return s.status[name] @@ -972,7 +977,7 @@ export const layer = Layer.effect( return result.status } - return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout) + return yield* storeClient(s, name, result.mcpClient, result.defs!, result.meta, mcp.timeout) }) const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCPV1.Info) { @@ -1304,19 +1309,19 @@ export const layer = Layer.effect( // altimate_change — McpCatalog.defs() tolerates annotation-null tools so // they don't block the post-OAuth connect from completing (#792). - const listed = client + const listing = client ? client.getServerCapabilities()?.tools - ? yield* McpCatalog.defs(client, mcpConfig.timeout) - : [] + ? yield* McpCatalog.defsWithMeta(client, mcpConfig.timeout) + : { tools: [], meta: undefined } : undefined - if (!client || !listed) { + if (!client || !listing) { yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore) return { status: "failed", error: "Failed to get tools" } satisfies Status } const s = yield* InstanceState.get(state) yield* auth.clearOAuthState(mcpName) - return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout) + return yield* storeClient(s, mcpName, client, listing.tools, listing.meta, mcpConfig.timeout) } const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 725846c23..fddb2ba05 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -467,6 +467,21 @@ describe("beforeTurn — what a turn boundary does", () => { expect(h.toasts[0].variant).toBe("warning") }) + test("the headline counts in the catalog's key space, and never a key the report names", async () => { + // `foo.bar` and `foo_bar` both sanitise to the served `datamate_foo_bar`; + // the report says which of them the tool stands for. Counting both would + // print "2 of 2" over a gap line naming `foo.bar`. (multi-model review; codex) + const report = [{ key: "foo.bar", integrationId: "i", reason: "unknown-key" }] + const h = install({ + declared: { keys: ["foo.bar", "foo_bar"], extensionKeys: [] }, + tools: { datamate_foo_bar: {} }, + meta: { [UNFULFILLED_META_KEY]: report }, + }) + await beforeTurn("s1") + expect(h.toasts[0].message).toContain("1 of 2 declared integration tools available") + expect(h.toasts[0].message).toContain("not offered by the integration: foo.bar") + }) + test("no-bridge entries in the report are expected, never missing", async () => { const report = [ { key: "get_projects", integrationId: "vscode-power-user", reason: "no-bridge" }, diff --git a/packages/opencode/test/altimate/workspace/engine-types.test.ts b/packages/opencode/test/altimate/workspace/engine-types.test.ts index 217e4dad5..484019bd7 100644 --- a/packages/opencode/test/altimate/workspace/engine-types.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-types.test.ts @@ -203,6 +203,20 @@ describe("messages", () => { ) }) + test("an entry with a malformed detail is a malformed report, not a report missing a field", () => { + // Dropping the field and accepting the rest would announce a gap on the + // strength of a report that failed its own contract. (codex) + const meta = (detail: unknown) => ({ + [UNFULFILLED_META_KEY]: [{ key: "x", integrationId: "i", reason: "exception", detail }], + }) + expect(parseUnfulfilled(meta(42))).toBeUndefined() + expect(parseUnfulfilled(meta(null))).toBeUndefined() + expect(parseUnfulfilled(meta({ code: "ENOENT" }))).toBeUndefined() + expect(parseUnfulfilled(meta("boom"))).toEqual([{ key: "x", integrationId: "i", reason: "exception", detail: "boom" }]) + expect(parseUnfulfilled(meta(""))).toEqual([{ key: "x", integrationId: "i", reason: "exception" }]) + expect(parseUnfulfilled(meta(undefined))).toEqual([{ key: "x", integrationId: "i", reason: "exception" }]) + }) + test("the engine's report is read out of tools/list _meta, and nothing is invented", () => { const report = [ { key: "a", integrationId: "jira", reason: "invalid-connection" }, diff --git a/packages/opencode/test/mcp/catalog-list-meta.test.ts b/packages/opencode/test/mcp/catalog-list-meta.test.ts index 5245c6f99..302d84798 100644 --- a/packages/opencode/test/mcp/catalog-list-meta.test.ts +++ b/packages/opencode/test/mcp/catalog-list-meta.test.ts @@ -111,4 +111,18 @@ describe("McpCatalog.listMeta", () => { await close() } }) + + test("defsWithMeta hands back the listing and its own _meta as one value", async () => { + // What the MCP service commits: the pair from THIS listing, not the + // per-client value a later listing may have overwritten meanwhile. (codex) + const report = [{ key: "k", integrationId: "i", reason: "unknown-key" }] + const { client, close } = await connected(() => ({ tools: [echo], _meta: { [KEY]: report } })) + try { + const listing = await Effect.runPromise(McpCatalog.defsWithMeta(client)) + expect(listing?.tools.map((t) => t.name)).toEqual(["echo"]) + expect(listing?.meta).toEqual({ [KEY]: report }) + } finally { + await close() + } + }) }) From 94521096547869224babaca1dd04cc6ee0ff4ddb Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 15 Sep 2026 20:14:27 +0800 Subject: [PATCH 11/14] fix(workspace): the headline counts catalog entries, not declarations Two raw keys that sanitise to one catalog name are one callable tool however many the engine lists; served and extension counts are the number of distinct sanitised entries that are present and unreported. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../src/altimate/workspace/engine-overlay.ts | 12 ++++++------ .../test/altimate/workspace/engine-overlay.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 2e30133e2..a92694633 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -671,16 +671,16 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // And never a key the engine itself reports as unfulfilled: two raw keys can // sanitise to one catalog name, and the report is the authority on which of // them the served tool stands for. (codex) + // And counted per catalog entry, not per declaration: two raw keys that both + // sanitise to `foo_bar` are one callable tool however many the engine lists. const reported = new Set((unfulfilled ?? []).map((u) => u.key)) - const served = declared - ? declared.keys.filter((k) => present.has(sanitize(k)) && !reported.has(k)).length - : present.size + const servedEntries = (keys: string[]) => + new Set(keys.filter((k) => present.has(sanitize(k)) && !reported.has(k)).map(sanitize)).size + const served = declared ? servedEntries(declared.keys) : present.size // Extension-declared tools appear in `present` only while the engine holds a // live IDE bridge; when they do they are real capability and the line names // them, but their absence is the normal no-IDE case, never `missing`. - const extServed = declared - ? declared.extensionKeys.filter((k) => present.has(sanitize(k)) && !reported.has(k)).length - : 0 + const extServed = declared ? servedEntries(declared.extensionKeys) : 0 const outcome: Outcome = { kind: "attached", available: present.size, diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index fddb2ba05..7ad626597 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -482,6 +482,18 @@ describe("beforeTurn — what a turn boundary does", () => { expect(h.toasts[0].message).toContain("not offered by the integration: foo.bar") }) + test("two declarations that sanitise to one catalog entry count once, even with nothing reported", async () => { + // The engine listed both `foo.bar` and `foo_bar`, so it reports neither; the + // MCP catalog keeps one `datamate_foo_bar`, so one tool is callable. (codex) + const h = install({ + declared: { keys: ["foo.bar", "foo_bar"], extensionKeys: [] }, + tools: { datamate_foo_bar: {} }, + meta: { [UNFULFILLED_META_KEY]: [] }, + }) + await beforeTurn("s1") + expect(h.toasts[0].message).toBe("1 of 2 declared integration tools available.") + }) + test("no-bridge entries in the report are expected, never missing", async () => { const report = [ { key: "get_projects", integrationId: "vscode-power-user", reason: "no-bridge" }, From 413fadceb65af1de81fc32c917649daf9c610e79 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 15 Sep 2026 20:25:55 +0800 Subject: [PATCH 12/14] fix(workspace): a catalog entry is counted once across the ordinary and extension groups Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- .../src/altimate/workspace/engine-overlay.ts | 16 ++++++++++++++-- .../altimate/workspace/engine-overlay.test.ts | 13 +++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index a92694633..152a30e91 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -673,9 +673,21 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // them the served tool stands for. (codex) // And counted per catalog entry, not per declaration: two raw keys that both // sanitise to `foo_bar` are one callable tool however many the engine lists. + // Consumed across both groups: an ordinary key and an extension key that + // collide are still one entry, counted where it is met first — with the + // ordinary keys, which are counted first. const reported = new Set((unfulfilled ?? []).map((u) => u.key)) - const servedEntries = (keys: string[]) => - new Set(keys.filter((k) => present.has(sanitize(k)) && !reported.has(k)).map(sanitize)).size + const consumed = new Set() + const servedEntries = (keys: string[]) => { + let n = 0 + for (const k of keys) { + const entry = sanitize(k) + if (!present.has(entry) || reported.has(k) || consumed.has(entry)) continue + consumed.add(entry) + n += 1 + } + return n + } const served = declared ? servedEntries(declared.keys) : present.size // Extension-declared tools appear in `present` only while the engine holds a // live IDE bridge; when they do they are real capability and the line names diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index 7ad626597..b8a7b6509 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -494,6 +494,19 @@ describe("beforeTurn — what a turn boundary does", () => { expect(h.toasts[0].message).toBe("1 of 2 declared integration tools available.") }) + test("a collision across the ordinary and extension groups is one entry, counted once", async () => { + // `foo.bar` declared as an ordinary key and `foo_bar` as an extension key + // are one `datamate_foo_bar`; it counts with the ordinary keys and not + // again as an extension tool. (codex) + const h = install({ + declared: { keys: ["foo.bar"], extensionKeys: ["foo_bar"] }, + tools: { datamate_foo_bar: {} }, + meta: { [UNFULFILLED_META_KEY]: [] }, + }) + await beforeTurn("s1") + expect(h.toasts[0].message).toBe("1 of 1 declared integration tools available.") + }) + test("no-bridge entries in the report are expected, never missing", async () => { const report = [ { key: "get_projects", integrationId: "vscode-power-user", reason: "no-bridge" }, From b0343bbb9b8864e1633b7128c3be0f777e6c0308 Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 15 Sep 2026 21:40:17 +0800 Subject: [PATCH 13/14] chore(workspace): wrap the two remaining hunks in altimate_change markers Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- packages/opencode/src/mcp/catalog.ts | 2 ++ packages/opencode/src/mcp/index.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index df50ad6f0..479f8ec36 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -75,7 +75,9 @@ export async function paginate( } export function defs(client: Client, timeout?: number) { + // altimate_change start — the listing's tools alone; `defsWithMeta` is the pair. return defsWithMeta(client, timeout).pipe(Effect.map((listing) => listing?.tools)) + // altimate_change end } // altimate_change start — a listing and its own `_meta`, as one value. The diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 37c09479e..f643f4e26 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -272,8 +272,9 @@ interface CreateResult { mcpClient?: MCPClient status: Status defs?: MCPToolDef[] - // altimate_change — the `_meta` of the listing `defs` came from, committed with it + // altimate_change start — the `_meta` of the listing `defs` came from, committed with it meta?: Record + // altimate_change end // altimate_change start — carry transport label for census telemetry transport?: TransportLabel // altimate_change end From ef0b8ed76a8297a64fbe0eb1a2abc17525a431db Mon Sep 17 00:00:00 2001 From: "Ralph Sto. Domingo" Date: Tue, 15 Sep 2026 21:43:30 +0800 Subject: [PATCH 14/14] chore(workspace): markers around the listing hunks the guard still saw as bare Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b --- packages/opencode/src/mcp/catalog.ts | 9 +++---- packages/opencode/src/mcp/index.ts | 37 ++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 479f8ec36..0f8c7aff5 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -74,15 +74,14 @@ export async function paginate( throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`) } +// altimate_change start — `defs` is the tools half of `defsWithMeta`: a listing +// and its own `_meta` as one value. The caller commits the pair; reading the +// per-client `listMeta` after the fact could hand it another listing's `_meta` +// when two refreshes overlap. (codex) export function defs(client: Client, timeout?: number) { - // altimate_change start — the listing's tools alone; `defsWithMeta` is the pair. return defsWithMeta(client, timeout).pipe(Effect.map((listing) => listing?.tools)) - // altimate_change end } -// altimate_change start — a listing and its own `_meta`, as one value. The -// caller commits the pair; reading the per-client `listMeta` after the fact -// could hand it another listing's `_meta` when two refreshes overlap. (codex) export function defsWithMeta(client: Client, timeout?: number) { return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void)) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index f643f4e26..cf6ae63c3 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -679,18 +679,22 @@ export const layer = Layer.effect( } return yield* Effect.gen(function* () { - // altimate_change — McpCatalog.defs() tolerates both outputSchema - // reference errors and Fabric-style null annotation hints (#792). + // altimate_change start — McpCatalog.defsWithMeta() tolerates both outputSchema + // reference errors and Fabric-style null annotation hints (#792), and hands + // back the listing with its own `_meta`. const listing = mcpClient.getServerCapabilities()?.tools ? yield* McpCatalog.defsWithMeta(mcpClient, mcp.timeout) : { tools: [], meta: undefined } if (!listing) { return yield* Effect.fail(new Error("Failed to get tools")) } + // altimate_change end // altimate_change start — fire-and-forget census telemetry once tools are listed if (transport) trackCensus(key, transport, listing.tools.length) // altimate_change end + // altimate_change start — the pair, committed together by the caller return { mcpClient, status, defs: listing.tools, meta: listing.meta, transport } satisfies CreateResult + // altimate_change end }).pipe( Effect.catchCause((cause) => Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore, Effect.andThen(Effect.failCause(cause))), @@ -737,7 +741,9 @@ export const layer = Layer.effect( if (s.clients[name] !== client) return delete s.clients[name] delete s.defs[name] + // altimate_change start — the report goes with the listing delete s.meta[name] + // altimate_change end s.status[name] = { status: "failed", error: "Connection closed" } bridge.fork( Effect.logWarning("MCP connection closed", { server: name }).pipe( @@ -755,17 +761,20 @@ export const layer = Layer.effect( client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - // altimate_change — matches create(): McpCatalog.defs() tolerates - // annotation-null tools on a live tool-list refresh (#792). + // altimate_change start — matches create(): McpCatalog.defsWithMeta() tolerates + // annotation-null tools on a live tool-list refresh (#792) and hands back the + // listing with its own `_meta`. const listing = await bridge.promise(McpCatalog.defsWithMeta(client, timeout)) if (!listing) return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + // altimate_change end - // altimate_change — tools and THEIR report land in one statement: the + // altimate_change start — tools and THEIR report land in one statement: the // pair the listing returned, not a per-client value another refresh // may have overwritten while this one was awaiting. (codex) s.defs[name] = listing.tools s.meta[name] = listing.meta + // altimate_change end await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) }) } @@ -798,7 +807,9 @@ export const layer = Layer.effect( status: {}, clients: {}, defs: {}, + // altimate_change start — see State.meta meta: {}, + // altimate_change end } // altimate_change start — auto-discover MCP servers from external AI tool configs @@ -830,7 +841,9 @@ export const layer = Layer.effect( if (result.mcpClient) { s.clients[key] = result.mcpClient s.defs[key] = result.defs! + // altimate_change start — the report goes with the listing s.meta[key] = result.meta + // altimate_change end watch(s, key, result.mcpClient, bridge, mcp.timeout) } }), @@ -897,7 +910,9 @@ export const layer = Layer.effect( const client = s.clients[name] delete s.clients[name] delete s.defs[name] + // altimate_change start — the report goes with the listing delete s.meta[name] + // altimate_change end if (!client) return Effect.void return Effect.tryPromise(() => client.close()).pipe(Effect.ignore) } @@ -907,7 +922,9 @@ export const layer = Layer.effect( name: string, client: MCPClient, listed: MCPToolDef[], + // altimate_change start — the listing's own `_meta`, committed beside it meta: Record | undefined, + // altimate_change end timeout?: number, ) { const bridge = yield* EffectBridge.make() @@ -915,7 +932,9 @@ export const layer = Layer.effect( s.status[name] = { status: "connected" } s.clients[name] = client s.defs[name] = listed + // altimate_change start — the report goes with the listing s.meta[name] = meta + // altimate_change end watch(s, name, client, bridge, timeout) if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore) return s.status[name] @@ -978,7 +997,9 @@ export const layer = Layer.effect( return result.status } + // altimate_change start — the listing's `_meta` rides along return yield* storeClient(s, name, result.mcpClient, result.defs!, result.meta, mcp.timeout) + // altimate_change end }) const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCPV1.Info) { @@ -1308,8 +1329,9 @@ export const layer = Layer.effect( Effect.tapError(() => Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)), ) - // altimate_change — McpCatalog.defs() tolerates annotation-null tools so - // they don't block the post-OAuth connect from completing (#792). + // altimate_change start — McpCatalog.defsWithMeta() tolerates annotation-null tools so + // they don't block the post-OAuth connect from completing (#792), and hands back the + // listing with its own `_meta`. const listing = client ? client.getServerCapabilities()?.tools ? yield* McpCatalog.defsWithMeta(client, mcpConfig.timeout) @@ -1323,6 +1345,7 @@ export const layer = Layer.effect( const s = yield* InstanceState.get(state) yield* auth.clearOAuthState(mcpName) return yield* storeClient(s, mcpName, client, listing.tools, listing.meta, mcpConfig.timeout) + // altimate_change end } const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)