Files

1352 lines
61 KiB
TypeScript

import "./setup-home";
/**
* Tests for the OpenCode TypeScript plugin entry point.
*
* Tests the ContextModePlugin factory and its three hooks:
* - tool.execute.before (routing enforcement)
* - tool.execute.after (session event capture)
* - experimental.session.compacting (snapshot generation)
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
// ── Test helpers ──────────────────────────────────────────
/**
* Create a plugin instance with DB in a temp directory.
* Uses dynamic import to resolve routing module from project root.
*/
async function createTestPlugin(tempDir: string) {
// Import the plugin module
const { ContextModePlugin } = await import("../src/adapters/opencode/plugin.js");
// Monkey-patch the session dir to use temp directory
// The plugin uses homedir() internally, but we can control the DB path
// by creating the plugin with a unique directory that produces a unique hash
return ContextModePlugin({
directory: tempDir,
client: {
app: {
log: async () => {},
},
},
});
}
// ── Tests ─────────────────────────────────────────────────
// MCP readiness sentinel — routing.mjs checks process.ppid in-process
const _sentinelDir = process.platform === "win32" ? tmpdir() : "/tmp";
const mcpSentinel = resolve(_sentinelDir, `context-mode-mcp-ready-${process.pid}`);
beforeEach(() => { writeFileSync(mcpSentinel, String(process.pid)); });
afterEach(() => { try { unlinkSync(mcpSentinel); } catch {} });
describe("ContextModePlugin", () => {
let tempDir: string;
beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), "opencode-plugin-test-"));
});
afterAll(() => {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch { /* cleanup best effort */ }
});
// ── Factory ───────────────────────────────────────────
describe("factory", () => {
it("returns object with 5 hook handlers", async () => {
const plugin = await createTestPlugin(join(tempDir, "factory-test"));
expect(plugin).toHaveProperty("tool.execute.before");
expect(plugin).toHaveProperty("tool.execute.after");
expect(plugin).toHaveProperty("experimental.session.compacting");
// SessionStart-equivalent (PR #376 / Mickey #1) — must be on
// chat.system.transform, NOT chat.messages.transform (whose input
// shape `{}` carries no sessionID and whose output {info,parts}[]
// does not accept the {role,content} shape we used to push).
expect(plugin).toHaveProperty("experimental.chat.system.transform");
expect(plugin).not.toHaveProperty("experimental.chat.messages.transform");
// OC-2 (Z2) — chat.message wired to capture user prompts.
expect(plugin).toHaveProperty("chat.message");
expect(typeof plugin["tool.execute.before"]).toBe("function");
expect(typeof plugin["tool.execute.after"]).toBe("function");
expect(typeof plugin["experimental.session.compacting"]).toBe("function");
expect(typeof plugin["experimental.chat.system.transform"]).toBe("function");
expect(typeof plugin["chat.message"]).toBe("function");
});
it("registers all ctx_* tools natively via the plugin tool map (#574)", async () => {
const plugin = await createTestPlugin(join(tempDir, "factory-native-tools"));
expect(plugin).toHaveProperty("tool");
expect(Object.keys(plugin.tool ?? {}).sort()).toEqual([
"ctx_batch_execute",
"ctx_doctor",
"ctx_execute",
"ctx_execute_file",
"ctx_fetch_and_index",
"ctx_index",
"ctx_insight",
"ctx_purge",
"ctx_search",
"ctx_stats",
"ctx_upgrade",
]);
});
// Both KiloCode and recent OpenCode (≥ ~1.14.x) bundle Zod v4 in their
// bun-compiled host runtime. Pre-fix, raw Zod v3 schemas crashed the host
// on first message with `TypeError: undefined is not an object
// (evaluating 'n._zod.def')`. Invariant: every arg schema handed to the
// host must carry the `_zod` v4 marker. Asserted on both platforms.
it.each([
{ platform: "kilo" as const, dir: "kilo-zod-v4-args", setEnv: true },
{ platform: "opencode" as const, dir: "opencode-zod-v4-args", setEnv: false },
])(
"converts tool args to Zod v4 when platform is $platform (#_zod.def fix)",
async ({ dir, setEnv }) => {
const prevKiloPid = process.env.KILO_PID;
if (setEnv) process.env.KILO_PID = String(process.pid);
else delete process.env.KILO_PID;
try {
const plugin = await createTestPlugin(join(tempDir, dir));
expect(plugin).toHaveProperty("tool");
expect(Object.keys(plugin.tool ?? {}).length).toBeGreaterThan(0);
for (const [toolName, toolDef] of Object.entries(plugin.tool ?? {})) {
const args = (toolDef as any).args as Record<string, unknown>;
for (const [argName, argSchema] of Object.entries(args)) {
expect(
(argSchema as any)._zod,
`tool ${toolName} arg ${argName} missing _zod (Zod v4 marker)`,
).toBeDefined();
}
}
} finally {
if (prevKiloPid !== undefined) process.env.KILO_PID = prevKiloPid;
else delete process.env.KILO_PID;
}
},
);
it("ctx_stats native plugin tool executes without an MCP child (#574 smoke)", async () => {
const projectDir = join(tempDir, "factory-native-tool-exec");
const plugin = await createTestPlugin(projectDir);
const result = await plugin.tool!.ctx_stats.execute({}, {
sessionID: "session-native-tool",
messageID: "msg-native-tool",
agent: "test-agent",
directory: projectDir,
worktree: projectDir,
abort: new AbortController().signal,
metadata: () => {},
ask: (() => ({}) as any) as any,
});
const output = typeof result === "string" ? result : result.output;
expect(output).toContain("context-mode");
});
// ── #621: native plugin must run Zod preprocessing on args ────────
// OpenCode's plugin tool registry (refs/platforms/opencode/packages/
// opencode/src/tool/registry.ts:127) only uses the Zod schema as a
// boolean type guard via .safeParse(u).success — it passes RAW args
// to def.execute(). Our handlers in server.ts rely on
// z.preprocess(coerceCommandsArray|coerceJsonArray, …) to coerce
// JSON-string args back into arrays and to fill defaults. Before
// the fix, registered.handler(args ?? {}) ran without parsing,
// so commands could arrive as `undefined` or `"[...]"`, and the
// handler crashed with `commands.map is not a function`.
describe("#621/#627: native plugin runs Zod preprocessing on args", () => {
const baseCtx = (projectDir: string) => ({
sessionID: "issue-621-sess",
messageID: "issue-621-msg",
agent: "test-agent",
directory: projectDir,
worktree: projectDir,
abort: new AbortController().signal,
metadata: () => {},
ask: (() => ({}) as any) as any,
});
it("ctx_batch_execute accepts well-formed args without crashing", async () => {
const projectDir = join(tempDir, "issue-621-baseline");
const plugin = await createTestPlugin(projectDir);
const result = await plugin.tool!.ctx_batch_execute.execute(
{
commands: [
{ label: "echo test", command: "echo issue621-baseline" },
],
queries: ["issue621-baseline"],
concurrency: 1,
},
baseCtx(projectDir),
);
const output = typeof result === "string" ? result : result.output;
// Should succeed (no "commands.map is not a function" error).
expect(output).toContain("Executed");
});
it("ctx_batch_execute coerces JSON-stringified commands array (#621)", async () => {
const projectDir = join(tempDir, "issue-621-json-string");
const plugin = await createTestPlugin(projectDir);
// Simulate OpenCode delivering JSON-stringified array — the
// coerceCommandsArray preprocessor must turn this back into an
// array via z.preprocess before the handler runs.
const result = await plugin.tool!.ctx_batch_execute.execute(
{
commands: JSON.stringify([
{ label: "echo coerced", command: "echo issue621-coerced" },
]) as unknown as Array<{ label: string; command: string }>,
queries: JSON.stringify(["issue621-coerced"]) as unknown as string[],
},
baseCtx(projectDir),
);
const output = typeof result === "string" ? result : result.output;
expect(output).toContain("Executed");
});
it("ctx_batch_execute coerces plain-string commands into {label,command} (#621)", async () => {
const projectDir = join(tempDir, "issue-621-string-cmds");
const plugin = await createTestPlugin(projectDir);
// coerceCommandsArray also lifts bare strings into {label,command}.
const result = await plugin.tool!.ctx_batch_execute.execute(
{
commands: ["echo issue621-string-cmd"] as unknown as Array<{
label: string;
command: string;
}>,
queries: ["issue621-string-cmd"],
},
baseCtx(projectDir),
);
const output = typeof result === "string" ? result : result.output;
expect(output).toContain("Executed");
});
it("ctx_batch_execute surfaces actionable error when commands is missing (#621)", async () => {
const projectDir = join(tempDir, "issue-621-missing-commands");
const plugin = await createTestPlugin(projectDir);
// Before the fix this crashed with "commands.map is not a function"
// — opaque, hard for the LLM to recover from. After the fix the
// Zod schema rejects the missing field with a clear error.
await expect(
plugin.tool!.ctx_batch_execute.execute(
{ queries: ["whatever"] } as unknown as Record<string, unknown>,
baseCtx(projectDir),
),
).rejects.toThrow(/Invalid arguments for ctx_batch_execute/);
});
it("ctx_search coerces JSON-stringified queries array (#621)", async () => {
const projectDir = join(tempDir, "issue-621-search-coerce");
const plugin = await createTestPlugin(projectDir);
// ctx_search also uses z.preprocess(coerceJsonArray, …) on queries.
// Empty knowledge base is fine — we only assert the call returns
// without a TypeError (the original symptom).
const result = await plugin.tool!.ctx_search.execute(
{
queries: JSON.stringify(["issue621-search"]) as unknown as string[],
},
baseCtx(projectDir),
);
const output = typeof result === "string" ? result : result.output;
// Should not throw; output is a normal search response (possibly
// "No results" / guidance) but never the JS TypeError symptom.
expect(typeof output).toBe("string");
});
// ─────────────────────────────────────────────────────────
// #627: same root cause class as #621, but for primitive coercion.
// OpenCode's plugin host (and several LLM providers' tool-call JSON)
// stringifies *primitive* args too — limit:"4" instead of limit:4,
// background:"false" instead of background:false. v1.0.139 (#621)
// started running inputSchema.parse() on the native path, which made
// these mismatches visible as "Invalid arguments" errors. The fix is
// to use z.coerce.* on the schemas (mirrors what ctx_batch_execute's
// timeout/concurrency and ctx_fetch_and_index's concurrency already
// do), AND to widen coerceJsonArray to lift bare-string queries.
// ─────────────────────────────────────────────────────────
it("ctx_search accepts stringified limit (#627 exact reporter case)", async () => {
const projectDir = join(tempDir, "issue-627-limit-string");
const plugin = await createTestPlugin(projectDir);
// Reporter's exact call shape: queries arrives as JSON string AND
// limit arrives as a number-string. v1.0.140's plain z.number()
// rejects "4" with "Expected number, received string".
const result = await plugin.tool!.ctx_search.execute(
{
queries: JSON.stringify([
"HTML file mermaid rendering flowchart display issue",
]) as unknown as string[],
limit: "4" as unknown as number,
},
baseCtx(projectDir),
);
const output = typeof result === "string" ? result : result.output;
// Should NOT throw — z.coerce.number() turns "4" into 4 before
// the handler sees it; queries coercion turns the JSON string
// into an array. Output is a normal "no results yet" guidance.
expect(typeof output).toBe("string");
});
it("ctx_search lifts bare-string queries into single-element array (#627)", async () => {
const projectDir = join(tempDir, "issue-627-bare-query");
const plugin = await createTestPlugin(projectDir);
// Some LLM providers send a single query as a bare string rather
// than a JSON-stringified array. Without widening, coerceJsonArray
// returns the string unchanged → z.array(z.string()) rejects it.
const result = await plugin.tool!.ctx_search.execute(
{
queries: "single bare query" as unknown as string[],
},
baseCtx(projectDir),
);
const output = typeof result === "string" ? result : result.output;
expect(typeof output).toBe("string");
});
it("ctx_execute accepts stringified background boolean (#627)", async () => {
// The project dir must exist on disk: the shell pipeline spawns
// /bin/zsh with cwd=projectDir, and Node's spawn() returns ENOENT
// for the shell binary itself when cwd doesn't exist (misleading
// — /bin/zsh is fine). The other tests in this block don't notice
// because ctx_batch_execute catches per-cmd executor errors and
// still returns its "Executed ..." wrapper text.
const projectDir = join(tempDir, "issue-627-background-bool");
mkdirSync(projectDir, { recursive: true });
const plugin = await createTestPlugin(projectDir);
// background: z.boolean() rejects "false". z.coerce.boolean would
// coerce — but Boolean("false") is true. Instead we use a small
// preprocessor (coerceBoolean) that maps "true"/"false" literals
// back to booleans and leaves real booleans untouched.
const result = await plugin.tool!.ctx_execute.execute(
{
language: "shell",
code: "echo issue627-bg",
background: "false" as unknown as boolean,
},
baseCtx(projectDir),
);
const output = typeof result === "string" ? result : result.output;
// Should run synchronously (background coerced to false) and emit
// the echoed line — never throw "Invalid arguments for ctx_execute".
expect(output).toContain("issue627-bg");
});
it("ctx_purge accepts stringified confirm boolean (#627)", async () => {
const projectDir = join(tempDir, "issue-627-purge-confirm");
const plugin = await createTestPlugin(projectDir);
// confirm: z.boolean() rejects "false". With the boolean coercion
// fix, "false" becomes false → ctx_purge returns "purge cancelled"
// (its documented refusal path) instead of throwing on validation.
const result = await plugin.tool!.ctx_purge.execute(
{
confirm: "false" as unknown as boolean,
},
baseCtx(projectDir),
);
const output = typeof result === "string" ? result : result.output;
expect(output).toMatch(/cancel/i);
});
});
it("native tool registry import does not leak process handlers or embedded env into OpenCode host", () => {
const childHome = mkdtempSync(join(tmpdir(), "opencode-plugin-side-effects-"));
try {
const tsx = resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs");
const pluginPath = pathToFileURL(resolve(process.cwd(), "src", "adapters", "opencode", "plugin.ts")).href;
const script = `
(async () => {
const { ContextModePlugin } = await import(${JSON.stringify(pluginPath)});
const before = {
unhandled: process.listenerCount("unhandledRejection"),
uncaught: process.listenerCount("uncaughtException"),
};
await ContextModePlugin({ directory: ${JSON.stringify(childHome)}, client: { app: { log: async () => {} } } });
const after = {
unhandled: process.listenerCount("unhandledRejection"),
uncaught: process.listenerCount("uncaughtException"),
embedded: process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS ?? null,
};
console.log(JSON.stringify({ before, after }));
})().catch((err) => { console.error(err); process.exit(1); });
`;
const run = spawnSync(process.execPath, [tsx, "-e", script], {
cwd: process.cwd(),
env: { ...process.env, HOME: childHome, USERPROFILE: childHome },
encoding: "utf-8",
});
expect(run.status, run.stderr).toBe(0);
const result = JSON.parse(run.stdout.trim());
expect(result.after.unhandled).toBe(result.before.unhandled);
expect(result.after.uncaught).toBe(result.before.uncaught);
expect(result.after.embedded).toBeNull();
} finally {
rmSync(childHome, { recursive: true, force: true });
}
});
it("does not write AGENTS.md routing instructions on startup", async () => {
const projectDir = join(tempDir, "factory-startup-routing");
mkdirSync(projectDir, { recursive: true });
await createTestPlugin(projectDir);
const agentsPath = join(projectDir, "AGENTS.md");
expect(existsSync(agentsPath)).toBe(false);
});
});
// ── tool.execute.before ───────────────────────────────
describe("tool.execute.before", () => {
it("modifies curl commands to block them", async () => {
const plugin = await createTestPlugin(join(tempDir, "before-curl"));
const input = { tool: "Bash", sessionID: "test-session", callID: "call-1" };
const output = { args: { command: "curl https://example.com/data" } };
// Routing should throw for blocked commands (deny action)
// or modify the args to replace the command
try {
await plugin["tool.execute.before"](input, output);
// If it didn't throw, the command was modified in output.args
expect(output.args.command).toMatch(/^echo /);
expect(output.args.command).toContain("context-mode");
} catch (e: any) {
// deny/ask action throws — still correct behavior
expect(e.message).toContain("context-mode");
}
});
it("modifies wget commands to block them", async () => {
const plugin = await createTestPlugin(join(tempDir, "before-wget"));
const input = { tool: "Bash", sessionID: "test-session", callID: "call-2" };
const output = { args: { command: "wget https://example.com/file" } };
try {
await plugin["tool.execute.before"](input, output);
expect(output.args.command).toMatch(/^echo /);
expect(output.args.command).toContain("context-mode");
} catch (e: any) {
expect(e.message).toContain("context-mode");
}
});
it("passes through normal tool calls", async () => {
const plugin = await createTestPlugin(join(tempDir, "before-pass"));
// TaskCreate is not routed — should passthrough
const result = await plugin["tool.execute.before"](
{ tool: "TaskCreate", sessionID: "test-session", callID: "call-3" },
{ args: { subject: "test task" } },
);
expect(result).toBeUndefined();
});
it("handles empty input gracefully", async () => {
const plugin = await createTestPlugin(join(tempDir, "before-empty"));
const result = await plugin["tool.execute.before"](
{} as any,
{ args: {} } as any,
);
expect(result).toBeUndefined();
});
it("injects guidance for allowed grep commands", async () => {
const plugin = await createTestPlugin(join(tempDir, "before-guidance"));
const input = { tool: "grep", sessionID: "test-session", callID: "call-4" };
const output = { args: { command: "grep hello", additionalContext: undefined } };
await plugin["tool.execute.before"](input, output);
// Guidance should be injected as additionalContext in args
expect(output.args).toHaveProperty("additionalContext");
expect(output.args.additionalContext).toContain("<context_guidance>");
});
});
// ── tool.execute.after ────────────────────────────────
describe("tool.execute.after", () => {
it("captures file read events without throwing", async () => {
const plugin = await createTestPlugin(join(tempDir, "after-read"));
// Should not throw
await expect(
plugin["tool.execute.after"](
{ tool: "Read", sessionID: "test-session", callID: "call-1", args: { file_path: "/test/file.ts" } },
{ title: "Read", output: "file contents here", metadata: {} },
),
).resolves.toBeUndefined();
});
it("captures file write events", async () => {
const plugin = await createTestPlugin(join(tempDir, "after-write"));
await expect(
plugin["tool.execute.after"](
{ tool: "Write", sessionID: "test-session", callID: "call-2", args: { file_path: "/test/new-file.ts", content: "code" } },
{ title: "Write", output: "", metadata: {} },
),
).resolves.toBeUndefined();
});
it("captures git events from Bash", async () => {
const plugin = await createTestPlugin(join(tempDir, "after-git"));
await expect(
plugin["tool.execute.after"](
{ tool: "Bash", sessionID: "test-session", callID: "call-3", args: { command: "git commit -m 'test'" } },
{ title: "Bash", output: "[main abc1234] test", metadata: {} },
),
).resolves.toBeUndefined();
});
it("handles empty input gracefully", async () => {
const plugin = await createTestPlugin(join(tempDir, "after-empty"));
await expect(
plugin["tool.execute.after"](
{} as any,
{ title: "", output: "", metadata: {} } as any,
),
).resolves.toBeUndefined();
});
});
// ── experimental.session.compacting ───────────────────
describe("experimental.session.compacting", () => {
it("returns empty string when no events captured", async () => {
const plugin = await createTestPlugin(join(tempDir, "compact-empty"));
const output = { context: [] as string[], prompt: undefined };
const snapshot = await plugin["experimental.session.compacting"](
{ sessionID: "test-session" },
output,
);
expect(snapshot).toBe("");
});
it("returns snapshot XML after events are captured", async () => {
const plugin = await createTestPlugin(join(tempDir, "compact-snap"));
// Capture several events first
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "test-session", callID: "call-1", args: { file_path: "/src/index.ts" } },
{ title: "Read", output: "export default {}", metadata: {} },
);
await plugin["tool.execute.after"](
{ tool: "Edit", sessionID: "test-session", callID: "call-2", args: { file_path: "/src/index.ts", old_string: "{}", new_string: "{ foo: 1 }" } },
{ title: "Edit", output: "", metadata: {} },
);
await plugin["tool.execute.after"](
{ tool: "Bash", sessionID: "test-session", callID: "call-3", args: { command: "git status" } },
{ title: "Bash", output: "On branch main", metadata: {} },
);
const output = { context: [] as string[], prompt: undefined };
const snapshot = await plugin["experimental.session.compacting"](
{ sessionID: "test-session" },
output,
);
expect(snapshot.length).toBeGreaterThan(0);
expect(snapshot).toContain("session_resume");
expect(snapshot).toContain("<files");
expect(snapshot).toContain("index.ts");
});
it("can be called multiple times (increments compact count)", async () => {
const plugin = await createTestPlugin(join(tempDir, "compact-multi"));
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "test-session", callID: "call-1", args: { file_path: "/test/a.ts" } },
{ title: "Read", output: "code", metadata: {} },
);
const output1 = { context: [] as string[], prompt: undefined };
const snap1 = await plugin["experimental.session.compacting"](
{ sessionID: "test-session" },
output1,
);
expect(snap1.length).toBeGreaterThan(0);
// Capture more events
await plugin["tool.execute.after"](
{ tool: "Write", sessionID: "test-session", callID: "call-2", args: { file_path: "/test/b.ts", content: "new file" } },
{ title: "Write", output: "", metadata: {} },
);
const output2 = { context: [] as string[], prompt: undefined };
const snap2 = await plugin["experimental.session.compacting"](
{ sessionID: "test-session" },
output2,
);
expect(snap2.length).toBeGreaterThan(0);
});
});
// ── experimental.chat.system.transform ────────────────
// SessionStart-equivalent (PR #376 / Mickey 3-issue fix). Verifies:
// • Snapshot is prepended to output.system (NOT output.messages)
// • Per-session at-most-once gate (multi-session reuse — Mickey #2)
// • Cross-session lookup via DB.claimLatestUnconsumedResume
// • Race-safe atomic claim — two parallel transforms get distinct rows
describe("experimental.chat.system.transform", () => {
it("is a no-op when sessionID is missing", async () => {
const plugin = await createTestPlugin(join(tempDir, "sysxform-no-sid"));
const out = { system: ["existing"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: undefined, model: {} } as any,
out,
);
expect(out.system).toEqual(["existing"]);
});
it("injects routing block but no resume snapshot when no prior row exists", async () => {
// v1.0.107 — routing-block injection (OC-1) is INDEPENDENT of resume
// snapshot. With no prior row, only the routing block lands.
const plugin = await createTestPlugin(join(tempDir, "sysxform-no-resume"));
const out = { system: ["HEADER"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "fresh-session", model: {} } as any,
out,
);
expect(out.system[0]).toBe("HEADER"); // header preserved
expect(out.system.length).toBe(2); // header + routing block (no resume)
expect(out.system[1]).toContain("<context_window_protection>");
expect(out.system.join("\n")).not.toContain("session_resume");
});
it("prepends a previously-recorded snapshot to output.system on first call", async () => {
const projectDir = join(tempDir, "sysxform-inject");
const plugin = await createTestPlugin(projectDir);
// Build a snapshot in a *prior* session
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "prior-session", callID: "c1", args: { file_path: "/a.ts" } },
{ title: "Read", output: "content", metadata: {} },
);
const compactOut = { context: [] as string[], prompt: undefined };
await plugin["experimental.session.compacting"](
{ sessionID: "prior-session" } as any,
compactOut,
);
// New session enters via system.transform — must inherit the snapshot
// PLUS the OC-1 routing block (no header in this fixture, so both go
// in via splice(1, 0, ...) — array becomes [routing, snapshot]).
const out = { system: ["HEADER"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "new-session", model: {} } as any,
out,
);
expect(out.system[0]).toBe("HEADER");
expect(out.system.length).toBe(3); // HEADER + routing + snapshot
expect(out.system.some((s) => s.includes("session_resume"))).toBe(true);
expect(out.system.some((s) => s.includes("<context_window_protection>"))).toBe(true);
});
it("preserves system[0] header so OpenCode's prompt-cache fold survives", async () => {
// OpenCode (packages/opencode/src/session/llm.ts:117-128) preserves a
// 2-part `[header, body]` system structure for provider prompt caching.
// It saves `header = system[0]` BEFORE invoking this hook, then folds
// the rest into `[header, body]` only when `system[0] === header` after
// the hook returns. If we `unshift(snapshot)` we replace system[0] →
// cache-fold is skipped → each system block ships as a separate
// `role: "system"` message → provider prompt cache invalidates on every
// resume injection (token cost regression). We insert at index 1 instead.
// v1.0.107 — both routing block and resume snapshot now live between
// HEADER and BODY (4 elements total).
const projectDir = join(tempDir, "sysxform-cache-fold");
const plugin = await createTestPlugin(projectDir);
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "seed", callID: "c1", args: { file_path: "/y.ts" } },
{ title: "Read", output: "y", metadata: {} },
);
await plugin["experimental.session.compacting"](
{ sessionID: "seed" } as any,
{ context: [] as string[], prompt: undefined },
);
const HEADER = "you are claude";
const BODY = "user system prompt here";
const out = { system: [HEADER, BODY] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "turn-cache", model: {} } as any,
out,
);
// The snapshot was inserted, but header at index 0 is preserved
// exactly as OpenCode saw it before the hook.
expect(out.system[0]).toBe(HEADER);
expect(out.system[out.system.length - 1]).toBe(BODY);
expect(out.system.length).toBe(4); // HEADER + routing + snapshot + BODY
const middle = out.system.slice(1, -1).join("\n");
expect(middle).toContain("session_resume");
expect(middle).toContain("<context_window_protection>");
});
it("does NOT re-inject resume snapshot on second call with the same sessionID (multi-turn)", async () => {
const projectDir = join(tempDir, "sysxform-once-per-session");
const plugin = await createTestPlugin(projectDir);
// Seed snapshot
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "seed", callID: "c1", args: { file_path: "/x.ts" } },
{ title: "Read", output: "x", metadata: {} },
);
await plugin["experimental.session.compacting"](
{ sessionID: "seed" } as any,
{ context: [] as string[], prompt: undefined },
);
const out1 = { system: ["HEADER"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "turn-X", model: {} } as any,
out1,
);
// First turn: HEADER + routing + snapshot
expect(out1.system.length).toBe(3);
const out2 = { system: ["HEADER"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "turn-X", model: {} } as any,
out2,
);
// Same session — resume snapshot consumed from DB. Routing block re-injects (no dedup).
expect(out2.system.length).toBe(2); // HEADER + routing block
expect(out2.system[1]).toContain("<context_window_protection>");
expect(out2.system.join("\n")).not.toContain("session_resume");
});
// v1.0.106 — Mickey #376 follow-up: self-injection guard
it("does NOT inject snapshot back into the session that produced it (self-injection guard)", async () => {
const projectDir = join(tempDir, "sysxform-self-inject");
const plugin = await createTestPlugin(projectDir);
// Session B does work and compacts — produces ITS OWN snapshot row.
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "B", callID: "c1", args: { file_path: "/p.ts" } },
{ title: "Read", output: "p", metadata: {} },
);
await plugin["experimental.session.compacting"](
{ sessionID: "B" } as any,
{ context: [] as string[], prompt: undefined },
);
// B's NEXT chat turn fires system.transform — must NOT splice B's
// own snapshot back into B's prompt (wasteful + would consume the
// row meant for the next fresh session). v1.0.107 — routing block
// STILL injects (it's session-agnostic, OC-1 contract).
const out = { system: ["HEADER", "BODY"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "B", model: {} } as any,
out,
);
// No resume snapshot for B (self-inject guard) but routing block lands.
expect(out.system.length).toBe(3);
expect(out.system[0]).toBe("HEADER");
expect(out.system[2]).toBe("BODY");
expect(out.system.join("\n")).not.toContain("session_resume");
expect(out.system[1]).toContain("<context_window_protection>");
});
// v1.0.106 — when no row exists, do NOT mark sessionId as injected,
// so a later call within the same session can still pick up a snapshot
// that arrived after the first attempt.
it("retries on next turn when no row exists (no premature gate)", async () => {
const projectDir = join(tempDir, "sysxform-retry");
const plugin = await createTestPlugin(projectDir);
// First call — no snapshot in DB yet. Routing block still fires.
const out1 = { system: ["HEADER"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "C", model: {} } as any,
out1,
);
expect(out1.system.length).toBe(2); // HEADER + routing block
expect(out1.system.join("\n")).not.toContain("session_resume");
// Now a different session compacts and produces a snapshot
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "donor", callID: "c1", args: { file_path: "/q.ts" } },
{ title: "Read", output: "q", metadata: {} },
);
await plugin["experimental.session.compacting"](
{ sessionID: "donor" } as any,
{ context: [] as string[], prompt: undefined },
);
// C's next turn — routing block re-injects (every turn), plus resume
// snapshot from donor (no premature gate — DB claim still available).
const out2 = { system: ["HEADER"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "C", model: {} } as any,
out2,
);
expect(out2.system.length).toBe(3); // HEADER + snapshot + routing
expect(out2.system[1]).toContain("session_resume");
expect(out2.system[2]).toContain("<context_window_protection>");
});
// v1.0.106 — prefer next session over self-injection
it("snapshot from B is consumed by C, not by B itself", async () => {
const projectDir = join(tempDir, "sysxform-b-to-c");
const plugin = await createTestPlugin(projectDir);
// Session B compacts → produces row
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "B", callID: "c1", args: { file_path: "/r.ts" } },
{ title: "Read", output: "r", metadata: {} },
);
await plugin["experimental.session.compacting"](
{ sessionID: "B" } as any,
{ context: [] as string[], prompt: undefined },
);
// B asks for inject — gets routing block but no snapshot (own row excluded)
const outB = { system: ["HEADER"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "B", model: {} } as any,
outB,
);
expect(outB.system.length).toBe(2);
expect(outB.system.join("\n")).not.toContain("session_resume");
expect(outB.system[1]).toContain("<context_window_protection>");
// C asks — gets B's snapshot AND routing block (both first-fire for C)
const outC = { system: ["HEADER"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "C", model: {} } as any,
outC,
);
expect(outC.system.length).toBe(3); // HEADER + routing
expect(outC.system.some((s) => s.includes("session_resume"))).toBe(true);
});
it("emits a snapshot", async () => {
const projectDir = join(tempDir, "sysxform-marker");
const plugin = await createTestPlugin(projectDir);
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "donor", callID: "c1", args: { file_path: "/m.ts" } },
{ title: "Read", output: "m", metadata: {} },
);
await plugin["experimental.session.compacting"](
{ sessionID: "donor" } as any,
{ context: [] as string[], prompt: undefined },
);
const out = { system: ["HEADER"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "consumer", model: {} } as any,
out,
);
// v1.0.107 — out.system is [HEADER, routing-block]
expect(out.system.length).toBe(3);
const snapshotEntry = out.system.find((s) => s.includes("session_resume"));
expect(snapshotEntry).toBeDefined();
});
});
// ── Integration: before + after + compact ─────────────
describe("end-to-end flow", () => {
it("captures events from allowed tools and generates snapshot", async () => {
const plugin = await createTestPlugin(join(tempDir, "e2e-flow"));
// Normal tool call passes through before hook
await plugin["tool.execute.before"](
{ tool: "Read", sessionID: "test-session", callID: "call-1" },
{ args: { file_path: "/app/main.ts" } },
);
// After hook captures the event
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "test-session", callID: "call-1", args: { file_path: "/app/main.ts" } },
{ title: "Read", output: "console.log('hello')", metadata: {} },
);
// Compacting generates snapshot
const output = { context: [] as string[], prompt: undefined };
const snapshot = await plugin["experimental.session.compacting"](
{ sessionID: "test-session" },
output,
);
expect(snapshot).toContain("session_resume");
expect(snapshot).toContain("<files");
expect(snapshot).toContain("main.ts");
});
// ── OC-1: ROUTING_BLOCK injection in chat.system.transform ────
// Mickey ana şikayet (CCv1). v1.0.107 — adapter must inject the
// <context_window_protection> XML routing block on the first
// chat.system.transform call per session, INDEPENDENT of any
// resume snapshot row (which may or may not exist yet).
it("OC-1: injects <context_window_protection> routing block on first turn per session", async () => {
const plugin = await createTestPlugin(join(tempDir, "oc1-routing"));
const out = { system: ["HEADER", "BODY"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "oc1-fresh", model: {} } as any,
out,
);
// header preserved at index 0 (cache-fold invariant)
expect(out.system[0]).toBe("HEADER");
// routing block spliced at index 1
const joined = out.system.join("\n");
expect(joined).toContain("<context_window_protection>");
expect(joined).toContain("<priority_instructions>");
// platform-specific tool name proves createToolNamer wired correctly
expect(joined).toContain("context-mode_ctx_search");
});
it("OC-1: re-injects routing block on every turn (per-turn reliability)", async () => {
const plugin = await createTestPlugin(join(tempDir, "oc1-every-turn"));
const out1 = { system: ["HEADER"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "oc1-twice", model: {} } as any,
out1,
);
expect(out1.system.join("\n")).toContain("<context_window_protection>");
const out2 = { system: ["HEADER"] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "oc1-twice", model: {} } as any,
out2,
);
// Routing block injects every turn for reliability (no dedup set).
expect(out2.system.join("\n")).toContain("<context_window_protection>");
expect(out2.system.length).toBe(2);
});
it("OC-1: skips routing block when system prompt already contains context-mode instructions", async () => {
const plugin = await createTestPlugin(join(tempDir, "oc1-dedup"));
// Simulate AGENTS.md already loaded by the host — contains routing markers.
// Post-#487: markers are <context_window_protection>, ctx_search, ctx_index
// (non-overlapping). Quorum requires 2-of-3 distinct.
const agentsContent = [
"# context-mode rules",
"<context_window_protection> applies to this project.",
"Use ctx_search for memory recall.",
"Use ctx_index to store new content.",
].join("\n");
const out = { system: ["HEADER", agentsContent] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "oc1-dedup-sess", model: {} } as any,
out,
);
// system unchanged — no routing block injected (2+ markers detected).
// Length stays 2 (HEADER + the existing AGENTS.md entry); the fixture
// itself contains <context_window_protection>, so we assert by structure
// (no new entry spliced in) rather than substring absence.
expect(out.system.length).toBe(2);
expect(out.system[0]).toBe("HEADER");
expect(out.system[1]).toBe(agentsContent);
});
it("OC-1: injects routing block when only one marker present (below quorum)", async () => {
const plugin = await createTestPlugin(join(tempDir, "oc1-below-quorum"));
// Only one marker — below the 2-of-3 quorum — routing block still injects.
// Post-#487: the active markers are <context_window_protection>, ctx_search,
// ctx_index. Use exactly one to assert below-quorum behavior.
const partialContent = "Some text mentioning ctx_search but nothing else";
const out = { system: ["HEADER", partialContent] };
await plugin["experimental.chat.system.transform"](
{ sessionID: "oc1-quorum-sess", model: {} } as any,
out,
);
expect(out.system.length).toBe(3); // HEADER + routing + partialContent
expect(out.system[1]).toContain("<context_window_protection>");
});
});
// ── OC-1 quorum substring overlap (#487) ────────────────
// RED proof: the marker set ["ctx_execute", "ctx_batch_execute", ...] uses
// overlapping substrings. `text.includes("ctx_execute")` matches ALSO on any
// `ctx_batch_execute` occurrence, so a SINGLE user paste mentioning
// ctx_batch_execute satisfies the 2-of-3 quorum and suppresses the routing
// block for the entire session. The fix replaces the markers with
// non-overlapping tokens (or word-boundary regex) while preserving the
// ≥2 distinct markers semantic.
describe("OC-1 quorum: single marker substring overlap", () => {
it("returns false for text mentioning ctx_batch_execute exactly once", async () => {
const { systemHasRoutingInstructions } = await import("../src/adapters/opencode/plugin.js");
const text = "what does ctx_batch_execute do?";
// 1 distinct marker → below quorum → false
expect(systemHasRoutingInstructions([text])).toBe(false);
});
it("returns true when two distinct (non-overlapping) markers are present", async () => {
const { systemHasRoutingInstructions } = await import("../src/adapters/opencode/plugin.js");
const text = "ctx_search and ctx_index help";
expect(systemHasRoutingInstructions([text])).toBe(true);
});
it("returns true when the routing-block XML tag is present alongside one tool", async () => {
const { systemHasRoutingInstructions } = await import("../src/adapters/opencode/plugin.js");
const text = "<context_window_protection> applies. use ctx_search.";
expect(systemHasRoutingInstructions([text])).toBe(true);
});
});
// ── OC-2: chat.message hook (Z2) ──────────────────────────
// Wires `chat.message` to capture user prompts. CCv2 inline filter
// skips synthetic system messages (<task-notification>, <system-reminder>,
// <context_guidance>, <tool-result>) so we don't flood the DB with noise.
describe("chat.message", () => {
it("OC-2: captures user prompt as user_prompt event", async () => {
const projectDir = join(tempDir, "oc2-capture");
mkdirSync(projectDir, { recursive: true });
const plugin = await createTestPlugin(projectDir);
const msg = "switch to mission mode and prefer the elegant solution";
await plugin["chat.message"](
{ sessionID: "oc2-sess", agent: "build", messageID: "m1" } as any,
{ message: { role: "user" } as any, parts: [{ type: "text", text: msg }] } as any,
);
// Verify SessionDB has the event
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
const adapter = new OpenCodeAdapter("opencode");
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
const events = db.getEvents("oc2-sess") as any[];
db.close();
const userPromptEvent = events.find((e: any) => e.type === "user_prompt");
expect(userPromptEvent).toBeDefined();
expect(userPromptEvent.data).toContain("mission mode");
});
it("OC-2: filters synthetic system tags (CCv2 inline filter)", async () => {
const projectDir = join(tempDir, "oc2-filter");
mkdirSync(projectDir, { recursive: true });
const plugin = await createTestPlugin(projectDir);
const synthetic = "<system-reminder>internal nudge</system-reminder>";
await plugin["chat.message"](
{ sessionID: "oc2-skip", agent: "build", messageID: "m1" } as any,
{ message: { role: "user" } as any, parts: [{ type: "text", text: synthetic }] } as any,
);
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
const adapter = new OpenCodeAdapter("opencode");
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
const events = db.getEvents("oc2-skip") as any[];
db.close();
const userPromptEvent = events.find((e: any) => e.type === "user_prompt");
expect(userPromptEvent).toBeUndefined();
});
it("OC-2: handles missing/empty parts gracefully", async () => {
const plugin = await createTestPlugin(join(tempDir, "oc2-empty"));
await expect(
plugin["chat.message"](
{ sessionID: "oc2-empty-sess" } as any,
{ message: {} as any, parts: [] } as any,
),
).resolves.toBeUndefined();
});
});
// ── OC-3: buildAutoInjection in compacting (Z3) ───────────
// Replace raw buildResumeSnapshot push with budget-aware
// buildAutoInjection (~500 tokens / ~2000 chars hard cap).
describe("session.compacting buildAutoInjection (OC-3)", () => {
it("OC-3: prepends budget-capped auto-injection block (≤2000 chars) to output.context", async () => {
const plugin = await createTestPlugin(join(tempDir, "oc3-budget"));
// Seed enough events to make a fat snapshot
for (let i = 0; i < 12; i++) {
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "oc3-sess", callID: `c${i}`, args: { file_path: `/src/file${i}.ts` } },
{ title: "Read", output: `content ${i}`.repeat(50), metadata: {} },
);
}
// Inject a behavioral_directive via chat.message so auto-injection has P1 role
await plugin["chat.message"](
{ sessionID: "oc3-sess", agent: "build", messageID: "mr" } as any,
{ message: {} as any, parts: [{ type: "text", text: "act as a senior staff engineer reviewing diffs" }] } as any,
);
const output = { context: [] as string[], prompt: undefined };
await plugin["experimental.session.compacting"](
{ sessionID: "oc3-sess" } as any,
output,
);
// The compacting handler still pushes the raw resume snapshot (existing
// contract). It MUST also push a separate auto-injection block whose
// length ≤ 2000 chars (~500 token budget per auto-injection.mjs).
const autoBlock = output.context.find((c) => c.includes("<session_state source=\"compaction\">"));
expect(autoBlock).toBeDefined();
expect(autoBlock!.length).toBeLessThanOrEqual(2000);
});
});
// ── OC-4 follow-up (#487 regression): AGENTS.md → rule_content ──
// PR #487 removed captureAgentsMd trusting host to deliver AGENTS.md
// events. But snapshot.ts:172 + analytics.ts:152 consume `rule_content`
// events that the OpenCode host does NOT auto-emit. Net effect: AGENTS.md
// never lands in snapshot/auto-memory for OpenCode users — silent
// regression. Restore capture path with idempotent per-session-id guard.
describe("OC-4 AGENTS.md capture restored", () => {
it("fires rule + rule_content events when AGENTS.md exists in project dir on first hook", async () => {
const projectDir = join(tempDir, "oc4-agents-md");
mkdirSync(projectDir, { recursive: true });
const agentsContent = "# Project rules\n- always prefer typescript\n- use tdd\n";
writeFileSync(join(projectDir, "AGENTS.md"), agentsContent);
const plugin = await createTestPlugin(projectDir);
// Trigger any hook that drives capture — using tool.execute.after is canonical
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "oc4-sess-1", callID: "c1", args: { file_path: "/x.ts" } },
{ title: "Read", output: "x", metadata: {} },
);
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
const adapter = new OpenCodeAdapter("opencode");
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
const events = db.getEvents("oc4-sess-1") as any[];
db.close();
const ruleEvents = events.filter((e: any) => e.type === "rule");
const ruleContentEvents = events.filter((e: any) => e.type === "rule_content");
expect(ruleEvents.length).toBeGreaterThanOrEqual(1);
expect(ruleContentEvents.length).toBeGreaterThanOrEqual(1);
// rule event payload references AGENTS.md path; rule_content carries body
expect(ruleEvents.some((e: any) => String(e.data).endsWith("AGENTS.md"))).toBe(true);
expect(ruleContentEvents.some((e: any) => String(e.data).includes("always prefer typescript"))).toBe(true);
});
it("is idempotent — second hook invocation does NOT re-fire rule events", async () => {
const projectDir = join(tempDir, "oc4-agents-idempotent");
mkdirSync(projectDir, { recursive: true });
writeFileSync(join(projectDir, "AGENTS.md"), "# rules\n- one\n");
const plugin = await createTestPlugin(projectDir);
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "oc4-idem", callID: "c1", args: { file_path: "/a.ts" } },
{ title: "Read", output: "a", metadata: {} },
);
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "oc4-idem", callID: "c2", args: { file_path: "/b.ts" } },
{ title: "Read", output: "b", metadata: {} },
);
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
const adapter = new OpenCodeAdapter("opencode");
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
const events = db.getEvents("oc4-idem") as any[];
db.close();
const ruleEvents = events.filter((e: any) => e.type === "rule");
const ruleContentEvents = events.filter((e: any) => e.type === "rule_content");
// Exactly one rule + one rule_content from AGENTS.md (no fallback files exist)
expect(ruleEvents.length).toBe(1);
expect(ruleContentEvents.length).toBe(1);
});
it("does NOT fire events when AGENTS.md / CLAUDE.md / CONTEXT.md all absent", async () => {
const projectDir = join(tempDir, "oc4-agents-missing");
mkdirSync(projectDir, { recursive: true });
// intentionally do NOT write any markdown rule files
const plugin = await createTestPlugin(projectDir);
// Should not throw when files absent
await expect(
plugin["tool.execute.after"](
{ tool: "Read", sessionID: "oc4-missing", callID: "c1", args: { file_path: "/z.ts" } },
{ title: "Read", output: "z", metadata: {} },
),
).resolves.toBeUndefined();
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
const adapter = new OpenCodeAdapter("opencode");
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
const events = db.getEvents("oc4-missing") as any[];
db.close();
const ruleEvents = events.filter((e: any) => e.type === "rule" || e.type === "rule_content");
expect(ruleEvents.length).toBe(0);
});
it("falls back to CLAUDE.md when AGENTS.md absent", async () => {
const projectDir = join(tempDir, "oc4-claude-fallback");
mkdirSync(projectDir, { recursive: true });
writeFileSync(join(projectDir, "CLAUDE.md"), "# claude rules\n- be terse\n");
const plugin = await createTestPlugin(projectDir);
await plugin["tool.execute.after"](
{ tool: "Read", sessionID: "oc4-claude", callID: "c1", args: { file_path: "/c.ts" } },
{ title: "Read", output: "c", metadata: {} },
);
const { resolveSessionDbPath, SessionDB } = await import("../src/session/db.js");
const { OpenCodeAdapter } = await import("../src/adapters/opencode/index.js");
const adapter = new OpenCodeAdapter("opencode");
const db = new SessionDB({ dbPath: resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() }) });
const events = db.getEvents("oc4-claude") as any[];
db.close();
const ruleContentEvents = events.filter((e: any) => e.type === "rule_content");
expect(ruleContentEvents.length).toBeGreaterThanOrEqual(1);
expect(ruleContentEvents.some((e: any) => String(e.data).includes("be terse"))).toBe(true);
});
});
// ── Integration: blocked tool flow ────────────────────
describe("end-to-end flow (blocked)", () => {
it("blocked tool command is replaced before execution", async () => {
const plugin = await createTestPlugin(join(tempDir, "e2e-block"));
const beforeInput = { tool: "Bash", sessionID: "test-session", callID: "call-1" };
const beforeOutput = { args: { command: "curl https://evil.com" } };
// Before hook blocks/modifies the command
let blocked = false;
try {
await plugin["tool.execute.before"](beforeInput, beforeOutput);
// If modified (not thrown), the command was replaced
expect(beforeOutput.args.command).toContain("context-mode");
} catch (e: any) {
// deny action throws
blocked = true;
expect(e.message).toContain("context-mode");
}
if (!blocked) {
// After hook still runs (with the replaced command)
await plugin["tool.execute.after"](
{ tool: "Bash", sessionID: "test-session", callID: "call-1", args: beforeOutput.args },
{ title: "Bash", output: beforeOutput.args.command, metadata: {} },
);
}
// Snapshot should be empty (echo/blocked commands don't generate events)
const compactOutput = { context: [] as string[], prompt: undefined };
const snapshot = await plugin["experimental.session.compacting"](
{ sessionID: "test-session" },
compactOutput,
);
expect(snapshot).toBe("");
});
});
// ── #448: debug-logger rejection must NOT break the turn ──
// OPENCODE_DEBUG=1 + a transport-rejecting `ctx.client.app.log` previously
// caused unhandled rejection from chat.system.transform → potential turn
// break. All `await logger(...)` sites in the OPENCODE_DEBUG branch must
// be wrapped so the handler still resolves and the routing block is still
// spliced into output.system.
describe("OPENCODE_DEBUG=1 + rejecting logger does not break the turn", () => {
async function createPluginWithRejectingLog(tempDir: string) {
const { ContextModePlugin } = await import("../src/adapters/opencode/plugin.js");
return ContextModePlugin({
directory: tempDir,
client: {
app: {
log: async () => {
throw new Error("transport");
},
},
},
});
}
let prevDebug: string | undefined;
beforeEach(() => {
prevDebug = process.env.OPENCODE_DEBUG;
process.env.OPENCODE_DEBUG = "1";
});
afterEach(() => {
if (prevDebug === undefined) delete process.env.OPENCODE_DEBUG;
else process.env.OPENCODE_DEBUG = prevDebug;
});
it("#448: rejecting logger on routing-block injection resolves and still injects", async () => {
const plugin = await createPluginWithRejectingLog(join(tempDir, "issue-448-routing"));
const out = { system: ["HEADER", "BODY"] };
// Must resolve — no rejection propagated to OpenCode core
await expect(
plugin["experimental.chat.system.transform"](
{ sessionID: "issue-448-routing-sess", model: {} } as any,
out,
),
).resolves.not.toThrow();
// Routing block still spliced at index 1 (turn-break would skip this)
expect(out.system[0]).toBe("HEADER");
expect(out.system.join("\n")).toContain("<context_window_protection>");
});
it("#448: rejecting logger on compaction snapshot resolves and still pushes context", async () => {
const projectDir = join(tempDir, "issue-448-compact");
mkdirSync(projectDir, { recursive: true });
const plugin = await createPluginWithRejectingLog(projectDir);
// Seed an event so the snapshot path runs (events.length > 0)
const sessionID = "issue-448-compact-sess";
await plugin["chat.message"](
{ sessionID, agent: "build", messageID: "m1" } as any,
{ message: { role: "user" } as any, parts: [{ type: "text", text: "seed prompt for snapshot" }] } as any,
);
const compactOutput = { context: [] as string[], prompt: undefined };
await expect(
plugin["experimental.session.compacting"](
{ sessionID } as any,
compactOutput,
),
).resolves.not.toThrow();
// Snapshot still pushed despite rejecting logger
expect(compactOutput.context.length).toBeGreaterThan(0);
});
it("#448: rejecting logger on dedup-skip branch still resolves", async () => {
const plugin = await createPluginWithRejectingLog(join(tempDir, "issue-448-dedup"));
const agentsContent = [
"# context-mode rules",
"<context_window_protection>",
"Use ctx_search for queries",
"Use ctx_index for indexing",
].join("\n");
const out = { system: ["HEADER", agentsContent] };
await expect(
plugin["experimental.chat.system.transform"](
{ sessionID: "issue-448-dedup-sess", model: {} } as any,
out,
),
).resolves.not.toThrow();
// Dedup branch unchanged (skipped routing block)
expect(out.system.length).toBe(2);
expect(out.system[1]).toBe(agentsContent);
});
});
});