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
53 changes: 41 additions & 12 deletions src/agent/opencode-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,25 @@
// openai tool list, src/compress-tool.ts) because reload-based refresh is not
// available on all observed surfaces.

import { randomUUID } from "node:crypto";
import { ACP_TOOLS_OPENAI, ABSORB_TOOL_OPENAI } from "../compress-tool.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";
import { logHostEvent } from "./host-log.js";

// OpenCode V2 TUI renders a synthetic message as a visible Notice row only when its display text fits the
// timeline cap (~1KB): longer text renders nothing (#880). Under the cap, panels go to description verbatim;
// over it, fitNoticeDescription hoists the Web UI deep link to line one and keeps whole leading lines (#1602) —
// the full panel stays reachable through that link.
// over it, fitNoticeDescription hoists the Web UI deep link and keeps whole leading lines (#1602).
// Body-rendering clients also receive the full report, excluded from outgoing context by its message ID.
const V2_SYNTHETIC_VISIBLE_MAX = 1024;
// /acp-cache renders a report the user explicitly asked to read — unlike the
// status panel whose leading lines carry the essence, its tail (LINE ITEMS)
// is the payload, so it gets a wider visible cap than V2_SYNTHETIC_VISIBLE_MAX.
const V2_CACHE_REPORT_VISIBLE_MAX = 8192;
// #5315: dedicated id namespace of the auto-generated /acp + /acp-cache report messages — the
// outgoing hooks filter by this exact id shape, so manually pasted reports can never be caught.
const BILI_REPORT_ID_PREFIX = "msg_bili_report_";
const BILI_REPORT_ID_RE = new RegExp(`^${BILI_REPORT_ID_PREFIX}[0-9a-f]{32}$`);

type V2Registration = { dispose?: () => void | Promise<void> };

Expand All @@ -70,6 +75,7 @@ interface V2Headers {
}

