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",
      ]);
    });

    it("converts tool args to Zod v4 when platform is kilo (#_zod.def fix)", async () => {
      const prevKiloPid = process.env.KILO_PID;
      process.env.KILO_PID = String(process.pid);
      try {
        const plugin = await createTestPlugin(join(tempDir, "kilo-zod-v4-args"));
        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.QUIET_CONTEXT_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);
    });
  });
});
