From 410ea4699155754404b1b828e631b13f99d5a690 Mon Sep 17 00:00:00 2001 From: awork Date: Fri, 9 Oct 2026 07:41:50 +0800 Subject: [PATCH] fix: register manifest-advertised conditional tools in the opencode V2 lane The opencode V2 lane registered only its static bundled list (5 core ACP tools + absorb) and never consulted the proxy's plugin manifest, so the conditional surface it advertises - acp_retrieve while CCR is armed, acp_rule while rules are on - was absent from the model tool catalog. With CCR enabled an [acp-stored] placeholder pointed at acp_retrieve as its restore path, but the tool was unreachable (retrieveCalls stuck at 0 while storedBytes grew); the only workaround was disabling CCR. Consume the manifest like the dsh/pi lanes already do: on the first routed request (when the proxy base becomes known) fetch GET /__bili/plugin/manifest and register whatever it advertises beyond the static set. Single-flight, inert-degrading (a failed fetch leaves the tools unregistered, same as before) with a retry cooldown. The always-on core keeps its exact bundled schemas; only the advertised delta is added. Closes #2443 --- src/agent/opencode-v2.ts | 99 +++++++++++++++++++++++++++------------ tests/opencode-v2.test.ts | 43 +++++++++++++++-- 2 files changed, 108 insertions(+), 34 deletions(-) diff --git a/src/agent/opencode-v2.ts b/src/agent/opencode-v2.ts index 53806dba0..c9ec5cd18 100644 --- a/src/agent/opencode-v2.ts +++ b/src/agent/opencode-v2.ts @@ -49,7 +49,7 @@ // available on all observed surfaces. import { ACP_TOOLS_OPENAI, ABSORB_TOOL_OPENAI } from "../compress-tool.js"; -import { asciiHeaderValue, fetchProxyVersion, fetchStatus, fitNoticeDescription, forwardTool, postIdentityRegister, proxyBaseFromEnv, proxyBaseFromUrl, reportCompactionBoundary, reportRuntimeInfoOnChange, V2_SYNTHETIC_TEXT } from "./shared.js"; +import { asciiHeaderValue, fetchManifest, fetchProxyVersion, fetchStatus, fitNoticeDescription, forwardTool, postIdentityRegister, proxyBaseFromEnv, proxyBaseFromUrl, reportCompactionBoundary, reportRuntimeInfoOnChange, V2_SYNTHETIC_TEXT } from "./shared.js"; import { createForkAdopter } from "./fork-adopt.js"; // OpenCode V2 TUI renders a synthetic message as a visible Notice row only when its display text fits the @@ -133,6 +133,8 @@ export interface V2PluginContext { } const WINDOW_REFRESH_MS = 60000; +// #2443: cooldown before re-trying a failed conditional-tool manifest fetch (pi/dsh parity); a down proxy must not be re-polled on every routed request. +const TOOL_DELTA_RETRY_MS = 15000; // Kill switch = fully inert (same semantics as detectProxyBase): gates header stamping, tool forwarding, compaction reporting. const pluginDisabled = (): boolean => process.env.BILLION_CONTEXT_PLUGIN === "0"; @@ -149,6 +151,9 @@ export interface V2State { outputs?: Map; windowsAt?: number; windowsWarned?: boolean; + toolDelta?: Promise; + toolDeltaReady?: boolean; + toolDeltaRetryAt?: number; } function refreshWindows(ctx: V2PluginContext, state: V2State): void { @@ -343,6 +348,7 @@ export function createOpencodeV2Setup(options: OpencodeV2SetupOptions = {}): (ct if (!state.proxyBase) return; refreshWindows(ctx, state); stampHeaders(e); + ensureConditionalTools(); // #2399 stage 3: adopt the fork child before its first request // reaches the proxy (see the forkAdopter comment above). const forkChild = typeof e.sessionID === "string" ? e.sessionID : ""; @@ -392,6 +398,66 @@ export function createOpencodeV2Setup(options: OpencodeV2SetupOptions = {}): (ct console.warn("[bili-opencode] WebSocket handshake hook unavailable; Responses WebSocket traffic is not intercepted by this host"); } + const makeToolExecutor = (toolName: string): ((input: Record, c: { sessionID: string }) => Promise<{ content: string }>) => async (args, tctx) => { + if (pluginDisabled()) return { content: "bili: disabled (BILLION_CONTEXT_PLUGIN=0)" }; + const base = state.proxyBase ?? proxyBaseFromEnv(); + if (!base) return { content: "bili: no proxy detected (launch opencode through `bili opencode`, or point the provider baseURL at the bili proxy)" }; + try { + // Panel-first for acp_status: the proxy's status endpoint renders the same rich panel the /acp command shows; the forwarded kernel tool returns the legacy flat report. acp_status is read-only, so reading the panel changes no state. Fall back to the tool call when no panel comes back (older proxy, unknown conversation). + if (toolName === "acp_status") { + const status = await fetchStatus(base, tctx.sessionID); + const panel = status?.["panel"]; + if (typeof panel === "string" && panel.length > 0) return { content: panel }; + } + const result = await forwardTool(base, tctx.sessionID, toolName, args, undefined, true); + // #2204: the V2 tool shape carries no error flag — a business failure rides the "[decompress FAILED:" receipt text the model reads. + return { content: result.text }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("no model request has arrived with this conversation id yet")) { + return { content: "bili: no ACP state for this session yet — no model request has been routed through the proxy. Tell the user to send one normal message first; ACP activates automatically once model traffic flows through the proxy (verify the provider baseURL goes through bili, or launch via `bili opencode` / the installed plugin)." }; + } + return { content: msg }; + } + }; + + const STATIC_TOOL_NAMES = new Set(V2_BILI_TOOLS.map((t) => t.name)); + + // #2443: the static list covers only the always-on core. The manifest is the host's authoritative declaration of the CONDITIONAL surface (acp_retrieve while CCR is armed, acp_rule while rules are on); without consulting it a CCR placeholder points at a tool the model can never call. The base is discovered lazily (native bootstrap / per-request routing), so register the delta on first routed request — single-flight, degrading inertly (tools stay unregistered) when the manifest is unreachable. + const ensureConditionalTools = (): void => { + if (state.toolDeltaReady || state.toolDelta !== undefined || pluginDisabled()) return; + const base = state.proxyBase; + if (base === undefined) return; + const retryAt = state.toolDeltaRetryAt; + if (retryAt !== undefined && Date.now() < retryAt) return; + state.toolDelta = fetchManifest(base, "openai") + .then((tools) => { + const delta = tools.filter((t) => !STATIC_TOOL_NAMES.has(t.name)); + if (delta.length > 0 && ctx.tool?.transform) { + const extraReg = ctx.tool.transform((editor) => { + for (const t of delta) { + editor.add({ + name: t.name, + description: t.description, + input: t.inputSchema, + options: { codemode: false, permission: "allow" }, + execute: makeToolExecutor(t.name), + }); + } + }); + void Promise.resolve(extraReg).then((reg) => { if (reg) registrations.push(reg); }).catch(() => {}); + } + state.toolDeltaReady = true; + state.toolDeltaRetryAt = undefined; + }) + .catch(() => { + state.toolDeltaRetryAt = Date.now() + TOOL_DELTA_RETRY_MS; + }) + .finally(() => { + state.toolDelta = undefined; + }); + }; + const toolReg = await ctx.tool?.transform?.((editor) => { for (const t of V2_BILI_TOOLS) { editor.add({ @@ -399,36 +465,7 @@ export function createOpencodeV2Setup(options: OpencodeV2SetupOptions = {}): (ct description: t.description, input: t.input, options: { codemode: false, permission: "allow" }, - execute: async (args, tctx) => { - if (pluginDisabled()) return { content: "bili: disabled (BILLION_CONTEXT_PLUGIN=0)" }; - const base = state.proxyBase ?? proxyBaseFromEnv(); - if (!base) return { content: "bili: no proxy detected (launch opencode through `bili opencode`, or point the provider baseURL at the bili proxy)" }; - try { - // Panel-first for acp_status: the proxy's status - // endpoint renders the same rich panel the /acp - // command shows; the forwarded kernel tool returns - // the legacy flat report. acp_status is read-only, - // so reading the panel changes no state. Fall back - // to the tool call when no panel comes back (older - // proxy, unknown conversation). - if (t.name === "acp_status") { - const status = await fetchStatus(base, tctx.sessionID); - const panel = status?.["panel"]; - if (typeof panel === "string" && panel.length > 0) return { content: panel }; - } - const result = await forwardTool(base, tctx.sessionID, t.name, args, undefined, true); - // #2204: the V2 tool shape carries no error flag — a - // business failure rides the "[decompress FAILED:" - // receipt text the model reads. - return { content: result.text }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("no model request has arrived with this conversation id yet")) { - return { content: "bili: no ACP state for this session yet — no model request has been routed through the proxy. Tell the user to send one normal message first; ACP activates automatically once model traffic flows through the proxy (verify the provider baseURL goes through bili, or launch via `bili opencode` / the installed plugin)." }; - } - return { content: msg }; - } - }, + execute: makeToolExecutor(t.name), }); } }); diff --git a/tests/opencode-v2.test.ts b/tests/opencode-v2.test.ts index 3548cbbe7..78f50e4c0 100644 --- a/tests/opencode-v2.test.ts +++ b/tests/opencode-v2.test.ts @@ -6,7 +6,7 @@ import test from "node:test"; process.env.NODE_ENV = "test"; import biliOpencodePlugin from "../src/agent/opencode.ts"; -import { ACP_TOOLS_OPENAI, ABSORB_TOOL_OPENAI } from "../src/compress-tool.ts"; +import { ACP_TOOLS_OPENAI, ABSORB_TOOL_OPENAI, RETRIEVE_TOOL_NAME, retrieveToolsFor } from "../src/compress-tool.ts"; import { fetchManifest, fitNoticeDescription } from "../src/agent/shared.ts"; const EXPECTED_TOOLS = [...ACP_TOOLS_OPENAI.map((t) => t.function.name), ABSORB_TOOL_OPENAI.function.name]; @@ -14,7 +14,7 @@ const EXPECTED_TOOLS = [...ACP_TOOLS_OPENAI.map((t) => t.function.name), ABSORB_ const LONG_PANEL = Array.from({ length: 30 }, (_, i) => `panel line ${String(i + 1).padStart(2, "0")}: ${"x".repeat(38)}`).join("\n"); const LONG_REPORT = Array.from({ length: 200 }, (_, i) => `report line ${String(i + 1).padStart(3, "0")}: ${"y".repeat(38)}`).join("\n"); -function startFakeProxyV2(): Promise<{ origin: string; toolCalls: Array<{ conversationId?: string; tool?: string; args?: unknown }>; compacts: string[]; close: () => Promise }> { +function startFakeProxyV2(manifestOpenaiTools?: Array<{ name: string; description?: string; parameters?: unknown }>): Promise<{ origin: string; toolCalls: Array<{ conversationId?: string; tool?: string; args?: unknown }>; compacts: string[]; close: () => Promise }> { const toolCalls: Array<{ conversationId?: string; tool?: string; args?: unknown }> = []; const compacts: string[] = []; const server = http.createServer((req, res) => { @@ -66,7 +66,10 @@ function startFakeProxyV2(): Promise<{ origin: string; toolCalls: Array<{ conver } if ((req.url ?? "") === "/__bili/plugin/manifest") { res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ version: "9.9.9-test" })); + const manifest = manifestOpenaiTools && manifestOpenaiTools.length > 0 + ? { version: "9.9.9-test", tools: { openai: manifestOpenaiTools } } + : { version: "9.9.9-test" }; + res.end(JSON.stringify(manifest)); return; } res.writeHead(404); @@ -756,6 +759,40 @@ test("fetchManifest openai format maps parameters to inputSchema", async () => { } }); +test("#2443: a manifest-advertised conditional tool (acp_retrieve) is registered on first routed request", async () => { + const rt = retrieveToolsFor(RETRIEVE_TOOL_NAME).openai.function; + const proxy = await startFakeProxyV2([{ name: rt.name, description: rt.description, parameters: rt.parameters }]); + try { + const fake = makeFakeCtx(); + const cleanup = await biliOpencodePlugin.setup(fake.ctx as never); + try { + // Before any traffic: only the static base set is registered (unchanged parity). + assert.deepEqual(fake.addedTools.map((t) => t.name), EXPECTED_TOOLS); + assert.ok(!fake.addedTools.some((t) => t.name === "acp_retrieve"), "no retrieve tool before first request"); + // First routed request discovers the proxy base and triggers the manifest delta. + await fake.fireModelRequest({ sessionID: "ses_ccr", baseURL: `${proxy.origin}/bili/http://upstream.example/v1` }); + await until(() => fake.addedTools.some((t) => t.name === "acp_retrieve")); + const names = fake.addedTools.map((t) => t.name); + // Base set intact, each present exactly once; retrieve added exactly once (no duplicate). + for (const expected of EXPECTED_TOOLS) { + assert.equal(names.filter((n) => n === expected).length, 1, `${expected} present exactly once`); + } + assert.equal(names.filter((n) => n === "acp_retrieve").length, 1, "acp_retrieve present exactly once"); + const retrieve = fake.addedTools.find((t) => t.name === "acp_retrieve")!; + assert.deepEqual(retrieve.input, rt.parameters); + assert.equal(retrieve.options?.["codemode"], false); + assert.equal(retrieve.options?.["permission"], "allow"); + // The registered executor forwards to the live proxy dispatcher (reachable, not a dead pointer). + const out = await retrieve.execute({ ref: "m00001" }, { sessionID: "ses_ccr" }); + assert.ok(typeof out.content === "string" && out.content.length > 0, "retrieve executes against the proxy"); + } finally { + await cleanup(); + } + } finally { + await proxy.close(); + } +}); + import { createOpencodeV2Setup } from "../src/agent/opencode-v2.ts"; function startRegisterProxy(failFirst = 0): Promise<{ origin: string; registers: Array<{ conversationId?: string; agent?: string; identity?: boolean; parentConversationId?: string }>; close: () => Promise }> {