export interface V2HttpRequestEvent {
messages?: Array<{ id?: string; role?: string; content?: unknown }>;
sessionID?: unknown;
agent?: unknown;
model?: { providerID?: unknown; id?: unknown };
Expand Down Expand Up @@ -110,7 +116,7 @@ interface V2CatalogModelEntry {
export interface V2PluginContext {
session?: {
hook?: (name: string, cb: (e: V2HttpRequestEvent) => void | Promise<void>) => V2Registration | void | Promise<V2Registration | undefined>;
synthetic?: (input: { sessionID: string; text: string; description?: string; resume?: boolean }) => Promise<unknown>;
synthetic?: (input: { id?: string; sessionID: string; text: string; description?: string; resume?: boolean }) => Promise<unknown>;
};
tool?: {
transform?: (cb: (editor: V2ToolEditor) => void) => V2Registration | void | Promise<V2Registration | undefined>;
Expand Down Expand Up @@ -517,6 +523,34 @@ export function createOpencodeV2Setup(options: OpencodeV2SetupOptions = {}): (ct
});
if (toolReg) registrations.push(toolReg);

let reportFiltering = false;
try {
if (ctx.session?.hook) {
for (const name of ["context", "compaction", "title", "generate"]) {
const reg = await ctx.session.hook(name, (event) => {
if (!event.messages) return;
const kept = event.messages.filter((message) => !(
message.role === "user" && BILI_REPORT_ID_RE.test(message.id ?? "")
));
event.messages.splice(0, event.messages.length, ...kept);
});
if (reg) registrations.push(reg);
}
reportFiltering = true;
}
} catch (err) {
logHostEvent("warn", "[native-client]", `[bili-opencode] report context hooks unavailable; retaining compact notices: ${err instanceof Error ? err.message : String(err)}`);
}
const renderReport = async (sid: string, text: string, description: string): Promise<void> => {
await ctx.session?.synthetic?.({
...(reportFiltering ? { id: `${BILI_REPORT_ID_PREFIX}${randomUUID().replaceAll("-", "")}` } : {}),
sessionID: sid,
text: reportFiltering ? `\`\`\`text\n${text}\n\`\`\`` : V2_SYNTHETIC_TEXT,
description,
resume: false,
});
};

// /acp status command (#809): registered via the V2 command editor where
// supported (2.0.x stable; inert-safe otherwise) and rendered as a
// synthetic non-model-turn message; TUI-only (`run` dispatches no slash cmds).
Expand Down Expand Up @@ -577,20 +611,15 @@ export function createOpencodeV2Setup(options: OpencodeV2SetupOptions = {}): (ct
try {
// resume:false — OpenCode V2 defaults to delivery "steer" + execution.wake(), which would
// start a model turn on every /acp invocation with no user input (spurious empty turns).
// Short panels become the visible description verbatim; long ones lead with the Web UI
// deep link (full panel stays reachable there) and keep their leading whole lines, so
// the model-facing body never repeats the whole panel (#1602).
// Keep the capped notice for TUI clients; body-rendering clients receive the full report.
const description = fitNoticeDescription(text, V2_SYNTHETIC_VISIBLE_MAX, "panel", webUrl);
await ctx.session?.synthetic?.({ sessionID: sid, text: V2_SYNTHETIC_TEXT, description, resume: false });
await renderReport(sid, text, description);
} catch (err) {
console.error(`[bili-opencode] /acp render failed: ${err instanceof Error ? err.message : String(err)}`);
}
},
});
// #1146: human entry point for the cache-reconciliation feature —
// same report as the acp_cache tool above. The model-facing body
// stays the inert one-liner (V2_SYNTHETIC_TEXT), so no proxy-side
// stripping wrap is needed here (unlike the V1 ignored-message path).
// #1146: human entry point for the same report as the acp_cache tool.
editor.add({
name: "acp-cache",
description: "Prompt-cache reconciliation for this session (same report as the acp_cache tool). Usage: /acp-cache [full]",
Expand Down Expand Up @@ -622,7 +651,7 @@ export function createOpencodeV2Setup(options: OpencodeV2SetupOptions = {}): (ct
}
try {
const description = fitNoticeDescription(text, V2_CACHE_REPORT_VISIBLE_MAX, "report");
await ctx.session?.synthetic?.({ sessionID: sid, text: V2_SYNTHETIC_TEXT, description, resume: false });
await renderReport(sid, text, description);
} catch (err) {
console.error(`[bili-opencode] /acp-cache render failed: ${err instanceof Error ? err.message : String(err)}`);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/opencode-native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ test("setup(route): header stamping applies to the REPLACED request, tools regis
};
const setup = createOpencodeV2Setup({ route: createNativeRoute(state, { probe: async () => true }) });
const cleanup = await setup(ctx);
assert.deepEqual(hooks.map((h) => h.name), ["http.request", "experimental.ws.handshake"]);
assert.deepEqual(hooks.map((h) => h.name), ["http.request", "experimental.ws.handshake", "context", "compaction", "title", "generate"]);
assert.deepEqual(tools.sort(), [...EXPECTED_TOOLS].sort());
const e: V2HttpRequestEvent = {
sessionID: "ses_abc",
Expand Down
68 changes: 60 additions & 8 deletions tests/opencode-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ function makeFakeCtx() {
let signal: AbortSignal | undefined;
const addedTools: FakeAddedTool[] = [];
const addedCommands: FakeAddedCommand[] = [];
const syntheticCalls: Array<{ sessionID: string; text: string; description?: string; resume?: boolean }> = [];
const syntheticCalls: Array<{ id?: string; sessionID: string; text: string; description?: string; resume?: boolean }> = [];
const contextHooks = new Map<string, (e: Record<string, unknown>) => void | Promise<void>>();
let modelRequestCb: ((e: Record<string, unknown>) => void | Promise<void>) | undefined;
let wsHandshakeCb: ((e: Record<string, unknown>) => void | Promise<void>) | undefined;
const disposed: number[] = [];
Expand All @@ -118,10 +119,11 @@ function makeFakeCtx() {
hook: async (name: string, cb: (e: Record<string, unknown>) => void | Promise<void>) => {
if (name === "http.request") modelRequestCb = cb;
else if (name === "experimental.ws.handshake") wsHandshakeCb = cb;
else if (["context", "compaction", "title", "generate"].includes(name)) contextHooks.set(name, cb);
else assert.fail(`unexpected hook ${name}`);
return { dispose: () => { disposed.push(1); } };
},
synthetic: async (input: { sessionID: string; text: string; description?: string; resume?: boolean }) => {
synthetic: async (input: { id?: string; sessionID: string; text: string; description?: string; resume?: boolean }) => {
syntheticCalls.push(input);
return {};
},
Expand Down Expand Up @@ -165,6 +167,7 @@ function makeFakeCtx() {

return {
ctx,
contextHooks,
fireModelRequest: async (opts: { sessionID?: unknown; baseURL?: unknown; model?: unknown; headers?: Record<string, string> }) => {
const store: Record<string, string> = {};
await modelRequestCb!({
Expand Down Expand Up @@ -212,6 +215,55 @@ function withEnv(vars: Record<string, string | undefined>, fn: () => void | Prom
});
}

test("v2 reports: outgoing hooks exclude only generated report IDs, including after restart", async () => {
const fake = makeFakeCtx();
const cleanup = await biliOpencodePlugin.setup(fake.ctx as never);
let report: (typeof fake.syntheticCalls)[number];
try {
await fake.addedCommands.find((c) => c.name === "acp")!.execute({ sessionID: "ses_report" });
report = fake.syntheticCalls[0];
assert.match(report.id!, /^msg_bili_report_[0-9a-f]{32}$/);
} finally {
cleanup();
}
const restarted = makeFakeCtx();
const stop = await biliOpencodePlugin.setup(restarted.ctx as never);
try {
for (const name of ["context", "compaction", "title", "generate"]) {
const retained = [
{ id: "msg_user", role: "user", content: [{ type: "text", text: report.text }] },
{ role: "user", content: [{ type: "text", text: report.text }] },
{ id: report.id, role: "assistant", content: [] },
{ id: "msg_bili_report_invalid", role: "user", content: [] },
];
const history = [{ id: report.id, role: "user", content: [{ type: "text", text: report.text }] }, ...retained];
const event = { sessionID: "ses_report", messages: [...history] };
await restarted.contextHooks.get(name)!(event);
assert.deepEqual(event.messages, retained, name);
assert.equal(history.length, 5, "persisted history is unchanged");
}
} finally {
stop();
}
});

test("v2 reports: unsupported context hooks retain the inert body", async () => {
const fake = makeFakeCtx();
const hook = fake.ctx.session.hook;
fake.ctx.session.hook = async (name, cb) => {
if (name === "context") throw new Error("unsupported hook");
return hook(name, cb);
};
const cleanup = await biliOpencodePlugin.setup(fake.ctx as never);
try {
await fake.addedCommands.find((c) => c.name === "acp")!.execute({ sessionID: "ses_old" });
assert.match(fake.syntheticCalls[0].text, /not an instruction/);
assert.equal(fake.syntheticCalls[0].id, undefined);
} finally {
cleanup();
}
});

test("object export: .id/.setup for V2 and .server for V1 >= 1.18.29", () => {
assert.equal(typeof biliOpencodePlugin, "object");
assert.ok(biliOpencodePlugin !== null);
Expand Down Expand Up @@ -396,8 +448,8 @@ test("v2 setup: /acp command registered and renders proxy status panel via synth
await until(() => fake.syntheticCalls.length === 1);
assert.equal(fake.syntheticCalls[0].sessionID, "ses_acp_1");
assert.match(fake.syntheticCalls[0].description!, /ACP-PANEL-OK/);
assert.match(fake.syntheticCalls[0].text, /displayed in your terminal/);
assert.match(fake.syntheticCalls[0].text, /not an instruction/);
assert.equal(fake.syntheticCalls[0].text, "```text\nACP-PANEL-OK\n```");
assert.match(fake.syntheticCalls[0].id!, /^msg_bili_report_[0-9a-f]{32}$/);
assert.equal(fake.syntheticCalls[0].resume, false);
} finally {
cleanup();
Expand All @@ -417,7 +469,7 @@ test("v2 setup: /acp reports no proxy detected via synthetic when no proxy", asy
await acp.execute({ sessionID: "s_nopx" });
await until(() => fake.syntheticCalls.length === 1);
assert.match(fake.syntheticCalls[0].description!, /no proxy detected/);
assert.match(fake.syntheticCalls[0].text, /not an instruction/);
assert.match(fake.syntheticCalls[0].text, /no proxy detected/);
assert.equal(fake.syntheticCalls[0].resume, false);
} finally {
cleanup();
Expand All @@ -436,7 +488,7 @@ test("v2 setup: first /acp before any model request shows the idle notice (proxy
await acp.execute({ sessionID: "ses_acp_idle" });
await until(() => fake.syntheticCalls.length === 1);
assert.match(fake.syntheticCalls[0].description!, /billion-context@9\.9\.9-test \u2014 proxy connected via http:\/\/127\.0\.0\.1:\d+, no ACP session yet/);
assert.match(fake.syntheticCalls[0].text, /not an instruction/);
assert.match(fake.syntheticCalls[0].text, /no ACP session yet/);
assert.equal(fake.syntheticCalls[0].resume, false);
} finally {
cleanup();
Expand Down Expand Up @@ -472,7 +524,7 @@ test("v2 setup: /acp long panels lead with the Web UI link and keep whole leadin
const m = desc.match(/\[panel truncated\] (\d+) more lines?$/);
assert.ok(m, "marker carries the dropped count");
assert.equal(Number(m![1]), lines.length - (keptLines.length - 1), "dropped count is exact");
assert.match(fake.syntheticCalls[0].text, /not an instruction/);
assert.ok(fake.syntheticCalls[0].text.length > desc.length);
} finally {
cleanup();
}
Expand Down Expand Up @@ -579,7 +631,7 @@ test("v2 setup: /acp-cache registered and renders the cache report via synthetic
await until(() => fake.syntheticCalls.length === 1);
assert.equal(fake.syntheticCalls[0].sessionID, "ses_cache_1");
assert.match(fake.syntheticCalls[0].description!, /CACHE-REPORT-OK/);
assert.match(fake.syntheticCalls[0].text, /not an instruction/);
assert.match(fake.syntheticCalls[0].text, /CACHE-REPORT-OK/);
assert.equal(fake.syntheticCalls[0].resume, false);
// #2072: host-native callers stamp their machine-minted id — the wire body carries the flag.
assert.deepEqual(proxy.toolCalls, [{ conversationId: "ses_cache_1", tool: "acp_cache", args: {}, nativeCaller: true }]);
Expand Down
Loading