import "./setup-home";
/**
 * Pi Extension Tests — TDD vertical slices.
 *
 * The Pi extension (src/adapters/pi/extension.ts) is a default-exported function that
 * receives a Pi API object and registers event handlers. Since we cannot test
 * against a real Pi runtime, we mock the Pi API to capture registered handlers
 * and invoke them with simulated events.
 *
 * Test slices:
 *   1. Tool name mapping (Pi names → context-mode canonical names)
 *   2. Event extraction from tool_result
 *   3. PreToolUse routing enforcement (tool_call)
 *   4. Session lifecycle
 *   5. Resume injection (before_agent_start)
 *   6. Stats command (/ctx-stats)
 */

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createHash } from "node:crypto";
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { SessionDB } from "../src/session/db.js";

// ── Mock Pi API ──────────────────────────────────────────────

type HandlerFn = (...args: any[]) => any;

interface MockCommandOpts {
  description?: string;
  handler?: HandlerFn;
  [key: string]: unknown;
}

function createMockPiApi() {
  const handlers: Record<string, HandlerFn[]> = {};
  const commands: Record<string, MockCommandOpts> = {};

  return {
    on: (event: string, handler: HandlerFn) => {
      if (!handlers[event]) handlers[event] = [];
      handlers[event].push(handler);
    },
    registerCommand: (name: string, opts: MockCommandOpts) => {
      commands[name] = opts;
    },
    registerTool: vi.fn(),
    sendMessage: vi.fn(),
    exec: vi.fn(),

    // ── Test helpers ──
    _trigger: async (event: string, ...args: any[]) => {
      for (const h of handlers[event] ?? []) {
        const result = await h(...args);
        if (result) return result;
      }
    },
    _getCommand: (name: string) => commands[name],
    _handlers: handlers,
    _commands: commands,
  };
}

// ── Shared state ────────────────────────────────────────────

let tempDir: string;
let api: ReturnType<typeof createMockPiApi>;

// ── Dynamic import helper ───────────────────────────────────

async function registerPiExtension(
  mockApi: ReturnType<typeof createMockPiApi>,
  opts?: { projectDir?: string },
) {
  // Set environment variable so the extension uses our temp directory
  const projectDir = opts?.projectDir ?? tempDir;
  process.env.PI_PROJECT_DIR = projectDir;
  process.env.CLAUDE_PROJECT_DIR = projectDir;

  const mod = await import("../src/adapters/pi/extension.js");
  const register = mod.default;
  await register(mockApi);

  return mockApi;
}

// ── Tests ───────────────────────────────────────────────────

