Skip to content
Draft
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
130 changes: 122 additions & 8 deletions test/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -952,27 +1056,37 @@ 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);
});

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", () => {
Expand Down
73 changes: 71 additions & 2 deletions test/gateway_reconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -131,6 +131,16 @@ function release_sleep(): void {
next?.resolve();
}

async function wait_for(ready: () => boolean): Promise<void> {
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;
}
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(":[email protected] 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();
});
});
Loading