From 871afdf813cb2906dc618663b4276ca081d6c6a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 10:16:03 +0000 Subject: [PATCH] test: cover gateway policy toolset and reply caps create_gateway_bus applies GatewayPolicy.tools_enabled over a wider top-level list, and gateway.tools_enabled "all" still exposes terminal, write_file, and git_commit. Discord and Twitch outbound replies stay within the declared cap, including a Twitch cut that would start the next chunk with /me. Telegram split tests call the adapter's splitter. Co-authored-by: moikapy --- test/gateway.test.ts | 130 +++++++++++++++++++++++++++++++-- test/gateway_reconnect.test.ts | 73 +++++++++++++++++- 2 files changed, 193 insertions(+), 10 deletions(-) diff --git a/test/gateway.test.ts b/test/gateway.test.ts index 79d3166..e95548d 100644 --- a/test/gateway.test.ts +++ b/test/gateway.test.ts @@ -19,8 +19,10 @@ import { create_discord_adapter } from "../src/gateway/discord.js"; import { create_twitch_adapter } from "../src/gateway/twitch.js"; import { logger } from "../src/util/log.js"; import { assert_bind_allowed, create_webhook_adapter, MAX_WEBHOOK_BODY_BYTES } from "../src/gateway/webhook.js"; -import { format_agent_reply, split_text } from "../src/gateway/format.js"; -import { create_telegram_adapter } from "../src/gateway/telegram.js"; +import { format_agent_reply } from "../src/gateway/format.js"; +import { create_gateway_bus } from "../src/gateway/runner.js"; +import { create_telegram_adapter, split_text, TELEGRAM_CAPABILITIES, TELEGRAM_MAX_MESSAGE_CHARS } from "../src/gateway/telegram.js"; +import { resolve_docs_root } from "../src/tools/builtin/docs_read.js"; import { parse_irc_line, sanitize_twitch_outbound, TWITCH_MESSAGE_CAP } from "../src/gateway/twitch.js"; import { note_partial_messages } from "../src/agent/loop.js"; import { GatewayBus } from "../src/gateway/bus.js"; @@ -639,6 +641,108 @@ describe("gateway access", () => { rmSync(work_dir, { recursive: true, force: true }); } }); + + it("logs a denial when the policy refuses a public-platform sender", () => { + const work_dir = temp_work_dir(); + const warn = vi.spyOn(logger, "warn").mockImplementation(() => undefined); + try { + const policy = create_gateway_policy(config_for(work_dir)); + expect(policy.allows("twitch", "lobby", "viewer")).toBe(false); + expect(warn).toHaveBeenCalledWith("gateway denied twitch chat=lobby user=viewer"); + } finally { + warn.mockRestore(); + rmSync(work_dir, { recursive: true, force: true }); + } + }); +}); + +describe("gateway agent toolset", () => { + function stop_reply(): Response { + return new Response( + JSON.stringify({ + model: "mock-model", + choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { status: 200 }, + ); + } + + function tool_names_from(body: string | undefined): string[] { + const parsed = JSON.parse(body ?? "{}") as { tools?: Array<{ function: { name: string } }> }; + return (parsed.tools ?? []).map((tool) => tool.function.name); + } + + /** Docs tools register only when a docs root resolves for the gateway agent. */ + function expected_default_tools(work_dir: string): string[] { + const names = [...DEFAULT_GATEWAY_TOOLS_ENABLED]; + const docs = resolve_docs_root({ + work_dir, + env: { + LICH_TERMINAL_TIMEOUT_MS: "60000", + LICH_TEST_COMMAND: process.env.LICH_TEST_COMMAND ?? "", + }, + }); + if (docs === undefined) { + return names.filter((name) => name.startsWith("docs_") === false); + } + return names; + } + + it("registers the safe policy toolset when the top-level list is all", async () => { + const work_dir = temp_work_dir(); + const seen: string[][] = []; + const fetch_fn: typeof fetch = async (_url, init) => { + seen.push(tool_names_from(typeof init?.body === "string" ? init.body : undefined)); + return stop_reply(); + }; + try { + const config = parse_agent_config({ + providers: [{ kind: "openai_compat", name: "main", model: "mock-model", base_url: "http://mock.local/v1", fetch_fn }], + work_dir, + tools_enabled: "all", + log_level: "error", + }); + const { agent, bus } = await create_gateway_bus(config); + try { + await bus.handle("webhook", "c1", "u1", "hello"); + } finally { + bus.stop(); + agent.close(); + } + expect([...(seen[0] ?? [])].sort()).toEqual(expected_default_tools(work_dir).sort()); + } finally { + rmSync(work_dir, { recursive: true, force: true }); + } + }); + + it("opens the full toolset, including git_commit, when gateway.tools_enabled is all", async () => { + const work_dir = temp_work_dir(); + const seen: string[][] = []; + const fetch_fn: typeof fetch = async (_url, init) => { + seen.push(tool_names_from(typeof init?.body === "string" ? init.body : undefined)); + return stop_reply(); + }; + try { + const config = parse_agent_config({ + providers: [{ kind: "openai_compat", name: "main", model: "mock-model", base_url: "http://mock.local/v1", fetch_fn }], + work_dir, + tools_enabled: ["read_file"], + gateway: { tools_enabled: "all" }, + log_level: "error", + }); + const { agent, bus } = await create_gateway_bus(config); + try { + await bus.handle("webhook", "c1", "u1", "hello"); + } finally { + bus.stop(); + agent.close(); + } + expect(seen[0]).toEqual(expect.arrayContaining(["terminal", "write_file", "git_commit"])); + } finally { + rmSync(work_dir, { recursive: true, force: true }); + } + }); }); describe("adapter capabilities", () => { @@ -952,13 +1056,15 @@ describe("gateway format", () => { }); describe("telegram splitting", () => { - it("splits a 5000-char message into two chunks within the limit", () => { + it("splits a 5000-char message into two chunks within the declared cap", () => { + const limit = TELEGRAM_MAX_MESSAGE_CHARS; + expect(TELEGRAM_CAPABILITIES.max_reply_chars).toBe(limit); const long = "word ".repeat(1000).trimEnd(); - expect(long.length).toBeGreaterThan(4096); - const chunks = split_text(long, 4096); + expect(long.length).toBeGreaterThan(limit); + const chunks = split_text(long, limit); expect(chunks.length).toBe(2); for (const chunk of chunks) { - expect(chunk.length).toBeLessThanOrEqual(4096); + expect(chunk.length).toBeLessThanOrEqual(limit); expect(chunk.length).toBeGreaterThan(0); } expect(chunks.join(" ")).toBe(long); @@ -966,13 +1072,21 @@ describe("telegram splitting", () => { it("prefers newline boundaries when available", () => { const text = `${"a".repeat(3000)}\n${"b".repeat(3000)}`; - const chunks = split_text(text, 4096); + const chunks = split_text(text, TELEGRAM_MAX_MESSAGE_CHARS); expect(chunks.length).toBe(2); expect(chunks[0]?.includes("\n")).toBe(false); }); + it("hard-cuts a token that has no whitespace inside the declared cap", () => { + const limit = TELEGRAM_MAX_MESSAGE_CHARS; + const token = "x".repeat(limit + 25); + const chunks = split_text(token, limit); + expect(chunks.map((chunk) => chunk.length)).toEqual([limit, 25]); + expect(chunks.join("")).toBe(token); + }); + it("keeps short messages intact as a single chunk", () => { - expect(split_text("short", 4096)).toEqual(["short"]); + expect(split_text("short", TELEGRAM_MAX_MESSAGE_CHARS)).toEqual(["short"]); }); it("degrades to an idle adapter when the token is missing", () => { diff --git a/test/gateway_reconnect.test.ts b/test/gateway_reconnect.test.ts index 4926aa4..3107128 100644 --- a/test/gateway_reconnect.test.ts +++ b/test/gateway_reconnect.test.ts @@ -5,8 +5,8 @@ */ import { afterEach, describe, expect, it, vi } from "vitest"; import { parse_agent_config } from "../src/agent/config.js"; -import { create_discord_adapter } from "../src/gateway/discord.js"; -import { create_twitch_adapter } from "../src/gateway/twitch.js"; +import { create_discord_adapter, DISCORD_CAPABILITIES } from "../src/gateway/discord.js"; +import { create_twitch_adapter, TWITCH_CAPABILITIES } from "../src/gateway/twitch.js"; import type { AdapterParams } from "../src/gateway/types.js"; const sleep_state = vi.hoisted(() => ({ @@ -131,6 +131,16 @@ function release_sleep(): void { next?.resolve(); } +async function wait_for(ready: () => boolean): Promise { + for (let attempt = 0; attempt < 40; attempt += 1) { + if (ready() === true) { + return; + } + await settle(); + } + throw new Error("condition not met"); +} + function heartbeat_count(socket: FakeSocket): number { return socket.sent.filter((line) => line.includes('"op":1')).length; } @@ -222,6 +232,37 @@ describe("discord reconnect", () => { expect(heartbeat_count(socket)).toBe(2); await adapter.stop(); }); + + it("posts a long reply in chunks no larger than the declared cap", async () => { + remember_env(); + process.env.LICH_DISCORD_BOT_TOKEN = "fixture-discord-token"; + const cap = DISCORD_CAPABILITIES.max_reply_chars ?? 0; + const reply = "z".repeat(cap + 1); + const contents: string[] = []; + vi.stubGlobal("WebSocket", FakeSocket); + vi.stubGlobal("fetch", async (_url: unknown, init?: RequestInit) => { + const raw = typeof init?.body === "string" ? init.body : ""; + const body = JSON.parse(raw) as { content?: string }; + if (typeof body.content === "string") { + contents.push(body.content); + } + return new Response("{}", { status: 200 }); + }); + const adapter = create_discord_adapter({ + ...adapter_params(), + handle_message: async () => reply, + }); + await adapter.start(); + const socket = await wait_for_socket(0); + socket.emit(JSON.stringify({ + t: "MESSAGE_CREATE", + d: { channel_id: "chan-1", content: "hi", author: { id: "user-1", bot: false } }, + })); + await wait_for(() => contents.length === 2); + expect(contents.map((chunk) => chunk.length)).toEqual([cap, 1]); + expect(contents.join("")).toBe(reply); + await adapter.stop(); + }); }); describe("twitch reconnect", () => { @@ -253,4 +294,32 @@ describe("twitch reconnect", () => { expect(sleep_state.delays).toEqual([5000, 5000, 10000]); await adapter.stop(); }); + + it("splits a long reply at the declared cap and strips a command marker the cut exposes", async () => { + remember_env(); + process.env.LICH_TWITCH_OAUTH_TOKEN = "fixture-twitch-token"; + process.env.LICH_TWITCH_NICK = "lichbot"; + process.env.LICH_TWITCH_CHANNELS = "lobby"; + const cap = TWITCH_CAPABILITIES.max_reply_chars ?? 0; + const reply = `${"a".repeat(cap)}/me pwn`; + vi.stubGlobal("WebSocket", FakeSocket); + const adapter = create_twitch_adapter({ + ...adapter_params(), + handle_message: async () => reply, + }); + await adapter.start(); + const socket = await wait_for_socket(0); + socket.emit(":viewer!viewer@viewer.tmi.twitch.tv PRIVMSG #lobby :hello\r\n"); + const bodies = (): string[] => + socket.sent.filter((line) => line.startsWith("PRIVMSG #")).map((line) => line.slice(line.indexOf(" :") + 2)); + await wait_for(() => bodies().length === 1 && sleep_state.pending.length > 0); + release_sleep(); + await wait_for(() => bodies().length === 2); + expect(bodies()[0]).toBe("a".repeat(cap)); + expect(bodies()[1]).toBe("me pwn"); + for (const body of bodies()) { + expect(body.length).toBeLessThanOrEqual(cap); + } + await adapter.stop(); + }); });