import { createHash } from "node:crypto";
import fs from "node:fs";
import fsPromises from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { emitAgentEvent } from "../../infra/agent-events.js";
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
import {
  buildSystemRunApprovalBinding,
  buildSystemRunApprovalEnvBinding,
} from "../../infra/system-run-approval-binding.js";
import { resetLogger, setLoggerOverride } from "../../logging.js";
import { projectRecentChatDisplayMessages } from "../chat-display-projection.js";
import { ExecApprovalManager } from "../exec-approval-manager.js";
import { validateExecApprovalRequestParams } from "../protocol/index.js";
import { waitForAgentJob } from "./agent-job.js";
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js";
import {
  DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
  augmentChatHistoryWithCanvasBlocks,
  resolveEffectiveChatHistoryMaxChars,
  sanitizeChatHistoryMessages,
  sanitizeChatSendMessageInput,
} from "./chat.js";
import { createExecApprovalHandlers } from "./exec-approval.js";
import { logsHandlers } from "./logs.js";

vi.mock("../../commands/status.js", () => ({
  getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
}));

function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
  let count = 0;
  for (const item of items) {
    if (predicate(item)) {
      count += 1;
    }
  }
  return count;
}

function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
  if (!record || typeof record !== "object") {
    throw new Error("Expected record");
  }
  const actual = record as Record<string, unknown>;
  for (const [key, value] of Object.entries(expected)) {
    expect(actual[key]).toEqual(value);
  }
  return actual;
}

function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0) {
  const call = mock.mock.calls[callIndex];
  if (!call) {
    throw new Error(`Expected mock call ${callIndex}`);
  }
  return call[argIndex];
}

function lastMockCallArg(mock: ReturnType<typeof vi.fn>, argIndex = 0) {
  const call = mock.mock.calls.at(-1);
  if (!call) {
    throw new Error("Expected mock call");
  }
  return call[argIndex];
}

