import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
  approvalResponse,
  approvalFromServerRequest,
  buildTurnStartParams,
  createCodexRuntimePool,
  getThreadRequest,
  publicCapabilitySummary,
  redactCodexHome,
  reviewDecision,
  runtimeEventFromNotification,
  startCodexRuntime,
  startCodexDeviceLogin,
  startCodexThreadWatcher
} from "./codex-runtime.js";

test("account runtime pool prewarms once and reuses one app-server across threads", async () => {
  const calls = [];
  let nextThread = 0;
  const client = {
    async start() { calls.push("start"); },
    async request(method) {
      calls.push(method);
      if (method === "thread/start") return { thread: { id: `thread-${++nextThread}` } };
      throw new Error(`unexpected_method:${method}`);
    },
    stop() { calls.push("stop"); }
  };
  const pool = createCodexRuntimePool({
    codexCliPath: "codex",
    accounts: [{ id: "account-a", codexHome: "A_HOME" }]
  }, { clientFactory: () => client });

  await pool.prewarm();
  const first = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\One", emit() {} });
  first.stop();
  const second = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\Two", emit() {} });

  assert.equal(first.threadId, "thread-1");
  assert.equal(second.threadId, "thread-2");
  assert.deepEqual(calls, ["start", "thread/start", "thread/start"]);

  pool.stop();
  assert.equal(calls.at(-1), "stop");
});

