diff --git a/CHANGELOG.md b/CHANGELOG.md index 060bfea..2496363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - **fix(compress): 摘要保真 — 入库解码双重转义摘要 + 标记无 ref 用户引言 (closes #309)** — 小模型会随工具输出模仿产出 `\uXXXX` 字面转义写进 compress 参数,内核原样存储后每轮 prompt 携带乱码、模型再模仿再产出(正反馈污染,事故会话实测)。新增 `src/summary-sanitize.ts`:`sanitizeSummary` 转义数严格 >20 才解码(仅 `\uXXXX` 含代理对,`\n`/`\\` 不动,合法少量转义示例存活);`findUnverifiableUserQuote` 检测“声称用户原话但无 mNNNNN ref”的引用(只记日志不改写,含中文“原话”模式);挂载 `compress-tool.ts` 的 `handleCompress` 在 `applyCompression` 之前逐 range 清洗,打 `summary-unescaped` / `summary-unverifiable-quote` 事件日志 - **feat(delegate): `acp_delegate` 新增 per-call `timeoutMinutes` 硬超时覆盖 (#286)** — 主 agent 可按任务体量单独设定该次 run 的硬墙钟预算,不再依赖全局默认(`async=true` 覆盖 async 硬限、sync 覆盖 sync 限;无输出 idle watchdog 仍用全局值);缺省/非法值回退配置默认,封顶 1440m(24h)防坏值长期占用并发槽位;`asyncWatchdogDescription` 反映生效值。实现:`resolvePerCallTimeoutMs`(`src/delegate-tool.ts`) - docs: AGENTS.md 新增问题报告规范 — 发现/修复的问题必须建 issue 追踪 (#313) +- **fix(proxy): 手动指向 bili 代理时自动让位,消除双重压缩(closes #296)** — bcp 此前只认 launcher 注入的 `BILLION_CONTEXT_PROXY` 环境变量;用户单独跑代理并手动把 pi 的 `models.json` baseUrl 指到 `http://127.0.0.1:PORT/bili/https://upstream…`(或仅设 `HTTPS_PROXY`)时 env 未设 → bcp 全功能激活 + 代理照常压缩 → 每轮请求两侧各压一遍。现在:`src/proxy-detect.ts` 新增 `isBiliProxyBaseUrl`(镜像 billion-context `proxyBaseFromUrl`:http(s) scheme、首段路径为 `bili`、余下部分匹配 `/^/https?:///`,避免普通 `/foo/bili/` 路径误判),session_start 惰性检测 `ctx.model.baseUrl`,命中即按 env 相同方式让位(进程内一次性警告、不取消宿主原生压缩、四个 ACP 工具返回让位原因而非 OMP 文案);context 事件作为兜底检测点(防 session_start 未先于首个 LLM 调用触发)。env 检查保留为主信号——MITM 透明模式 URL 无 `/bili/` 前缀、只能靠 env 识别;README(en/zh)提示手动用 `HTTPS_PROXY` 的用户同时导出 `BILLION_CONTEXT_PROXY=1`。测试:URL 矩阵 14 例 + 集成 6 例(stand-down / warn-once / headless stderr / 直连 endpoint 保持激活 / context 兜底) - **feat(delegate): 按角色配置默认模型与 thinking level (closes #117)** — `acp_delegate` 现支持为不同角色持久化配置默认模型与 reasoning/thinking level:`delegate.agents..model`(`"provider/id"`)、`delegate.agents..thinkingLevel`,以及全局 `delegate.thinkingLevel`。模型解析优先级 per-call `model` > 角色默认 > 父 Agent 当前模型;thinking level 优先级 per-call > 角色 > 全局 > Pi 默认。角色配置的模型经 `ctx.modelRegistry.find()` 校验,不存在时回退到父模型并记警告(**绝不失败**,omo 教训);per-call / 继承的模型原样透传(允许自定义非目录模型)。无任何配置时行为完全不变(继承父模型 + Pi 默认)。子进程 CLI 新增 `--thinking ` 透传(pi 支持 off|minimal|low|medium|high|xhigh|max,非法值告警不失败);`acp_delegate` 新增可选 `thinkingLevel` 参数 - **feat(delegate): `delegate.maxConcurrent` 后台子代理并发上限 / 强制串行开关(closes #294)** — 此前每个 `acp_delegate` async 调用立即 spawn 独立 pi 进程,无并发上限;低性能机器上并行子代理争抢 CPU 导致全部 watchdog 超时退出,主代理被迫自己干完。新增数值配置 `delegate.maxConcurrent`(默认 unlimited):限制**同时运行**的后台(async)delegate 数量,达上限后后续启动进入 FIFO 队列、有空位自动开始(不丢弃、只是等待)。`1` = 强制严格串行。优先级 env `PI_ACP_DELEGATE_MAX_CONCURRENT` > acp.json > unlimited,无效值(非整数 / `<1`)带警告回退而非失败。新增 `RunStatus:"queued"`:排队 run 立即可被 wait(挂起 waiter 跨排队→终态)与 cancel(释放槽位并显式唤醒挂起的 waiter,因其无子进程触发 finalize);watchdog 计时从实际 spawn 起算(不含排队时间);remaining-counts 计入 queued。sync 调用始终立即运行、不受影响。实现:`ConcurrencyGate`(provider 容量、FIFO、cancel 只标记不删除以防槽位泄漏)+ `spawnDelegateChild` 抽取;并发上限解析并入 `resolveDelegate`/`DelegatePolicy`;工具描述在有限制时动态提示;CONFIGURATION.md(+zh-CN) 文档。测试:`resolveDelegate().maxConcurrent` 解析 + ConcurrencyGate(FIFO/容量 N/cancel-跳过/无限)+ env>acp.json 解析与无效回退 - **fix(guardrail): 通用工具调用重复熔断 — 打断字节级相同调用的死循环(closes #308)** — 贪心解码小模型会把完全一致的 `(工具调用 → 工具返回)` 对逐轮自我强化(序列级吸引子,token 级惩罚跨轮无效),实测 `acp_status` 同参连发 30 次、上下文 57K→130K。现有防线全部失效:`compress-retry-capped` 熔断只统计 compress 失败(acp_status 永远成功 → 零计数)、nudge 去重按 turnKey(循环期无新用户消息 → 全程 `nudge-suppressed`)、`tool-guardrails.ts` 只有 bash 输出上限/超时检测。新增 `repetitionGuard`(默认开启,可配 `{enabled,warn,abort}`):每次调用指纹化 `sha1(toolName + 键排序 canonical JSON(args))`,只比参数不比结果(结果里可见消息数会微变也不影响命中);会话内跟踪连续相同次数,达 `warn`(默认 3) 在该 toolResult 尾部追加强警告,达 `abort`(默认 5) 拦截该调用不执行 + 中止本轮 + 终端通知;参数变化 / 换工具 / 真实用户消息重置计数(扩展自发消息不重置)。实现挂 `src/tool-guardrails.ts`(`RepetitionTracker` / `canonicalStringify` / `repetitionFingerprint`),配置解析在 `src/config.ts`(`resolveRepetitionGuard` / `REPETITION_GUARD_DEFAULTS`) diff --git a/README.md b/README.md index 2abb49b..8df8d38 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ billion-context-pi is built for the **Pi** coding agent (`@earendil-works/pi-cod Full details: [docs/omp.md](./docs/omp.md). +- **Coexisting with the [billion-context](https://github.com/ranxianglei/billion-context) wire proxy** — running both on the same session double-compresses every request (wasted tokens, nested summaries, two ref coordinate systems). This is prevented automatically: launcher paths (`bili pi`, …) export `BILLION_CONTEXT_PROXY`, and models whose `baseUrl` routes through the proxy (`…/bili/https://upstream…`) are detected at session start — in both cases billion-context-pi stands down with a warning and leaves the proxy as the sole compressor. One exception: transparent mode, where traffic reaches the proxy via `HTTPS_PROXY` so the URL carries no `/bili/` prefix — that is undetectable from the URL, so export `BILLION_CONTEXT_PROXY=1` before starting pi in that case. + ## Model-facing tools | Tool | What it does | diff --git a/README.zh-CN.md b/README.zh-CN.md index 21f70be..ac2343d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -102,6 +102,8 @@ billion-context-pi 面向 **Pi** 编码代理(`@earendil-works/pi-coding-agent`) 完整说明:[docs/omp.zh-CN.md](./docs/omp.zh-CN.md)。 +- **与 [billion-context](https://github.com/ranxianglei/billion-context) 线代理共存** —— 两者同时作用于同一会话会对每个请求双重压缩(token 浪费、嵌套摘要、两套 ref 坐标系)。这会被自动防止:launcher 路径(`bili pi` 等)导出 `BILLION_CONTEXT_PROXY`;模型 `baseUrl` 经代理路由(`…/bili/https://upstream…`)时在会话开始即被检测到 —— 两种情况下 billion-context-pi 都会带警告让位,由代理独占压缩。一个例外:透明模式(流量经 `HTTPS_PROXY` 到达代理、URL 无 `/bili/` 前缀)无法从 URL 识别 —— 此时请在启动 pi 前导出 `BILLION_CONTEXT_PROXY=1`。 + ## 模型工具 | 工具 | 作用 | diff --git a/src/compress-tool.ts b/src/compress-tool.ts index d91a765..e17588f 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -56,7 +56,7 @@ export function makeCompressTool(runtime: AcpRuntime): ToolDefinition> { - if (runtime.refused) return { details: undefined, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] }; + if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] }; let result: string; try { result = await handleCompress(params as CompressArgs, runtime, ctx, toolCallId); diff --git a/src/decompress-tool.ts b/src/decompress-tool.ts index 3648d14..ec72830 100644 --- a/src/decompress-tool.ts +++ b/src/decompress-tool.ts @@ -46,7 +46,7 @@ export function makeDecompressTool(runtime: AcpRuntime): ToolDefinition> { - if (runtime.refused) return { details: undefined, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] }; + if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] }; let result: string; try { result = await handleDecompress(params as DecompressArgs, runtime, ctx); diff --git a/src/index.ts b/src/index.ts index 59b24a5..b73ab72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ import { defaultCountTokens } from "acp-kernel"; import { formatSystemPromptForEvent, getSystemPromptText } from "./compat.js"; import { inspectOverflowMessage, reserveOutputHeadroom, shouldReserveOutputHeadroom } from "./overflow-selfheal.js"; import { isOmpHost, OMP_UNSUPPORTED_MESSAGE } from "./omp.js"; +import { isBiliProxyBaseUrl, PROXY_STAND_DOWN_MESSAGE } from "./proxy-detect.js"; type AgentMessage = SessionMessageEntry["message"]; @@ -56,10 +57,31 @@ export function createAcpExtension(adapter: AdapterConfig = {}): ExtensionFactor return; } const runtime = createRuntime(adapter); + // Manual-wiring double-compression guard (issue #296): the launcher path + // exports BILLION_CONTEXT_PROXY (checked above), but a user who starts the + // proxy standalone (`bili start`) and points models.json baseUrl at + // http://127.0.0.1:PORT/bili/://upstream... never sets the env var — + // without this check bcp and the proxy both compress every request. Yields + // exactly like the env path: stand down, let the proxy own compression. + // Checked lazily because ctx.model only exists on events, not in the + // factory; warn once per process like the OMP refusal. + let proxyWarned = false; + const standDownIfProxied = (ctx: ExtensionContext): boolean => { + if (!isBiliProxyBaseUrl((ctx.model as { baseUrl?: string } | undefined)?.baseUrl)) return false; + runtime.refused = true; + runtime.refusalMessage = PROXY_STAND_DOWN_MESSAGE; + if (!proxyWarned) { + proxyWarned = true; + logWarn("host", { event: "proxy-baseurl-detected", sid: ctx.sessionManager.getSessionId(), action: "refused" }); + if (ctx.hasUI) ctx.ui.notify(PROXY_STAND_DOWN_MESSAGE, "warning"); + else console.error(PROXY_STAND_DOWN_MESSAGE); + } + return true; + }; wireCompactionDisable(pi, runtime); wireDelegateReadTracking(pi); - wireSessionLifecycle(pi, runtime); - wireContextTransform(pi, runtime); + wireSessionLifecycle(pi, runtime, standDownIfProxied); + wireContextTransform(pi, runtime, standDownIfProxied); wireSystemPrompt(pi, runtime); wireToolGuardrails(pi, runtime); wireOverflowSelfHeal(pi, runtime); @@ -131,7 +153,7 @@ function wireDelegateReadTracking(pi: ExtensionAPI): void { }); } -function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime): void { +function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime, standDownIfProxied: (ctx: ExtensionContext) => boolean): void { let ompWarned = false; pi.on("session_start", async (_event, ctx) => { // OMP (oh-my-pi) is not supported: its in-process live-entries integration @@ -151,6 +173,7 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime): void { } return; } + if (standDownIfProxied(ctx)) return; runtime.store.invalidate(); runtime.clearNudgeTracking(); runtime.throttleFor(ctx.sessionManager.getSessionId()).reset(); @@ -217,12 +240,15 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime): void { // The core integration: Pi's `context` event fires before every LLM call with the // messages about to be sent. We run acp-kernel's processTurn (prune + ref-tag + // nudge decision) and return the transformed AgentMessage[]. -function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void { +function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIfProxied: (ctx: ExtensionContext) => boolean): void { pi.on("context", async (event, ctx) => { - // Refused host (OMP): leave the context completely untouched — no ref tags, - // no compression, no nudge. Returning undefined makes pi send the original - // messages verbatim. + // Refused host (OMP / proxied baseUrl): leave the context completely + // untouched — no ref tags, no compression, no nudge. Returning undefined + // makes pi send the original messages verbatim. if (runtime.refused) return; + // Fallback for hosts where session_start did not fire before the first LLM + // call: detect the proxied baseUrl here instead. + if (standDownIfProxied(ctx)) return; const sid = ctx.sessionManager.getSessionId(); const release = await runtime.acquireLock(sid); try { diff --git a/src/proxy-detect.ts b/src/proxy-detect.ts new file mode 100644 index 0000000..e887b08 --- /dev/null +++ b/src/proxy-detect.ts @@ -0,0 +1,36 @@ +// Manual-wiring detection for the billion-context wire proxy (issue #296): +// a user who starts the proxy standalone (`bili start`) and points pi's +// models.json baseUrl at `http://127.0.0.1:PORT/bili/://upstream...` +// never gets the BILLION_CONTEXT_PROXY env var (only the `bili pi` launcher +// exports it), so without this check bcp and the proxy both compress every +// request. Mirrors proxyBaseFromUrl in billion-context (src/agent/shared.ts): +// the real prefix embeds the full upstream URL, so require `bili` as the FIRST +// path segment followed by an http(s) URL — a plain `/foo/bili/` path is NOT a +// bili proxy. MITM transparent mode (HTTPS_PROXY) has no /bili/ prefix and +// stays undetectable from the URL; those users must export the env var. +export function isBiliProxyBaseUrl(baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + try { + const url = new URL(baseUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + const segments = url.pathname.split("/").filter((s) => s.length > 0); + if (segments[0] !== "bili") return false; + const rest = url.pathname.slice(url.pathname.indexOf("bili") + "bili".length); + return /^\/https?:\/\//.test(rest); + } catch { + return false; + } +} + +/** + * Shown (and logged) when the extension detects that the model's baseUrl + * routes through the billion-context wire proxy and stands down. The proxy + * runs compression server-side and owns the ref coordinate space, so running + * ACP in-process on top of it would double-compress every request. + */ +export const PROXY_STAND_DOWN_MESSAGE = [ + "[billion-context-pi] Model baseUrl routes through the billion-context wire proxy (/bili/ prefix) — ACP client-side compression has been disabled for this session to avoid double compression.", + "The proxy runs the same compression pipeline server-side and owns the ref coordinate space; nothing else to configure.", + "If your traffic reaches the proxy via HTTPS_PROXY instead (no /bili/ prefix in the URL), export BILLION_CONTEXT_PROXY=1 before starting pi.", + "Docs: https://github.com/ranxianglei/billion-context", +].join("\n"); diff --git a/src/runtime.ts b/src/runtime.ts index dcf95f9..b0a36e6 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -40,11 +40,15 @@ export function isPiHost(sm: ExtensionContext["sessionManager"]): boolean { export interface AcpRuntime { core: CompressionCore; - /** Set when the host is unsupported (currently: OMP / oh-my-pi). Once true, - * the extension stands down: the context transform, system-prompt injection, - * compaction-cancel and ACP tools all no-op so the host runs untouched. - * Set at session_start (see wireOmpRefusal in src/index.ts). */ + /** Set when the host is unsupported (currently: OMP / oh-my-pi) or when the + * model's baseUrl routes through the billion-context wire proxy (#296). + * Once true, the extension stands down: the context transform, system-prompt + * injection, compaction-cancel and ACP tools all no-op so the host runs + * untouched. Set at session_start (first context event as fallback). */ refused: boolean; + /** User-facing reason shown when a refused ACP tool is invoked; null means + * the default OMP refusal text applies. Set together with `refused`. */ + refusalMessage: string | null; /** Per-session provider-throttle retry episode (attempt budget + kick * pacing), keyed by session id so concurrent sessions in one extension * instance cannot share an episode. Reset on session_start and on any @@ -405,4 +409,5 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { } let refused = false; - return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop };} + let refusalMessage: string | null = null; + return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop };} diff --git a/src/search-tool.ts b/src/search-tool.ts index 72c0f1a..085683c 100644 --- a/src/search-tool.ts +++ b/src/search-tool.ts @@ -27,7 +27,7 @@ export function makeSearchTool(runtime: AcpRuntime): ToolDefinition> { - if (runtime.refused) return { details: undefined, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] }; + if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] }; let result: string; try { result = await handleSearch(params as SearchArgs, runtime, ctx); diff --git a/src/status-tool.ts b/src/status-tool.ts index c399aad..a248528 100644 --- a/src/status-tool.ts +++ b/src/status-tool.ts @@ -34,7 +34,7 @@ export function makeStatusTool(runtime: AcpRuntime): ToolDefinition> { - if (runtime.refused) return { details: undefined, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] }; + if (runtime.refused) return { details: undefined, content: [{ type: "text", text: runtime.refusalMessage ?? OMP_UNSUPPORTED_MESSAGE }] }; let result: string; try { result = await handleStatus(params as StatusArgs, runtime, ctx); diff --git a/tests/proxy-standdown.test.ts b/tests/proxy-standdown.test.ts new file mode 100644 index 0000000..aab6ad1 --- /dev/null +++ b/tests/proxy-standdown.test.ts @@ -0,0 +1,184 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { createAcpExtension } from "../src/index.js"; +import { isBiliProxyBaseUrl, PROXY_STAND_DOWN_MESSAGE } from "../src/proxy-detect.js"; +import { setRunNpmForTest } from "../src/update.js"; + +// Hermetic session_start: the pi path runs the auto-update check, so disable +// it and stub npm (no network) — mirroring omp-refuse.test.ts. +setRunNpmForTest(async (args) => ({ code: 0, stdout: args[0] === "view" ? "0.0.1\n" : "", stderr: "" })); +process.env.ACP_AUTO_UPDATE = "false"; +delete process.env.BILLION_CONTEXT_PROXY; + +function captureApi() { + const handlers = new Map any)[]>(); + const api = { + on(event: string, handler: (e: any, ctx: any) => any) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + tools: [] as any[], + commands: new Map(), + registerTool(tool: any) { + this.tools.push(tool); + }, + registerCommand(name: string, options: any) { + this.commands.set(name, options); + }, + }; + return { api, handlers }; +} + +type Notify = (msg: string, type?: string) => void; + +function piCtx(notify: Notify, baseUrl: string | undefined, hasUI = true) { + return { + mode: "rpc", + hasUI, + cwd: "/tmp", + ui: { notify, confirm: async () => true, select: async () => undefined, input: async () => "", setStatus: () => {} }, + model: baseUrl === undefined ? { contextWindow: 200_000 } : { contextWindow: 200_000, baseUrl }, + sessionManager: { + buildContextEntries: () => [], + getBranch: () => [], + getSessionId: () => "pi-session", + getSessionFile: () => "/tmp/pi-proxy.session.json", + }, + }; +} + +const startSession = (handlers: any, ctx: any) => + handlers.get("session_start")![0]!({ type: "session_start", reason: "startup" }, ctx); + +const PROXIED_BASE_URL = "http://127.0.0.1:8787/bili/https://api.openai.com/v1"; + +describe("isBiliProxyBaseUrl (#296)", () => { + const hits = [ + "http://127.0.0.1:8787/bili/https://api.openai.com/v1", + "http://localhost:9090/bili/http://internal:11434/v1", + "https://proxy.example.com/bili/https://upstream.example.com/api", + "http://127.0.0.1:8787/bili/https://api.openai.com/v1/", + ]; + const misses: Array = [ + "https://api.openai.com/v1", + "https://example.com/foo/bili/https://api.x.com/v1", + "ftp://host/bili/https://x", + "http://host/bili", + "http://host/bili/", + "http://host/bili/relative/path", + "http://host/BILI/https://x", + "not a url", + "", + undefined, + ]; + for (const u of hits) test(`hit: ${u}`, () => assert.equal(isBiliProxyBaseUrl(u), true)); + for (const u of misses) test(`miss: ${String(u)}`, () => assert.equal(isBiliProxyBaseUrl(u), false)); +}); + +describe("proxied baseUrl stand-down (#296)", () => { + test("detects at session_start, stands down, warns once via UI", async () => { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + const notes: Array<{ msg: string; type?: string }> = []; + const ctx = piCtx((msg, type) => notes.push({ msg, type }), PROXIED_BASE_URL); + + await startSession(handlers, ctx); + + assert.equal(notes.length, 1, "warns exactly once"); + assert.equal(notes[0]!.msg, PROXY_STAND_DOWN_MESSAGE); + assert.equal(notes[0]!.type, "warning"); + // Stands down: does not cancel the host's own compaction (the proxy owns compression). + assert.equal(handlers.get("session_before_compact")![0]!({}, {}), undefined); + // Does not inject the ACP system prompt (model must not learn compress here). + assert.equal(handlers.get("before_agent_start")![0]!({ systemPrompt: "BASE" }, {}), undefined); + // Leaves the context untouched (no ref tags / nudge) — returns undefined. + const ctxResult = await handlers.get("context")![0]!( + { type: "context", messages: [{ role: "user", content: "hi" }] }, + ctx, + ); + assert.equal(ctxResult, undefined, "context untouched on a proxied host"); + }); + + test("all four ACP tools report the stand-down reason", async () => { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + const ctx = piCtx(() => {}, PROXIED_BASE_URL); + await startSession(handlers, ctx); + + for (const name of ["compress", "decompress", "search_context", "acp_status"]) { + const tool = api.tools.find((t: any) => t.name === name); + assert.ok(tool, `${name} tool is registered`); + const res = await (tool as any).execute("t1", {}, undefined, undefined, ctx); + assert.equal((res.content[0] as any).text, PROXY_STAND_DOWN_MESSAGE, `${name} reports the stand-down reason`); + } + }); + + test("warns only once across repeated session_start events", async () => { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + const notes: string[] = []; + const ctx = piCtx((msg) => notes.push(msg), PROXIED_BASE_URL); + + await startSession(handlers, ctx); + await startSession(handlers, ctx); + + assert.equal(notes.length, 1, "second session_start must not re-warn"); + assert.equal(notes[0], PROXY_STAND_DOWN_MESSAGE); + }); + + test("prints the warning to stderr when there is no UI (headless one-shot)", async () => { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + const ctx = piCtx(() => {}, PROXIED_BASE_URL, false); + + const orig = console.error; + const errs: string[] = []; + console.error = (...a: any[]) => { + errs.push(a.join(" ")); + }; + try { + await startSession(handlers, ctx); + } finally { + console.error = orig; + } + + assert.equal(errs.length, 1, "exactly one stderr line"); + assert.equal(errs[0], PROXY_STAND_DOWN_MESSAGE); + }); + + test("stays active when baseUrl is a plain LLM endpoint", async () => { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + const notes: string[] = []; + const ctx = piCtx((msg) => notes.push(msg), "https://api.openai.com/v1"); + + await startSession(handlers, ctx); + + assert.equal(notes.filter((m) => m === PROXY_STAND_DOWN_MESSAGE).length, 0, "no stand-down warning on a direct endpoint"); + assert.deepEqual(handlers.get("session_before_compact")![0]!({}, {}), { cancel: true }); + const sp = handlers.get("before_agent_start")![0]!({ systemPrompt: "BASE" }, {}); + assert.ok(sp.systemPrompt.startsWith("BASE")); + assert.ok(sp.systemPrompt.includes("compress")); + }); + + test("falls back to the context event when session_start did not fire", async () => { + const { api, handlers } = captureApi(); + createAcpExtension()(api as any); + const notes: string[] = []; + const ctx = piCtx((msg) => notes.push(msg), PROXIED_BASE_URL); + + const ctxResult = await handlers.get("context")![0]!( + { type: "context", messages: [{ role: "user", content: "hi" }] }, + ctx, + ); + assert.equal(ctxResult, undefined, "context left untouched"); + assert.equal(notes.length, 1, "warned via the fallback detection point"); + assert.equal(notes[0], PROXY_STAND_DOWN_MESSAGE); + + assert.equal(handlers.get("before_agent_start")![0]!({ systemPrompt: "BASE" }, {}), undefined); + const tool = api.tools.find((t: any) => t.name === "compress") as any; + const res = await tool.execute("t1", {}, undefined, undefined, ctx); + assert.equal((res.content[0] as any).text, PROXY_STAND_DOWN_MESSAGE); + }); +});