describe("waitForAgentJob", () => {
  async function runLifecycleScenario(params: {
    runIdPrefix: string;
    startedAt: number;
    endedAt: number;
    aborted?: boolean;
  }) {
    const runId = `${params.runIdPrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 });

    emitAgentEvent({
      runId,
      stream: "lifecycle",
      data: { phase: "start", startedAt: params.startedAt },
    });
    emitAgentEvent({
      runId,
      stream: "lifecycle",
      data: { phase: "end", endedAt: params.endedAt, aborted: params.aborted },
    });

    return waitPromise;
  }

  it("maps lifecycle end events with aborted=true to timeout after the retry grace window", async () => {
    vi.useFakeTimers();
    try {
      const runId = `run-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}`;
      const snapshotPromise = waitForAgentJob({ runId, timeoutMs: 20_000 });

      emitAgentEvent({
        runId,
        stream: "lifecycle",
        data: { phase: "start", startedAt: 100 },
      });
      emitAgentEvent({
        runId,
        stream: "lifecycle",
        data: { phase: "end", endedAt: 200, aborted: true },
      });

      await vi.advanceTimersByTimeAsync(15_000);
      const snapshot = await snapshotPromise;
      expectRecordFields(snapshot, {
        status: "timeout",
        startedAt: 100,
        endedAt: 200,
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it("keeps non-aborted lifecycle end events as ok", async () => {
    const snapshot = await runLifecycleScenario({
      runIdPrefix: "run-ok",
      startedAt: 300,
      endedAt: 400,
    });
    expectRecordFields(snapshot, {
      status: "ok",
      startedAt: 300,
      endedAt: 400,
    });
  });

  it("ignores transient aborted end events when the same run later succeeds", async () => {
    const runId = `run-timeout-retry-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 });

    emitAgentEvent({
      runId,
      stream: "lifecycle",
      data: { phase: "start", startedAt: 500 },
    });
    emitAgentEvent({
      runId,
      stream: "lifecycle",
      data: { phase: "end", startedAt: 500, endedAt: 600, aborted: true },
    });

    queueMicrotask(() => {
      emitAgentEvent({
        runId,
        stream: "lifecycle",
        data: { phase: "end", startedAt: 500, endedAt: 700 },
      });
    });

    const snapshot = await waitPromise;
    expectRecordFields(snapshot, {
      status: "ok",
      startedAt: 500,
      endedAt: 700,
    });
  });

  it("lets a later aborted timeout replace a pending lifecycle error", async () => {
    vi.useFakeTimers();
    try {
      const runId = `run-error-then-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}`;
      const waitPromise = waitForAgentJob({ runId, timeoutMs: 20_000 });

      emitAgentEvent({
        runId,
        stream: "lifecycle",
        data: { phase: "start", startedAt: 800 },
      });
      emitAgentEvent({
        runId,
        stream: "lifecycle",
        data: { phase: "error", startedAt: 800, endedAt: 900, error: "transient error" },
      });
      emitAgentEvent({
        runId,
        stream: "lifecycle",
        data: { phase: "end", startedAt: 800, endedAt: 1_000, aborted: true },
      });

      await vi.advanceTimersByTimeAsync(15_000);
      const snapshot = await waitPromise;
      expectRecordFields(snapshot, {
        status: "timeout",
        startedAt: 800,
        endedAt: 1_000,
      });
      expect(snapshot?.error).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  it("lets a later lifecycle error replace a pending aborted timeout", async () => {
    vi.useFakeTimers();
    try {
      const runId = `run-timeout-then-error-${Date.now()}-${Math.random().toString(36).slice(2)}`;
      const waitPromise = waitForAgentJob({ runId, timeoutMs: 20_000 });

      emitAgentEvent({
        runId,
        stream: "lifecycle",
        data: { phase: "start", startedAt: 1_100 },
      });
      emitAgentEvent({
        runId,
        stream: "lifecycle",
        data: { phase: "end", startedAt: 1_100, endedAt: 1_200, aborted: true },
      });
      emitAgentEvent({
        runId,
        stream: "lifecycle",
        data: { phase: "error", startedAt: 1_100, endedAt: 1_300, error: "final error" },
      });

      await vi.advanceTimersByTimeAsync(15_000);
      const snapshot = await waitPromise;
      expectRecordFields(snapshot, {
        status: "error",
        startedAt: 1_100,
        endedAt: 1_300,
        error: "final error",
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it("can ignore cached snapshots and wait for fresh lifecycle events", async () => {
    const runId = `run-ignore-cache-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    emitAgentEvent({
      runId,
      stream: "lifecycle",
      data: { phase: "end", startedAt: 100, endedAt: 110 },
    });

    const cached = await waitForAgentJob({ runId, timeoutMs: 1_000 });
    expect(cached?.status).toBe("ok");
    expect(cached?.startedAt).toBe(100);
    expect(cached?.endedAt).toBe(110);

    const freshWait = waitForAgentJob({
      runId,
      timeoutMs: 1_000,
      ignoreCachedSnapshot: true,
    });
    queueMicrotask(() => {
      emitAgentEvent({
        runId,
        stream: "lifecycle",
        data: { phase: "start", startedAt: 200 },
      });
      emitAgentEvent({
        runId,
        stream: "lifecycle",
        data: { phase: "end", startedAt: 200, endedAt: 210 },
      });
    });

    const fresh = await freshWait;
    expect(fresh?.status).toBe("ok");
    expect(fresh?.startedAt).toBe(200);
    expect(fresh?.endedAt).toBe(210);
  });
});

describe("augmentChatHistoryWithCanvasBlocks", () => {
  it("ignores user messages that merely contain canvas-shaped text", () => {
    const previewJson = JSON.stringify({
      kind: "canvas",
      view: {
        backend: "canvas",
        id: "cv_user_text",
        url: "/__openclaw__/canvas/documents/cv_user_text/index.html",
        title: "User pasted preview",
        preferred_height: 240,
      },
      presentation: {
        target: "assistant_message",
      },
    });

    const messages = [
      {
        role: "user",
        content: previewJson,
        timestamp: 1,
      },
      {
        role: "assistant",
        content: "Plain assistant reply",
        timestamp: 2,
      },
    ];

    expect(augmentChatHistoryWithCanvasBlocks(messages)).toEqual(messages);
  });
});

describe("injectTimestamp", () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z"));
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it("prepends a compact timestamp matching formatZonedTimestamp", () => {
    const result = injectTimestamp("Is it the weekend?", {
      timezone: "America/New_York",
    });

    expect(result).toMatch(/^\[Wed 2026-01-28 20:30 EST\] Is it the weekend\?$/);
  });

  it("uses channel envelope format with DOW prefix", () => {
    const now = new Date();
    const expected = formatZonedTimestamp(now, { timeZone: "America/New_York" });

    const result = injectTimestamp("hello", { timezone: "America/New_York" });

    expect(result).toBe(`[Wed ${expected}] hello`);
  });

  it("always uses 24-hour format", () => {
    const result = injectTimestamp("hello", { timezone: "America/New_York" });

    expect(result).toContain("20:30");
    expect(result).not.toContain("PM");
    expect(result).not.toContain("AM");
  });

  it("uses the configured timezone", () => {
    const result = injectTimestamp("hello", { timezone: "America/Chicago" });

    expect(result).toMatch(/^\[Wed 2026-01-28 19:30 CST\]/);
  });

  it("defaults to UTC when no timezone specified", () => {
    const result = injectTimestamp("hello", {});

    expect(result).toMatch(/^\[Thu 2026-01-29 01:30/);
  });

  it("returns empty/whitespace messages unchanged", () => {
    expect(injectTimestamp("", { timezone: "UTC" })).toBe("");
    expect(injectTimestamp("   ", { timezone: "UTC" })).toBe("   ");
  });

  it("does NOT double-stamp messages with channel envelope timestamps", () => {
    const enveloped = "[Discord user1 2026-01-28 20:30 EST] hello there";
    const result = injectTimestamp(enveloped, { timezone: "America/New_York" });

    expect(result).toBe(enveloped);
  });

  it("does NOT double-stamp messages already injected by us", () => {
    const alreadyStamped = "[Wed 2026-01-28 20:30 EST] hello there";
    const result = injectTimestamp(alreadyStamped, { timezone: "America/New_York" });

    expect(result).toBe(alreadyStamped);
  });

  it("does NOT double-stamp messages with cron-injected timestamps", () => {
    const cronMessage =
      "[cron:abc123 my-job] do the thing\nCurrent time: Wednesday, January 28th, 2026 — 8:30 PM (America/New_York)";
    const result = injectTimestamp(cronMessage, { timezone: "America/New_York" });

    expect(result).toBe(cronMessage);
  });

  it("handles midnight correctly", () => {
    vi.setSystemTime(new Date("2026-02-01T05:00:00.000Z"));

    const result = injectTimestamp("hello", { timezone: "America/New_York" });

    expect(result).toMatch(/^\[Sun 2026-02-01 00:00 EST\]/);
  });

  it("handles date boundaries (just before midnight)", () => {
    vi.setSystemTime(new Date("2026-02-01T04:59:00.000Z"));

    const result = injectTimestamp("hello", { timezone: "America/New_York" });

    expect(result).toMatch(/^\[Sat 2026-01-31 23:59 EST\]/);
  });

  it("handles DST correctly (same UTC hour, different local time)", () => {
    vi.setSystemTime(new Date("2026-01-15T05:00:00.000Z"));
    const winter = injectTimestamp("winter", { timezone: "America/New_York" });
    expect(winter).toMatch(/^\[Thu 2026-01-15 00:00 EST\]/);

    vi.setSystemTime(new Date("2026-07-15T04:00:00.000Z"));
    const summer = injectTimestamp("summer", { timezone: "America/New_York" });
    expect(summer).toMatch(/^\[Wed 2026-07-15 00:00 EDT\]/);
  });

  it("accepts a custom now date", () => {
    const customDate = new Date("2025-07-04T16:00:00.000Z");

    const result = injectTimestamp("fireworks?", {
      timezone: "America/New_York",
      now: customDate,
    });

    expect(result).toMatch(/^\[Fri 2025-07-04 12:00 EDT\]/);
  });
});

describe("sanitizeChatHistoryMessages", () => {
  it("redacts base64 audio content blocks from chat history", () => {
    const data = Buffer.from("voice-bytes").toString("base64");
    const result = sanitizeChatHistoryMessages([
      {
        role: "assistant",
        content: [
          { type: "text", text: "Audio reply" },
          {
            type: "audio",
            source: {
              type: "base64",
              media_type: "audio/mp3",
              data,
            },
          },
        ],
        timestamp: 1,
      },
    ]);

    expect(result).toEqual([
      {
        role: "assistant",
        content: [
          { type: "text", text: "Audio reply" },
          {
            type: "audio",
            source: {
              type: "base64",
              media_type: "audio/mp3",
              omitted: true,
              bytes: Buffer.byteLength(data, "utf8"),
            },
          },
        ],
        timestamp: 1,
      },
    ]);
  });

  it("drops commentary-only assistant entries when phase exists only in textSignature", () => {
    const result = sanitizeChatHistoryMessages([
      {
        role: "user",
        content: [{ type: "text", text: "hello" }],
        timestamp: 1,
      },
      {
        role: "assistant",
        content: [
          {
            type: "text",
            text: "thinking like caveman",
            textSignature: JSON.stringify({ v: 1, id: "msg_commentary", phase: "commentary" }),
          },
        ],
        timestamp: 2,
      },
      {
        role: "assistant",
        content: [{ type: "text", text: "real reply" }],
        timestamp: 3,
      },
    ]);

    expect(result).toEqual([
      {
        role: "user",
        content: [{ type: "text", text: "hello" }],
        timestamp: 1,
      },
      {
        role: "assistant",
        content: [{ type: "text", text: "real reply" }],
        timestamp: 3,
      },
    ]);
  });
});

describe("projectRecentChatDisplayMessages", () => {
  it("keeps visible assistant progress text from mixed tool-use messages", () => {
    const result = projectRecentChatDisplayMessages([
      {
        role: "user",
        content: [{ type: "text", text: "fix it" }],
        timestamp: 1,
      },
      {
        role: "assistant",
        content: [
          { type: "thinking", thinking: "private reasoning" },
          {
            type: "text",
            text: "I will clean that up now.",
            textSignature: JSON.stringify({
              v: 1,
              id: "msg-progress",
              phase: "commentary",
            }),
          },
          {
            type: "toolCall",
            id: "call-read",
            name: "read",
            arguments: { path: "AGENTS.md" },
          },
        ],
        timestamp: 2,
        __openclaw: { seq: 2 },
      },
      {
        role: "toolResult",
        toolCallId: "call-read",
        toolName: "read",
        content: [{ type: "text", text: "file contents" }],
        timestamp: 3,
      },
    ]);

    expect(result[1]).toEqual({
      role: "assistant",
      content: [{ type: "text", text: "I will clean that up now." }],
      timestamp: 2,
      __openclaw: { seq: 2 },
    });
  });

  it("keeps pure commentary assistant messages hidden", () => {
    const result = projectRecentChatDisplayMessages([
      {
        role: "user",
        content: [{ type: "text", text: "status" }],
        timestamp: 1,
      },
      {
        role: "assistant",
        content: [
          {
            type: "text",
            text: "Working...",
            textSignature: JSON.stringify({
              v: 1,
              id: "msg-commentary",
              phase: "commentary",
            }),
          },
        ],
        timestamp: 2,
      },
    ]);

    expect(result).toEqual([
      {
        role: "user",
        content: [{ type: "text", text: "status" }],
        timestamp: 1,
      },
    ]);
  });

  it("applies history limits after dropping display-hidden messages", () => {
    const result = projectRecentChatDisplayMessages(
      [
        { role: "user", content: "older visible", timestamp: 1 },
        { role: "assistant", content: "older answer", timestamp: 2 },
        { role: "assistant", content: "NO_REPLY", timestamp: 3 },
        { role: "assistant", content: "ANNOUNCE_SKIP", timestamp: 4 },
      ],
      { maxMessages: 1 },
    );

    expect(result).toEqual([{ role: "assistant", content: "older answer", timestamp: 2 }]);
  });

  it("keeps media-only user messages while dropping empty text-only user messages", () => {
    const mediaOnly = {
      role: "user",
      content: "",
      MediaPath: "/tmp/openclaw/user-upload.png",
      timestamp: 1,
    };
    const multiMediaOnly = {
      role: "user",
      content: "",
      MediaPaths: ["/tmp/openclaw/first.png", "/tmp/openclaw/second.jpg"],
      timestamp: 2,
    };
    const result = projectRecentChatDisplayMessages([
      mediaOnly,
      multiMediaOnly,
      { role: "user", content: "", timestamp: 3 },
    ]);

    expect(result).toEqual([mediaOnly, multiMediaOnly]);
  });

  it("merges delayed TTS supplements into their original assistant message", () => {
    const visibleText = "**Here** is the answer.";
    const spokenText = "Here is the answer.";
    const textSha256 = createHash("sha256").update(visibleText).digest("hex");

    const result = projectRecentChatDisplayMessages([
      {
        role: "user",
        content: [{ type: "text", text: "first" }],
        timestamp: 1,
      },
      {
        role: "assistant",
        content: [{ type: "text", text: visibleText }],
        timestamp: 2,
      },
      {
        role: "user",
        content: [{ type: "text", text: "second" }],
        timestamp: 3,
      },
      {
        role: "assistant",
        content: [
          { type: "text", text: "Audio reply" },
          {
            type: "attachment",
            attachment: {
              url: "/tmp/tts.mp3",
              kind: "audio",
              label: "tts.mp3",
              mimeType: "audio/mpeg",
            },
          },
        ],
        openclawTtsSupplement: { textSha256, spokenText },
        timestamp: 4,
      },
    ]);

    expect(result).toEqual([
      {
        role: "user",
        content: [{ type: "text", text: "first" }],
        timestamp: 1,
      },
      {
        role: "assistant",
        content: [
          { type: "text", text: visibleText },
          {
            type: "attachment",
            attachment: {
              url: "/tmp/tts.mp3",
              kind: "audio",
              label: "tts.mp3",
              mimeType: "audio/mpeg",
            },
          },
        ],
        timestamp: 2,
      },
      {
        role: "user",
        content: [{ type: "text", text: "second" }],
        timestamp: 3,
      },
    ]);
  });

  it("merges delayed TTS supplements when directive tags are stripped for display", () => {
    const rawVisibleText = "[[reply_to_current]]Visible answer.";
    const projectedVisibleText = "Visible answer.";
    const textSha256 = createHash("sha256").update(projectedVisibleText).digest("hex");

    const result = projectRecentChatDisplayMessages([
      {
        role: "assistant",
        content: [{ type: "text", text: rawVisibleText }],
        timestamp: 1,
      },
      {
        role: "assistant",
        content: [
          { type: "text", text: "Audio reply" },
          {
            type: "attachment",
            attachment: {
              url: "/tmp/tts.mp3",
              kind: "audio",
              label: "tts.mp3",
              mimeType: "audio/mpeg",
            },
          },
        ],
        openclawTtsSupplement: { textSha256 },
        timestamp: 2,
      },
    ]);

    expect(result).toEqual([
      {
        role: "assistant",
        content: [
          { type: "text", text: projectedVisibleText },
          {
            type: "attachment",
            attachment: {
              url: "/tmp/tts.mp3",
              kind: "audio",
              label: "tts.mp3",
              mimeType: "audio/mpeg",
            },
          },
        ],
        timestamp: 1,
      },
    ]);
  });

  it("merges delayed TTS supplements before display truncation", () => {
    const projectedVisibleText = "Visible answer ".repeat(8).trim();
    const rawVisibleText = `[[reply_to_current]]${projectedVisibleText}`;
    const textSha256 = createHash("sha256").update(projectedVisibleText).digest("hex");

    const result = projectRecentChatDisplayMessages(
      [
        {
          role: "assistant",
          content: [{ type: "text", text: rawVisibleText }],
          timestamp: 1,
        },
        {
          role: "assistant",
          content: [
            { type: "text", text: "Audio reply" },
            {
              type: "attachment",
              attachment: {
                url: "/tmp/tts.mp3",
                kind: "audio",
                label: "tts.mp3",
                mimeType: "audio/mpeg",
              },
            },
          ],
          openclawTtsSupplement: { textSha256 },
          timestamp: 2,
        },
      ],
      { maxChars: 24 },
    );

    expect(result).toEqual([
      {
        role: "assistant",
        content: [
          { type: "text", text: `${projectedVisibleText.slice(0, 24)}\n...(truncated)...` },
          {
            type: "attachment",
            attachment: {
              url: "/tmp/tts.mp3",
              kind: "audio",
              label: "tts.mp3",
              mimeType: "audio/mpeg",
            },
          },
        ],
        timestamp: 1,
      },
    ]);
  });

  it("does not merge visible TTS finals into an older identical assistant message", () => {
    const visibleText = "Done.";
    const textSha256 = createHash("sha256").update(visibleText).digest("hex");
    const ttsSupplement = { textSha256 };

    const result = projectRecentChatDisplayMessages([
      {
        role: "assistant",
        content: [{ type: "text", text: visibleText }],
        timestamp: 1,
      },
      {
        role: "user",
        content: [{ type: "text", text: "again" }],
        timestamp: 2,
      },
      {
        role: "assistant",
        content: [
          { type: "text", text: visibleText },
          {
            type: "attachment",
            attachment: {
              url: "/tmp/tts.mp3",
              kind: "audio",
              label: "tts.mp3",
              mimeType: "audio/mpeg",
            },
          },
        ],
        openclawTtsSupplement: ttsSupplement,
        timestamp: 3,
      },
    ]);

    expect(result).toEqual([
      {
        role: "assistant",
        content: [{ type: "text", text: visibleText }],
        timestamp: 1,
      },
      {
        role: "user",
        content: [{ type: "text", text: "again" }],
        timestamp: 2,
      },
      {
        role: "assistant",
        content: [
          { type: "text", text: visibleText },
          {
            type: "attachment",
            attachment: {
              url: "/tmp/tts.mp3",
              kind: "audio",
              label: "tts.mp3",
              mimeType: "audio/mpeg",
            },
          },
        ],
        openclawTtsSupplement: ttsSupplement,
        timestamp: 3,
      },
    ]);
  });
});

describe("resolveEffectiveChatHistoryMaxChars", () => {
  it("uses gateway.webchat.chatHistoryMaxChars when RPC maxChars is absent", () => {
    expect(
      resolveEffectiveChatHistoryMaxChars(
        { gateway: { webchat: { chatHistoryMaxChars: 123 } } },
        undefined,
      ),
    ).toBe(123);
  });

  it("prefers RPC maxChars over config", () => {
    expect(
      resolveEffectiveChatHistoryMaxChars(
        { gateway: { webchat: { chatHistoryMaxChars: 123 } } },
        45,
      ),
    ).toBe(45);
  });

  it("falls back to the default hardcoded limit", () => {
    expect(resolveEffectiveChatHistoryMaxChars({}, undefined)).toBe(
      DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
    );
  });
});

describe("timestampOptsFromConfig", () => {
  it.each([
    {
      name: "extracts timezone from config",
      cfg: { agents: { defaults: { userTimezone: "America/Chicago" } } } as any,
      expected: "America/Chicago",
    },
    {
      name: "falls back gracefully with empty config",
      cfg: {} as any,
      expected: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
    },
  ])("$name", ({ cfg, expected }) => {
    expect(timestampOptsFromConfig(cfg).timezone).toBe(expected);
  });
});

describe("normalizeRpcAttachmentsToChatAttachments", () => {
  it.each([
    {
      name: "passes through string content",
      attachments: [{ type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }],
      expected: [{ type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }],
    },
    {
      name: "converts Uint8Array content to base64",
      attachments: [{ content: new TextEncoder().encode("foo") }],
      expected: [{ type: undefined, mimeType: undefined, fileName: undefined, content: "Zm9v" }],
    },
    {
      name: "converts ArrayBuffer content to base64",
      attachments: [{ content: new TextEncoder().encode("bar").buffer }],
      expected: [{ type: undefined, mimeType: undefined, fileName: undefined, content: "YmFy" }],
    },
    {
      name: "drops attachments without usable content",
      attachments: [{ content: undefined }, { mimeType: "image/png" }],
      expected: [],
    },
  ])("$name", ({ attachments, expected }) => {
    expect(normalizeRpcAttachmentsToChatAttachments(attachments)).toEqual(expected);
  });

  it("accepts dashboard image attachments with nested base64 source", () => {
    const res = normalizeRpcAttachmentsToChatAttachments([
      {
        type: "image",
        source: {
          type: "base64",
          media_type: "image/png",
          data: "Zm9v",
        },
      },
    ]);
    expect(res).toEqual([
      {
        type: "image",
        mimeType: "image/png",
        fileName: undefined,
        content: "Zm9v",
      },
    ]);
  });
});

describe("sanitizeChatSendMessageInput", () => {
  it.each([
    {
      name: "rejects null bytes",
      input: "before\u0000after",
      expected: { ok: false as const, error: "message must not contain null bytes" },
    },
    {
      name: "strips unsafe control characters while preserving tab/newline/carriage return",
      input: "a\u0001b\tc\nd\re\u0007f\u007f",
      expected: { ok: true as const, message: "ab\tc\nd\ref" },
    },
    {
      name: "normalizes unicode to NFC",
      input: "Cafe\u0301",
      expected: { ok: true as const, message: "Café" },
    },
  ])("$name", ({ input, expected }) => {
    expect(sanitizeChatSendMessageInput(input)).toEqual(expected);
  });
});

describe("gateway chat transcript writes (guardrail)", () => {
  it("routes transcript writes through helper and async parentId append", () => {
    const chatTs = fileURLToPath(new URL("./chat.ts", import.meta.url));
    const chatSrc = fs.readFileSync(chatTs, "utf-8");
    const helperTs = fileURLToPath(new URL("./chat-transcript-inject.ts", import.meta.url));
    const helperSrc = fs.readFileSync(helperTs, "utf-8");

    expect(chatSrc.includes("fs.appendFileSync(transcriptPath")).toBe(false);
    expect(chatSrc).toContain("appendInjectedAssistantMessageToTranscript(");

    expect(helperSrc).toContain("appendSessionTranscriptMessage({");
    expect(helperSrc).toContain("useRawWhenLinear: true");
    expect(helperSrc).not.toContain("SessionManager.open(params.transcriptPath)");
  });
});

describe("exec approval handlers", () => {
  const execApprovalNoop = () => false;
  type ExecApprovalHandlers = ReturnType<typeof createExecApprovalHandlers>;
  type ExecApprovalGetArgs = Parameters<ExecApprovalHandlers["exec.approval.get"]>[0];
  type ExecApprovalRequestArgs = Parameters<ExecApprovalHandlers["exec.approval.request"]>[0];
  type ExecApprovalResolveArgs = Parameters<ExecApprovalHandlers["exec.approval.resolve"]>[0];

  const defaultExecApprovalRequestParams = {
    command: "echo ok",
    commandArgv: ["echo", "ok"],
    systemRunPlan: {
      argv: ["/usr/bin/echo", "ok"],
      cwd: "/tmp",
      commandText: "/usr/bin/echo ok",
      agentId: "main",
      sessionKey: "agent:main:main",
    },
    cwd: "/tmp",
    nodeId: "node-1",
    host: "node",
    timeoutMs: 2000,
  } as const;

  function toExecApprovalRequestContext(context: {
    broadcast: (event: string, payload: unknown) => void;
    hasExecApprovalClients?: () => boolean;
  }): ExecApprovalRequestArgs["context"] {
    return context as unknown as ExecApprovalRequestArgs["context"];
  }

  function toExecApprovalResolveContext(context: {
    broadcast: (event: string, payload: unknown) => void;
  }): ExecApprovalResolveArgs["context"] {
    return context as unknown as ExecApprovalResolveArgs["context"];
  }

  async function getExecApproval(params: {
    handlers: ExecApprovalHandlers;
    id: string;
    respond: ReturnType<typeof vi.fn>;
    client?: ExecApprovalGetArgs["client"];
  }) {
    return params.handlers["exec.approval.get"]({
      params: { id: params.id } as ExecApprovalGetArgs["params"],
      respond: params.respond as unknown as ExecApprovalGetArgs["respond"],
      context: {} as ExecApprovalGetArgs["context"],
      client: params.client ?? null,
      req: { id: "req-get", type: "req", method: "exec.approval.get" },
      isWebchatConnect: execApprovalNoop,
    });
  }

  async function listExecApprovals(params: {
    handlers: ExecApprovalHandlers;
    respond: ReturnType<typeof vi.fn>;
    client?: ExecApprovalResolveArgs["client"];
  }) {
    return params.handlers["exec.approval.list"]({
      params: {} as never,
      respond: params.respond as never,
      context: {} as never,
      client: params.client ?? null,
      req: { id: "req-list", type: "req", method: "exec.approval.list" },
      isWebchatConnect: execApprovalNoop,
    });
  }

  async function requestExecApproval(params: {
    handlers: ExecApprovalHandlers;
    respond: ReturnType<typeof vi.fn>;
    context: { broadcast: (event: string, payload: unknown) => void };
    params?: Record<string, unknown>;
    client?: ExecApprovalRequestArgs["client"];
  }) {
    const requestParams = {
      ...defaultExecApprovalRequestParams,
      ...params.params,
    } as unknown as ExecApprovalRequestArgs["params"];
    const hasExplicitPlan = !!params.params && Object.hasOwn(params.params, "systemRunPlan");
    if (
      !hasExplicitPlan &&
      (requestParams as { host?: string }).host === "node" &&
      Array.isArray((requestParams as { commandArgv?: unknown }).commandArgv)
    ) {
      const commandArgv = (requestParams as { commandArgv: unknown[] }).commandArgv.map((entry) =>
        String(entry),
      );
      const cwdValue =
        typeof (requestParams as { cwd?: unknown }).cwd === "string"
          ? ((requestParams as { cwd: string }).cwd ?? null)
          : null;
      const commandText =
        typeof (requestParams as { command?: unknown }).command === "string"
          ? ((requestParams as { command: string }).command ?? null)
          : null;
      requestParams.systemRunPlan = {
        argv: commandArgv,
        cwd: cwdValue,
        commandText: commandText ?? commandArgv.join(" "),
        agentId:
          typeof (requestParams as { agentId?: unknown }).agentId === "string"
            ? ((requestParams as { agentId: string }).agentId ?? null)
            : null,
        sessionKey:
          typeof (requestParams as { sessionKey?: unknown }).sessionKey === "string"
            ? ((requestParams as { sessionKey: string }).sessionKey ?? null)
            : null,
      };
    }
    return params.handlers["exec.approval.request"]({
      params: requestParams,
      respond: params.respond as unknown as ExecApprovalRequestArgs["respond"],
      context: toExecApprovalRequestContext({
        hasExecApprovalClients: () => true,
        ...params.context,
      }),
      client: params.client ?? null,
      req: { id: "req-1", type: "req", method: "exec.approval.request" },
      isWebchatConnect: execApprovalNoop,
    });
  }

  async function resolveExecApproval(params: {
    handlers: ExecApprovalHandlers;
    id: string;
    decision?: "allow-once" | "allow-always" | "deny";
    respond: ReturnType<typeof vi.fn>;
    context: { broadcast: (event: string, payload: unknown) => void };
    client?: ExecApprovalResolveArgs["client"];
  }) {
    return params.handlers["exec.approval.resolve"]({
      params: {
        id: params.id,
        decision: params.decision ?? "allow-once",
      } as ExecApprovalResolveArgs["params"],
      respond: params.respond as unknown as ExecApprovalResolveArgs["respond"],
      context: toExecApprovalResolveContext(params.context),
      client: params.client ?? null,
      req: { id: "req-2", type: "req", method: "exec.approval.resolve" },
      isWebchatConnect: execApprovalNoop,
    });
  }

  function createExecApprovalFixture(opts?: { config?: OpenClawConfig }) {
    const manager = new ExecApprovalManager();
    const handlers = createExecApprovalHandlers(manager);
    const broadcasts: Array<{ event: string; payload: unknown }> = [];
    const respond = vi.fn();
    const context = {
      getRuntimeConfig: () => opts?.config ?? {},
      broadcast: (event: string, payload: unknown) => {
        broadcasts.push({ event, payload });
      },
      hasExecApprovalClients: () => true,
    };
    return { manager, handlers, broadcasts, respond, context };
  }

  function getRequestedExecApprovalPayload(
    broadcasts: Array<{ event: string; payload: unknown }>,
  ): { id: string; request: Record<string, unknown> } {
    const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
    if (!requested) {
      throw new Error("exec approval requested broadcast missing");
    }
    const payload = requested.payload as { id?: unknown; request?: Record<string, unknown> };
    if (typeof payload.id !== "string" || payload.id.length === 0) {
      throw new Error("exec approval requested id missing");
    }
    return {
      id: payload.id,
      request: payload.request ?? {},
    };
  }

  function createForwardingExecApprovalFixture(opts?: {
    iosPushDelivery?: {
      handleRequested: ReturnType<typeof vi.fn>;
      handleResolved: ReturnType<typeof vi.fn>;
      handleExpired: ReturnType<typeof vi.fn>;
    };
  }) {
    const manager = new ExecApprovalManager();
    const forwarder = {
      handleRequested: vi.fn(async () => false),
      handleResolved: vi.fn(async () => {}),
      stop: vi.fn(),
    };
    const handlers = createExecApprovalHandlers(manager, {
      forwarder,
      iosPushDelivery: opts?.iosPushDelivery as never,
    });
    const respond = vi.fn();
    const context = {
      broadcast: (_event: string, _payload: unknown) => {},
      hasExecApprovalClients: () => false,
    };
    return {
      manager,
      handlers,
      forwarder,
      iosPushDelivery: opts?.iosPushDelivery,
      respond,
      context,
    };
  }

  async function drainApprovalRequestTicks() {
    for (let idx = 0; idx < 20; idx += 1) {
      await Promise.resolve();
    }
  }

  describe("ExecApprovalRequestParams validation", () => {
    const baseParams = {
      command: "echo hi",
      cwd: "/tmp",
      nodeId: "node-1",
      host: "node",
    };

    it.each([
      { label: "omitted", extra: {} },
      { label: "string", extra: { resolvedPath: "/usr/bin/echo" } },
      { label: "undefined", extra: { resolvedPath: undefined } },
      { label: "null", extra: { resolvedPath: null } },
    ])("accepts request with resolvedPath $label", ({ extra }) => {
      const params = { ...baseParams, ...extra };
      expect(validateExecApprovalRequestParams(params)).toBe(true);
    });
  });

  it("rejects host=node approval requests without nodeId", async () => {
    const { handlers, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        nodeId: undefined,
      },
    });
    expect(mockCallArg(respond)).toBe(false);
    expect(mockCallArg(respond, 0, 1)).toBeUndefined();
    expectRecordFields(mockCallArg(respond, 0, 2), {
      message: "nodeId is required for host=node",
    });
  });

  it("rejects host=node approval requests without systemRunPlan", async () => {
    const { handlers, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        systemRunPlan: undefined,
      },
    });
    expect(mockCallArg(respond)).toBe(false);
    expect(mockCallArg(respond, 0, 1)).toBeUndefined();
    expectRecordFields(mockCallArg(respond, 0, 2), {
      message: "systemRunPlan is required for host=node",
    });
  });

  it("rejects whitespace-only approval commands without trimming display text", async () => {
    const { handlers, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        command: "   ",
        host: "gateway",
        nodeId: undefined,
        systemRunPlan: undefined,
      },
    });
    expect(mockCallArg(respond)).toBe(false);
    expect(mockCallArg(respond, 0, 1)).toBeUndefined();
    expectRecordFields(mockCallArg(respond, 0, 2), { message: "command is required" });
  });

  it("rejects approval requests when the command display would be truncated", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        command: `printf visible # ${"A".repeat(18 * 1024)}\nprintf hidden`,
        host: "gateway",
        nodeId: undefined,
        systemRunPlan: undefined,
      },
    });

    expect(mockCallArg(respond)).toBe(false);
    expect(mockCallArg(respond, 0, 1)).toBeUndefined();
    expectRecordFields(mockCallArg(respond, 0, 2), {
      message: "command exceeds exec approval display limit",
    });
    expectRecordFields((mockCallArg(respond, 0, 2) as { details?: unknown }).details, {
      reason: "EXEC_APPROVAL_COMMAND_DISPLAY_LIMIT",
    });
    expect(broadcasts).toEqual([]);
  });

  it("returns pending approval details for exec.approval.get", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();

    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        twoPhase: true,
        host: "gateway",
        command: "echo ok",
        commandArgv: ["echo", "ok"],
        systemRunPlan: undefined,
        nodeId: undefined,
      },
    });

    const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
    const id = (requested?.payload as { id?: string })?.id ?? "";
    expect(id).not.toBe("");

    const getRespond = vi.fn();
    await getExecApproval({ handlers, id, respond: getRespond });

    expect(mockCallArg(getRespond)).toBe(true);
    const approval = mockCallArg(getRespond, 0, 1) as Record<string, unknown>;
    expectRecordFields(approval, {
      id,
      commandText: "echo ok",
      host: "gateway",
      nodeId: null,
      agentId: null,
    });
    expect(approval.allowedDecisions).toEqual(["allow-once", "allow-always", "deny"]);
    expect(mockCallArg(getRespond, 0, 2)).toBeUndefined();

    const resolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id,
      respond: resolveRespond,
      context,
    });
    await requestPromise;
  });

  it("attaches shared command analysis to gateway exec approval requests", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();

    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        twoPhase: true,
        host: "gateway",
        command: "python3 -c 'print(1)'",
        commandArgv: ["python3", "script.py"],
        systemRunPlan: undefined,
        nodeId: undefined,
      },
    });

    const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
    const request = requested?.payload as { id?: string; request?: { commandAnalysis?: unknown } };
    const commandAnalysis = request.request?.commandAnalysis as Record<string, unknown>;
    expect(commandAnalysis.commandCount).toBe(1);
    expect(commandAnalysis.riskKinds).toEqual(["inline-eval"]);
    expect(commandAnalysis.warningLines).toEqual(["Contains inline-eval: python3 -c"]);

    const resolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id: request.id ?? "",
      respond: resolveRespond,
      context,
    });
    await requestPromise;
  });

  it("lists pending exec approvals", async () => {
    const { handlers, respond, context } = createExecApprovalFixture();
    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        id: "approval-list-1",
        twoPhase: true,
        host: "gateway",
        systemRunPlan: undefined,
        nodeId: undefined,
      },
    });

    const listRespond = vi.fn();
    await listExecApprovals({ handlers, respond: listRespond });

    expect(mockCallArg(listRespond)).toBe(true);
    const approvals = mockCallArg(listRespond, 0, 1) as Array<Record<string, unknown>>;
    const approval = approvals.find((entry) => entry.id === "approval-list-1");
    expectRecordFields(approval, { id: "approval-list-1" });
    expectRecordFields((approval as Record<string, unknown>).request, { command: "echo ok" });
    expect(mockCallArg(listRespond, 0, 2)).toBeUndefined();

    const resolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id: "approval-list-1",
      respond: resolveRespond,
      context,
    });
    await requestPromise;
  });

  it("lists and resolves only exec approvals owned by the caller", async () => {
    const manager = new ExecApprovalManager();
    const handlers = createExecApprovalHandlers(manager);
    const context = {
      broadcast: (_event: string, _payload: unknown) => {},
    };
    const ownerClient = {
      connId: "conn-owner",
      connect: {
        client: { id: "client-owner" },
        device: { id: "device-owner" },
      },
    } as unknown as ExecApprovalResolveArgs["client"];
    const otherClient = {
      connId: "conn-other",
      connect: {
        client: { id: "client-other" },
        device: { id: "device-other" },
      },
    } as unknown as ExecApprovalResolveArgs["client"];

    const visible = manager.create({ command: "echo visible" }, 60_000, "approval-abcd-visible");
    visible.requestedByDeviceId = "device-owner";
    visible.requestedByConnId = "conn-owner";
    visible.requestedByClientId = "client-owner";
    void manager.register(visible, 60_000);

    const hidden = manager.create({ command: "echo hidden" }, 60_000, "approval-abcd-hidden");
    hidden.requestedByDeviceId = "device-other";
    hidden.requestedByConnId = "conn-other";
    hidden.requestedByClientId = "client-other";
    void manager.register(hidden, 60_000);

    const listRespond = vi.fn();
    await listExecApprovals({ handlers, respond: listRespond, client: ownerClient });
    expect(mockCallArg(listRespond)).toBe(true);
    const approvals = mockCallArg(listRespond, 0, 1) as Array<Record<string, unknown>>;
    expect(approvals.map((entry) => entry.id)).toEqual(["approval-abcd-visible"]);

    const resolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id: "approval-abcd",
      respond: resolveRespond,
      context,
      client: ownerClient,
    });
    expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
    expect(manager.getSnapshot(visible.id)?.decision).toBe("allow-once");
    expect(manager.getSnapshot(hidden.id)?.decision).toBeUndefined();

    const hiddenRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id: hidden.id,
      respond: hiddenRespond,
      context,
      client: ownerClient,
    });
    expect(mockCallArg(hiddenRespond)).toBe(false);
    expectRecordFields(mockCallArg(hiddenRespond, 0, 2), {
      code: "INVALID_REQUEST",
      message: "unknown or expired approval id",
    });
    expect(manager.getSnapshot(hidden.id)?.decision).toBeUndefined();

    const otherRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id: hidden.id,
      respond: otherRespond,
      context,
      client: otherClient,
    });
    expect(otherRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
  });

  it("returns not found for stale exec.approval.get ids", async () => {
    const { handlers, respond, context } = createExecApprovalFixture();

    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context,
      params: { twoPhase: true, host: "gateway", systemRunPlan: undefined, nodeId: undefined },
    });
    const acceptedId = respond.mock.calls.find((call) => call[1]?.status === "accepted")?.[1]?.id;
    expect(typeof acceptedId).toBe("string");

    const resolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id: acceptedId as string,
      respond: resolveRespond,
      context,
    });
    await requestPromise;

    const getRespond = vi.fn();
    await getExecApproval({ handlers, id: acceptedId as string, respond: getRespond });
    expect(mockCallArg(getRespond)).toBe(false);
    expect(mockCallArg(getRespond, 0, 1)).toBeUndefined();
    expectRecordFields(mockCallArg(getRespond, 0, 2), {
      code: "INVALID_REQUEST",
      message: "unknown or expired approval id",
    });
  });

  it("broadcasts request + resolve", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();

    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context,
      params: { twoPhase: true },
    });

    const { id } = getRequestedExecApprovalPayload(broadcasts);

    expect(mockCallArg(respond)).toBe(true);
    expectRecordFields(mockCallArg(respond, 0, 1), { status: "accepted", id });
    expect(mockCallArg(respond, 0, 2)).toBeUndefined();

    const resolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id,
      respond: resolveRespond,
      context,
    });

    await requestPromise;

    expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
    expect(lastMockCallArg(respond)).toBe(true);
    expectRecordFields(lastMockCallArg(respond, 1), { id, decision: "allow-once" });
    expect(lastMockCallArg(respond, 2)).toBeUndefined();
    expect(broadcasts.map((entry) => entry.event)).toContain("exec.approval.resolved");
  });

  it("treats duplicate same-decision exec resolves as idempotent during grace", async () => {
    const { manager, handlers, broadcasts, respond, context } = createExecApprovalFixture();

    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context,
      params: { id: "approval-repeat-1", twoPhase: true },
    });

    const firstResolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id: "approval-repeat-1",
      respond: firstResolveRespond,
      context,
    });
    await requestPromise;
    expect(manager.consumeAllowOnce("approval-repeat-1")).toBe(true);

    const resolvedBroadcastCount = broadcasts.filter(
      (entry) => entry.event === "exec.approval.resolved",
    ).length;

    const repeatResolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id: "approval-repeat-1",
      respond: repeatResolveRespond,
      context,
    });

    const conflictingResolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id: "approval-repeat-1",
      decision: "deny",
      respond: conflictingResolveRespond,
      context,
    });

    expect(firstResolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
    expect(repeatResolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
    expect(countMatching(broadcasts, (entry) => entry.event === "exec.approval.resolved")).toBe(
      resolvedBroadcastCount,
    );
    expect(mockCallArg(conflictingResolveRespond)).toBe(false);
    expect(mockCallArg(conflictingResolveRespond, 0, 1)).toBeUndefined();
    const error = mockCallArg(conflictingResolveRespond, 0, 2) as Record<string, unknown>;
    expect(error.message).toBe("approval already resolved");
    expectRecordFields(error.details, { reason: "APPROVAL_ALREADY_RESOLVED" });
  });

  it("rejects allow-always when the request ask mode is always", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();

    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context,
      params: { twoPhase: true, ask: "always" },
    });

    const { id } = getRequestedExecApprovalPayload(broadcasts);

    const resolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id,
      decision: "allow-always",
      respond: resolveRespond,
      context,
    });

    expect(mockCallArg(resolveRespond)).toBe(false);
    expect(mockCallArg(resolveRespond, 0, 1)).toBeUndefined();
    expectRecordFields(mockCallArg(resolveRespond, 0, 2), {
      message:
        "allow-always is unavailable because the effective policy requires approval every time",
    });

    const denyRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id,
      decision: "deny",
      respond: denyRespond,
      context,
    });

    await requestPromise;
    expect(denyRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
  });

  it("does not reuse a resolved exact id as a prefix for another pending approval", () => {
    const manager = new ExecApprovalManager();
    const resolvedRecord = manager.create({ command: "echo old", host: "gateway" }, 2_000, "abc");
    void manager.register(resolvedRecord, 2_000);
    expect(manager.resolve("abc", "allow-once")).toBe(true);

    const pendingRecord = manager.create({ command: "echo new", host: "gateway" }, 2_000, "abcdef");
    void manager.register(pendingRecord, 2_000);

    expect(manager.lookupPendingId("abc")).toEqual({ kind: "none" });
    expect(manager.lookupPendingId("abcdef")).toEqual({ kind: "exact", id: "abcdef" });
  });

  it("stores versioned system.run binding and sorted env keys on approval request", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        timeoutMs: 10,
        commandArgv: ["echo", "ok"],
        env: {
          Z_VAR: "z",
          A_VAR: "a",
        },
      },
    });
    const { request } = getRequestedExecApprovalPayload(broadcasts);
    expect(request["envKeys"]).toEqual(["A_VAR", "Z_VAR"]);
    expect(request["systemRunBinding"]).toEqual(
      buildSystemRunApprovalBinding({
        argv: ["echo", "ok"],
        cwd: "/tmp",
        env: { A_VAR: "a", Z_VAR: "z" },
      }).binding,
    );
  });

  it("includes Windows-compatible env keys in approval env bindings", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        timeoutMs: 10,
        commandArgv: ["cmd.exe", "/c", "echo", "ok"],
        command: "cmd.exe /c echo ok",
        env: {
          "ProgramFiles(x86)": "C:\\Program Files (x86)",
        },
      },
    });
    const { request } = getRequestedExecApprovalPayload(broadcasts);
    const envBinding = buildSystemRunApprovalEnvBinding({
      "ProgramFiles(x86)": "C:\\Program Files (x86)",
    });
    expect(request["envKeys"]).toEqual(envBinding.envKeys);
    expect(request["systemRunBinding"]).toEqual(
      buildSystemRunApprovalBinding({
        argv: ["cmd.exe", "/c", "echo", "ok"],
        cwd: "/tmp",
        env: { "ProgramFiles(x86)": "C:\\Program Files (x86)" },
      }).binding,
    );
  });

  it("stores sorted env keys for gateway approvals without node-only binding", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        timeoutMs: 10,
        host: "gateway",
        nodeId: undefined,
        systemRunPlan: undefined,
        env: {
          Z_VAR: "z",
          A_VAR: "a",
        },
      },
    });
    const { request } = getRequestedExecApprovalPayload(broadcasts);
    expect(request["envKeys"]).toEqual(
      buildSystemRunApprovalEnvBinding({ A_VAR: "a", Z_VAR: "z" }).envKeys,
    );
    expect(request["systemRunBinding"]).toBeNull();
  });

  it("prefers systemRunPlan canonical command/cwd when present", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        timeoutMs: 10,
        command: "echo stale",
        commandArgv: ["echo", "stale"],
        cwd: "/tmp/link/sub",
        systemRunPlan: {
          argv: ["/usr/bin/echo", "ok"],
          cwd: "/real/cwd",
          commandText: "/usr/bin/echo ok",
          commandPreview: "echo ok",
          agentId: "main",
          sessionKey: "agent:main:main",
        },
      },
    });
    const { request } = getRequestedExecApprovalPayload(broadcasts);
    expect(request["command"]).toBe("/usr/bin/echo ok");
    expect(request["commandPreview"]).toBeUndefined();
    expect(request["commandArgv"]).toBeUndefined();
    expect(request["cwd"]).toBe("/real/cwd");
    expect(request["agentId"]).toBe("main");
    expect(request["sessionKey"]).toBe("agent:main:main");
    expect(request["systemRunPlan"]).toEqual({
      argv: ["/usr/bin/echo", "ok"],
      cwd: "/real/cwd",
      commandText: "/usr/bin/echo ok",
      commandPreview: "echo ok",
      agentId: "main",
      sessionKey: "agent:main:main",
    });
  });

  it("derives a command preview from the fallback command for older node plans", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        timeoutMs: 10,
        command: "jq --version",
        commandArgv: ["./env", "sh", "-c", "jq --version"],
        systemRunPlan: {
          argv: ["./env", "sh", "-c", "jq --version"],
          cwd: "/real/cwd",
          commandText: './env sh -c "jq --version"',
          agentId: "main",
          sessionKey: "agent:main:main",
        },
      },
    });
    const { request } = getRequestedExecApprovalPayload(broadcasts);
    expect(request["command"]).toBe('./env sh -c "jq --version"');
    expect(request["commandPreview"]).toBeUndefined();
    expect((request["systemRunPlan"] as { commandPreview?: string }).commandPreview).toBe(
      "jq --version",
    );
  });

  it("sanitizes invisible Unicode format chars in approval display text without changing node bindings", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        timeoutMs: 10,
        command: "bash safe\u200B.sh",
        commandArgv: ["bash", "safe\u200B.sh"],
        systemRunPlan: {
          argv: ["bash", "safe\u200B.sh"],
          cwd: "/real/cwd",
          commandText: "bash safe\u200B.sh",
          agentId: "main",
          sessionKey: "agent:main:main",
        },
      },
    });
    const { request } = getRequestedExecApprovalPayload(broadcasts);
    expect(request["command"]).toBe("bash safe\\u{200B}.sh");
    expect((request["systemRunPlan"] as { commandText?: string }).commandText).toBe(
      "bash safe\u200B.sh",
    );
  });

  it("preserves approval warning line breaks while sanitizing hidden characters", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        timeoutMs: 10,
        warningText: "Diagnostics line one\r\n\r\nOpenAI Codex harness:\nSend feedback\u200B",
      },
    });
    const { request } = getRequestedExecApprovalPayload(broadcasts);
    expect(request["warningText"]).toBe(
      "Diagnostics line one\n\nOpenAI Codex harness:\nSend feedback\\u{200B}",
    );
    expect(request["warningText"]).not.toContain("\\u{A}");
  });

  it("preserves command analysis and normalizes command spans", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture({
      config: { tools: { exec: { commandHighlighting: true } } },
    });
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        timeoutMs: 10,
        command: "ls | python -c 'print(1)'",
        commandSpans: [
          { startIndex: 5, endIndex: 11 },
          { startIndex: 0, endIndex: 2 },
          { startIndex: 1, endIndex: 4 },
          { startIndex: 12, endIndex: 999 },
          { startIndex: 11, endIndex: 11 },
        ],
      },
    });
    const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
    expectRecordFields(requested, { event: "exec.approval.requested" });
    const request = (requested?.payload as { request?: Record<string, unknown> })?.request ?? {};
    expectRecordFields(request["commandAnalysis"], { commandCount: 1, nestedCommandCount: 0 });
    expect(request["commandSpans"]).toEqual([
      { startIndex: 0, endIndex: 2 },
      { startIndex: 5, endIndex: 11 },
    ]);
  });

  it("drops command spans by default", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        timeoutMs: 10,
        command: "ls | python -c 'print(1)'",
        commandSpans: [
          { startIndex: 0, endIndex: 2 },
          { startIndex: 5, endIndex: 11 },
        ],
      },
    });
    const { request } = getRequestedExecApprovalPayload(broadcasts);
    expectRecordFields(request["commandAnalysis"], { commandCount: 1, nestedCommandCount: 0 });
    expect(request["commandSpans"]).toBeUndefined();
  });

  it("drops command spans when command highlighting is disabled", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture({
      config: { tools: { exec: { commandHighlighting: false } } },
    });
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        timeoutMs: 10,
        command: "ls | python -c 'print(1)'",
        commandSpans: [
          { startIndex: 0, endIndex: 2 },
          { startIndex: 5, endIndex: 11 },
        ],
      },
    });
    const { request } = getRequestedExecApprovalPayload(broadcasts);
    expectRecordFields(request["commandAnalysis"], { commandCount: 1, nestedCommandCount: 0 });
    expect(request["commandSpans"]).toBeUndefined();
  });

  it("drops command spans when command display sanitization changes offsets", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture({
      config: { tools: { exec: { commandHighlighting: true } } },
    });
    await requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        timeoutMs: 10,
        command: "ls\u0000 | python -c 'print(1)'",
        commandSpans: [
          { startIndex: 0, endIndex: 2 },
          { startIndex: 6, endIndex: 12 },
        ],
      },
    });
    const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
    const request = (requested?.payload as { request?: Record<string, unknown> })?.request ?? {};
    expect(request["command"]).not.toBe("ls\u0000 | python -c 'print(1)'");
    expect(request["commandSpans"]).toBeUndefined();
  });

  it("accepts resolve during broadcast", async () => {
    const manager = new ExecApprovalManager();
    const handlers = createExecApprovalHandlers(manager);
    const respond = vi.fn();
    const resolveRespond = vi.fn();

    const resolveContext = {
      broadcast: () => {},
    };

    const context = {
      broadcast: (event: string, payload: unknown) => {
        if (event !== "exec.approval.requested") {
          return;
        }
        const id = (payload as { id?: string })?.id ?? "";
        void resolveExecApproval({
          handlers,
          id,
          respond: resolveRespond,
          context: resolveContext,
        });
      },
    };

    await requestExecApproval({
      handlers,
      respond,
      context,
    });

    expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
    expect(lastMockCallArg(respond)).toBe(true);
    expectRecordFields(lastMockCallArg(respond, 1), { decision: "allow-once" });
    expect(lastMockCallArg(respond, 2)).toBeUndefined();
  });

  it("accepts explicit approval ids", async () => {
    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();

    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context,
      params: { id: "approval-123", host: "gateway" },
    });

    const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
    const id = (requested?.payload as { id?: string })?.id ?? "";
    expect(id).toBe("approval-123");

    const resolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id,
      respond: resolveRespond,
      context,
    });

    await requestPromise;
    expect(lastMockCallArg(respond)).toBe(true);
    expectRecordFields(lastMockCallArg(respond, 1), {
      id: "approval-123",
      decision: "allow-once",
    });
    expect(lastMockCallArg(respond, 2)).toBeUndefined();
    expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
  });

  it("rejects explicit approval ids with the reserved plugin prefix", async () => {
    const { handlers, respond, context } = createExecApprovalFixture();

    await requestExecApproval({
      handlers,
      respond,
      context,
      params: { id: "plugin:approval-123", host: "gateway" },
    });

    expect(mockCallArg(respond)).toBe(false);
    expect(mockCallArg(respond, 0, 1)).toBeUndefined();
    expectRecordFields(mockCallArg(respond, 0, 2), {
      code: "INVALID_REQUEST",
      message: "approval ids starting with plugin: are reserved",
    });
  });

  it("accepts unique short approval id prefixes", async () => {
    const manager = new ExecApprovalManager();
    const handlers = createExecApprovalHandlers(manager);
    const respond = vi.fn();
    const context = {
      broadcast: (_event: string, _payload: unknown) => {},
    };

    const record = manager.create({ command: "echo ok" }, 60_000, "approval-12345678-aaaa");
    void manager.register(record, 60_000);

    await resolveExecApproval({
      handlers,
      id: "approval-1234",
      respond,
      context,
    });

    expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
    expect(manager.getSnapshot(record.id)?.decision).toBe("allow-once");
  });

  it("rejects ambiguous short approval id prefixes without leaking candidate ids", async () => {
    const manager = new ExecApprovalManager();
    const handlers = createExecApprovalHandlers(manager);
    const respond = vi.fn();
    const context = {
      broadcast: (_event: string, _payload: unknown) => {},
    };

    void manager.register(
      manager.create({ command: "echo one" }, 60_000, "approval-abcd-1111"),
      60_000,
    );
    void manager.register(
      manager.create({ command: "echo two" }, 60_000, "approval-abcd-2222"),
      60_000,
    );

    await resolveExecApproval({
      handlers,
      id: "approval-abcd",
      respond,
      context,
    });

    expect(mockCallArg(respond)).toBe(false);
    expect(mockCallArg(respond, 0, 1)).toBeUndefined();
    expectRecordFields(mockCallArg(respond, 0, 2), {
      message: "ambiguous approval id prefix; use the full id",
    });
  });

  it("returns deterministic unknown/expired message for missing approval ids", async () => {
    const { handlers, respond, context } = createExecApprovalFixture();

    await resolveExecApproval({
      handlers,
      id: "missing-approval-id",
      respond,
      context,
    });

    expect(mockCallArg(respond)).toBe(false);
    expect(mockCallArg(respond, 0, 1)).toBeUndefined();
    const error = mockCallArg(respond, 0, 2) as Record<string, unknown>;
    expectRecordFields(error, {
      code: "INVALID_REQUEST",
      message: "unknown or expired approval id",
    });
    expectRecordFields(error.details, { reason: "APPROVAL_NOT_FOUND" });
  });

  it("resolves only the targeted approval id when multiple requests are pending", async () => {
    const manager = new ExecApprovalManager();
    const handlers = createExecApprovalHandlers(manager);
    const context = {
      broadcast: (_event: string, _payload: unknown) => {},
      hasExecApprovalClients: () => true,
    };
    const respondOne = vi.fn();
    const respondTwo = vi.fn();

    const requestOne = requestExecApproval({
      handlers,
      respond: respondOne,
      context,
      params: { id: "approval-one", host: "gateway", timeoutMs: 60_000 },
    });
    const requestTwo = requestExecApproval({
      handlers,
      respond: respondTwo,
      context,
      params: { id: "approval-two", host: "gateway", timeoutMs: 60_000 },
    });

    await drainApprovalRequestTicks();

    const resolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id: "approval-one",
      respond: resolveRespond,
      context,
    });

    expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
    expect(manager.getSnapshot("approval-one")?.decision).toBe("allow-once");
    expect(manager.getSnapshot("approval-two")?.decision).toBeUndefined();
    expect(manager.getSnapshot("approval-two")?.resolvedAtMs).toBeUndefined();

    expect(manager.expire("approval-two", "test-expire")).toBe(true);
    await requestOne;
    await requestTwo;

    expect(lastMockCallArg(respondOne)).toBe(true);
    expectRecordFields(lastMockCallArg(respondOne, 1), {
      id: "approval-one",
      decision: "allow-once",
    });
    expect(lastMockCallArg(respondOne, 2)).toBeUndefined();
    expect(lastMockCallArg(respondTwo)).toBe(true);
    expectRecordFields(lastMockCallArg(respondTwo, 1), {
      id: "approval-two",
      decision: null,
    });
    expect(lastMockCallArg(respondTwo, 2)).toBeUndefined();
  });

  it("forwards turn-source metadata to exec approval forwarding", async () => {
    vi.useFakeTimers();
    try {
      const { handlers, forwarder, respond, context } = createForwardingExecApprovalFixture();

      const requestPromise = requestExecApproval({
        handlers,
        respond,
        context,
        params: {
          timeoutMs: 60_000,
          turnSourceChannel: "whatsapp",
          turnSourceTo: "+15555550123",
          turnSourceAccountId: "work",
          turnSourceThreadId: "1739201675.123",
        },
      });
      await drainApprovalRequestTicks();
      expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
      const forwarded = mockCallArg(forwarder.handleRequested) as Record<string, unknown>;
      expectRecordFields(forwarded.request, {
        turnSourceChannel: "whatsapp",
        turnSourceTo: "+15555550123",
        turnSourceAccountId: "work",
        turnSourceThreadId: "1739201675.123",
      });

      await vi.runOnlyPendingTimersAsync();
      await requestPromise;
    } finally {
      vi.useRealTimers();
    }
  });

  it("resolves Control UI-style approvals by id while preserving stored turn-source metadata", async () => {
    const { handlers, forwarder, respond, context } = createForwardingExecApprovalFixture();
    const broadcasts: Array<{ event: string; payload: unknown }> = [];
    const requestContext = {
      ...context,
      hasExecApprovalClients: () => true,
      broadcast: (event: string, payload: unknown) => {
        broadcasts.push({ event, payload });
      },
    };

    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context: requestContext,
      params: {
        id: "approval-control-ui-multichannel",
        twoPhase: true,
        timeoutMs: 60_000,
        host: "gateway",
        nodeId: undefined,
        systemRunPlan: undefined,
        sessionKey: "agent:main:feishu:chat-123",
        turnSourceChannel: "feishu",
        turnSourceTo: "chat-123",
        turnSourceAccountId: "work",
        turnSourceThreadId: "thread-456",
      },
    });
    await drainApprovalRequestTicks();

    const resolveRespond = vi.fn();
    await resolveExecApproval({
      handlers,
      id: "approval-control-ui-multichannel",
      respond: resolveRespond,
      context: requestContext,
    });
    await requestPromise;

    expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
    const resolved = mockCallArg(forwarder.handleResolved) as Record<string, unknown>;
    expectRecordFields(resolved, {
      id: "approval-control-ui-multichannel",
      decision: "allow-once",
    });
    expectRecordFields(resolved.request, {
      sessionKey: "agent:main:feishu:chat-123",
      turnSourceChannel: "feishu",
      turnSourceTo: "chat-123",
      turnSourceAccountId: "work",
      turnSourceThreadId: "thread-456",
    });
    const resolvedBroadcast = broadcasts.find((entry) => entry.event === "exec.approval.resolved");
    expect(resolvedBroadcast?.event).toBe("exec.approval.resolved");
    const payload = resolvedBroadcast?.payload as Record<string, unknown>;
    expect(payload.id).toBe("approval-control-ui-multichannel");
    expectRecordFields(payload.request, {
      turnSourceChannel: "feishu",
      turnSourceTo: "chat-123",
    });
  });

  it("fast-fails approvals when no approver clients and no forwarding targets", async () => {
    const { manager, handlers, forwarder, respond, context } =
      createForwardingExecApprovalFixture();
    const expireSpy = vi.spyOn(manager, "expire");

    await requestExecApproval({
      handlers,
      respond,
      context,
      params: { timeoutMs: 60_000, id: "approval-no-approver", host: "gateway" },
    });

    expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
    expect(expireSpy).toHaveBeenCalledWith("approval-no-approver", "no-approval-route");
    expect(lastMockCallArg(respond)).toBe(true);
    expectRecordFields(lastMockCallArg(respond, 1), {
      id: "approval-no-approver",
      decision: null,
    });
    expect(lastMockCallArg(respond, 2)).toBeUndefined();
  });

  it("keeps approvals pending when iOS push delivery accepted the request", async () => {
    const iosPushDelivery = {
      handleRequested: vi.fn(async () => true),
      handleResolved: vi.fn(async () => {}),
      handleExpired: vi.fn(async () => {}),
    };
    const { manager, handlers, forwarder, respond, context } = createForwardingExecApprovalFixture({
      iosPushDelivery,
    });
    const expireSpy = vi.spyOn(manager, "expire");

    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context,
      params: {
        twoPhase: true,
        timeoutMs: 60_000,
        id: "approval-ios-push",
        host: "gateway",
      },
    });

    await vi.waitFor(() => {
      expect(lastMockCallArg(respond)).toBe(true);
      expectRecordFields(lastMockCallArg(respond, 1), {
        status: "accepted",
        id: "approval-ios-push",
      });
      expect(lastMockCallArg(respond, 2)).toBeUndefined();
    });

    expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
    expectRecordFields(mockCallArg(iosPushDelivery.handleRequested), { id: "approval-ios-push" });
    expect(expireSpy).not.toHaveBeenCalled();

    manager.resolve("approval-ios-push", "allow-once");
    await requestPromise;
  });

  it("does not count iOS push delivery to hidden approval targets as a route", async () => {
    const iosPushDelivery = {
      handleRequested: vi.fn(
        async (
          _request: unknown,
          opts?: {
            isTargetVisible?: (target: { deviceId: string; scopes: readonly string[] }) => boolean;
          },
        ) =>
          opts?.isTargetVisible?.({
            deviceId: "device-other",
            scopes: ["operator.approvals"],
          }) ?? true,
      ),
      handleResolved: vi.fn(async () => {}),
      handleExpired: vi.fn(async () => {}),
    };
    const { manager, handlers, respond, context } = createForwardingExecApprovalFixture({
      iosPushDelivery,
    });
    const expireSpy = vi.spyOn(manager, "expire");

    await requestExecApproval({
      handlers,
      respond,
      context,
      client: {
        connId: "conn-owner",
        connect: {
          client: { id: "client-owner" },
          device: { id: "device-owner" },
          scopes: ["operator.approvals"],
        },
      } as unknown as ExecApprovalRequestArgs["client"],
      params: {
        timeoutMs: 60_000,
        id: "approval-ios-hidden-push",
        host: "gateway",
      },
    });

    expect(iosPushDelivery.handleRequested).toHaveBeenCalledTimes(1);
    expect(expireSpy).toHaveBeenCalledWith("approval-ios-hidden-push", "no-approval-route");
    expect(lastMockCallArg(respond)).toBe(true);
    expectRecordFields(lastMockCallArg(respond, 1), {
      id: "approval-ios-hidden-push",
      decision: null,
    });
    expect(lastMockCallArg(respond, 2)).toBeUndefined();
  });

  it("sends iOS cleanup delivery on resolve", async () => {
    const iosPushDelivery = {
      handleRequested: vi.fn(async () => true),
      handleResolved: vi.fn(async () => {}),
      handleExpired: vi.fn(async () => {}),
    };
    const { handlers, respond, context } = createForwardingExecApprovalFixture({ iosPushDelivery });
    const resolveRespond = vi.fn();

    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context,
      params: { timeoutMs: 60_000, id: "approval-ios-cleanup", host: "gateway" },
    });
    await drainApprovalRequestTicks();

    await resolveExecApproval({
      handlers,
      id: "approval-ios-cleanup",
      respond: resolveRespond,
      context,
    });
    await requestPromise;

    await vi.waitFor(() => {
      expectRecordFields(mockCallArg(iosPushDelivery.handleResolved), {
        id: "approval-ios-cleanup",
        decision: "allow-once",
      });
    });
  });

  it("sends iOS cleanup delivery on expiration", async () => {
    vi.useFakeTimers();
    try {
      const iosPushDelivery = {
        handleRequested: vi.fn(async () => true),
        handleResolved: vi.fn(async () => {}),
        handleExpired: vi.fn(async () => {}),
      };
      const { handlers, respond, context } = createForwardingExecApprovalFixture({
        iosPushDelivery,
      });

      const requestPromise = requestExecApproval({
        handlers,
        respond,
        context,
        params: {
          twoPhase: true,
          timeoutMs: 250,
          id: "approval-ios-expire",
          host: "gateway",
        },
      });
      await drainApprovalRequestTicks();
      await vi.advanceTimersByTimeAsync(250);
      await requestPromise;

      await vi.waitFor(() => {
        expectRecordFields(mockCallArg(iosPushDelivery.handleExpired), {
          id: "approval-ios-expire",
        });
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it("keeps approvals pending when the originating chat can handle /approve directly", async () => {
    vi.useFakeTimers();
    try {
      const { manager, handlers, forwarder, respond, context } =
        createForwardingExecApprovalFixture();
      const expireSpy = vi.spyOn(manager, "expire");

      const requestPromise = requestExecApproval({
        handlers,
        respond,
        context,
        params: {
          twoPhase: true,
          timeoutMs: 60_000,
          id: "approval-chat-route",
          host: "gateway",
          turnSourceChannel: "slack",
          turnSourceTo: "D123",
        },
      });

      await vi.waitFor(() => {
        expect(lastMockCallArg(respond)).toBe(true);
        expectRecordFields(lastMockCallArg(respond, 1), {
          status: "accepted",
          id: "approval-chat-route",
        });
        expect(lastMockCallArg(respond, 2)).toBeUndefined();
      });

      expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
      expect(expireSpy).not.toHaveBeenCalled();

      manager.resolve("approval-chat-route", "allow-once");
      await requestPromise;
    } finally {
      vi.useRealTimers();
    }
  });

  it("keeps approvals pending when no approver clients but forwarding accepted the request", async () => {
    const { manager, handlers, forwarder, respond, context } =
      createForwardingExecApprovalFixture();
    const expireSpy = vi.spyOn(manager, "expire");
    const resolveRespond = vi.fn();
    forwarder.handleRequested.mockResolvedValueOnce(true);

    const requestPromise = requestExecApproval({
      handlers,
      respond,
      context,
      params: { timeoutMs: 60_000, id: "approval-forwarded", host: "gateway" },
    });
    await drainApprovalRequestTicks();

    expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
    expect(expireSpy).not.toHaveBeenCalled();

    await resolveExecApproval({
      handlers,
      id: "approval-forwarded",
      respond: resolveRespond,
      context,
    });
    await requestPromise;

    expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
    expect(lastMockCallArg(respond)).toBe(true);
    expectRecordFields(lastMockCallArg(respond, 1), {
      id: "approval-forwarded",
      decision: "allow-once",
    });
    expect(lastMockCallArg(respond, 2)).toBeUndefined();
  });
});

describe("gateway healthHandlers.status scope handling", () => {
  let statusModule: typeof import("../../commands/status.js");
  let healthHandlers: typeof import("./health.js").healthHandlers;

  beforeAll(async () => {
    statusModule = await import("../../commands/status.js");
    ({ healthHandlers } = await import("./health.js"));
  });

  beforeEach(() => {
    vi.mocked(statusModule.getStatusSummary).mockClear();
  });

  async function runHealthStatus(scopes: string[]) {
    const respond = vi.fn();

    await healthHandlers.status({
      req: {} as never,
      params: {} as never,
      respond: respond as never,
      context: {} as never,
      client: { connect: { role: "operator", scopes } } as never,
      isWebchatConnect: () => false,
    });

    return respond;
  }

  it.each([
    { scopes: ["operator.read"], includeSensitive: false },
    { scopes: ["operator.admin"], includeSensitive: true },
  ])(
    "requests includeSensitive=$includeSensitive for scopes $scopes",
    async ({ scopes, includeSensitive }) => {
      const respond = await runHealthStatus(scopes);

      expect(vi.mocked(statusModule.getStatusSummary)).toHaveBeenCalledWith({
        includeSensitive,
        includeChannelSummary: true,
      });
      expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
    },
  );

  it("can skip channel summary work for liveness-only status requests", async () => {
    const respond = vi.fn();

    await healthHandlers.status({
      req: {} as never,
      params: { includeChannelSummary: false },
      respond: respond as never,
      context: {} as never,
      client: { connect: { role: "operator", scopes: ["operator.read"] } } as never,
      isWebchatConnect: () => false,
    });

    expect(vi.mocked(statusModule.getStatusSummary)).toHaveBeenCalledWith({
      includeSensitive: false,
      includeChannelSummary: false,
    });
    expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
  });
});

describe("gateway healthHandlers.health cache freshness", () => {
  let healthHandlers: typeof import("./health.js").healthHandlers;
  let pricingState: typeof import("../model-pricing-cache-state.js");

  beforeAll(async () => {
    ({ healthHandlers } = await import("./health.js"));
    pricingState = await import("../model-pricing-cache-state.js");
  });

  beforeEach(() => {
    pricingState.clearGatewayModelPricingCacheState();
  });

  afterEach(() => {
    pricingState.clearGatewayModelPricingCacheState();
  });

  it("refreshes cached health when runtime channel lifecycle has changed", async () => {
    const cached = {
      ok: true,
      ts: Date.now(),
      durationMs: 1,
      channels: {
        discord: {
          configured: true,
          running: false,
          connected: false,
          accounts: {
            default: {
              accountId: "default",
              configured: true,
              running: false,
              connected: false,
            },
          },
        },
      },
      channelOrder: ["discord"],
      channelLabels: { discord: "Discord" },
      heartbeatSeconds: 0,
      defaultAgentId: "main",
      agents: [],
      sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
    };
    const fresh = {
      ...cached,
      ts: cached.ts + 1,
      channels: {
        discord: {
          ...cached.channels.discord,
          running: true,
          connected: true,
          accounts: {
            default: {
              ...cached.channels.discord.accounts.default,
              running: true,
              connected: true,
            },
          },
        },
      },
    };
    const respond = vi.fn();
    const refreshHealthSnapshot = vi.fn().mockResolvedValue(fresh);

    await healthHandlers.health({
      req: {} as never,
      params: {} as never,
      respond: respond as never,
      context: {
        getHealthCache: () => cached,
        refreshHealthSnapshot,
        getRuntimeSnapshot: () => ({
          channels: {},
          channelAccounts: {
            discord: {
              default: {
                accountId: "default",
                running: true,
                connected: true,
              },
            },
          },
        }),
        logHealth: { error: vi.fn() },
      } as never,
      client: { connect: { role: "operator", scopes: ["operator.read"] } } as never,
      isWebchatConnect: () => false,
    });

    expect(refreshHealthSnapshot).toHaveBeenCalledWith({
      probe: false,
      includeSensitive: false,
    });
    expect(respond).toHaveBeenCalledWith(true, fresh, undefined);
  });

  it("preserves event-loop health sampled by the refresh path", async () => {
    const eventLoop = {
      degraded: true,
      reasons: ["event_loop_delay" as const],
      intervalMs: 2_000,
      delayP99Ms: 1_500,
      delayMaxMs: 1_800,
      utilization: 0.2,
      cpuCoreRatio: 0.1,
    };
    const replacementEventLoop = {
      degraded: false,
      reasons: [],
      intervalMs: 1,
      delayP99Ms: 0,
      delayMaxMs: 0,
      utilization: 0,
      cpuCoreRatio: 0,
    };
    const fresh = {
      ok: true,
      ts: Date.now(),
      durationMs: 1,
      channels: {},
      channelOrder: [],
      channelLabels: {},
      heartbeatSeconds: 0,
      defaultAgentId: "main",
      agents: [],
      sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
      eventLoop,
    };
    const respond = vi.fn();
    const refreshHealthSnapshot = vi.fn().mockResolvedValue(fresh);
    const getEventLoopHealth = vi.fn(() => replacementEventLoop);

    await healthHandlers.health({
      req: {} as never,
      params: {} as never,
      respond: respond as never,
      context: {
        getHealthCache: () => null,
        refreshHealthSnapshot,
        getRuntimeSnapshot: () => ({ channels: {}, channelAccounts: {} }),
        getEventLoopHealth,
        logHealth: { error: vi.fn() },
      } as never,
      client: { connect: { role: "operator", scopes: ["operator.read"] } } as never,
      isWebchatConnect: () => false,
    });

    expect(refreshHealthSnapshot).toHaveBeenCalledWith({
      probe: false,
      includeSensitive: false,
    });
    expect(getEventLoopHealth).not.toHaveBeenCalled();
    expect(mockCallArg(respond)).toBe(true);
    expectRecordFields(mockCallArg(respond, 0, 1), { eventLoop });
    expect(mockCallArg(respond, 0, 2)).toBeUndefined();
  });

  it("merges live model-pricing state into cached health responses", async () => {
    const cached = {
      ok: true,
      ts: Date.now(),
      durationMs: 1,
      channels: {},
      channelOrder: [],
      channelLabels: {},
      heartbeatSeconds: 0,
      defaultAgentId: "main",
      agents: [],
      sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
      modelPricing: { state: "ok", sources: [] },
    };
    pricingState.recordGatewayModelPricingSourceFailure(
      "openrouter",
      "OpenRouter pricing fetch failed: TypeError: fetch failed",
      123,
    );
    const respond = vi.fn();
    const refreshHealthSnapshot = vi.fn().mockResolvedValue(cached);

    await healthHandlers.health({
      req: {} as never,
      params: {} as never,
      respond: respond as never,
      context: {
        getHealthCache: () => cached,
        refreshHealthSnapshot,
        getRuntimeSnapshot: () => ({ channels: {}, channelAccounts: {} }),
        logHealth: { error: vi.fn() },
      } as never,
      client: { connect: { role: "operator", scopes: ["operator.read"] } } as never,
      isWebchatConnect: () => false,
    });

    const payload = mockCallArg(respond, 0, 1) as
      | {
          modelPricing?: {
            state?: string;
            detail?: string;
            sources?: Array<{ source?: string; state?: string; lastFailureAt?: number }>;
          };
        }
      | undefined;
    expect(payload?.modelPricing?.state).toBe("degraded");
    expect(payload?.modelPricing?.detail).toBe(
      "OpenRouter pricing fetch failed: TypeError: fetch failed",
    );
    expect(payload?.modelPricing?.sources).toHaveLength(1);
    expect(payload?.modelPricing?.sources?.[0]?.source).toBe("openrouter");
    expect(payload?.modelPricing?.sources?.[0]?.state).toBe("degraded");
    expect(payload?.modelPricing?.sources?.[0]?.lastFailureAt).toBe(123);
    expect(mockCallArg(respond, 0, 3)).toEqual({ cached: true });
    expect(refreshHealthSnapshot).toHaveBeenCalledWith({
      probe: false,
      includeSensitive: false,
    });
  });

  it("refreshes cached health when a runtime account is missing from the cached account summary", async () => {
    const cached = {
      ok: true,
      ts: Date.now(),
      durationMs: 1,
      channels: {
        discord: {
          configured: true,
          running: true,
          connected: true,
          accounts: {
            default: {
              accountId: "default",
              configured: true,
              running: true,
              connected: true,
            },
          },
        },
      },
      channelOrder: ["discord"],
      channelLabels: { discord: "Discord" },
      heartbeatSeconds: 0,
      defaultAgentId: "main",
      agents: [],
      sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
    };
    const fresh = {
      ...cached,
      ts: cached.ts + 1,
      channels: {
        discord: {
          ...cached.channels.discord,
          accounts: {
            ...cached.channels.discord.accounts,
            work: {
              accountId: "work",
              configured: true,
              running: true,
              connected: true,
            },
          },
        },
      },
    };
    const respond = vi.fn();
    const refreshHealthSnapshot = vi.fn().mockResolvedValue(fresh);

    await healthHandlers.health({
      req: {} as never,
      params: {} as never,
      respond: respond as never,
      context: {
        getHealthCache: () => cached,
        refreshHealthSnapshot,
        getRuntimeSnapshot: () => ({
          channels: {},
          channelAccounts: {
            discord: {
              work: {
                accountId: "work",
                running: true,
                connected: true,
              },
            },
          },
        }),
        logHealth: { error: vi.fn() },
      } as never,
      client: { connect: { role: "operator", scopes: ["operator.read"] } } as never,
      isWebchatConnect: () => false,
    });

    expect(refreshHealthSnapshot).toHaveBeenCalledWith({
      probe: false,
      includeSensitive: false,
    });
    expect(respond).toHaveBeenCalledWith(true, fresh, undefined);
  });
});

describe("logs.tail", () => {
  const logsNoop = () => false;

  afterEach(() => {
    resetLogger();
    setLoggerOverride(null);
  });

  it("falls back to latest rolling log file when today is missing", async () => {
    const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-logs-"));
    const older = path.join(tempDir, "openclaw-2026-01-20.log");
    const newer = path.join(tempDir, "openclaw-2026-01-21.log");

    await fsPromises.writeFile(older, '{"msg":"old"}\n');
    await fsPromises.writeFile(newer, '{"msg":"new"}\n');
    await fsPromises.utimes(older, new Date(0), new Date(0));
    await fsPromises.utimes(newer, new Date(), new Date());

    setLoggerOverride({ file: path.join(tempDir, "openclaw-2026-01-22.log") });

    const respond = vi.fn();
    await logsHandlers["logs.tail"]({
      params: {},
      respond,
      context: {} as unknown as Parameters<(typeof logsHandlers)["logs.tail"]>[0]["context"],
      client: null,
      req: { id: "req-1", type: "req", method: "logs.tail" },
      isWebchatConnect: logsNoop,
    });

    expect(mockCallArg(respond)).toBe(true);
    expectRecordFields(mockCallArg(respond, 0, 1), {
      file: newer,
      lines: ['{"msg":"new"}'],
    });
    expect(mockCallArg(respond, 0, 2)).toBeUndefined();

    await fsPromises.rm(tempDir, { recursive: true, force: true });
  });

  it("redacts sensitive CLI tokens from returned lines", async () => {
    const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-logs-"));
    const file = path.join(tempDir, "openclaw-2026-01-22.log");

    await fsPromises.writeFile(
      file,
      "starting gog gmail watch serve --token push-token-bbbbbbbbbbbbbbbbbbbb --hook-token hook-token-aaaaaaaaaaaaaaaaaaaa\n",
    );

    setLoggerOverride({ file });

    const respond = vi.fn();
    await logsHandlers["logs.tail"]({
      params: {},
      respond,
      context: {} as unknown as Parameters<(typeof logsHandlers)["logs.tail"]>[0]["context"],
      client: null,
      req: { id: "req-1", type: "req", method: "logs.tail" },
      isWebchatConnect: logsNoop,
    });

    expect(mockCallArg(respond)).toBe(true);
    expectRecordFields(mockCallArg(respond, 0, 1), {
      file,
      lines: ["starting gog gmail watch serve --token push-t…bbbb --hook-token hook-t…aaaa"],
    });
    expect(mockCallArg(respond, 0, 2)).toBeUndefined();

    await fsPromises.rm(tempDir, { recursive: true, force: true });
  });
});
