diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 6a0d45a1a..c4ba3ed4a 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -1,3 +1,4 @@ +import path from "path" import z from "zod" import { Tool } from "../../tool/tool" import { AltimateApi } from "../api/client" @@ -15,6 +16,8 @@ import { Log } from "@/altimate/util/log" import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport" // altimate_change - workspace mode owns the datamate key import { managedWorkspaceLoaded } from "../workspace/engine-overlay" +// altimate_change - extension-type rows depend on a live IDE bridge +import { liveBridge } from "../workspace/engine-probes" const log = Log.create({ service: "datamate" }) @@ -138,37 +141,67 @@ async function handleList() { } } +// altimate_change start — the cwd the shared datamate entry would be spawned +// with, mirroring connectLocal: a local entry's `cwd` resolved against the +// instance directory, else the instance directory. Runtime-added entries win +// over file config (MCP.entry uses the connect path's own precedence). Falls +// back to the instance directory when no MCP runtime is up — that is +// connectLocal's default too. +async function engineSpawnCwd(): Promise { + try { + const entry = await MCP.entry(DATAMATE_KEY) + if (entry && entry.type === "local" && entry.cwd) return path.resolve(Instance.directory, entry.cwd) + } catch (e) { + log.warn("could not read the datamate MCP entry for the bridge probe", { error: String(e) }) + } + return Instance.directory +} +// altimate_change end + async function handleListIntegrations() { try { const catalog = await AltimateApi.listIntegrations() // altimate_change start — extension-type integrations are RPC into a live VS - // Code host and cannot work from the CLI. Hide them from this surface (the - // workspace UI still offers them) and say how many were hidden. - const integrations = catalog.filter((i) => i.type !== "extension") + // Code host. With a bridge running for this project they serve from the CLI + // like any other integration, so list them; without one, keep them out of + // the table and say how they come back — not that they never can. E2E + // (2026-09-03) proved the full chain: compile_model/run_model over the + // bridge materialized a model on the warehouse from a headless run. + const extension = catalog.filter((i) => i.type === "extension") + // Probe with the cwd the engine is actually spawned with: connectLocal + // resolves a local entry's `cwd` against the instance directory and falls + // back to the instance directory itself — not the Git root projectRoot() + // returns. Probing anything else diverged whenever the effective spawn cwd + // and the probe input straddled different sidecars. (codex review, r1+r2) + const bridged = extension.length > 0 && liveBridge(await engineSpawnCwd()) + const integrations = bridged ? catalog : catalog.filter((i) => i.type !== "extension") const hidden = catalog.length - integrations.length - const omitted = - hidden > 0 - ? `${hidden} extension-type integration${hidden === 1 ? " was" : "s were"} omitted — they require a live VS Code bridge and are not available from the CLI.` + const footer = bridged + ? `${extension.length} extension-type integration${extension.length === 1 ? " is" : "s are"} served via the connected VS Code window.` + : hidden > 0 + ? `${hidden} extension-type integration${hidden === 1 ? " was" : "s were"} omitted — they serve while VS Code with the Altimate extension is open on this project.` : "" if (integrations.length === 0) { return { title: hidden > 0 ? `Integrations: none available on the CLI (${hidden} hidden)` : "Integrations: none found", - metadata: { count: 0, hidden }, - output: omitted ? `No integrations available. ${omitted}` : "No integrations available.", + metadata: { count: 0, hidden, bridge: bridged }, + output: footer ? `No integrations available. ${footer}` : "No integrations available.", } } + const extensionIds = new Set(extension.map((i) => i.id)) // altimate_change end const lines = ["ID | Name | Tools", "---|------|------"] for (const i of integrations) { const tools = i.tools?.map((t) => t.key).join(", ") ?? "none" - lines.push(`${i.id} | ${i.name} | ${tools}`) + // altimate_change — mark the rows the IDE bridge serves + lines.push(`${i.id} | ${i.name}${extensionIds.has(i.id) ? " (via VS Code)" : ""} | ${tools}`) } // altimate_change start - if (omitted) lines.push("", `(${omitted})`) + if (footer) lines.push("", `(${footer})`) // altimate_change end return { title: `Integrations: ${integrations.length} available`, - metadata: { count: integrations.length, hidden }, + metadata: { count: integrations.length, hidden, bridge: bridged }, output: lines.join("\n"), } } catch (e) { diff --git a/packages/opencode/src/altimate/workspace/engine-overlay.ts b/packages/opencode/src/altimate/workspace/engine-overlay.ts index 424c0f140..521ef8c2e 100644 --- a/packages/opencode/src/altimate/workspace/engine-overlay.ts +++ b/packages/opencode/src/altimate/workspace/engine-overlay.ts @@ -43,6 +43,7 @@ import { REPAIRABLE, TOOL_PREFIX, clearsFloor, + describeExtensionServed, describeMissing, describeRefusal, engineEntry, @@ -650,6 +651,10 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS // 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 + // 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 outcome: Outcome = { kind: "attached", available: present.size, @@ -658,7 +663,10 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS const rec = record(sessionID, outcome) // Keyed on the workspace too: a re-link with an identical inventory is still // a new verdict the user should hear. - const signature = `attached:${workspace.key}:${outcome.available}:${outcome.declared ?? "?"}:${(missing ?? []).join(",")}` + // 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}` if (rec.announced === signature) return rec.announced = signature log.info("workspace engine attached", { @@ -671,7 +679,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS await notify({ title: `Workspace "${workspace.name}"`, message: declared - ? `${served} of ${declared.keys.length} declared integration tools available.${describeMissing(missing ?? [])}` + ? `${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", }) diff --git a/packages/opencode/src/altimate/workspace/engine-probes.ts b/packages/opencode/src/altimate/workspace/engine-probes.ts index 80676f2c4..02b1603f6 100644 --- a/packages/opencode/src/altimate/workspace/engine-probes.ts +++ b/packages/opencode/src/altimate/workspace/engine-probes.ts @@ -2,7 +2,9 @@ // // Everything that asks the outside world a question: the binary, its // version, the workspace allowlist, and the user-facing surfaces. -import { statSync } from "fs" +import { readFileSync, readdirSync, statSync } from "fs" +import { homedir } from "os" +import { isAbsolute, join, relative, resolve, sep } from "path" import launch from "cross-spawn" import { which as whichBinary } from "@opencode-ai/core/util/which" import { AltimateApi } from "@/altimate/api/client" @@ -164,6 +166,85 @@ export async function declaredBounded(workspaceId: string): Promise 0 && pidAlive(data.pid))) + continue + // Validate the folders shape: this is an unvalidated JSON file, and a + // non-array must degrade to "live bridge, no recorded folders", not + // throw out of the probe. Only fully qualified strings survive — + // anything resolve() would complete from the process's own cwd or + // drive could spuriously match and bypass the two-bridge decline. + // (codex r3+r4) + const folders = Array.isArray(data.workspaceFolders) + ? data.workspaceFolders.filter((f): f is string => typeof f === "string" && qualifiedFolder(f)) + : [] + bridges.push(folders) + } catch { + // An unreadable sidecar is not a live bridge. + } + } + } catch { + return false + } + if (bridges.length === 0) return false + const within = (folder: string) => { + const rel = relative(resolve(folder), resolve(cwd)) + // ".." must be a complete path component: a child literally named + // "..cache" yields rel "..cache", which is inside. (bot review) + return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) + } + if (bridges.some((folders) => folders.some(within))) return true + return bridges.length === 1 +} + +/** A recorded folder must be fully qualified. On Windows, drive-relative + * paths like "\repo" count as absolute to Node, but resolve() completes them + * with the process's CURRENT drive — so a corrupt entry could match any cwd + * on that drive and defeat the two-bridge decline. Drive-qualified (C:\ or + * C:/) or complete UNC only: a UNC value needs nonempty server AND share + * components — resolve("\\\\") is "C:\\" and resolve("\\\\server") is + * "C:\\server", both on the current drive again. POSIX keeps plain + * isAbsolute. The platform parameter exists for tests. (codex r4+r5) */ +export function qualifiedFolder(f: string, win: boolean = process.platform === "win32"): boolean { + if (!f) return false + return win ? /^([a-zA-Z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(f) : isAbsolute(f) +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (err) { + // EPERM is a live process owned by someone else. + return (err as NodeJS.ErrnoException).code === "EPERM" + } +} + export async function notify(toast: Toast): Promise { if (syncInternals.notify) return syncInternals.notify(toast) try { diff --git a/packages/opencode/src/altimate/workspace/engine-seams.ts b/packages/opencode/src/altimate/workspace/engine-seams.ts index 9d2f0ad99..093a02b97 100644 --- a/packages/opencode/src/altimate/workspace/engine-seams.ts +++ b/packages/opencode/src/altimate/workspace/engine-seams.ts @@ -27,6 +27,7 @@ export const syncInternals: { versionOf?: (bin: string) => Promise fingerprint?: (bin: string) => string | null declared?: (workspaceId: string) => Promise + liveBridge?: (cwd: string) => boolean notify?: (toast: Toast) => Promise printLine?: (line: string) => void /** Install-offer seams (see engine-offer.ts). */ diff --git a/packages/opencode/src/altimate/workspace/engine-types.ts b/packages/opencode/src/altimate/workspace/engine-types.ts index e88d8c3b5..070c26dc6 100644 --- a/packages/opencode/src/altimate/workspace/engine-types.ts +++ b/packages/opencode/src/altimate/workspace/engine-types.ts @@ -214,6 +214,14 @@ export function describeMissing(missing: string[]): string { return ` Declared but not available: ${shown}${more}.` } +/** 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.` +} + /** What each outcome MEANS, as tables over the whole union: a new variant * fails to compile until every table names it, and the safe answer is false. */ export const SERVING: Record = { diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 1d2dc2102..d5dbb65b9 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -326,6 +326,12 @@ export interface Interface { readonly supportsOAuth: (mcpName: string) => Effect.Effect readonly hasStoredTokens: (mcpName: string) => Effect.Effect readonly getAuthStatus: (mcpName: string) => Effect.Effect + // altimate_change start — the effective config for one key: runtime-added + // entries win over file config, the same precedence the connect path uses. + // Lets callers mirror connectLocal's spawn environment (notably `cwd`) + // without re-deriving the merge. + readonly entry: (name: string) => Effect.Effect + // altimate_change end } export class Service extends Context.Service()("@opencode/MCP") {} @@ -1372,6 +1378,9 @@ export const layer = Layer.effect( supportsOAuth, hasStoredTokens, getAuthStatus, + // altimate_change start — see Interface.entry + entry: getMcpConfig, + // altimate_change end }) }), ) @@ -1404,6 +1413,11 @@ export async function status() { export async function tools() { return runMcp((svc) => svc.tools()) } +// altimate_change start — see Interface.entry +export async function entry(name: string) { + return runMcp((svc) => svc.entry(name)) +} +// altimate_change end export async function add(name: string, mcp: ConfigMCPV1.Info) { return runMcp((svc) => svc.add(name, mcp)) } diff --git a/packages/opencode/test/altimate/tools/datamate-list-integrations.test.ts b/packages/opencode/test/altimate/tools/datamate-list-integrations.test.ts new file mode 100644 index 000000000..a41df5409 --- /dev/null +++ b/packages/opencode/test/altimate/tools/datamate-list-integrations.test.ts @@ -0,0 +1,96 @@ +// altimate_change - new file +// +// `datamate_manager list-integrations` and extension-type rows. Extension +// integrations are RPC into a live VS Code host: with a bridge running for +// this project they serve from the CLI like any other integration; without +// one they are dormant, not impossible. The listing must say which of those +// is true rather than a blanket "not available from the CLI". +import { afterEach, describe, expect, test } from "bun:test" +import { initTool } from "../tool-fixture" +import { tmpdir } from "../../fixture/fixture" +import { Instance } from "../../../src/project/instance" +import { AltimateApi } from "../../../src/altimate/api/client" +import { DatamateManagerTool } from "../../../src/altimate/tools/datamate" +import { syncInternals } from "../../../src/altimate/workspace/engine-seams" +import { SessionID, MessageID } from "../../../src/session/schema" + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "call_test", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +const CATALOG = [ + { id: "snowflake", name: "Snowflake", type: "connection", tools: [{ key: "execute_query" }] }, + { + id: "power-user-for-dbt", + name: "Power User for dbt", + type: "extension", + tools: [{ key: "compile_model" }, { key: "run_model" }], + }, +] + +const originalList = AltimateApi.listIntegrations +const originalIsConfigured = AltimateApi.isConfigured + +afterEach(() => { + ;(AltimateApi as unknown as { listIntegrations: typeof originalList }).listIntegrations = originalList + ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = originalIsConfigured + for (const key of Object.keys(syncInternals)) delete (syncInternals as Record)[key] +}) + +function serveCatalog(catalog: unknown[] = CATALOG): void { + ;(AltimateApi as unknown as { isConfigured: () => Promise }).isConfigured = async () => true + ;(AltimateApi as unknown as { listIntegrations: () => Promise }).listIntegrations = async () => catalog +} + +async function list() { + await using tmp = await tmpdir() + return await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await initTool(DatamateManagerTool) + return tool.execute({ operation: "list-integrations" }, ctx as any) + }, + }) +} + +describe("datamate_manager list-integrations and extension-type integrations", () => { + test("without a bridge they are omitted with copy that says how they come back", async () => { + serveCatalog() + syncInternals.liveBridge = () => false + const result = await list() + expect(result.metadata).toMatchObject({ count: 1, hidden: 1, bridge: false }) + expect(result.output).toContain("snowflake | Snowflake") + expect(result.output).not.toContain("power-user-for-dbt") + expect(result.output).toContain( + "1 extension-type integration was omitted — they serve while VS Code with the Altimate extension is open on this project.", + ) + expect(result.output).not.toContain("not available from the CLI") + }) + + test("with a live bridge they are listed, marked, and counted", async () => { + serveCatalog() + syncInternals.liveBridge = () => true + const result = await list() + expect(result.metadata).toMatchObject({ count: 2, hidden: 0, bridge: true }) + expect(result.output).toContain("power-user-for-dbt | Power User for dbt (via VS Code) | compile_model, run_model") + expect(result.output).toContain("1 extension-type integration is served via the connected VS Code window.") + expect(result.title).toBe("Integrations: 2 available") + }) + + test("a catalog with no extension rows never probes for a bridge", async () => { + serveCatalog([CATALOG[0]]) + syncInternals.liveBridge = () => { + throw new Error("must not probe") + } + const result = await list() + expect(result.metadata).toMatchObject({ count: 1, hidden: 0, bridge: false }) + expect(result.output).not.toContain("extension-type") + }) +}) diff --git a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts index af96b1f6a..3b4ee48d6 100644 --- a/packages/opencode/test/altimate/workspace/engine-overlay.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-overlay.test.ts @@ -425,6 +425,21 @@ describe("beforeTurn — what a turn boundary does", () => { expect(h.toasts[0].message).toBe("2 integration tools available.") }) + test("extension tools a live bridge serves are announced; absent ones are expected, not missing", async () => { + const h = install({ + tools: { datamate_dbt_build_model: {}, datamate_dbt_compile_model: {}, datamate_get_projects: {} }, + declared: { keys: ["dbt_build_model", "dbt_compile_model"], extensionKeys: ["get_projects", "run_model"] }, + }) + 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(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("the inventory is announced per session, not per process", async () => { const h = install({}) await beforeTurn("s1") diff --git a/packages/opencode/test/altimate/workspace/engine-probes.test.ts b/packages/opencode/test/altimate/workspace/engine-probes.test.ts index a5edccfd8..9354fe4da 100644 --- a/packages/opencode/test/altimate/workspace/engine-probes.test.ts +++ b/packages/opencode/test/altimate/workspace/engine-probes.test.ts @@ -6,7 +6,7 @@ import { describe, expect, test } from "bun:test" import { chmodSync, mkdtempSync, statSync, utimesSync, writeFileSync } from "node:fs" import os from "node:os" import path from "node:path" -import { fingerprint, versionOf } from "../../../src/altimate/workspace/engine-probes" +import { fingerprint, liveBridge, qualifiedFolder, versionOf } from "../../../src/altimate/workspace/engine-probes" const posix = process.platform !== "win32" @@ -59,3 +59,134 @@ describe("versionOf", () => { expect(await versionOf(path.join(os.tmpdir(), "definitely-not-here-" + process.pid))).toBeNull() }) }) + +describe("liveBridge", () => { + // A pid above any realistic pid_max, so the liveness check reports it dead — + // the same trick the engine's own discovery tests use. + const DEAD_PID = 2 ** 31 - 1 + + function sidecars(entries: Record): string { + const dir = mkdtempSync(path.join(os.tmpdir(), "bridge-sidecar-")) + for (const [name, data] of Object.entries(entries)) { + writeFileSync(path.join(dir, name), JSON.stringify(data)) + } + return dir + } + + test("a live bridge recording this directory is found, from the folder or below it", () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "bridge-ws-")) + // A second live bridge keeps the single-bridge fallback out of play, so + // these assertions exercise the cwd match alone. + const dir = sidecars({ + "a.json": { socketPath: "/tmp/a.sock", workspaceFolders: [cwd], pid: process.pid }, + "b.json": { socketPath: "/tmp/b.sock", workspaceFolders: ["/somewhere/else"], pid: process.pid }, + }) + expect(liveBridge(cwd, dir)).toBe(true) + expect(liveBridge(path.join(cwd, "models", "staging"), dir)).toBe(true) + // A child literally named "..cache" is inside: ".." only counts as a + // complete path component. + expect(liveBridge(path.join(cwd, "..cache"), dir)).toBe(true) + // A sibling directory that merely shares the prefix string is not within. + expect(liveBridge(cwd + "-other", dir)).toBe(false) + }) + + test("a dead bridge is skipped and its sidecar is left for the engine to GC", () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "bridge-ws-")) + const dir = sidecars({ + "a.json": { socketPath: "/tmp/a.sock", workspaceFolders: [cwd], pid: DEAD_PID }, + }) + expect(liveBridge(cwd, dir)).toBe(false) + expect(statSync(path.join(dir, "a.json")).isFile()).toBe(true) + }) + + test("a present-but-invalid pid is a corrupt record, never a live pid-less sidecar", () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "bridge-ws-")) + // Non-positive and non-integer: kill(0)/kill(-1) probe process groups. + // Wrong type entirely: the extension always writes a positive integer. + for (const pid of [0, -1, 1.5, "1234", null, {}] as unknown[]) { + const dir = sidecars({ + "a.json": { socketPath: "/tmp/a.sock", workspaceFolders: [cwd], pid: pid as number }, + }) + expect(liveBridge(cwd, dir)).toBe(false) + } + }) + + test("a recorded folder must be fully qualified on Windows — drive-relative resolves onto the current drive", () => { + // win branch + expect(qualifiedFolder("C:\\ws", true)).toBe(true) + expect(qualifiedFolder("c:/ws", true)).toBe(true) + expect(qualifiedFolder("\\\\server\\share\\ws", true)).toBe(true) + expect(qualifiedFolder("\\\\server\\share", true)).toBe(true) + expect(qualifiedFolder("\\repo", true)).toBe(false) + expect(qualifiedFolder("\\", true)).toBe(false) + // Incomplete pseudo-UNC: resolve("\\\\") is "C:\\", resolve("\\\\server") + // is "C:\\server" — the current drive again, not a UNC device. + expect(qualifiedFolder("\\\\", true)).toBe(false) + expect(qualifiedFolder("\\\\server", true)).toBe(false) + expect(qualifiedFolder("\\\\server\\", true)).toBe(false) + expect(qualifiedFolder("C:relative", true)).toBe(false) + expect(qualifiedFolder("", true)).toBe(false) + // posix branch + expect(qualifiedFolder("/home/ws", false)).toBe(true) + expect(qualifiedFolder("relative/dir", false)).toBe(false) + expect(qualifiedFolder("", false)).toBe(false) + }) + + test("empty and relative folder strings are dropped — resolve('') is the process cwd", () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "bridge-ws-")) + // Bridge A is malformed, bridge B is live elsewhere: A must not match via + // resolve("") and must not defeat the two-bridge decline. + const dir = sidecars({ + "a.json": { socketPath: "/tmp/a.sock", workspaceFolders: ["", "relative/dir"], pid: process.pid }, + "b.json": { socketPath: "/tmp/b.sock", workspaceFolders: ["/somewhere/else"], pid: process.pid }, + }) + expect(liveBridge(process.cwd(), dir)).toBe(false) + expect(liveBridge(cwd, dir)).toBe(false) + }) + + test("the sole live bridge counts even for an unrelated directory; two decline to guess", () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "bridge-ws-")) + const one = sidecars({ + "a.json": { socketPath: "/tmp/a.sock", workspaceFolders: ["/somewhere/else"], pid: process.pid }, + }) + expect(liveBridge(cwd, one)).toBe(true) + const two = sidecars({ + "a.json": { socketPath: "/tmp/a.sock", workspaceFolders: ["/somewhere/else"], pid: process.pid }, + "b.json": { socketPath: "/tmp/b.sock", workspaceFolders: ["/somewhere/third"], pid: process.pid }, + }) + expect(liveBridge(cwd, two)).toBe(false) + }) + + test("garbage is not a bridge: no dir, no socketPath, unparseable JSON", () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "bridge-ws-")) + expect(liveBridge(cwd, path.join(os.tmpdir(), "no-such-dir-" + process.pid))).toBe(false) + const dir = sidecars({ + "no-sock.json": { workspaceFolders: [cwd], pid: process.pid }, + }) + writeFileSync(path.join(dir, "broken.json"), "{not json") + writeFileSync(path.join(dir, "not-a-sidecar.txt"), "ignored") + expect(liveBridge(cwd, dir)).toBe(false) + }) + + test("a malformed folders shape degrades to a folderless live bridge, never a throw", () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "bridge-ws-")) + // Object-valued and mixed-type folders come from an unvalidated JSON file. + const dir = sidecars({ + "obj.json": { socketPath: "/tmp/a.sock", workspaceFolders: {}, pid: process.pid }, + }) + expect(liveBridge(cwd, dir)).toBe(true) // sole live bridge, no folders — fallback + const mixed = sidecars({ + "mixed.json": { socketPath: "/tmp/a.sock", workspaceFolders: [42, cwd], pid: process.pid }, + "other.json": { socketPath: "/tmp/b.sock", workspaceFolders: ["/somewhere/else"], pid: process.pid }, + }) + expect(liveBridge(cwd, mixed)).toBe(true) // the string folder still matches + }) + + test("a sidecar without a pid counts as live, matching the engine's discovery", () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "bridge-ws-")) + const dir = sidecars({ + "no-pid.json": { socketPath: "/tmp/a.sock", workspaceFolders: [cwd] }, + }) + expect(liveBridge(cwd, dir)).toBe(true) + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 92fe3f813..cf14e14c2 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -140,6 +140,7 @@ const mcp = Layer.succeed( supportsOAuth: () => Effect.succeed(false), hasStoredTokens: () => Effect.succeed(false), getAuthStatus: () => Effect.succeed("not_authenticated" as const), + entry: () => Effect.succeed(undefined), }), ) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index f1990520a..c39aa1b83 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -53,6 +53,7 @@ const mcp = Layer.succeed( supportsOAuth: () => Effect.succeed(false), hasStoredTokens: () => Effect.succeed(false), getAuthStatus: () => Effect.succeed("not_authenticated" as const), + entry: () => Effect.succeed(undefined), }), )