describe("Pi Extension", () => {
  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
    mkdirSync(tempDir, { recursive: true });
    api = createMockPiApi();
  });

  afterEach(() => {
    try {
      rmSync(tempDir, { recursive: true, force: true });
    } catch {
      /* cleanup best effort */
    }
    delete process.env.PI_PROJECT_DIR;
    delete process.env.CLAUDE_PROJECT_DIR;
  });

  // ═══════════════════════════════════════════════════════════
  // Slice 1: Tool name mapping
  // ═══════════════════════════════════════════════════════════

  describe("Slice 1: Tool name mapping", () => {
    it("maps Pi 'bash' to context-mode 'Bash'", async () => {
      await registerPiExtension(api);

      // Trigger a tool_result event with Pi's "bash" tool name
      // and verify it gets mapped correctly for event extraction
      await api._trigger("tool_result", {
        tool_name: "bash",
        tool_input: { command: "git status" },
        tool_result: "On branch main\nnothing to commit",
      });

      // The handler should not throw — successful mapping means
      // extractEvents recognized "Bash" and produced git events
    });

    it("maps Pi 'read' to context-mode 'Read'", async () => {
      await registerPiExtension(api);

      await api._trigger("tool_result", {
        tool_name: "read",
        tool_input: { file_path: "/src/app.ts" },
        tool_result: "export default {}",
      });

      // Should not throw — "Read" mapping enables file_read event extraction
    });

    it("passes unknown tool names through unchanged", async () => {
      await registerPiExtension(api);

      // Unknown tools should pass through without error
      await api._trigger("tool_result", {
        tool_name: "SomeCustomTool",
        tool_input: { data: "test" },
        tool_result: "ok",
      });
    });

    it("maps 'context_mode_' prefix to 'mcp__context_mode__' so MCP tool calls get extracted", async () => {
      await registerPiExtension(api);
      await api._trigger("session_start", {
        session_id: "test-fix1",
        project_dir: tempDir,
      });

      // Pi prefixes MCP-registered tools with "context_mode_". Without
      // normalisation the extract functions (P, F) silently drop all
      // events because they gate on e.startsWith("mcp__").
      await api._trigger("tool_result", {
        tool_name: "context_mode_ctx_execute",
        params: { language: "javascript", code: "console.log(1)" },
        result: "1",
      });

      // Verify via ctx-stats that events include "mcp" category
      // (meaning they were extracted, not just the generic fallback).
      const stats = (await api._getCommand("ctx-stats")!.handler!({})) as {
        text: string;
      };
      expect(stats.text).toContain("mcp");
    });
  });

  // ═══════════════════════════════════════════════════════════
  // Slice 2: Event extraction from tool_result
  // ═══════════════════════════════════════════════════════════

  describe("Slice 2: Event extraction from tool_result", () => {
    it("extracts file and git events from bash command", async () => {
      await registerPiExtension(api);

      // Bash with git command should produce git events
      await api._trigger("tool_result", {
        tool_name: "bash",
        tool_input: { command: "git commit -m 'initial'" },
        tool_result: "[main abc1234] initial\n 1 file changed",
      });

      // No throw = events extracted successfully
    });

    it("extracts file_read event from read tool", async () => {
      await registerPiExtension(api);

      await api._trigger("tool_result", {
        tool_name: "read",
        tool_input: { file_path: "/src/index.ts" },
        tool_result: "export const hello = 'world';",
      });
    });

    it("extracts file_read event when Pi passes 'path' instead of 'file_path'", async () => {
      await registerPiExtension(api);
      await api._trigger("session_start", {
        session_id: "test-fix2",
        project_dir: tempDir,
      });

      // Pi's native Read tool sends { path: "..." } rather than
      // { file_path: "..." } (Claude Code convention). Without
      // normalisation the extractor reads n.file_path → undefined,
      // producing file_read events with an empty path.
      await api._trigger("tool_result", {
        tool_name: "read",
        params: { path: "/src/index.ts" },
        result: "export const hello = 'world';",
      });

      // Verify the file_read event was captured by checking
      // ctx-stats shows "file" category events.
      const stats = (await api._getCommand("ctx-stats")!.handler!({})) as {
        text: string;
      };
      expect(stats.text).toContain("file");
    });

    it("extracts cwd event from cd command", async () => {
      await registerPiExtension(api);

      await api._trigger("tool_result", {
        tool_name: "bash",
        tool_input: { command: "cd /tmp/workspace && ls" },
        tool_result: "file1.ts\nfile2.ts",
      });
    });

    it("extracts error event from failed tool result", async () => {
      await registerPiExtension(api);

      await api._trigger("tool_result", {
        tool_name: "bash",
        tool_input: { command: "npm test" },
        tool_result: "Error: test failed with exit code 1",
        is_error: true,
      });
    });

    it("handles missing tool_result gracefully", async () => {
      await registerPiExtension(api);

      await api._trigger("tool_result", {
        tool_name: "bash",
        tool_input: { command: "echo hello" },
      });
    });

    it("handles empty event gracefully", async () => {
      await registerPiExtension(api);

      await api._trigger("tool_result", {});
    });
  });

  // ═══════════════════════════════════════════════════════════
  // Slice 3: PreToolUse routing enforcement (tool_call)
  // ═══════════════════════════════════════════════════════════

  describe("Slice 3: PreToolUse routing enforcement", () => {
    it("blocks bash with curl", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: { command: "curl https://example.com" },
      });

      expect(result).toBeDefined();
      expect(result.block).toBe(true);
    });

    it("blocks bash with wget", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: { command: "wget https://example.com -O out.html" },
      });

      expect(result).toBeDefined();
      expect(result.block).toBe(true);
    });

    it("allows bash with git status", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: { command: "git status" },
      });

      // git status should NOT be blocked — result is undefined (passthrough)
      // or an allow/context action (not deny/blocked)
      if (result) {
        expect(result.blocked).not.toBe(true);
      }
    });

    it("allows read tool (no blocking)", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "read",
        input: { file_path: "/src/app.ts" },
      });

      // Read should never be blocked — at most it gets routing guidance
      if (result) {
        expect(result.blocked).not.toBe(true);
      }
    });

    it("handles missing tool_name gracefully", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {});
      // Should not throw, and should passthrough
      if (result) {
        expect(result.blocked).not.toBe(true);
      }
    });

    // ── Issue #625 — escape hatches + quoted-string false positives ──
    //
    // The original BLOCKED_BASH_PATTERNS blocked every curl/wget unconditionally
    // and matched inside quoted CLI arguments. Two consequences:
    //
    //   1. False positive: `gh issue list --search "...curl..."` was blocked
    //      because the literal word `curl` appeared inside the quoted search
    //      argument. The reporter literally could not file the issue via `gh`
    //      until they rephrased the query.
    //
    //   2. Unrecoverable trap: when the MCP bridge dies, the agent's only
    //      escape hatch is a disk-buffered HTTP download (curl -s -o file).
    //      With every curl/wget invocation blocked, there is no way to fetch
    //      a URL until the user restarts Pi entirely.
    //
    // Fix mirrors hooks/core/routing.mjs:660–722:
    //   - Strip quoted content before regex matching
    //   - Split on chain operators (&&, ||, ;)
    //   - Allow segments that are: silent (-s/-q) + file output (-o/-O or >)
    //     + no verbose (-v) + no stdout alias (-o -, -o /dev/stdout)
    //
    // This preserves the original "do not flood context" intent while letting
    // the agent gracefully self-recover when MCP is unreachable.

    it("does NOT block gh command with 'curl' inside quoted --search argument (Issue #625 bonus)", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: {
          command: 'gh issue list --search "BLOCKED_BASH_PATTERNS curl wget block"',
        },
      });

      // The literal word "curl" appears only inside a quoted argument; the
      // command itself is `gh`, which has nothing to do with HTTP fetching.
      // Stripping quoted content before matching must allow this through.
      if (result) {
        expect(result.block).not.toBe(true);
      }
    });

    it("does NOT block echo with 'wget' inside a quoted log message (Issue #625 bonus)", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: {
          command: 'echo "users tried to wget the bundle and it failed"',
        },
      });

      if (result) {
        expect(result.block).not.toBe(true);
      }
    });

    it("allows curl with silent + file output for MCP-down recovery (Issue #625)", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: { command: "curl -s -o /tmp/data.json https://example.com/api" },
      });

      // -s (silent) + -o file means the body never enters context. This must
      // remain available as an escape hatch when ctx_fetch_and_index is
      // unreachable (e.g. MCP bridge dead between requests).
      if (result) {
        expect(result.block).not.toBe(true);
      }
    });

    it("allows wget with quiet + output-document for MCP-down recovery (Issue #625)", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: { command: "wget -q -O /tmp/data.json https://example.com/api" },
      });

      if (result) {
        expect(result.block).not.toBe(true);
      }
    });

    it("still blocks curl that would flood context (no file output)", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: { command: "curl https://example.com/big-response" },
      });

      // No -o flag → body goes straight to stdout → straight into context.
      // Must remain blocked.
      expect(result).toBeDefined();
      expect(result.block).toBe(true);
    });

    it("still blocks curl -o - (explicit stdout alias)", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: { command: "curl -s -o - https://example.com/api" },
      });

      // -o - means stdout, which feeds context — must remain blocked even
      // when silent flag is present.
      expect(result).toBeDefined();
      expect(result.block).toBe(true);
    });

    it("still blocks verbose curl (floods stderr → context)", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: { command: "curl -v -s -o /tmp/x.json https://example.com" },
      });

      // -v dumps request/response headers to stderr — flooding context.
      expect(result).toBeDefined();
      expect(result.block).toBe(true);
    });

    it("blocks chained command if ANY segment is unsafe curl", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: {
          command:
            "curl -s -o /tmp/a.json https://example.com/a && curl https://example.com/b",
        },
      });

      // First segment safe, second unsafe → must block the whole chain.
      expect(result).toBeDefined();
      expect(result.block).toBe(true);
    });

    it("allows chained command where every curl segment is safe", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("tool_call", {
        toolName: "bash",
        input: {
          command:
            "curl -s -o /tmp/a.json https://example.com/a && curl -s -o /tmp/b.json https://example.com/b",
        },
      });

      // Both segments: silent + file output + no stdout alias + no verbose.
      // The chain must pass.
      if (result) {
        expect(result.block).not.toBe(true);
      }
    });
  });

  // ═══════════════════════════════════════════════════════════
  // Slice 4: Session lifecycle
  // ═══════════════════════════════════════════════════════════

  describe("Slice 4: Session lifecycle", () => {
    it("session_start initializes session in DB", async () => {
      await registerPiExtension(api);

      // session_start should register without error
      await api._trigger("session_start", {
        session_id: "test-session-abc123",
        project_dir: tempDir,
      });
    });

    it("session_start uses Pi context arg for stable session ID", async () => {
      await registerPiExtension(api);
      const sessionFile = join(tempDir, "stable-session.jsonl");
      const expectedSessionId = createHash("sha256")
        .update(sessionFile)
        .digest("hex")
        .slice(0, 8);

      await api._trigger(
        "session_start",
        { type: "session_start", reason: "startup" },
        { sessionManager: { getSessionFile: () => sessionFile } },
      );

      const result = await api._getCommand("ctx-stats")!.handler!({});
      expect((result as { text: string }).text).toContain(
        `Session: \`${expectedSessionId}`,
      );
    });

    it("session_before_compact builds resume snapshot", async () => {
      await registerPiExtension(api);

      // First capture some events
      await api._trigger("tool_result", {
        tool_name: "read",
        tool_input: { file_path: "/src/index.ts" },
        tool_result: "export default {}",
      });

      // Then trigger compaction
      const result = await api._trigger("session_before_compact", {});

      // Should return a snapshot string or undefined if no events
      if (result !== undefined) {
        expect(typeof result).toBe("string");
        if (typeof result === "string" && result.length > 0) {
          expect(result).toContain("session_resume");
        }
      }
    });

    it("session_compact increments compact counter", async () => {
      await registerPiExtension(api);

      // Capture events first
      await api._trigger("tool_result", {
        tool_name: "read",
        tool_input: { file_path: "/src/app.ts" },
        tool_result: "code here",
      });

      // Build snapshot
      await api._trigger("session_before_compact", {});

      // Increment counter
      await api._trigger("session_compact", {});

      // No throw = success
    });

    it("session_shutdown cleans up", async () => {
      await registerPiExtension(api);

      await api._trigger("session_start", {
        session_id: "cleanup-session-xyz",
        project_dir: tempDir,
      });

      // Shutdown should clean up without error
      await api._trigger("session_shutdown", {});
    });

    it("session_start receives (event, ctx) — ctx.sessionManager is used for session ID", async () => {
      await registerPiExtension(api);

      // The handler signature must be (event, ctx), not (ctx).
      // Pass a real-looking ctx as the second argument with sessionManager;
      // if the signature were wrong, sessionManager would be on the event
      // object and deriveSessionId would fall back to pi-<timestamp>.
      const sessionFile = `/fake/session-${Date.now()}.jsonl`;
      await api._trigger("session_start",
        { reason: "startup" },                               // event (1st arg)
        { sessionManager: { getSessionFile: () => sessionFile } }, // ctx (2nd arg)
      );

      // Verify the session was initialised with the file-derived ID by checking
      // that before_agent_start doesn't blow up (it needs a valid _sessionId).
      const result = await api._trigger("before_agent_start", {
        systemPrompt: "Base.",
      });
      expect(result?.systemPrompt).toBeDefined();
    });

    it("handles session lifecycle in correct order", async () => {
      await registerPiExtension(api);

      // Full lifecycle: start → events → compact → more events → shutdown
      await api._trigger("session_start", {
        session_id: "lifecycle-test",
        project_dir: tempDir,
      });

      await api._trigger("tool_result", {
        tool_name: "bash",
        tool_input: { command: "git status" },
        tool_result: "On branch main",
      });

      await api._trigger("session_before_compact", {});
      await api._trigger("session_compact", {});

      await api._trigger("tool_result", {
        tool_name: "read",
        tool_input: { file_path: "/src/file.ts" },
        tool_result: "content",
      });

      await api._trigger("session_shutdown", {});
    });
  });

  // ═══════════════════════════════════════════════════════════
  // Slice 5: Resume injection (before_agent_start)
  // ═══════════════════════════════════════════════════════════

  describe("Slice 5: Resume injection", () => {
    it("returns modified systemPrompt when unconsumed resume exists", async () => {
      await registerPiExtension(api);

      // Build up session state: capture events → compact → build resume
      await api._trigger("session_start", {
        session_id: "resume-test-1",
        project_dir: tempDir,
      });

      await api._trigger("tool_result", {
        tool_name: "read",
        tool_input: { file_path: "/src/main.ts" },
        tool_result: "import express from 'express';",
      });

      await api._trigger("tool_result", {
        tool_name: "bash",
        tool_input: { command: "git commit -m 'feat: add express'" },
        tool_result: "[main abc1234] feat: add express",
      });

      // Trigger compaction to build resume snapshot
      await api._trigger("session_before_compact", {});
      await api._trigger("session_compact", {});

      // Now before_agent_start should inject the resume
      const result = await api._trigger("before_agent_start", {
        systemPrompt: "You are a helpful assistant.",
      });

      // If resume injection is supported, the result should contain
      // a modified system prompt with session_resume data
      if (result?.systemPrompt) {
        expect(result.systemPrompt).toContain("session_resume");
      }
    });

    it("returns nothing when no resume exists", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("before_agent_start", {
        systemPrompt: "You are a helpful assistant.",
      });

      // No resume → no modification (undefined or original prompt)
      if (result?.systemPrompt) {
        expect(result.systemPrompt).not.toContain("session_resume");
      }
    });

    it("extracts user prompt events", async () => {
      await registerPiExtension(api);

      // User prompt with decision-like content should extract events
      await api._trigger("user_prompt", {
        message: "Don't use lodash, use native Array methods instead",
      });

      // Should not throw — user events are silently captured
    });

    it("handles missing systemPrompt gracefully", async () => {
      await registerPiExtension(api);

      const result = await api._trigger("before_agent_start", {});

      // Should not throw
      if (result) {
        expect(result).toBeDefined();
      }
    });
  });

  // ═══════════════════════════════════════════════════════════
  // Slice 6: Stats command (/ctx-stats)
  // ═══════════════════════════════════════════════════════════

  describe("Slice 6: Stats command", () => {
    it("registers /ctx-stats command", async () => {
      await registerPiExtension(api);

      const cmd = api._getCommand("ctx-stats");
      expect(cmd).toBeDefined();
    });

    it("/ctx-stats returns formatted stats text", async () => {
      await registerPiExtension(api);

      // Capture some events first
      await api._trigger("tool_result", {
        tool_name: "read",
        tool_input: { file_path: "/src/app.ts" },
        tool_result: "export default {}",
      });

      await api._trigger("tool_result", {
        tool_name: "bash",
        tool_input: { command: "git status" },
        tool_result: "On branch main",
      });

      const cmd = api._getCommand("ctx-stats");
      expect(cmd).toBeDefined();
      expect(cmd!.handler).toBeDefined();

      const result = await cmd!.handler!({});

      // Stats should contain formatted text with session info
      expect(result).toBeDefined();
      if (typeof result === "object" && result !== null && "text" in result) {
        const text = (result as { text: string }).text;
        expect(typeof text).toBe("string");
        expect(text.length).toBeGreaterThan(0);
        // Should contain typical stats output
        expect(text).toMatch(/stat|session|event/i);
      } else if (typeof result === "string") {
        expect(result.length).toBeGreaterThan(0);
        expect(result).toMatch(/stat|session|event/i);
      }
    });

    it("/ctx-stats works with empty session", async () => {
      await registerPiExtension(api);

      const cmd = api._getCommand("ctx-stats");
      expect(cmd).toBeDefined();
      expect(cmd!.handler).toBeDefined();

      // Should not throw even with no events
      const result = await cmd!.handler!({});
      expect(result).toBeDefined();
    });

    it("/ctx-stats treats SQLite started_at as UTC", async () => {
      vi.useFakeTimers();
      vi.setSystemTime(new Date("2026-05-09T12:30:00Z"));

      const originalTZ = process.env.TZ;
      process.env.TZ = "America/Los_Angeles";

      const sessionFile = join(tempDir, "stats-utc-session.jsonl");
      const sessionId = createHash("sha256")
        .update(sessionFile)
        .digest("hex")
        .slice(0, 16);

      try {
        await registerPiExtension(api);
        await api._trigger(
          "session_start",
          { type: "session_start", reason: "startup" },
          { sessionManager: { getSessionFile: () => sessionFile } },
        );

        // Issue #645 — Pi extension now resolves the SessionDB file
        // through `resolveSessionDbPath`, the same helper the MCP
        // server uses. The shared "context-mode.db" literal is gone;
        // the file lives at <canonical-hash>.db (case-folded on
        // darwin/win32, worktree-suffixed when applicable). Mirror
        // the production resolver here so this test reads from the
        // exact path the extension just wrote to.
        const { resolveSessionDbPath } = await import("../src/session/db.js");
        const sessionsDir = join(
          process.env.HOME!,
          ".pi",
          "context-mode",
          "sessions",
        );
        const dbPath = resolveSessionDbPath({
          projectDir: process.env.PI_PROJECT_DIR!,
          sessionsDir,
        });
        const db = new SessionDB({ dbPath });
        try {
          // SQLite datetime('now') stores UTC as "YYYY-MM-DD HH:MM:SS".
          // If parsed as local time in America/Los_Angeles, this would be
          // 2026-05-09T19:00:00Z and the age would not be 30 minutes.
          db.db
            .prepare(
              "UPDATE session_meta SET started_at = ? WHERE session_id = ?",
            )
            .run("2026-05-09 12:00:00", sessionId);
        } finally {
          db.close();
        }

        const result = await api._getCommand("ctx-stats")!.handler!({});

        expect((result as { text: string }).text).toContain(
          "- Session age: 30m",
        );
      } finally {
        if (originalTZ === undefined) {
          delete process.env.TZ;
        } else {
          process.env.TZ = originalTZ;
        }
        vi.useRealTimers();
      }
    });
  });

  // ═══════════════════════════════════════════════════════════
  // Slice 7: Routing block injection (Pi-1)
  // ═══════════════════════════════════════════════════════════

  describe("Slice 7: Routing block injection", () => {
    it("injects <context_window_protection> on first before_agent_start", async () => {
      await registerPiExtension(api);
      await api._trigger("session_start", {}, {
        sessionManager: { getSessionFile: () => `routing-1-${Date.now()}-${Math.random()}` },
      });

      const result = await api._trigger("before_agent_start", {
        systemPrompt: "Base prompt.",
      });

      expect(result?.systemPrompt).toBeDefined();
      expect(result.systemPrompt).toContain("<context_window_protection>");
    });

    it("re-injects the routing block on every subsequent call (Pi rebuilds system prompt each turn)", async () => {
      await registerPiExtension(api);
      await api._trigger("session_start", {}, {
        sessionManager: { getSessionFile: () => `routing-2-${Date.now()}-${Math.random()}` },
      });

      const first = await api._trigger("before_agent_start", {
        systemPrompt: "Base.",
      });
      const second = await api._trigger("before_agent_start", {
        systemPrompt: "Base.",
      });
      const third = await api._trigger("before_agent_start", {
        systemPrompt: "Base.",
      });

      // Unlike Claude Code where the SessionStart hook persists context for the whole
      // session, Pi rebuilds the system prompt fresh every turn. The routing block
      // must be present on every call or the LLM loses MCP tool awareness after turn 1.
      expect(first?.systemPrompt).toContain("<context_window_protection>");
      expect(second?.systemPrompt).toContain("<context_window_protection>");
      expect(third?.systemPrompt).toContain("<context_window_protection>");
    });
  });

  // ═══════════════════════════════════════════════════════════
  // Slice 8: before_provider_response (Pi-2)
  // ═══════════════════════════════════════════════════════════

  describe("Slice 8: before_provider_response handler", () => {
    it("registers a before_provider_response handler", async () => {
      await registerPiExtension(api);
      expect(api._handlers["before_provider_response"]).toBeDefined();
      expect(api._handlers["before_provider_response"].length).toBeGreaterThan(0);
    });

    it("invokes the handler without throwing on metadata payloads", async () => {
      await registerPiExtension(api);
      await api._trigger("session_start", {
        session_id: "provider-1",
        project_dir: tempDir,
      });

      await expect(
        api._trigger("before_provider_response", {
          model: "pi-1",
          provider: "pi",
          latencyMs: 42,
          usage: { prompt: 10, completion: 20 },
        }),
      ).resolves.not.toThrow();
    });

    it("handles empty payload gracefully", async () => {
      await registerPiExtension(api);
      await expect(
        api._trigger("before_provider_response", {}),
      ).resolves.not.toThrow();
    });
  });

  // ═══════════════════════════════════════════════════════════
  // Slice 9: active_memory always-on + token cap (Pi-3, Pi-4)
  // ═══════════════════════════════════════════════════════════

  describe("Slice 9: active_memory injection", () => {
    it("injects <active_memory> even when compact_count is 0", async () => {
      await registerPiExtension(api);
      await api._trigger("session_start", {
        sessionManager: { getSessionFile: () => `active-mem-1-${Date.now()}-${Math.random()}` },
      });

      // Seed user prompt with role pattern (priority 3) so the extractor
      // produces a priority>=3 event the active_memory builder can pick up.
      await api._trigger("before_agent_start", {
        prompt: "You are a senior staff engineer reviewing this codebase.",
        systemPrompt: "Base.",
      });

      // Second call should now contain <active_memory> built from those events.
      const result = await api._trigger("before_agent_start", {
        systemPrompt: "Base 2.",
      });

      expect(result?.systemPrompt).toBeDefined();
      // Either the auto-injection helper (rules/decisions) OR the inline
      // fallback (active_memory) should have produced injected content.
      const sp = String(result.systemPrompt);
      const hasActiveMemory =
        sp.includes("<active_memory>") ||
        sp.includes("<rules>") ||
        sp.includes("<behavioral_directive>");
      expect(hasActiveMemory).toBe(true);
    });

    it("caps active_memory at ≤ 2000 characters", async () => {
      await registerPiExtension(api);
      await api._trigger("session_start", {
        sessionManager: { getSessionFile: () => `active-mem-2-${Date.now()}-${Math.random()}` },
      });

      // Flood with very long role-pattern prompts (priority 3).
      const longText = "You are a senior staff engineer. " + "x".repeat(500);
      for (let i = 0; i < 20; i++) {
        await api._trigger("before_agent_start", {
          prompt: `${longText} #${i}`,
          systemPrompt: "Base.",
        });
      }

      const result = await api._trigger("before_agent_start", {
        systemPrompt: "Base final.",
      });

      const sp = String(result?.systemPrompt ?? "");
      // Slice out the injected memory block (auto-injection or fallback).
      const memMatch =
        sp.match(/<active_memory>[\s\S]*?<\/active_memory>/) ??
        sp.match(/<behavioral_directive>[\s\S]*?<\/behavioral_directive>/) ??
        sp.match(/<rules>[\s\S]*?<\/rules>/);
      if (memMatch) {
        // 500 token cap × 4 chars/token = 2000 chars; allow small padding for
        // XML wrappers from buildAutoInjection / fallback markers.
        expect(memMatch[0].length).toBeLessThanOrEqual(2200);
      } else {
        // If no block exists, the test should surface the failure.
        expect(memMatch).not.toBeNull();
      }
    });
  });

  // ═══════════════════════════════════════════════════════════
  // Registration integrity
  // ═══════════════════════════════════════════════════════════

  describe("Registration integrity", () => {
    it("registers expected event handlers", async () => {
      await registerPiExtension(api);

      // The extension should register handlers for key lifecycle events
      const registeredEvents = Object.keys(api._handlers);
      expect(registeredEvents.length).toBeGreaterThan(0);

      // At minimum, tool_call and tool_result should be handled
      const hasToolCall = registeredEvents.includes("tool_call");
      const hasToolResult = registeredEvents.includes("tool_result");

      // At least one of the core event types should be registered
      expect(hasToolCall || hasToolResult).toBe(true);
    });

    it("does not throw during registration", async () => {
      await expect(registerPiExtension(api)).resolves.not.toThrow();
    });

    it("can be registered multiple times without error", async () => {
      const api1 = createMockPiApi();
      const api2 = createMockPiApi();

      await registerPiExtension(api1, { projectDir: join(tempDir, "reg1") });
      await registerPiExtension(api2, { projectDir: join(tempDir, "reg2") });
    });
  });
});

