From aea775fc6cadf48bad0969d7905eba648ba4646e Mon Sep 17 00:00:00 2001 From: FineFenng Date: Sat, 31 Oct 2026 14:45:16 +0800 Subject: [PATCH 1/3] fix: show full OpenCode V2 reports without model context pollution --- src/agent/opencode-v2.ts | 49 +++++++++++++++++++++------- tests/opencode-v2.test.ts | 68 ++++++++++++++++++++++++++++++++++----- 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/src/agent/opencode-v2.ts b/src/agent/opencode-v2.ts index c3dea872bf..e909204e7b 100644 --- a/src/agent/opencode-v2.ts +++ b/src/agent/opencode-v2.ts @@ -48,6 +48,7 @@ // 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"; @@ -55,8 +56,8 @@ 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) @@ -70,6 +71,7 @@ interface V2Headers { } export interface V2HttpRequestEvent { + messages?: Array<{ id?: string; role?: string; content?: unknown }>; sessionID?: unknown; agent?: unknown; model?: { providerID?: unknown; id?: unknown }; @@ -110,7 +112,7 @@ interface V2CatalogModelEntry { export interface V2PluginContext { session?: { hook?: (name: string, cb: (e: V2HttpRequestEvent) => void | Promise) => V2Registration | void | Promise; - synthetic?: (input: { sessionID: string; text: string; description?: string; resume?: boolean }) => Promise; + synthetic?: (input: { id?: string; sessionID: string; text: string; description?: string; resume?: boolean }) => Promise; }; tool?: { transform?: (cb: (editor: V2ToolEditor) => void) => V2Registration | void | Promise; @@ -517,6 +519,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" && /^msg_bili_report_[0-9a-f]{32}$/.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 => { + await ctx.session?.synthetic?.({ + ...(reportFiltering ? { id: `msg_bili_report_${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). @@ -577,20 +607,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]", @@ -622,7 +647,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)}`); } diff --git a/tests/opencode-v2.test.ts b/tests/opencode-v2.test.ts index e57190ed9b..ba5f79a639 100644 --- a/tests/opencode-v2.test.ts +++ b/tests/opencode-v2.test.ts @@ -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) => void | Promise>(); let modelRequestCb: ((e: Record) => void | Promise) | undefined; let wsHandshakeCb: ((e: Record) => void | Promise) | undefined; const disposed: number[] = []; @@ -118,10 +119,11 @@ function makeFakeCtx() { hook: async (name: string, cb: (e: Record) => void | Promise) => { 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 {}; }, @@ -165,6 +167,7 @@ function makeFakeCtx() { return { ctx, + contextHooks, fireModelRequest: async (opts: { sessionID?: unknown; baseURL?: unknown; model?: unknown; headers?: Record }) => { const store: Record = {}; await modelRequestCb!({ @@ -212,6 +215,55 @@ function withEnv(vars: Record, 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); @@ -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(); @@ -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(); @@ -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(); @@ -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(); } @@ -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 }]); From 0a835e45c310ba7aa8ce62f37ccac45127dbaafb Mon Sep 17 00:00:00 2001 From: ework-daemon Date: Sat, 31 Oct 2026 18:05:12 +0800 Subject: [PATCH 2/3] refactor: name the report id namespace constants (review polish) The msg_bili_report_ literal appeared twice (filter regex + id template); extract BILI_REPORT_ID_PREFIX and derive BILI_REPORT_ID_RE from it so the minter and the filter can never drift apart (#5316 review). --- src/agent/opencode-v2.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/agent/opencode-v2.ts b/src/agent/opencode-v2.ts index e909204e7b..b372b8ceae 100644 --- a/src/agent/opencode-v2.ts +++ b/src/agent/opencode-v2.ts @@ -63,6 +63,10 @@ const V2_SYNTHETIC_VISIBLE_MAX = 1024; // 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 }; @@ -526,7 +530,7 @@ export function createOpencodeV2Setup(options: OpencodeV2SetupOptions = {}): (ct const reg = await ctx.session.hook(name, (event) => { if (!event.messages) return; const kept = event.messages.filter((message) => !( - message.role === "user" && /^msg_bili_report_[0-9a-f]{32}$/.test(message.id ?? "") + message.role === "user" && BILI_REPORT_ID_RE.test(message.id ?? "") )); event.messages.splice(0, event.messages.length, ...kept); }); @@ -539,7 +543,7 @@ export function createOpencodeV2Setup(options: OpencodeV2SetupOptions = {}): (ct } const renderReport = async (sid: string, text: string, description: string): Promise => { await ctx.session?.synthetic?.({ - ...(reportFiltering ? { id: `msg_bili_report_${randomUUID().replaceAll("-", "")}` } : {}), + ...(reportFiltering ? { id: `${BILI_REPORT_ID_PREFIX}${randomUUID().replaceAll("-", "")}` } : {}), sessionID: sid, text: reportFiltering ? `\`\`\`text\n${text}\n\`\`\`` : V2_SYNTHETIC_TEXT, description, From 2405b0177982a487c21f0eba2cbfa3e6969413b7 Mon Sep 17 00:00:00 2001 From: ci-check Date: Tue, 3 Nov 2026 23:48:21 +0800 Subject: [PATCH 3/3] fix(tests): opencode-native hook inventory includes the four report-context hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR adds context/compaction/title/generate session-hook registrations to createOpencodeV2Setup; the strict hook-name inventory assertion in tests/opencode-native.test.ts pinned the pre-PR two-hook list and broke on rebased heads (3 of 12 CI shards red). Update the expected list to the full registration order — the assertion keeps its exact-inventory teeth. --- tests/opencode-native.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/opencode-native.test.ts b/tests/opencode-native.test.ts index a7e0e2d423..691105210e 100644 --- a/tests/opencode-native.test.ts +++ b/tests/opencode-native.test.ts @@ -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",