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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 68 additions & 31 deletions src/agent/opencode-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand All @@ -149,6 +151,9 @@ export interface V2State {
outputs?: Map<string, number>;
windowsAt?: number;
windowsWarned?: boolean;
toolDelta?: Promise<void>;
toolDeltaReady?: boolean;
toolDeltaRetryAt?: number;
}

function refreshWindows(ctx: V2PluginContext, state: V2State): void {
Expand Down Expand Up @@ -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 : "";
Expand Down Expand Up @@ -392,43 +398,74 @@ 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<string, unknown>, 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({
name: t.name,
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),
});
}
});
Expand Down
43 changes: 40 additions & 3 deletions tests/opencode-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ 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];

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<void> }> {
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<void> }> {
const toolCalls: Array<{ conversationId?: string; tool?: string; args?: unknown }> = [];
const compacts: string[] = [];
const server = http.createServer((req, res) => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> }> {
Expand Down
Loading