// ────────────────────────────────────────────────────────────────────────
// MCP bridge — bridges context-mode MCP tools into Pi's pi.registerTool()
// surface so the LLM can actually reach ctx_execute / ctx_search / etc.
// (#426). Pi 0.73.x has no native MCP support; without this bridge the
// routing block tells the LLM about tools it cannot call.
// ────────────────────────────────────────────────────────────────────────

describe("Pi MCP bridge (#426)", () => {
  let mcpScratch: string;

  beforeEach(() => {
    mcpScratch = mkdtempSync(join(tmpdir(), "ctx-pi-bridge-"));
  });

  afterEach(() => {
    try {
      rmSync(mcpScratch, { recursive: true, force: true });
    } catch {
      /* best effort */
    }
  });

  function writeFakeServer(source: string): string {
    const fs = require("node:fs") as typeof import("node:fs");
    const path = join(mcpScratch, `fake-mcp-${Date.now()}-${Math.random()}.mjs`);
    fs.writeFileSync(path, source, "utf-8");
    return path;
  }

  // ── Unit: MCPStdioClient framing & lifecycle ──────────────────────

  describe("MCPStdioClient", () => {
    it("matches request id to response result over newline-delimited JSON", async () => {
      const fakePath = writeFakeServer(`
        let buf = "";
        process.stdin.on("data", (chunk) => {
          buf += chunk.toString("utf-8");
          let idx;
          while ((idx = buf.indexOf("\\n")) >= 0) {
            const line = buf.slice(0, idx).trim();
            buf = buf.slice(idx + 1);
            if (!line) continue;
            let msg; try { msg = JSON.parse(line); } catch { continue; }
            if (msg.id == null) continue;
            process.stdout.write(JSON.stringify({
              jsonrpc: "2.0", id: msg.id,
              result: { echoed: msg.method, params: msg.params },
            }) + "\\n");
          }
        });
      `);
      const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
      const client = new MCPStdioClient(fakePath);
      client.start();
      try {
        const r1 = await client.request("tools/list", { foo: 1 });
        expect(r1).toEqual({ echoed: "tools/list", params: { foo: 1 } });
        const r2 = await client.request("tools/call", { bar: 2 });
        expect(r2).toEqual({ echoed: "tools/call", params: { bar: 2 } });
      } finally {
        client.shutdown();
      }
    });

    it("matches concurrent in-flight requests by id (out-of-order responses)", async () => {
      // Reverse delays so the slowest goes first — exercises the id-map
      // dispatch, not just FIFO ordering.
      const fakePath = writeFakeServer(`
        let buf = "";
        process.stdin.on("data", (chunk) => {
          buf += chunk.toString("utf-8");
          let idx;
          while ((idx = buf.indexOf("\\n")) >= 0) {
            const line = buf.slice(0, idx).trim();
            buf = buf.slice(idx + 1);
            if (!line) continue;
            let msg; try { msg = JSON.parse(line); } catch { continue; }
            if (msg.id == null) continue;
            const delay = msg.params?.delay ?? 0;
            setTimeout(() => {
              process.stdout.write(JSON.stringify({
                jsonrpc: "2.0", id: msg.id, result: { id: msg.id },
              }) + "\\n");
            }, delay);
          }
        });
      `);
      const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
      const client = new MCPStdioClient(fakePath);
      client.start();
      try {
        const promises = [50, 40, 30, 20, 10].map((delay, i) =>
          client.request<{ id: number }>("probe", { delay, idx: i }),
        );
        const results = await Promise.all(promises);
        expect(results).toHaveLength(5);
        for (const r of results) expect(typeof r.id).toBe("number");
      } finally {
        client.shutdown();
      }
    });

    it("rejects in-flight requests when the child exits", async () => {
      const fakePath = writeFakeServer(`
        process.stdin.once("data", () => process.exit(0));
      `);
      const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
      const client = new MCPStdioClient(fakePath);
      client.start();
      const promise = client.request("tools/list", {});
      await expect(promise).rejects.toThrow(/exited|MCP/);
      client.shutdown();
    });

    it("times out instead of hanging on a silent server", async () => {
      const fakePath = writeFakeServer(`
        process.stdin.on("data", () => {});
        setInterval(() => {}, 1000);
      `);
      const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
      const client = new MCPStdioClient(fakePath);
      client.start();
      try {
        await expect(
          client.request("tools/list", {}, 200),
        ).rejects.toThrow(/timeout/);
      } finally {
        client.shutdown();
      }
    });

    it("ignores non-JSON stdout lines without crashing the parser", async () => {
      const fakePath = writeFakeServer(`
        process.stdout.write("[some startup banner]\\n");
        process.stdout.write("not valid json {{{\\n");
        let buf = "";
        process.stdin.on("data", (chunk) => {
          buf += chunk.toString("utf-8");
          let idx;
          while ((idx = buf.indexOf("\\n")) >= 0) {
            const line = buf.slice(0, idx).trim();
            buf = buf.slice(idx + 1);
            if (!line) continue;
            let msg; try { msg = JSON.parse(line); } catch { continue; }
            if (msg.id == null) continue;
            process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { ok: true } }) + "\\n");
          }
        });
      `);
      const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
      const client = new MCPStdioClient(fakePath);
      client.start();
      try {
        const r = await client.request<{ ok: boolean }>("ping", {});
        expect(r.ok).toBe(true);
      } finally {
        client.shutdown();
      }
    });
  });

  // ── Integration: bootstrapMCPTools + real MCP server ──────────────

  describe("bootstrapMCPTools — registers every ctx_* tool with Pi", () => {
    // Lifted out of each `it` so the path resolution lives in one place
    // and a future MCP-entrypoint move only has to change one line.
    const path = require("node:path") as typeof import("node:path");
    const url = require("node:url") as typeof import("node:url");
    const here = path.dirname(url.fileURLToPath(import.meta.url));
    const mcpEntry = path.resolve(here, "..", "start.mjs");
    const mcpEnv = { ...process.env, QUIET_CONTEXT_DISABLE_VERSION_CHECK: "1" };

    let bridge: { tools: string[]; shutdown: () => void } | null = null;

    afterEach(() => {
      if (bridge) {
        bridge.shutdown();
        bridge = null;
      }
    });

    it("registers the canonical ctx_* tool set", async () => {
      const registered: Array<{ name: string; label: string; description: string; parameters: unknown; execute: Function }> = [];
      const fakePi = {
        registerTool: (tool: any) => {
          registered.push(tool);
        },
      };

      const { bootstrapMCPTools } = await import("../src/adapters/pi/mcp-bridge.js");
      bridge = await bootstrapMCPTools(fakePi, mcpEntry, { env: mcpEnv });

      // Pin the canonical names — adding new MCP tools is fine
      // (arrayContaining), but losing one of these is the bug regression.
      expect(bridge.tools).toEqual(
        expect.arrayContaining([
          "ctx_execute",
          "ctx_execute_file",
          "ctx_search",
          "ctx_index",
          "ctx_batch_execute",
          "ctx_fetch_and_index",
          "ctx_doctor",
          "ctx_stats",
          "ctx_purge",
        ]),
      );

      // Each registration must satisfy the Pi contract.
      for (const reg of registered) {
        expect(reg.name).toMatch(/^ctx_/);
        expect(reg.label).toBe(reg.name);
        expect(typeof reg.description).toBe("string");
        expect(reg.parameters).toBeTruthy();
        expect(typeof reg.execute).toBe("function");
      }
    }, 30_000);

    it("execute() round-trips through tools/call to the MCP server", async () => {
      const registered: any[] = [];
      const fakePi = {
        registerTool: (tool: any) => registered.push(tool),
      };

      const { bootstrapMCPTools } = await import("../src/adapters/pi/mcp-bridge.js");
      bridge = await bootstrapMCPTools(fakePi, mcpEntry, { env: mcpEnv });

      const indexTool = registered.find((t) => t.name === "ctx_index");
      expect(indexTool).toBeDefined();

      const marker = `pi-bridge-marker-${process.pid}-${Date.now()}`;
      const result = await indexTool.execute("test-call-1", {
        content: `# heading\n\n${marker}\n`,
        source: "pi-bridge-smoke",
      });

      expect(result.content).toHaveLength(1);
      expect(result.content[0].type).toBe("text");
      // Server returns "Indexed N sections … from: pi-bridge-smoke" on
      // success — pin the source label so a regression in tools/call
      // arg-passing also fails this test.
      expect(result.content[0].text).toMatch(/pi-bridge-smoke/);
      expect(result.isError).toBeFalsy();
    }, 30_000);
  });

  // ── Wiring: pi-extension.ts default export must call bootstrapMCPTools
  //
  // This is the regression that the rest of the suite does NOT catch: if
  // a future refactor drops the `bootstrapMCPTools(pi, …)` call from
  // src/adapters/pi/extension.ts but keeps the bridge module intact, every other
  // bridge test stays green and the bug silently re-enters. We assert
  // here that the extension's default export, after `_mcpBridgeReady`
  // settles, has actually called `pi.registerTool` for at least the
  // canonical ctx_* set.

  describe("pi-extension.ts wiring (#426 regression guard)", () => {
    it("registerPiExtension awaits bridge bootstrap and registers ctx_* via pi.registerTool", async () => {
      const wireApi = createMockPiApi();
      // PI_PROJECT_DIR / CLAUDE_PROJECT_DIR set inside registerPiExtension.
      await registerPiExtension(wireApi, { projectDir: tempDir });

      // Bootstrap is fire-and-forget on extension load — wait on the
      // exported promise so the test does not race the spawn.
      const mod = await import("../src/adapters/pi/extension.js");
      await mod._mcpBridgeReady;

      const calls = (wireApi.registerTool as any).mock.calls as Array<[any]>;
      const registeredNames = calls.map(([t]) => t?.name).filter(Boolean);

      // Same canonical pin as the bridge integration test — but reached
      // through registerPiExtension instead of bootstrapMCPTools, so
      // dropping the wiring fails this test even when the bridge module
      // still works.
      expect(registeredNames).toEqual(
        expect.arrayContaining([
          "ctx_execute",
          "ctx_search",
          "ctx_index",
          "ctx_batch_execute",
          "ctx_fetch_and_index",
        ]),
      );

      // Cleanup: SIGTERM the bridge child the wiring spawned so it does
      // not leak past this test.
      const sd = mod.default as any;
      void sd; // silence unused
      await wireApi._trigger("session_shutdown");
    }, 30_000);

    // Race regression — comment 4412197109 on PR #472.
    //
    // Each Pi subagent spawns a fresh `pi --mode json -p --no-session`
    // process that loads context-mode and then immediately fires
    // `before_agent_start` to dispatch the LLM call. The MCP bridge
    // bootstrap (spawn server.bundle.mjs → initialize → tools/list →
    // pi.registerTool × N) is fire-and-forget via `_mcpBridgeReady`, so
    // without an explicit await the LLM call goes out with an empty
    // ctx_* tool registry and the routing block (~2.5K tokens) becomes
    // dead weight — the LLM is told to call ctx_execute / ctx_search /
    // etc. but Pi has not yet registered them.
    //
    // This test pins the contract: by the time `before_agent_start`
    // resolves, the canonical ctx_* tools MUST have been registered
    // through pi.registerTool. The pre-trigger assertion confirms the
    // race window is open (otherwise the test would pass for the wrong
    // reason — bridge happened to win the race).
    it("before_agent_start awaits MCP bridge bootstrap so ctx_* are registered before LLM call", async () => {
      const wireApi = createMockPiApi();
      await registerPiExtension(wireApi, { projectDir: tempDir });

      // Establish a session so before_agent_start does real work
      // (the handler early-returns when `!_sessionId`).
      await wireApi._trigger(
        "session_start",
        {},
        { session_id: "race-test", project_dir: tempDir },
      );

      // Sanity: bridge bootstrap is in flight, no tool registered yet.
      // If this ever fails, the bridge stopped racing and the test
      // loses meaning — adjust the bootstrap or remove the guard.
      const preCalls = (wireApi.registerTool as any).mock.calls.length;
      expect(preCalls).toBe(0);

      // The race: trigger before_agent_start now. Pi will dispatch the
      // LLM call as soon as this resolves — so the handler MUST block
      // until the bridge has registered ctx_* tools.
      await wireApi._trigger("before_agent_start", {
        sessionID: "race-test",
        prompt: "anything",
        systemPrompt: "",
      });

      const calls = (wireApi.registerTool as any).mock.calls as Array<[any]>;
      const registeredNames = calls.map(([t]) => t?.name).filter(Boolean);
      expect(registeredNames).toEqual(
        expect.arrayContaining([
          "ctx_execute",
          "ctx_search",
          "ctx_index",
          "ctx_batch_execute",
          "ctx_fetch_and_index",
        ]),
      );

      await wireApi._trigger("session_shutdown");
    }, 30_000);
  });

  // ── Pi bridge resilience (#472 round-3) ───────────────────────────
  //
  // Three reliability gaps verified on PR #472 round-3 review:
  //   1. stdio[2] = "ignore" silently swallows server crash logs — when
  //      the MCP child dies during bootstrap the user sees nothing in
  //      stderr, only "ctx_* tools will not be callable from this
  //      session" with no diagnostic of WHY.
  //   2. shutdown() sends SIGTERM and immediately nulls the child handle.
  //      A child that ignores SIGTERM (or hangs on cleanup) becomes a
  //      zombie because no SIGKILL fallback ever fires.
  //   3. session_shutdown does not await `_mcpBridgeReady`. If shutdown
  //      fires while bootstrap is still in flight, `_mcpBridge` is null
  //      → the freshly-spawned MCP child is orphaned once bootstrap
  //      eventually resolves.
  //
  // These tests pin the resilience contract: stderr piped, SIGKILL
  // bounded at 5s, shutdown awaits bridge bootstrap up to 2s.

  describe("Pi bridge resilience (#472 round-3)", () => {
    it("captures child stderr instead of swallowing it (case 1: crash diagnostics)", async () => {
      // Server writes a diagnostic line to stderr then exits non-zero,
      // mimicking a real crash during initialize. With stdio[2] = "ignore"
      // the diagnostic vanishes; with "pipe" it must reach process.stderr.
      const fakePath = writeFakeServer(`
        process.stderr.write("FAKE_MCP_CRASH_DIAG: bundle corrupted at line 42\\n");
        process.exit(1);
      `);
      const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
      const client = new MCPStdioClient(fakePath);

      const captured: string[] = [];
      const origWrite = process.stderr.write.bind(process.stderr);
      // @ts-expect-error monkeypatch
      process.stderr.write = (chunk: any, ...rest: any[]) => {
        captured.push(typeof chunk === "string" ? chunk : chunk.toString("utf-8"));
        return origWrite(chunk, ...rest);
      };

      try {
        client.start();
        // Give the child time to write its diag line and exit.
        await new Promise((r) => setTimeout(r, 300));
      } finally {
        // @ts-expect-error restore
        process.stderr.write = origWrite;
        client.shutdown();
      }

      const all = captured.join("");
      expect(all).toMatch(/FAKE_MCP_CRASH_DIAG/);
      // Prefix lets ops grep across the noise of a real session.
      expect(all).toMatch(/\[mcp-bridge\]/);
    }, 10_000);

    it.skipIf(process.platform === "win32")("escalates to SIGKILL when SIGTERM is ignored (case 2: bounded shutdown)", async () => {
      // Windows has no POSIX signal delivery: child.kill('SIGTERM') maps to
      // TerminateProcess (unblockable), and child.kill('SIGKILL') hits the
      // same syscall. The fake server's `process.on('SIGTERM', ...)` handler
      // never fires, so the SIGKILL-escalation contract that this test pins
      // is fundamentally unobservable on win32. POSIX (Linux/macOS) coverage
      // is sufficient for the resilience guarantee.
      // Child explicitly ignores SIGTERM and refuses to exit. Without a
      // SIGKILL fallback the process leaks indefinitely.
      // Also ignore stdin 'end'/'close' so closing the pipe doesn't end
      // the process — only SIGKILL should be able to terminate this.
      // Emit "READY" on stdout once handlers are registered so the test
      // does not race the spawn (otherwise SIGTERM arrives during Node
      // bootstrap before our handler is registered → default terminate).
      const fakePath = writeFakeServer(`
        process.on("SIGTERM", () => { /* deliberately ignore */ });
        process.on("SIGINT", () => { /* deliberately ignore */ });
        process.on("SIGHUP", () => { /* deliberately ignore */ });
        process.stdin.on("end", () => { /* stay alive */ });
        process.stdin.on("close", () => { /* stay alive */ });
        process.stdin.on("data", () => {});
        setInterval(() => {}, 1000);
        process.stdout.write("READY\\n");
      `);
      const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
      const client = new MCPStdioClient(fakePath);
      client.start();

      // Reach into the private child handle for the assertion. Tests
      // already do this elsewhere and the field is the only way to
      // observe the OS-level lifecycle without a polling sentinel.
      const childRef: any = (client as any).child;
      expect(childRef).toBeTruthy();
      expect(childRef.killed).toBe(false);

      // Wait for "READY" — confirms SIGTERM handler is installed.
      await new Promise<void>((resolve, reject) => {
        const timer = setTimeout(() => reject(new Error("child never READY")), 3000);
        childRef.stdout.on("data", (chunk: Buffer) => {
          if (chunk.toString("utf-8").includes("READY")) {
            clearTimeout(timer);
            resolve();
          }
        });
      });

      const exitPromise = new Promise<{ code: number | null; signal: string | null }>((resolve) => {
        childRef.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
          resolve({ code, signal });
        });
      });

      const t0 = Date.now();
      client.shutdown();

      // Bounded by the 5s SIGKILL timer — give it a small grace window.
      const result = await Promise.race([
        exitPromise.then((r) => ({ ...r, timedOut: false })),
        new Promise<{ code: null; signal: null; timedOut: true }>((r) =>
          setTimeout(() => r({ code: null, signal: null, timedOut: true }), 7500),
        ),
      ]);
      const elapsedMs = Date.now() - t0;

      // Must NOT have timed out — that would mean no SIGKILL fallback.
      expect(result.timedOut).toBe(false);
      // Killed by signal (SIGKILL) — Node's behavior across platforms:
      // signal field populated when killed by signal.
      expect(result.signal).toBe("SIGKILL");
      // Bounded at ~5s — assert lower bound (escalation, not immediate)
      // and upper bound (not hung beyond the 5s ceiling + grace).
      expect(elapsedMs).toBeGreaterThanOrEqual(4500);
      expect(elapsedMs).toBeLessThan(7000);
    }, 15_000);

    it.skipIf(process.platform === "win32")("session_shutdown awaits bridge bootstrap so child is not orphaned (case 3: race)", async () => {
      // Windows ChildProcess.killed/exitCode/signalCode lifecycle for
      // tsx-spawned MCP children races with TerminateProcess in CI in a way
      // that produces a false-negative for the dead-child assertion. The
      // shutdown-await contract this test pins is platform-agnostic logic
      // (the await on _mcpBridgeReady) — POSIX coverage exercises the race.
      // Repro: trigger session_shutdown WHILE bootstrap is still in
      // flight. Without an await on `_mcpBridgeReady`, _mcpBridge is
      // null at shutdown time → the bootstrap eventually resolves and
      // a live MCP child stays orphaned for the rest of the process.
      //
      // Instrument bootstrapMCPTools by patching the bridge module's
      // `bootstrapMCPTools` to capture the resolved BridgeHandle, then
      // check its underlying client.child after shutdown completes.
      const wireApi = createMockPiApi();

      const bridgeMod = await import("../src/adapters/pi/mcp-bridge.js");
      const handles: any[] = [];
      const origBootstrap = bridgeMod.bootstrapMCPTools;
      const spy = vi
        .spyOn(bridgeMod, "bootstrapMCPTools")
        .mockImplementation(async (...args: any[]) => {
          const handle = await (origBootstrap as any)(...args);
          handles.push(handle);
          return handle;
        });

      try {
        await registerPiExtension(wireApi, { projectDir: tempDir });

        const mod = await import("../src/adapters/pi/extension.js");

        // Fire shutdown immediately — bootstrap is still in flight.
        await wireApi._trigger("session_shutdown");

        // Wait for the in-flight bootstrap to settle. By this point the
        // child has been spawned. If session_shutdown did NOT await
        // _mcpBridgeReady, the bridge handle resolves AFTER shutdown
        // returned → the child is alive and orphaned.
        await mod._mcpBridgeReady;
        await new Promise((r) => setTimeout(r, 300));

        expect(handles.length).toBeGreaterThan(0);
        for (const handle of handles) {
          const child = (handle.client as any).child;
          // After session_shutdown, the bridge child must be dead OR
          // the bridge handle's internal child reference must be null
          // (already cleaned via shutdown()). If it's a live ChildProcess
          // with no exit signal, the orphan bug is present.
          if (child) {
            const dead =
              child.killed ||
              child.exitCode !== null ||
              child.signalCode !== null;
            expect(dead).toBe(true);
          }
        }
      } finally {
        spy.mockRestore();
        for (const handle of handles) {
          try {
            handle.shutdown();
          } catch {
            /* best effort */
          }
        }
      }
    }, 30_000);
  });
});