test("account runtime pool reuses the prewarmed app-server for read-only history requests", async () => {
  const calls = [];
  let clientCount = 0;
  const client = {
    async start() { calls.push("start"); },
    async request(method, params) {
      calls.push({ method, params });
      return method === "thread/list" ? { data: [] } : { thread: { id: params.threadId } };
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({
    codexCliPath: "codex",
    accounts: [{ id: "account-a", codexHome: "A_HOME" }]
  }, { clientFactory: () => { clientCount += 1; return client; } });

  await pool.prewarm();
  await pool.requestHistory({ accountId: "account-a", codexHome: "A_HOME", method: "thread/list", params: { limit: 50 } });
  await pool.requestHistory({ accountId: "account-a", codexHome: "A_HOME", method: "thread/read", params: { threadId: "thread-1" } });

  assert.equal(clientCount, 1);
  assert.deepEqual(calls, [
    "start",
    { method: "thread/list", params: { limit: 50 } },
    { method: "thread/read", params: { threadId: "thread-1" } }
  ]);
  await assert.rejects(
    pool.requestHistory({ accountId: "account-a", codexHome: "A_HOME", method: "thread/archive", params: { threadId: "thread-1" } }),
    /unsupported_history_method/
  );
});

test("account runtime pool generates a concise title in an ephemeral thread and names the source thread", async () => {
  const requests = [];
  let emit;
  const client = {
    async start() {},
    async request(method, params) {
      requests.push({ method, params });
      if (method === "thread/start") return { thread: { id: "title-thread" } };
      if (method === "turn/start") {
        queueMicrotask(() => {
          emit("session.output", {
            threadId: "title-thread",
            turnId: "title-turn",
            itemId: "title-item",
            text: '{"title":"로그인 오류 수정"}',
            kind: "assistant_delta"
          });
          emit("turn.completed", {
            threadId: "title-thread",
            turnId: "title-turn",
            status: "completed"
          });
        });
        return { turn: { id: "title-turn" } };
      }
      if (method === "thread/read") return { thread: { id: "source-thread", name: null } };
      if (method === "thread/name/set") return {};
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, {
    clientFactory: (_command, _home, callback) => { emit = callback; return client; }
  });

  const title = await pool.generateConversationTitle({
    accountId: "account-a",
    codexHome: "A_HOME",
    threadId: "source-thread",
    conversation: "User: 로그인 오류를 고쳐줘\nAssistant: 오류를 수정했습니다."
  });

  assert.equal(title, "로그인 오류 수정");
  assert.equal(requests[0].method, "thread/start");
  assert.equal(requests[0].params.ephemeral, true);
  assert.equal(requests[0].params.sandbox, "read-only");
  assert.deepEqual(requests.at(-1), {
    method: "thread/name/set",
    params: { threadId: "source-thread", name: "로그인 오류 수정" }
  });
});

test("account runtime pool never overwrites an existing source thread name", async () => {
  const requests = [];
  let emit;
  const client = {
    async start() {},
    async request(method, params) {
      requests.push({ method, params });
      if (method === "thread/start") return { thread: { id: "title-thread" } };
      if (method === "turn/start") {
        queueMicrotask(() => {
          emit("session.output", {
            threadId: "title-thread",
            turnId: "title-turn",
            itemId: "title-item",
            text: '{"title":"자동 생성 제목"}',
            kind: "assistant_delta"
          });
          emit("turn.completed", { threadId: "title-thread", turnId: "title-turn", status: "completed" });
        });
        return { turn: { id: "title-turn" } };
      }
      if (method === "thread/read") return { thread: { id: "source-thread", name: "사용자 제목" } };
      if (method === "thread/name/set") return {};
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, {
    clientFactory: (_command, _home, callback) => { emit = callback; return client; }
  });

  const title = await pool.generateConversationTitle({
    accountId: "account-a",
    codexHome: "A_HOME",
    threadId: "source-thread",
    conversation: "User: 작업해줘\nAssistant: 완료했습니다."
  });

  assert.equal(title, undefined);
  assert.equal(requests.some(({ method }) => method === "thread/name/set"), false);
});

test("account runtime pool rejects title generation when the app-server fails", async () => {
  let emit;
  const client = {
    async start() {},
    async request(method) {
      if (method === "thread/start") return { thread: { id: "title-thread" } };
      if (method === "turn/start") {
        queueMicrotask(() => emit("error", { message: "temporary failure" }));
        return { turn: { id: "title-turn" } };
      }
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, {
    clientFactory: (_command, _home, callback) => { emit = callback; return client; }
  });

  await assert.rejects(Promise.race([
    pool.generateConversationTitle({
      accountId: "account-a",
      codexHome: "A_HOME",
      threadId: "source-thread",
      conversation: "User: 작업해줘\nAssistant: 완료했습니다."
    }),
    new Promise((_, reject) => setTimeout(() => reject(new Error("generator_timeout")), 50))
  ]), /title_generation_failed/);
});

test("account runtime pool times out title generation that never completes", async () => {
  const client = {
    async start() {},
    async request(method) {
      if (method === "thread/start") return { thread: { id: "title-thread" } };
      if (method === "turn/start") return { turn: { id: "title-turn" } };
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, {
    clientFactory: () => client,
    titleGenerationTimeoutMs: 10
  });

  await assert.rejects(Promise.race([
    pool.generateConversationTitle({
      accountId: "account-a",
      codexHome: "A_HOME",
      threadId: "source-thread",
      conversation: "User: 작업해줘\nAssistant: 완료했습니다."
    }),
    new Promise((_, reject) => setTimeout(() => reject(new Error("test_timeout")), 50))
  ]), /title_generation_timeout/);
});

test("account runtime pool routes concurrent turns to their own thread handles", async () => {
  const events = { first: [], second: [] };
  let emit;
  let nextThread = 0;
  const client = {
    async start() {},
    async request(method, params) {
      if (method === "thread/start") return { thread: { id: `thread-${++nextThread}` } };
      if (method === "turn/start") return { turn: { id: `turn-${params.threadId}` } };
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, {
    clientFactory: (_command, _home, callback) => { emit = callback; return client; }
  });
  const first = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\One", emit: (event, data) => events.first.push({ event, data }) });
  const second = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\Two", emit: (event, data) => events.second.push({ event, data }) });

  await Promise.all([first.sendMessage("First"), second.sendMessage("Second")]);
  emit("session.output", { threadId: "thread-1", turnId: "turn-thread-1", itemId: "item-1", text: "A" });
  emit("session.output", { threadId: "thread-2", turnId: "turn-thread-2", itemId: "item-2", text: "B" });

  assert.deepEqual(events.first, [{ event: "session.output", data: { threadId: "thread-1", turnId: "turn-thread-1", itemId: "item-1", text: "A" } }]);
  assert.deepEqual(events.second, [{ event: "session.output", data: { threadId: "thread-2", turnId: "turn-thread-2", itemId: "item-2", text: "B" } }]);
});

test("account runtime pool interrupts a turn created after its pending start was canceled", async () => {
  let resolveTurnStart;
  const requests = [];
  const client = {
    async start() {},
    async request(method, params) {
      requests.push({ method, params });
      if (method === "thread/start") return { thread: { id: "thread-1" } };
      if (method === "turn/start") return new Promise((resolve) => { resolveTurnStart = resolve; });
      if (method === "turn/interrupt") return {};
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, { clientFactory: () => client });
  const runtime = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\One", emit() {} });

  const sending = runtime.sendMessage("Start");
  await new Promise((resolve) => setImmediate(resolve));
  const canceling = runtime.cancelPendingStart();
  resolveTurnStart({ turn: { id: "turn-late" } });
  assert.equal(await canceling, true);

  await assert.rejects(sending, /turn_start_canceled/);
  assert.deepEqual(requests.at(-1), {
    method: "turn/interrupt",
    params: { threadId: "thread-1", turnId: "turn-late" }
  });
});

test("account runtime pool quarantines an unknown timed-out start from other thread handles", async () => {
  let emit;
  let nextThread = 0;
  const events = { first: [], second: [] };
  const client = {
    async start() {},
    async request(method, params) {
      if (method === "thread/start") return { thread: { id: `thread-${++nextThread}` } };
      if (method === "turn/start" && params.threadId === "thread-1") throw new Error("turn/start timeout");
      if (method === "thread/read" && params.threadId === "thread-1") throw new Error("thread read unavailable");
      if (method === "turn/start") return { turn: { id: "turn-2" } };
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, {
    clientFactory: (_command, _home, callback) => { emit = callback; return client; }
  });
  const first = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\One", emit: (event, data) => events.first.push({ event, data }) });
  const second = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\Two", emit: (event, data) => events.second.push({ event, data }) });

  await assert.rejects(first.sendMessage("Start"), /turn_start_outcome_unknown/);
  emit("turn.started", { threadId: "thread-1", turnId: "turn-late" });
  assert.equal(events.first.at(-1)?.event, "turn.started");
  assert.equal(events.second.length, 0);

  first.stop();
  emit("session.output", { threadId: "thread-1", turnId: "turn-late", itemId: "late", text: "late" });
  assert.equal(events.second.length, 0);
  second.stop();
});

test("account runtime pool reconciles an unknown start before confirming cancellation", async () => {
  let threadReadCount = 0;
  const requests = [];
  const client = {
    async start() {},
    async request(method, params) {
      requests.push({ method, params });
      if (method === "thread/start") return { thread: { id: "thread-1" } };
      if (method === "turn/start") throw new Error("turn/start timeout");
      if (method === "thread/read") {
        threadReadCount += 1;
        if (threadReadCount === 1) throw new Error("thread read unavailable");
        return { thread: { id: "thread-1", turns: [{ id: "turn-late", status: "inProgress" }] } };
      }
      if (method === "turn/interrupt") return {};
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, { clientFactory: () => client });
  const runtime = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\One", emit() {} });

  await assert.rejects(runtime.sendMessage("Start"), /turn_start_outcome_unknown/);
  assert.equal(await runtime.cancelPendingStart(), true);
  assert.deepEqual(requests.at(-1), {
    method: "turn/interrupt",
    params: { threadId: "thread-1", turnId: "turn-late" }
  });
});

test("account runtime pool waits for stable reconciliation before clearing a timed-out start", async () => {
  let threadReadCount = 0;
  const requests = [];
  const client = {
    async start() {},
    async request(method, params) {
      requests.push({ method, params });
      if (method === "thread/start") return { thread: { id: "thread-1" } };
      if (method === "turn/start") throw new Error("turn/start timeout");
      if (method === "thread/read") {
        threadReadCount += 1;
        return threadReadCount === 1
          ? { thread: { id: "thread-1", turns: [] } }
          : { thread: { id: "thread-1", turns: [{ id: "turn-late", status: "inProgress" }] } };
      }
      if (method === "turn/interrupt") return {};
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, {
    clientFactory: () => client,
    turnStartReconcileAttempts: 3,
    turnStartReconcileDelayMs: 1
  });
  const runtime = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\One", emit() {} });

  await assert.rejects(runtime.sendMessage("Start"), /turn\/start timeout/);
  assert.equal(threadReadCount, 2);
  assert.deepEqual(requests.at(-1), {
    method: "turn/interrupt",
    params: { threadId: "thread-1", turnId: "turn-late" }
  });
  assert.equal(await runtime.cancelPendingStart(), false);
});

test("account runtime pool routes approvals only to the matching concurrent thread", async () => {
  const events = { first: [], second: [] };
  let emit;
  let nextThread = 0;
  const client = {
    async start() {},
    async request(method) {
      if (method === "thread/start") return { thread: { id: `thread-${++nextThread}` } };
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, {
    clientFactory: (_command, _home, callback) => { emit = callback; return client; }
  });
  await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\One", emit: (event, data) => events.first.push({ event, data }) });
  await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\Two", emit: (event, data) => events.second.push({ event, data }) });

  emit("approval.requested", { runtimeRequestId: 7, threadId: "thread-2", turnId: "turn-2", itemId: "item-2", kind: "command" });

  assert.deepEqual(events.first, []);
  assert.deepEqual(events.second, [{
    event: "approval.requested",
    data: { runtimeRequestId: 7, threadId: "thread-2", turnId: "turn-2", itemId: "item-2", kind: "command" }
  }]);
});

test("account runtime pool rejects approvals with conflicting routing identifiers", async () => {
  const events = { first: [], second: [] };
  const responses = [];
  let emit;
  let nextThread = 0;
  const client = {
    async start() {},
    async request(method, params) {
      if (method === "thread/start") return { thread: { id: `thread-${++nextThread}` } };
      if (method === "turn/start") return { turn: { id: `turn-${params.threadId}` } };
      throw new Error(`unexpected_method:${method}`);
    },
    respond(id, result) { responses.push({ id, result }); },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, {
    clientFactory: (_command, _home, callback) => { emit = callback; return client; }
  });
  const first = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\One", emit: (event, data) => events.first.push({ event, data }) });
  const second = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\Two", emit: (event, data) => events.second.push({ event, data }) });
  await Promise.all([first.sendMessage("First"), second.sendMessage("Second")]);

  emit("approval.requested", { runtimeRequestId: 9, threadId: "thread-1", turnId: "turn-thread-2", itemId: "item-x", kind: "command" });

  assert.deepEqual(events, { first: [], second: [] });
  assert.equal(responses.length, 1);
  assert.equal(responses[0].id, 9);
});

test("stopping an active pooled runtime unregisters its event routes", async () => {
  const events = [];
  let emit;
  const client = {
    async start() {},
    async request(method) {
      if (method === "thread/start") return { thread: { id: "thread-1" } };
      if (method === "turn/start") return { turn: { id: "turn-1" } };
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const pool = createCodexRuntimePool({ codexCliPath: "codex" }, {
    clientFactory: (_command, _home, callback) => { emit = callback; return client; }
  });
  const runtime = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\One", emit: (event, data) => events.push({ event, data }) });
  await runtime.sendMessage("First");

  runtime.stop();
  emit("session.output", { threadId: "thread-1", turnId: "turn-1", itemId: "item-1", text: "late" });

  assert.deepEqual(events, []);
});

test("account runtime pool isolates prewarm failure and retries that account on demand", async () => {
  let starts = 0;
  const client = {
    async start() {
      starts += 1;
      if (starts === 1) throw new Error("temporary_start_failure");
    },
    async request() { return { thread: { id: "thread-1" } }; },
    stop() {}
  };
  const pool = createCodexRuntimePool({
    codexCliPath: "codex",
    accounts: [{ id: "account-a", codexHome: "A_HOME" }]
  }, { clientFactory: () => client });

  await pool.prewarm();
  const runtime = await pool.startRuntime({ accountId: "account-a", codexHome: "A_HOME", cwd: "C:\\Project", emit() {} });

  assert.equal(runtime.threadId, "thread-1");
  assert.equal(starts, 2);
});

test("account runtime pool keeps configured accounts isolated even when homes match", async () => {
  let starts = 0;
  const pool = createCodexRuntimePool({
    codexCliPath: "codex",
    accounts: [
      { id: "account-a", codexHome: "SHARED_HOME" },
      { id: "account-b", codexHome: "SHARED_HOME" }
    ]
  }, {
    clientFactory: () => ({
      async start() { starts += 1; },
      async request() { return { thread: { id: `thread-${starts}` } }; },
      stop() {}
    })
  });

  await pool.prewarm();

  assert.equal(starts, 2);
});

test("steers the active turn with the turn id returned by app-server", async () => {
  const requests = [];
  const client = {
    async start() {},
    async request(method, params) {
      requests.push({ method, params });
      if (method === "thread/start") return { thread: { id: "thread-1" } };
      if (method === "turn/start") return { turn: { id: "turn-1" } };
      if (method === "turn/steer") return { turnId: "turn-1" };
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const runtime = await startCodexRuntime({
    codexCliPath: "codex",
    codexHome: "CODEX_HOME",
    cwd: "C:\\Project",
    emit() {},
    clientFactory: () => client
  });

  const startedTurnId = await runtime.sendMessage("Start");
  const steeredTurnId = await runtime.steerMessage("Redirect");

  assert.equal(startedTurnId, "turn-1");
  assert.equal(steeredTurnId, "turn-1");
  assert.deepEqual(requests.at(-1), {
    method: "turn/steer",
    params: {
      threadId: "thread-1",
      expectedTurnId: "turn-1",
      input: [{ type: "text", text: "Redirect" }]
    }
  });
});

test("steers an active resumed turn from thread history", async () => {
  const requests = [];
  const client = {
    async start() {},
    async request(method, params) {
      requests.push({ method, params });
      if (method === "thread/resume") {
        return {
          thread: {
            id: "thread-1",
            turns: [
              { id: "turn-1", status: "completed" },
              { id: "turn-2", status: "inProgress" }
            ]
          }
        };
      }
      if (method === "turn/steer") return { turnId: "turn-2" };
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const runtime = await startCodexRuntime({
    codexCliPath: "codex",
    codexHome: "CODEX_HOME",
    cwd: "C:\\Project",
    threadId: "thread-1",
    emit() {},
    clientFactory: () => client
  });

  const steeredTurnId = await runtime.steerMessage("Redirect");

  assert.equal(steeredTurnId, "turn-2");
  assert.deepEqual(requests.at(-1), {
    method: "turn/steer",
    params: {
      threadId: "thread-1",
      expectedTurnId: "turn-2",
      input: [{ type: "text", text: "Redirect" }]
    }
  });
});

test("starts device-code login and keeps app-server alive until completion", async () => {
  const calls = [];
  let emit;
  const client = {
    async start() { calls.push("start"); },
    async request(method, params) {
      calls.push({ method, params });
      return { type: "chatgptDeviceCode", loginId: "login-1", verificationUrl: "https://example.com/device", userCode: "ABCD-EFGH" };
    },
    stop() { calls.push("stop"); }
  };
  const completed = [];

  const result = await startCodexDeviceLogin({
    codexCliPath: "codex.cmd",
    codexHome: "C:\\CodexHome",
    onComplete: (value) => completed.push(value),
    clientFactory: (_command, _home, callback) => { emit = callback; return client; }
  });

  assert.equal(result.loginId, "login-1");
  assert.equal(result.verificationUrl, "https://example.com/device");
  assert.equal(result.userCode, "ABCD-EFGH");
  assert.equal(typeof result.stop, "function");
  assert.deepEqual(calls, ["start", { method: "account/login/start", params: { type: "chatgptDeviceCode" } }]);

  result.stop();
  assert.deepEqual(calls.at(-1), "stop");

  emit("account.login.completed", { loginId: "login-1", success: true });
  assert.deepEqual(completed, [{ loginId: "login-1", success: true }]);
  assert.deepEqual(calls.at(-1), "stop");
});

test("thread watcher reuses one app-server client and stops cleanly", async () => {
  const calls = [];
  const client = {
    async start() { calls.push("start"); },
    async request(method, params) {
      calls.push({ method, params });
      return { thread: { id: params.threadId, turns: [] } };
    },
    stop() { calls.push("stop"); }
  };
  const snapshots = [];

  const watcher = await startCodexThreadWatcher({
    threadId: "thread-1",
    onThread: (thread) => snapshots.push(thread),
    intervalMs: 60_000,
    clientFactory: () => client
  });
  await new Promise((resolve) => setImmediate(resolve));
  watcher.stop();

  assert.deepEqual(calls, ["start", { method: "thread/read", params: { threadId: "thread-1", includeTurns: true } }, "stop"]);
  assert.deepEqual(snapshots, [{ id: "thread-1", turns: [] }]);
});

test("interrupts the active turn through app-server", async () => {
  const requests = [];
  const client = {
    async start() {},
    async request(method, params) {
      requests.push({ method, params });
      if (method === "thread/start") return { thread: { id: "thread-1" } };
      if (method === "turn/start") return { turn: { id: "turn-1" } };
      if (method === "turn/interrupt") return {};
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const runtime = await startCodexRuntime({
    codexCliPath: "codex",
    codexHome: "CODEX_HOME",
    cwd: "C:\\Project",
    emit() {},
    clientFactory: () => client
  });

  await runtime.sendMessage("Start");
  await runtime.interrupt();

  assert.deepEqual(requests.at(-1), {
    method: "turn/interrupt",
    params: { threadId: "thread-1", turnId: "turn-1" }
  });
});

test("cancels a pending turn start and interrupts a late-created turn", async () => {
  let resolveTurnStart;
  const requests = [];
  const client = {
    async start() {},
    async request(method, params) {
      requests.push({ method, params });
      if (method === "thread/start") return { thread: { id: "thread-1" } };
      if (method === "turn/start") {
        return new Promise((resolve) => { resolveTurnStart = resolve; });
      }
      if (method === "turn/interrupt") return {};
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const runtime = await startCodexRuntime({
    codexCliPath: "codex",
    codexHome: "CODEX_HOME",
    cwd: "C:\\Project",
    emit() {},
    clientFactory: () => client
  });

  const sending = runtime.sendMessage("Start");
  await new Promise((resolve) => setImmediate(resolve));

  const canceling = runtime.cancelPendingStart();
  resolveTurnStart({ turn: { id: "turn-late" } });
  assert.equal(await canceling, true);
  await assert.rejects(sending, /turn_start_canceled/);
  assert.deepEqual(requests.at(-1), {
    method: "turn/interrupt",
    params: { threadId: "thread-1", turnId: "turn-late" }
  });
});

test("interrupts a just-created turn before the session leaves starting state", async () => {
  const requests = [];
  const client = {
    async start() {},
    async request(method, params) {
      requests.push({ method, params });
      if (method === "thread/start") return { thread: { id: "thread-1" } };
      if (method === "turn/start") return { turn: { id: "turn-1" } };
      if (method === "turn/interrupt") return {};
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const runtime = await startCodexRuntime({
    codexCliPath: "codex",
    codexHome: "CODEX_HOME",
    cwd: "C:\\Project",
    emit() {},
    clientFactory: () => client
  });

  await runtime.sendMessage("Start");
  assert.equal(await runtime.cancelPendingStart(), true);
  assert.deepEqual(requests.at(-1), {
    method: "turn/interrupt",
    params: { threadId: "thread-1", turnId: "turn-1" }
  });
});

test("interrupts a notified active turn when turn start later times out", async () => {
  let emit;
  const requests = [];
  const client = {
    async start() {},
    async request(method, params) {
      requests.push({ method, params });
      if (method === "thread/start") return { thread: { id: "thread-1" } };
      if (method === "turn/start") {
        queueMicrotask(() => emit("turn.started", { threadId: "thread-1", turnId: "turn-1" }));
        await new Promise((resolve) => setImmediate(resolve));
        throw new Error("turn/start timeout");
      }
      if (method === "turn/interrupt") return {};
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const runtime = await startCodexRuntime({
    codexCliPath: "codex",
    codexHome: "CODEX_HOME",
    cwd: "C:\\Project",
    emit() {},
    clientFactory: (_command, _home, callback) => { emit = callback; return client; }
  });

  await assert.rejects(runtime.sendMessage("Start"), /turn\/start timeout/);
  assert.deepEqual(requests.at(-1), {
    method: "turn/interrupt",
    params: { threadId: "thread-1", turnId: "turn-1" }
  });
  assert.equal(await runtime.cancelPendingStart(), false);
});

test("keeps a late-start cancellation unconfirmed when interrupt fails", async () => {
  let resolveTurnStart;
  const client = {
    async start() {},
    async request(method, params) {
      if (method === "thread/start") return { thread: { id: "thread-1" } };
      if (method === "turn/start") return new Promise((resolve) => { resolveTurnStart = resolve; });
      if (method === "turn/interrupt") throw new Error("private interrupt failure");
      throw new Error(`unexpected_method:${method}`);
    },
    stop() {}
  };
  const runtime = await startCodexRuntime({
    codexCliPath: "codex",
    codexHome: "CODEX_HOME",
    cwd: "C:\\Project",
    emit() {},
    clientFactory: () => client
  });

  const sending = runtime.sendMessage("Start");
  await new Promise((resolve) => setImmediate(resolve));
  const canceling = runtime.cancelPendingStart();
  resolveTurnStart({ turn: { id: "turn-late" } });

  await assert.rejects(canceling, /turn_stop_unconfirmed/);
  await assert.rejects(sending, /turn_stop_unconfirmed/);
});

test("thread watcher forwards translated runtime notifications", async () => {
  let emit;
  const client = {
    async start() {},
    async request(method, params) {
      return { thread: { id: params.threadId, turns: [] } };
    },
    stop() {}
  };
  const events = [];

  const watcher = await startCodexThreadWatcher({
    threadId: "thread-1",
    onThread() {},
    onEvent: (event, data) => events.push({ event, data }),
    intervalMs: 60_000,
    clientFactory: (_command, _home, callback) => {
      emit = callback;
      return client;
    }
  });
  try {
    if (emit) {
      emit("thread.token_usage", {
        threadId: "thread-1",
        tokenUsage: { last: { totalTokens: 1200 }, total: { totalTokens: 10000 }, modelContextWindow: 4000 }
      });
      emit("turn.completed", {
        turnId: "turn-1",
        status: "completed"
      });
    }
  } finally {
    watcher.stop();
  }

  assert.deepEqual(events, [{
    event: "thread.token_usage",
    data: {
      threadId: "thread-1",
      tokenUsage: { last: { totalTokens: 1200 }, total: { totalTokens: 10000 }, modelContextWindow: 4000 }
    }
  }, {
    event: "turn.completed",
    data: {
      turnId: "turn-1",
      status: "completed"
    }
  }]);
});

test("starts a new thread in the selected workspace", () => {
  assert.deepEqual(getThreadRequest(undefined, "C:\\Project\\Coard"), {
    method: "thread/start",
    params: { approvalPolicy: "on-request", cwd: "C:\\Project\\Coard" }
  });
});

test("resumes the stored thread id", () => {
  assert.deepEqual(getThreadRequest("thread-1"), {
    method: "thread/resume",
    params: { threadId: "thread-1", approvalPolicy: "on-request" }
  });
});

test("builds turn start params with composer options and inputs", () => {
  const result = buildTurnStartParams({
    threadId: "thread-1",
    cwd: "C:\\Project",
    text: "Inspect this",
    options: {
      model: "gpt-5-codex",
      effort: "high",
      serviceTier: "priority",
      permissionProfile: ":workspace",
      approvalPolicy: "untrusted",
      sandbox: "workspace-write",
      personality: "pragmatic",
      planMode: true
    },
    attachments: [{ kind: "image", path: "C:\\Data\\sessions\\s1\\a.png" }],
    skills: [{ name: "imagegen", path: "C:\\Users\\me\\.codex\\skills\\imagegen" }]
  });

  assert.deepEqual(result, {
    threadId: "thread-1",
    cwd: "C:\\Project",
    model: "gpt-5-codex",
    effort: "high",
    serviceTier: "priority",
    approvalPolicy: "untrusted",
    personality: "pragmatic",
    permissions: ":workspace",
    input: [
      { type: "text", text: "Inspect this" },
      { type: "localImage", path: "C:\\Data\\sessions\\s1\\a.png" },
      { type: "skill", name: "imagegen", path: "C:\\Users\\me\\.codex\\skills\\imagegen" },
      {
        type: "text",
        text: "Use Plan mode for this turn. Create a plan first and wait for confirmation before implementation."
      }
    ]
  });
});

test("projects capabilities without local paths", () => {
  const result = publicCapabilitySummary({
    models: {
      data: [{
        id: "model-1",
        model: "gpt-5-codex",
        displayName: "GPT-5 Codex",
        description: "Best for coding",
        isDefault: true,
        inputModalities: ["text", "image"],
        supportedReasoningEfforts: [{ reasoningEffort: "high", description: "Deep" }],
        serviceTiers: [{ id: "priority", name: "Priority", description: "Fast" }]
      }]
    },
    permissions: { data: [{ id: "workspace-write", allowed: true, description: "Workspace write" }] },
    plugins: {
      marketplaces: [{
        name: "local",
        path: "C:\\Users\\me\\.codex\\plugins\\marketplace.json",
        plugins: [{
          id: "plugin-1",
          name: "Plugin One",
          enabled: true,
          installed: true,
          interface: { displayName: "Plugin One", shortDescription: "Useful", logo: "C:\\private\\logo.png" }
        }]
      }]
    },
    skills: {
      data: [{
        cwd: "C:\\Project",
        skills: [{
          name: "imagegen",
          path: "C:\\Users\\me\\.codex\\skills\\imagegen",
          enabled: true,
          description: "Generate images",
          scope: "user",
          interface: { displayName: "Imagegen", shortDescription: "Images" }
        }],
        errors: [{ path: "C:\\secret\\bad", message: "ignored" }]
      }]
    }
  });

  assert.deepEqual(result.plugins[0].plugins[0], {
    id: "plugin-1",
    name: "Plugin One",
    displayName: "Plugin One",
    description: "Useful",
    enabled: true,
    installed: true
  });
  assert.equal(JSON.stringify(result).includes("C:\\Users\\me"), false);
  assert.equal(result.models[0].supportsImages, true);
  assert.equal(result.skills[0].name, "imagegen");
});

test("hides the automatic review model from public model capabilities", () => {
  const model = {
    displayName: "GPT-5.6-Terra",
    description: "Balanced agentic coding model for everyday work.",
    isDefault: false,
    inputModalities: ["text", "image"],
    supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Balanced" }],
    serviceTiers: [],
    supportsPersonality: true
  };
  const result = publicCapabilitySummary({
    models: {
      data: [
        { ...model, id: "codex-auto-review", model: "codex-auto-review" },
        { ...model, id: "gpt-5.6-terra", model: "gpt-5.6-terra" }
      ]
    }
  });

  assert.deepEqual(result.models.map((item) => item.id), ["gpt-5.6-terra"]);
});

test("converts command approval request into a safe mobile card", () => {
  const result = approvalFromServerRequest({
    id: 42,
    method: "item/commandExecution/requestApproval",
    params: {
      itemId: "item-1",
      command: "npm install example",
      cwd: "C:\\Project\\Example",
      reason: "Needs network access"
    }
  });

  assert.deepEqual(result, {
    runtimeRequestId: 42,
    kind: "command",
    itemId: "item-1",
    title: "Command approval required",
    reason: "Needs network access",
    commandPreview: "npm install example",
    cwd: "C:\\Project\\Example"
  });
});

test("maps mobile decisions to app-server decisions", () => {
  assert.equal(reviewDecision("approved"), "accept");
  assert.equal(reviewDecision("approved_for_session"), "acceptForSession");
  assert.equal(reviewDecision("rejected"), "decline");
});

test("preserves app-server approval scopes for backend-only decision handling", () => {
  const result = approvalFromServerRequest({
    id: 44,
    method: "item/commandExecution/requestApproval",
    params: {
      itemId: "item-3",
      command: "npm test",
      proposedExecpolicyAmendment: ["npm", "test"]
    }
  });

  assert.deepEqual(result.proposedExecpolicyAmendment, ["npm", "test"]);
});

test("accepts a proposed execpolicy amendment for similar future commands", () => {
  const approval = {
    kind: "command",
    proposedExecpolicyAmendment: ["npm", "test"]
  };

  assert.deepEqual(approvalResponse(approval, "approved_always"), {
    decision: {
      acceptWithExecpolicyAmendment: {
        execpolicy_amendment: ["npm", "test"]
      }
    }
  });
  assert.throws(
    () => approvalResponse({ kind: "command" }, "approved_always"),
    /approval_scope_unavailable/
  );
});

test("returns requested permissions for one turn when approved", () => {
  const approval = {
    kind: "permissions",
    permissions: {
      network: { enabled: true },
      fileSystem: null
    }
  };

  assert.deepEqual(approvalResponse(approval, "approved"), {
    permissions: { network: { enabled: true } },
    scope: "turn"
  });
  assert.deepEqual(approvalResponse(approval, "rejected"), {
    permissions: {},
    scope: "turn"
  });
  assert.deepEqual(approvalResponse(approval, "approved_for_session"), {
    permissions: { network: { enabled: true } },
    scope: "session"
  });
});

test("preserves requested permission profile for approval response", () => {
  const permissions = {
    network: { enabled: true },
    fileSystem: { read: ["C:\\Project"] }
  };

  const result = approvalFromServerRequest({
    id: 43,
    method: "item/permissions/requestApproval",
    params: { itemId: "item-2", permissions }
  });

  assert.deepEqual(result.permissions, permissions);
});

test("redacts CODEX_HOME from JSON-escaped stderr", () => {
  const codexHome = "C:\\Users\\person\\CodeRemoteManager\\account-a\\codex-home";
  const stderr = JSON.stringify({ path: `${codexHome}\\.tmp\\plugins` });

  const result = redactCodexHome(stderr, codexHome);

  assert.equal(result.includes("person"), false);
  assert.equal(result.includes("[CODEX_HOME]"), true);
});

test("redacts app-server process errors and requests across stdio JSON-RPC", async () => {
  const directory = await mkdtemp(join(tmpdir(), "code-remote-manager-fake-server-"));
  const commandPath = join(directory, "fake-codex.cmd");
  const serverPath = join(directory, "fake-app-server.mjs");
  const codexHome = "C:\\Users\\person\\CodeRemoteManager\\account-a\\codex-home";
  const events = [];

  await writeFile(commandPath, `@echo off\r\nnode "%~dp0fake-app-server.mjs"\r\n`, "utf8");
  await writeFile(serverPath, `
import readline from "node:readline";

const rl = readline.createInterface({ input: process.stdin });
const codexHome = process.env.CODEX_HOME;
process.stderr.write(JSON.stringify({ path: codexHome + "\\\\auth.json", token: "Bearer abc.def" }) + "\\n");

for await (const line of rl) {
  if (!line.trim()) continue;
  const message = JSON.parse(line);
  if (message.method === "initialize") {
    process.stdout.write(JSON.stringify({ id: message.id, result: {} }) + "\\n");
  } else if (message.method === "thread/start") {
    process.stdout.write(JSON.stringify({ id: message.id, result: { thread: { id: "thread-1" } } }) + "\\n");
  } else if (message.method === "turn/start") {
    process.stdout.write(JSON.stringify({
      id: 77,
      method: "item/permissions/requestApproval",
      params: {
        itemId: "approval-item",
        reason: "Need " + codexHome + "\\\\auth.json",
        cwd: codexHome,
        permissions: { network: { enabled: true } }
      }
    }) + "\\n");
    process.stdout.write(JSON.stringify({
      id: message.id,
      error: { message: "Failed at " + codexHome + " with Bearer abc.def and sk-secret" }
    }) + "\\n");
    process.exit(0);
  }
}
`, "utf8");

  try {
    const runtime = await startCodexRuntime({
      codexCliPath: commandPath,
      codexHome,
      cwd: "C:\\Project\\Codex-remote",
      emit: (event, data) => events.push({ event, data })
    });

    await assert.rejects(
      runtime.sendMessage("Start"),
      (error) => {
        assert.equal(error.message.includes("person"), false);
        assert.equal(error.message.includes("abc.def"), false);
        assert.equal(error.message.includes("sk-secret"), false);
        assert.match(error.message, /\[CODEX_HOME\]|\[redacted\]|\[private-path\]/);
        return true;
      }
    );
    await runtime.stop();

    const serializedEvents = JSON.stringify(events);
    assert.equal(serializedEvents.includes("person"), false);
    assert.equal(serializedEvents.includes("abc.def"), false);
    assert.equal(serializedEvents.includes("sk-secret"), false);
    assert.match(serializedEvents, /\[CODEX_HOME\]|\[redacted\]/);
  } finally {
    await rm(directory, { recursive: true, force: true });
  }
});

test("translates turn lifecycle notifications", () => {
  assert.deepEqual(runtimeEventFromNotification("turn/started", {
    turn: { id: "turn-1", status: "inProgress", startedAt: 100 }
  }), {
    event: "turn.started",
    data: { turnId: "turn-1", status: "running", startedAt: 100000 }
  });

  assert.deepEqual(runtimeEventFromNotification("turn/completed", {
    turn: {
      id: "turn-1",
      status: "completed",
      startedAt: 100,
      completedAt: 104,
      durationMs: 4000,
      error: null
    }
  }), {
    event: "turn.completed",
    data: {
      turnId: "turn-1",
      status: "completed",
      startedAt: 100000,
      completedAt: 104000,
      durationMs: 4000
    }
  });
});

test("translates account login completion without credentials", () => {
  assert.deepEqual(runtimeEventFromNotification("account/login/completed", {
    loginId: "login-1",
    success: true,
    accessToken: "must-not-pass"
  }), {
    event: "account.login.completed",
    data: { loginId: "login-1", success: true }
  });
});

test("translates reasoning and plan notifications", () => {
  assert.deepEqual(runtimeEventFromNotification("item/reasoning/summaryTextDelta", {
    turnId: "turn-1",
    itemId: "reason-1",
    delta: "Checking files"
  }), {
    event: "reasoning.delta",
    data: { turnId: "turn-1", itemId: "reason-1", text: "Checking files" }
  });

  assert.deepEqual(runtimeEventFromNotification("turn/plan/updated", {
    turnId: "turn-1",
    plan: [{ step: "Inspect files", status: "inProgress" }]
  }), {
    event: "plan.updated",
    data: {
      turnId: "turn-1",
      plan: [{ step: "Inspect files", status: "inProgress" }]
    }
  });
});

test("translates agent message phase for compact chat routing", () => {
  assert.deepEqual(runtimeEventFromNotification("item/started", {
    turnId: "turn-1",
    startedAtMs: 2000,
    item: { id: "msg-1", type: "agentMessage", phase: "commentary" }
  }), {
    event: "activity.started",
    data: {
      turnId: "turn-1",
      itemId: "msg-1",
      kind: "agentMessage",
      phase: "commentary",
      summary: "Working: agentMessage",
      status: "running",
      startedAt: 2000
    }
  });
});

test("projects nested shell commands from js tool calls as safe command activity", () => {
  assert.deepEqual(runtimeEventFromNotification("item/completed", {
    turnId: "turn-1",
    item: {
      id: "tool-1",
      type: "dynamicToolCall",
      tool: "js",
      arguments: { code: 'await tools.shell_command({"command":"type C:\\\\private\\\\secret.txt"})' },
      status: "completed"
    }
  }), {
    event: "activity.completed",
    data: {
      turnId: "turn-1",
      itemId: "tool-1",
      kind: "commandExecution",
      summary: "PowerShell command",
      status: "completed",
      toolCall: {
        tool: "js",
        arguments: { code: 'await tools.shell_command({"command":"type C:\\\\private\\\\secret.txt"})' }
      }
    }
  });
});

test("marks only backend-owned approval fallback copy for localization", () => {
  const fallback = approvalFromServerRequest({
    id: 43,
    method: "item/commandExecution/requestApproval",
    params: { itemId: "item-fallback" }
  });
  const dynamic = approvalFromServerRequest({
    id: 44,
    method: "item/fileChange/requestApproval",
    params: { itemId: "item-dynamic", reason: "Model-provided reason", grantRoot: "project-child" }
  });

  assert.equal(fallback.reasonKey, "approval.reason.command");
  assert.equal(fallback.commandPreviewKey, "approval.preview.commandUnavailable");
  assert.equal(dynamic.reason, "Model-provided reason");
  assert.equal(dynamic.reasonKey, undefined);
  assert.equal(dynamic.commandPreviewKey, undefined);
});

test("projects tool call metadata for compact activity detail sheets", () => {
  assert.deepEqual(runtimeEventFromNotification("item/completed", {
    turnId: "turn-1",
    item: {
      id: "tool-2",
      type: "mcpToolCall",
      server: "node_repl",
      tool: "js",
      arguments: { code: "await viewportCap.set({ width: 390, height: 844 })" },
      status: "completed",
      durationMs: 700
    }
  }), {
    event: "activity.completed",
    data: {
      turnId: "turn-1",
      itemId: "tool-2",
      kind: "mcpToolCall",
      summary: "node_repl: js",
      status: "completed",
      durationMs: 700,
      toolCall: {
        server: "node_repl",
        tool: "js",
        arguments: { code: "await viewportCap.set({ width: 390, height: 844 })" }
      }
    }
  });
});

test("projects bounded command output and exit code on completion", () => {
  const projected = runtimeEventFromNotification("item/completed", {
    turnId: "turn-command",
    item: {
      id: "command-output",
      type: "commandExecution",
      command: "npm test",
      aggregatedOutput: "passed\nC:\\Users\\person\\secret.txt",
      exitCode: 1,
      status: "failed"
    }
  });

  assert.equal(projected.data.text, "passed\n[private-path]");
  assert.equal(projected.data.exitCode, 1);
  assert.equal(projected.data.status, "failed");
});

test("projects public dynamic and MCP results without image data or credentials", () => {
  const dynamic = runtimeEventFromNotification("item/completed", {
    turnId: "turn-tool-result",
    item: {
      id: "dynamic-result",
      type: "dynamicToolCall",
      tool: "search",
      status: "completed",
      contentItems: [
        { type: "inputText", text: "Found 3 rows" },
        { type: "inputImage", imageUrl: "data:image/png;base64,private" }
      ]
    }
  });
  const failed = runtimeEventFromNotification("item/completed", {
    turnId: "turn-tool-result",
    item: {
      id: "mcp-error",
      type: "mcpToolCall",
      server: "example",
      tool: "lookup",
      status: "failed",
      error: { message: "Bearer secret-token failed at C:\\Private\\auth.json" }
    }
  });

  assert.equal(dynamic.data.text, "Found 3 rows");
  assert.equal(JSON.stringify(dynamic).includes("base64"), false);
  assert.equal(failed.data.error, "Bearer [redacted] failed at [private-path]");
});

test("keeps only bounded allowlisted tool arguments", () => {
  const projected = runtimeEventFromNotification("item/completed", {
    turnId: "turn-safe-tool",
    item: {
      id: "tool-safe-args",
      type: "mcpToolCall",
      server: "browser",
      tool: "open",
      arguments: {
        code: "await viewportCap.set({ width: 390, height: 844 })",
        query: "safe query",
        headers: { authorization: "Basic dXNlcjpwYXNz" },
        access_token: "secret-token",
        unknownPrivatePayload: "must-not-pass"
      },
      status: "completed"
    }
  });

  assert.deepEqual(projected.data.toolCall.arguments, {
    code: "await viewportCap.set({ width: 390, height: 844 })",
    query: "safe query"
  });

  const oversized = runtimeEventFromNotification("item/completed", {
    turnId: "turn-large-tool",
    item: {
      id: "tool-large-args",
      type: "mcpToolCall",
      tool: "js",
      arguments: { code: "x".repeat(5000) },
      status: "completed"
    }
  });
  assert.deepEqual(oversized.data.toolCall.arguments, { notice: "arguments_too_large" });
});

test("keeps file changes on internal runtime activity for workspace validation", () => {
  assert.deepEqual(runtimeEventFromNotification("item/completed", {
    turnId: "turn-1",
    item: {
      id: "files-1",
      type: "fileChange",
      changes: [{ path: "apps/pwa/src/App.jsx" }],
      status: "completed"
    }
  }).data.changes, [{ path: "apps/pwa/src/App.jsx" }]);
});

test("translates thread token usage without hidden content", () => {
  assert.deepEqual(runtimeEventFromNotification("thread/tokenUsage/updated", {
    threadId: "thread-1",
    turnId: "turn-1",
    tokenUsage: {
      total: { totalTokens: 1200, inputTokens: 1000, cachedInputTokens: 500, outputTokens: 200, reasoningOutputTokens: 10 },
      last: { totalTokens: 200 },
      modelContextWindow: 4000
    }
  }), {
    event: "thread.token_usage",
    data: {
      threadId: "thread-1",
      tokenUsage: {
        total: { totalTokens: 1200, inputTokens: 1000, cachedInputTokens: 500, outputTokens: 200, reasoningOutputTokens: 10 },
        last: { totalTokens: 200 },
        modelContextWindow: 4000
      }
    }
  });
});