// ────────────────────────────────────────────────────────────────────────
// #473 round-3: extension MUST route session dir through PiAdapter
// rather than re-encoding the ~/.pi/context-mode/sessions literal. Any
// drift (e.g., PiAdapter changes the segment list) would silently desync
// otherwise — extension keeps writing to ~/.pi while the rest of the
// harness reads from the new location.
// ────────────────────────────────────────────────────────────────────────

describe("Pi extension respects PiAdapter session dir (#473 round-3)", () => {
  let scratch: string;
  let mockedSessionDir: string;

  beforeEach(() => {
    scratch = mkdtempSync(join(tmpdir(), "pi-ext-r3-"));
    mockedSessionDir = join(scratch, "custom-pi-sess");
    mkdirSync(mockedSessionDir, { recursive: true });
    vi.resetModules();
  });

  afterEach(() => {
    try {
      rmSync(scratch, { recursive: true, force: true });
    } catch {
      /* best effort */
    }
    vi.doUnmock("../src/adapters/pi/index.js");
    vi.resetModules();
  });

  it("opens SessionDB at the path returned by PiAdapter.getSessionDir, not at ~/.pi literal", async () => {
    vi.doMock("../src/adapters/pi/index.js", () => {
      class MockPiAdapter {
        getSessionDir() {
          return mockedSessionDir;
        }
      }
      return { PiAdapter: MockPiAdapter };
    });

    const projectDir = join(scratch, "project");
    mkdirSync(projectDir, { recursive: true });
    process.env.PI_PROJECT_DIR = projectDir;
    process.env.CLAUDE_PROJECT_DIR = projectDir;

    const localApi = createMockPiApi();
    const mod = await import("../src/adapters/pi/extension.js");
    await mod.default(localApi);

    await localApi._trigger("session_start", {}, {});

    // DB file must be created under the mocked dir — proof that the
    // extension routes through PiAdapter rather than the hardcoded
    // ~/.pi/context-mode/sessions literal. Issue #645: the filename
    // inside that dir is the canonical per-project `<hash>.db`, the
    // same one the MCP server reads via `resolveSessionDbPath`.
    const { resolveSessionDbPath } = await import("../src/session/db.js");
    const expectedDbPath = resolveSessionDbPath({
      projectDir,
      sessionsDir: mockedSessionDir,
    });
    const { existsSync: fileExists } = await import("node:fs");
    expect(fileExists(expectedDbPath)).toBe(true);

    // Also assert the doctor command surfaces the mocked path so
    // future contributors do not silently regress (it reads getDBPath()).
    const doctor = localApi._getCommand("ctx-doctor");
    expect(doctor?.handler).toBeDefined();
    const result = await doctor!.handler!({}, { hasUI: false });
    const text = String((result as { text?: string } | undefined)?.text ?? "");
    expect(text).toContain(mockedSessionDir);

    await localApi._trigger("session_shutdown");

    delete process.env.PI_PROJECT_DIR;
    delete process.env.CLAUDE_PROJECT_DIR;
  });
});

// ═══════════════════════════════════════════════════════════
// Issue #645 — Pi extension opens SessionDB at the canonical
// per-project hash path (`<hash>.db`), the same file the MCP
// server reads via `resolveSessionDbPath`. The shared
// `context-mode.db` literal is divergent — `ctx_stats` and
// `ctx_search(sort: "timeline")` silently degrade because the
// MCP server resolves a `<hash>.db` that does not exist.
// Regression test pins the contract: Pi must use the canonical
// helper, NOT the hardcoded literal.
// ═══════════════════════════════════════════════════════════

describe("Pi extension SessionDB path matches MCP server's canonical resolver (#645)", () => {
  let scratch: string;
  let mockedSessionDir: string;

  beforeEach(() => {
    scratch = mkdtempSync(join(tmpdir(), "pi-ext-645-"));
    mockedSessionDir = join(scratch, "pi-sess");
    mkdirSync(mockedSessionDir, { recursive: true });
    vi.resetModules();
  });

  afterEach(() => {
    try {
      rmSync(scratch, { recursive: true, force: true });
    } catch {
      /* best effort */
    }
    vi.doUnmock("../src/adapters/pi/index.js");
    vi.resetModules();
    delete process.env.PI_PROJECT_DIR;
    delete process.env.CLAUDE_PROJECT_DIR;
  });

  it("writes SessionDB to resolveSessionDbPath({projectDir, sessionsDir}), not the shared 'context-mode.db' literal", async () => {
    vi.doMock("../src/adapters/pi/index.js", () => {
      class MockPiAdapter {
        getSessionDir() {
          return mockedSessionDir;
        }
      }
      return { PiAdapter: MockPiAdapter };
    });

    const projectDir = join(scratch, "project");
    mkdirSync(projectDir, { recursive: true });
    process.env.PI_PROJECT_DIR = projectDir;
    process.env.CLAUDE_PROJECT_DIR = projectDir;

    const localApi = createMockPiApi();
    const mod = await import("../src/adapters/pi/extension.js");
    await mod.default(localApi);

    await localApi._trigger("session_start", {}, {});

    // The MCP server (src/server.ts) resolves the SessionDB path via
    // resolveSessionDbPath({ projectDir, sessionsDir }). The Pi
    // extension MUST write to the same file. Compute the canonical
    // path with the SAME helper the server uses and require it to
    // exist on disk after session_start.
    const { resolveSessionDbPath } = await import("../src/session/db.js");
    const canonicalPath = resolveSessionDbPath({
      projectDir,
      sessionsDir: mockedSessionDir,
    });
    const { existsSync: fileExists } = await import("node:fs");
    expect(fileExists(canonicalPath)).toBe(true);

    // The shared literal must NOT be created — that was the bug.
    const buggyLiteralPath = join(mockedSessionDir, "context-mode.db");
    // Only fail the "no literal" check when canonical ≠ literal.
    // (They differ for any real projectDir → 16-hex hash.)
    if (canonicalPath !== buggyLiteralPath) {
      expect(fileExists(buggyLiteralPath)).toBe(false);
    }

    // ctx-doctor must surface the same canonical path so users
    // diagnosing degraded ctx_stats see the actual on-disk file.
    const doctor = localApi._getCommand("ctx-doctor");
    expect(doctor?.handler).toBeDefined();
    const result = await doctor!.handler!({}, { hasUI: false });
    const text = String((result as { text?: string } | undefined)?.text ?? "");
    expect(text).toContain(canonicalPath);

    await localApi._trigger("session_shutdown");
  });
});
