import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { ChannelType } from "discord-api-types/v10";
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
  createPluginStateSyncKeyedStoreForTests,
  resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import {
  clearRuntimeConfigSnapshot,
  setRuntimeConfigSnapshot,
  type OpenClawConfig,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
// Discord tests cover thread bindings.lifecycle plugin behavior.
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setDiscordRuntime } from "../runtime.js";
import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js";
import { resetThreadBindingsForTests } from "./thread-bindings.test-support.js";

type DiscordRuntime = Parameters<typeof setDiscordRuntime>[0];

const hoisted = vi.hoisted(() => {
  const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
  const sendWebhookMessageDiscord = vi.fn(async (_text: string, _opts?: unknown) => ({}));
  const restGet = vi.fn(async (..._args: unknown[]) => ({
    id: "thread-1",
    type: 11,
    parent_id: "parent-1",
  }));
  const restPost = vi.fn(async (..._args: unknown[]) => ({
    id: "wh-created",
    token: "tok-created",
  }));
  const createDiscordRestClient = vi.fn((..._args: unknown[]) => ({
    rest: {
      get: restGet,
      post: restPost,
    },
  }));
  const createThreadDiscord = vi.fn(async (..._args: unknown[]) => ({ id: "thread-created" }));
  const readAcpSessionEntry = vi.fn();
  return {
    sendMessageDiscord,
    sendWebhookMessageDiscord,
    restGet,
    restPost,
    createDiscordRestClient,
    createThreadDiscord,
    readAcpSessionEntry,
  };
});

vi.mock("../send.js", async () => {
  const actual = await vi.importActual<typeof import("../send.js")>("../send.js");
  return {
    ...actual,
    addRoleDiscord: vi.fn(),
    sendMessageDiscord: hoisted.sendMessageDiscord,
    sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord,
  };
});

vi.mock("../send.messages.js", () => ({
  createThreadDiscord: hoisted.createThreadDiscord,
}));

const { createThreadBindingManager } = await import("./thread-bindings.manager.js");
const {
  autoBindSpawnedDiscordSubagent,
  reconcileAcpThreadBindingsOnStartup,
  setThreadBindingIdleTimeoutBySessionKey,
  setThreadBindingMaxAgeBySessionKey,
  unbindThreadBindingsBySessionKey,
} = await import("./thread-bindings.lifecycle.js");
const { resolveThreadBindingInactivityExpiresAt, resolveThreadBindingMaxAgeExpiresAt } =
  await import("./thread-bindings.state.js");
const discordClientModule = await import("../client.js");
const discordThreadBindingApi = await import("./thread-bindings.discord-api.js");
const acpRuntime = await import("openclaw/plugin-sdk/acp-runtime");

function createTestThreadBindingManager(
  params: Omit<Parameters<typeof createThreadBindingManager>[0], "cfg"> & {
    cfg?: OpenClawConfig;
  },
) {
  return createThreadBindingManager({
    cfg: EMPTY_DISCORD_TEST_CONFIG,
    ...params,
  });
}

const requireRecord = createRequireRecord("record", "expected-label-capitalized");

function expectFields(
  value: unknown,
  label: string,
  fields: Record<string, unknown>,
): Record<string, unknown> {
  const record = requireRecord(value, label);
  for (const [key, expected] of Object.entries(fields)) {
    expect(record[key]).toEqual(expected);
  }
  return record;
}

function expectThreadCreateOptionsWithoutArchiveOverride(value: unknown): void {
  const options = requireRecord(value, "thread options");
  expect(options.name).toBeTypeOf("string");
  expect(options).not.toHaveProperty("autoArchiveMinutes");
}

function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
  const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
  if (!Array.isArray(calls)) {
    throw new Error(`Expected ${label} mock calls`);
  }
  const call = calls[callIndex];
  if (!call) {
    throw new Error(`Expected ${label} call ${callIndex + 1}`);
  }
  return call[argIndex];
}

describe("thread binding lifecycle", () => {
  beforeEach(() => {
    resetPluginStateStoreForTests();
    resetThreadBindingsForTests();
    setDiscordRuntime({
      state: {
        openSyncKeyedStore: (options: OpenKeyedStoreOptions) =>
          createPluginStateSyncKeyedStoreForTests("discord", options),
      },
    } as unknown as DiscordRuntime);
    clearRuntimeConfigSnapshot();
    vi.restoreAllMocks();
    hoisted.sendMessageDiscord.mockReset().mockResolvedValue({});
    hoisted.sendWebhookMessageDiscord.mockReset().mockResolvedValue({});
    hoisted.restGet.mockReset().mockResolvedValue({
      id: "thread-1",
      type: 11,
      parent_id: "parent-1",
    });
    hoisted.restPost.mockReset().mockResolvedValue({
      id: "wh-created",
      token: "tok-created",
    });
    hoisted.createDiscordRestClient.mockReset().mockImplementation((..._args: unknown[]) => ({
      rest: {
        get: hoisted.restGet,
        post: hoisted.restPost,
      },
    }));
    hoisted.createThreadDiscord.mockReset().mockResolvedValue({ id: "thread-created" });
    hoisted.readAcpSessionEntry.mockReset().mockReturnValue(null);
    vi.spyOn(discordClientModule, "createDiscordRestClient").mockImplementation(
      (...args) =>
        hoisted.createDiscordRestClient(...args) as unknown as ReturnType<
          typeof discordClientModule.createDiscordRestClient
        >,
    );
    vi.spyOn(discordThreadBindingApi, "createWebhookForChannel").mockImplementation(
      async (params) => {
        const rest = hoisted.createDiscordRestClient(
          {
            accountId: params.accountId,
            token: params.token,
          },
          params.cfg,
        ).rest;
        const created = (await rest.post("mock:channel-webhook")) as {
          id?: string;
          token?: string;
        };
        return {
          webhookId: typeof created?.id === "string" ? created.id.trim() || undefined : undefined,
          webhookToken:
            typeof created?.token === "string" ? created.token.trim() || undefined : undefined,
        };
      },
    );
    vi.spyOn(discordThreadBindingApi, "resolveChannelIdForBinding").mockImplementation(
      async (params) => {
        const explicit = params.channelId?.trim();
        if (explicit) {
          return explicit;
        }
        const rest = hoisted.createDiscordRestClient(
          {
            accountId: params.accountId,
            token: params.token,
          },
          params.cfg,
        ).rest;
        const channel = (await rest.get("mock:channel-resolve")) as {
          id?: string;
          type?: number;
          parent_id?: string;
          parentId?: string;
        };
        const channelId = typeof channel?.id === "string" ? channel.id.trim() : "";
        const parentId =
          typeof channel?.parent_id === "string"
            ? channel.parent_id.trim()
            : typeof channel?.parentId === "string"
              ? channel.parentId.trim()
              : "";
        const isThreadType =
          channel?.type === ChannelType.PublicThread ||
          channel?.type === ChannelType.PrivateThread ||
          channel?.type === ChannelType.AnnouncementThread;
        if (parentId && isThreadType) {
          return parentId;
        }
        return channelId || null;
      },
    );
    vi.spyOn(discordThreadBindingApi, "createThreadForBinding").mockImplementation(
      async (params) => {
        const created = await hoisted.createThreadDiscord(
          params.channelId,
          {
            name: params.threadName,
          },
          {
            accountId: params.accountId,
            token: params.token,
            cfg: params.cfg,
          },
        );
        return typeof created?.id === "string" ? created.id.trim() || null : null;
      },
    );
    vi.spyOn(discordThreadBindingApi, "maybeSendBindingMessage").mockImplementation(
      async (params) => {
        if (
          params.preferWebhook !== false &&
          params.record.webhookId &&
          params.record.webhookToken
        ) {
          await hoisted.sendWebhookMessageDiscord(params.text, {
            cfg: params.cfg,
            webhookId: params.record.webhookId,
            webhookToken: params.record.webhookToken,
            accountId: params.record.accountId,
            threadId: params.record.threadId,
          });
          return;
        }
        await hoisted.sendMessageDiscord(`channel:${params.record.threadId}`, params.text, {
          cfg: params.cfg,
          accountId: params.record.accountId,
        });
      },
    );
    vi.spyOn(acpRuntime, "readAcpSessionEntry").mockImplementation(hoisted.readAcpSessionEntry);
    vi.useRealTimers();
  });

  const createDefaultSweeperManager = () =>
    createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: true,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

  const bindDefaultThreadTarget = async (
    manager: ReturnType<typeof createThreadBindingManager>,
  ) => {
    await manager.bindTarget({
      threadId: "thread-1",
      channelId: "parent-1",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:child",
      agentId: "main",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    });
  };

  const requireBinding = (
    manager: ReturnType<typeof createThreadBindingManager>,
    threadId: string,
  ) => {
    const binding = manager.getByThreadId(threadId);
    if (!binding) {
      throw new Error(`missing thread binding: ${threadId}`);
    }
    return binding;
  };

  it.each([
    {
      name: "auto-unfocuses idle-expired bindings and sends inactivity message",
      idleTimeoutMs: 60_000,
      maxAgeMs: 0,
      introText: "intro",
      farewellText: "after 1m of inactivity",
      expectNoProbe: true,
    },
    {
      name: "auto-unfocuses max-age-expired bindings and sends max-age message",
      idleTimeoutMs: 0,
      maxAgeMs: 60_000,
      farewellText: "max age of 1m",
      expectNoProbe: false,
    },
  ])("$name", async ({ idleTimeoutMs, maxAgeMs, introText, farewellText, expectNoProbe }) => {
    vi.useFakeTimers();
    try {
      const manager = createTestThreadBindingManager({
        accountId: "default",
        cfg: EMPTY_DISCORD_TEST_CONFIG,
        persist: false,
        enableSweeper: true,
        idleTimeoutMs,
        maxAgeMs,
      });

      const binding = await manager.bindTarget({
        threadId: "thread-1",
        channelId: "parent-1",
        targetKind: "subagent",
        targetSessionKey: "agent:main:subagent:child",
        agentId: "main",
        webhookId: "wh-1",
        webhookToken: "tok-1",
        ...(introText ? { introText } : {}),
      });
      expectFields(binding, "binding", {
        threadId: "thread-1",
        targetSessionKey: "agent:main:subagent:child",
      });
      hoisted.sendMessageDiscord.mockClear();
      hoisted.sendWebhookMessageDiscord.mockClear();

      await vi.advanceTimersByTimeAsync(120_000);

      expect(manager.getByThreadId("thread-1")).toBeUndefined();
      if (expectNoProbe) {
        expect(hoisted.restGet).not.toHaveBeenCalled();
      }
      expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
      expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
      const farewell = mockCallArg(hoisted.sendMessageDiscord, 0, 1, "sendMessageDiscord") as
        | string
        | undefined;
      expect(farewell).toContain(farewellText);
    } finally {
      vi.useRealTimers();
    }
  });

  it.each<{
    name: string;
    probeError: unknown;
    keepsBinding: boolean;
  }>([
    {
      name: "keeps binding when thread sweep probe fails transiently",
      probeError: new Error("ECONNRESET"),
      keepsBinding: true,
    },
    {
      name: "unbinds when thread sweep probe reports unknown channel",
      probeError: { status: 404, rawError: { code: 10003, message: "Unknown Channel" } },
      keepsBinding: false,
    },
  ])("$name", async ({ probeError, keepsBinding }) => {
    vi.useFakeTimers();
    try {
      const manager = createDefaultSweeperManager();
      await bindDefaultThreadTarget(manager);

      hoisted.restGet.mockRejectedValueOnce(probeError);

      await vi.advanceTimersByTimeAsync(120_000);

      if (keepsBinding) {
        expectFields(requireBinding(manager, "thread-1"), "thread binding", {
          threadId: "thread-1",
          targetSessionKey: "agent:main:subagent:child",
          webhookId: "wh-1",
          webhookToken: "tok-1",
        });
      } else {
        expect(manager.getByThreadId("thread-1")).toBeUndefined();
      }
      expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
    } finally {
      vi.useRealTimers();
    }
  });

  it("updates idle timeout by target session key", async () => {
    vi.useFakeTimers();
    try {
      vi.setSystemTime(new Date("2026-02-20T23:00:00.000Z"));
      const manager = createTestThreadBindingManager({
        accountId: "default",
        persist: false,
        enableSweeper: false,
        idleTimeoutMs: 24 * 60 * 60 * 1000,
        maxAgeMs: 0,
      });

      await manager.bindTarget({
        threadId: "thread-1",
        channelId: "parent-1",
        targetKind: "subagent",
        targetSessionKey: "agent:main:subagent:child",
        agentId: "main",
        webhookId: "wh-1",
        webhookToken: "tok-1",
      });

      const boundAt = manager.getByThreadId("thread-1")?.boundAt;
      vi.setSystemTime(new Date("2026-02-20T23:15:00.000Z"));

      const updated = setThreadBindingIdleTimeoutBySessionKey({
        accountId: "default",
        targetSessionKey: "agent:main:subagent:child",
        idleTimeoutMs: 2 * 60 * 60 * 1000,
      });

      expect(updated).toHaveLength(1);
      const updatedBinding = expectDefined(updated[0], "idle-timeout thread binding");
      expect(updated[0]?.lastActivityAt).toBe(new Date("2026-02-20T23:15:00.000Z").getTime());
      expect(updated[0]?.boundAt).toBe(boundAt);
      expect(
        resolveThreadBindingInactivityExpiresAt({
          record: updatedBinding,
          defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
        }),
      ).toBe(new Date("2026-02-21T01:15:00.000Z").getTime());
    } finally {
      vi.useRealTimers();
    }
  });

  it("updates max age by target session key", async () => {
    vi.useFakeTimers();
    try {
      vi.setSystemTime(new Date("2026-02-20T10:00:00.000Z"));
      const manager = createTestThreadBindingManager({
        accountId: "default",
        persist: false,
        enableSweeper: false,
        idleTimeoutMs: 24 * 60 * 60 * 1000,
        maxAgeMs: 0,
      });

      await manager.bindTarget({
        threadId: "thread-1",
        channelId: "parent-1",
        targetKind: "subagent",
        targetSessionKey: "agent:main:subagent:child",
        agentId: "main",
      });

      vi.setSystemTime(new Date("2026-02-20T10:30:00.000Z"));
      const updated = setThreadBindingMaxAgeBySessionKey({
        accountId: "default",
        targetSessionKey: "agent:main:subagent:child",
        maxAgeMs: 3 * 60 * 60 * 1000,
      });

      expect(updated).toHaveLength(1);
      const updatedBinding = expectDefined(updated[0], "max-age thread binding");
      expect(updated[0]?.boundAt).toBe(new Date("2026-02-20T10:30:00.000Z").getTime());
      expect(updated[0]?.lastActivityAt).toBe(new Date("2026-02-20T10:30:00.000Z").getTime());
      expect(
        resolveThreadBindingMaxAgeExpiresAt({
          record: updatedBinding,
          defaultMaxAgeMs: manager.getMaxAgeMs(),
        }),
      ).toBe(new Date("2026-02-20T13:30:00.000Z").getTime());
    } finally {
      vi.useRealTimers();
    }
  });

  it("preserves explicit lifecycle windows when rebinding the same thread", async () => {
    vi.useFakeTimers();
    try {
      vi.setSystemTime(new Date("2026-02-20T10:00:00.000Z"));
      const manager = createTestThreadBindingManager({
        accountId: "default",
        persist: false,
        enableSweeper: false,
        idleTimeoutMs: 24 * 60 * 60 * 1000,
        maxAgeMs: 0,
      });

      await manager.bindTarget({
        threadId: "thread-1",
        channelId: "parent-1",
        targetKind: "subagent",
        targetSessionKey: "agent:main:subagent:child",
        agentId: "main",
        webhookId: "wh-1",
        webhookToken: "tok-1",
      });

      setThreadBindingIdleTimeoutBySessionKey({
        accountId: "default",
        targetSessionKey: "agent:main:subagent:child",
        idleTimeoutMs: 2 * 60 * 60 * 1000,
      });
      setThreadBindingMaxAgeBySessionKey({
        accountId: "default",
        targetSessionKey: "agent:main:subagent:child",
        maxAgeMs: 3 * 60 * 60 * 1000,
      });

      vi.setSystemTime(new Date("2026-02-20T10:30:00.000Z"));
      const rebound = await manager.bindTarget({
        threadId: "thread-1",
        channelId: "parent-1",
        targetKind: "subagent",
        targetSessionKey: "agent:main:subagent:child",
        webhookId: "wh-1",
        webhookToken: "tok-1",
      });

      expectFields(rebound, "rebound binding", {
        idleTimeoutMs: 2 * 60 * 60 * 1000,
        maxAgeMs: 3 * 60 * 60 * 1000,
      });
      expectFields(requireBinding(manager, "thread-1"), "thread binding", {
        idleTimeoutMs: 2 * 60 * 60 * 1000,
        maxAgeMs: 3 * 60 * 60 * 1000,
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it("keeps binding when idle timeout is disabled per session key", async () => {
    vi.useFakeTimers();
    try {
      const manager = createTestThreadBindingManager({
        accountId: "default",
        persist: false,
        enableSweeper: true,
        idleTimeoutMs: 60_000,
        maxAgeMs: 0,
      });

      await manager.bindTarget({
        threadId: "thread-1",
        channelId: "parent-1",
        targetKind: "subagent",
        targetSessionKey: "agent:main:subagent:child",
        agentId: "main",
        webhookId: "wh-1",
        webhookToken: "tok-1",
      });

      const updated = setThreadBindingIdleTimeoutBySessionKey({
        accountId: "default",
        targetSessionKey: "agent:main:subagent:child",
        idleTimeoutMs: 0,
      });
      expect(updated).toHaveLength(1);
      expect(updated[0]?.idleTimeoutMs).toBe(0);

      await vi.advanceTimersByTimeAsync(240_000);

      expectFields(requireBinding(manager, "thread-1"), "thread binding", {
        threadId: "thread-1",
        targetSessionKey: "agent:main:subagent:child",
        idleTimeoutMs: 0,
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it("keeps a binding when activity is touched during the same sweep pass", async () => {
    vi.useFakeTimers();
    try {
      const manager = createTestThreadBindingManager({
        accountId: "default",
        persist: false,
        enableSweeper: true,
        idleTimeoutMs: 60_000,
        maxAgeMs: 0,
      });

      await manager.bindTarget({
        threadId: "thread-1",
        channelId: "parent-1",
        targetKind: "subagent",
        targetSessionKey: "agent:main:subagent:first",
        agentId: "main",
        webhookId: "wh-1",
        webhookToken: "tok-1",
      });
      await manager.bindTarget({
        threadId: "thread-2",
        channelId: "parent-1",
        targetKind: "subagent",
        targetSessionKey: "agent:main:subagent:second",
        agentId: "main",
        webhookId: "wh-2",
        webhookToken: "tok-2",
      });

      // Keep the first binding off the idle-expire path so the sweep performs
      // an awaited probe and gives a window for in-pass touches.
      setThreadBindingIdleTimeoutBySessionKey({
        accountId: "default",
        targetSessionKey: "agent:main:subagent:first",
        idleTimeoutMs: 0,
      });

      hoisted.restGet.mockImplementation(async (...args: unknown[]) => {
        const route = typeof args[0] === "string" ? args[0] : "";
        if (route.includes("thread-1")) {
          manager.touchThread({ threadId: "thread-2", persist: false });
        }
        return {
          id: route.split("/").at(-1) ?? "thread-1",
          type: 11,
          parent_id: "parent-1",
        };
      });
      hoisted.sendMessageDiscord.mockClear();

      await vi.advanceTimersByTimeAsync(120_000);

      expectFields(requireBinding(manager, "thread-2"), "thread binding", {
        threadId: "thread-2",
        targetSessionKey: "agent:main:subagent:second",
      });
      expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled();
    } finally {
      vi.useRealTimers();
    }
  });

  it("refreshes inactivity window when thread activity is touched", async () => {
    vi.useFakeTimers();
    try {
      vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
      const manager = createTestThreadBindingManager({
        accountId: "default",
        persist: false,
        enableSweeper: false,
        idleTimeoutMs: 60_000,
        maxAgeMs: 0,
      });

      await manager.bindTarget({
        threadId: "thread-1",
        channelId: "parent-1",
        targetKind: "subagent",
        targetSessionKey: "agent:main:subagent:child",
        agentId: "main",
      });

      vi.setSystemTime(new Date("2026-02-20T00:00:30.000Z"));
      const touched = manager.touchThread({ threadId: "thread-1", persist: false });
      expectFields(touched, "touched binding", {
        threadId: "thread-1",
        lastActivityAt: new Date("2026-02-20T00:00:30.000Z").getTime(),
      });

      const record = requireBinding(manager, "thread-1");
      expect(record.lastActivityAt).toBe(new Date("2026-02-20T00:00:30.000Z").getTime());
      expect(
        resolveThreadBindingInactivityExpiresAt({
          record,
          defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
        }),
      ).toBe(new Date("2026-02-20T00:01:30.000Z").getTime());
    } finally {
      vi.useRealTimers();
    }
  });

  it("persists touched activity timestamps across restart when persistence is enabled", async () => {
    vi.useFakeTimers();
    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-"));
    process.env.OPENCLAW_STATE_DIR = stateDir;
    try {
      resetThreadBindingsForTests();
      vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
      const manager = createTestThreadBindingManager({
        accountId: "default",
        persist: true,
        enableSweeper: false,
        idleTimeoutMs: 60_000,
        maxAgeMs: 0,
      });

      await manager.bindTarget({
        threadId: "thread-1",
        channelId: "parent-1",
        targetKind: "subagent",
        targetSessionKey: "agent:main:subagent:child",
        agentId: "main",
        webhookId: "wh-1",
        webhookToken: "tok-1",
      });

      const touchedAt = new Date("2026-02-20T00:00:30.000Z").getTime();
      vi.setSystemTime(touchedAt);
      manager.touchThread({ threadId: "thread-1" });

      resetThreadBindingsForTests();
      const reloaded = createTestThreadBindingManager({
        accountId: "default",
        persist: true,
        enableSweeper: false,
        idleTimeoutMs: 60_000,
        maxAgeMs: 0,
      });

      const record = requireBinding(reloaded, "thread-1");
      expect(record.lastActivityAt).toBe(touchedAt);
      expect(
        resolveThreadBindingInactivityExpiresAt({
          record,
          defaultIdleTimeoutMs: reloaded.getIdleTimeoutMs(),
        }),
      ).toBe(new Date("2026-02-20T00:01:30.000Z").getTime());
    } finally {
      resetThreadBindingsForTests();
      if (previousStateDir === undefined) {
        delete process.env.OPENCLAW_STATE_DIR;
      } else {
        process.env.OPENCLAW_STATE_DIR = previousStateDir;
      }
      fs.rmSync(stateDir, { recursive: true, force: true });
      vi.useRealTimers();
    }
  });

  it("keeps thread binding startup in memory when SQLite persistence is unavailable", async () => {
    setDiscordRuntime({
      state: {
        openSyncKeyedStore: () => {
          throw new Error("sqlite unavailable");
        },
      },
    } as unknown as DiscordRuntime);

    const manager = createTestThreadBindingManager({
      accountId: "default",
      persist: true,
      enableSweeper: false,
    });

    await manager.bindTarget({
      threadId: "thread-sqlite-down",
      channelId: "parent-1",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:sqlite-down",
      agentId: "main",
    });

    expect(manager.getByThreadId("thread-sqlite-down")).toMatchObject({
      targetSessionKey: "agent:main:subagent:sqlite-down",
    });
  });

  it("reuses webhook credentials after unbind when rebinding in the same channel", async () => {
    const manager = createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    const first = await manager.bindTarget({
      threadId: "thread-1",
      channelId: "parent-1",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:child-1",
      agentId: "main",
    });
    expectFields(first, "first binding", {
      threadId: "thread-1",
      targetSessionKey: "agent:main:subagent:child-1",
    });
    expect(hoisted.restPost).toHaveBeenCalledTimes(1);

    manager.unbindThread({
      threadId: "thread-1",
      sendFarewell: false,
    });

    const second = await manager.bindTarget({
      threadId: "thread-2",
      channelId: "parent-1",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:child-2",
      agentId: "main",
    });
    expectFields(second, "second binding", {
      webhookId: "wh-created",
      webhookToken: "tok-created",
    });
    expect(hoisted.restPost).toHaveBeenCalledTimes(1);
  });

  it("creates a new thread when spawning from an already bound thread", async () => {
    const manager = createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    await manager.bindTarget({
      threadId: "thread-1",
      channelId: "parent-1",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:parent",
      agentId: "main",
    });
    hoisted.createThreadDiscord.mockClear();
    hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-2" });

    const childBinding = await autoBindSpawnedDiscordSubagent({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      accountId: "default",
      channel: "discord",
      to: "channel:thread-1",
      threadId: "thread-1",
      childSessionKey: "agent:main:subagent:child-2",
      agentId: "main",
    });

    expectFields(childBinding, "child binding", {
      threadId: "thread-created-2",
      targetSessionKey: "agent:main:subagent:child-2",
    });
    expect(hoisted.createThreadDiscord).toHaveBeenCalledTimes(1);
    expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe("parent-1");
    expectThreadCreateOptionsWithoutArchiveOverride(
      mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
    );
    expectFields(
      mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
      "thread context",
      {
        accountId: "default",
      },
    );
    expect(manager.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:parent");
    expect(manager.getByThreadId("thread-created-2")?.targetSessionKey).toBe(
      "agent:main:subagent:child-2",
    );
  });

  it("resolves parent channel when thread target is passed via to without threadId", async () => {
    createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    hoisted.restGet.mockClear();
    hoisted.restGet.mockResolvedValueOnce({
      id: "thread-lookup",
      type: 11,
      parent_id: "parent-1",
    });
    hoisted.createThreadDiscord.mockClear();
    hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-lookup" });

    const childBinding = await autoBindSpawnedDiscordSubagent({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      accountId: "default",
      channel: "discord",
      to: "channel:thread-lookup",
      childSessionKey: "agent:main:subagent:child-lookup",
      agentId: "main",
    });

    expectFields(childBinding, "child binding", { channelId: "parent-1" });
    expect(hoisted.restGet).toHaveBeenCalledTimes(1);
    expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe("parent-1");
    expectThreadCreateOptionsWithoutArchiveOverride(
      mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
    );
    expectFields(
      mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
      "thread context",
      {
        accountId: "default",
      },
    );
  });

  it("passes manager token when resolving parent channels for auto-bind", async () => {
    const cfg = {
      channels: { discord: { token: "tok" } },
    } as OpenClawConfig;
    createTestThreadBindingManager({
      accountId: "runtime",
      token: "runtime-token",
      cfg,
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    hoisted.createDiscordRestClient.mockClear();
    hoisted.restGet.mockClear();
    hoisted.restGet.mockResolvedValueOnce({
      id: "thread-runtime",
      type: 11,
      parent_id: "parent-runtime",
    });
    hoisted.createThreadDiscord.mockClear();
    hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime" });

    const childBinding = await autoBindSpawnedDiscordSubagent({
      cfg,
      accountId: "runtime",
      channel: "discord",
      to: "channel:thread-runtime",
      childSessionKey: "agent:main:subagent:child-runtime",
      agentId: "main",
    });

    expectFields(childBinding, "child binding", {
      threadId: "thread-created-runtime",
      targetSessionKey: "agent:main:subagent:child-runtime",
    });
    const firstClientArgs = mockCallArg(
      hoisted.createDiscordRestClient,
      0,
      0,
      "createDiscordRestClient",
    ) as { accountId?: string; token?: string } | undefined;
    expectFields(firstClientArgs, "first client args", {
      accountId: "runtime",
      token: "runtime-token",
    });
    const usedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => {
      if (call?.[1] === cfg) {
        return true;
      }
      const first = call?.[0];
      return (
        typeof first === "object" && first !== null && (first as { cfg?: unknown }).cfg === cfg
      );
    });
    expect(usedCfg).toBe(true);
  });

  it("uses the active runtime snapshot cfg for manager operations", async () => {
    const startupCfg = {
      channels: { discord: { token: "startup-token" } },
    } as OpenClawConfig;
    const refreshedCfg = {
      channels: { discord: { token: "refreshed-token" } },
    } as OpenClawConfig;
    const manager = createTestThreadBindingManager({
      accountId: "runtime",
      token: "runtime-token",
      cfg: startupCfg,
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    setRuntimeConfigSnapshot(refreshedCfg);
    hoisted.createDiscordRestClient.mockClear();
    hoisted.createThreadDiscord.mockClear();
    hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime-cfg" });

    const bound = await manager.bindTarget({
      createThread: true,
      channelId: "parent-runtime",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:runtime-cfg",
      agentId: "main",
    });

    expectFields(bound, "bound thread", {
      threadId: "thread-created-runtime-cfg",
      targetSessionKey: "agent:main:subagent:runtime-cfg",
    });
    const usedRefreshedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => {
      if (call?.[1] === refreshedCfg) {
        return true;
      }
      const first = call?.[0];
      return (
        typeof first === "object" &&
        first !== null &&
        (first as { cfg?: unknown }).cfg === refreshedCfg
      );
    });
    expect(usedRefreshedCfg).toBe(true);
    const usedStartupCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => {
      if (call?.[1] === startupCfg) {
        return true;
      }
      const first = call?.[0];
      return (
        typeof first === "object" &&
        first !== null &&
        (first as { cfg?: unknown }).cfg === startupCfg
      );
    });
    expect(usedStartupCfg).toBe(false);
  });

  it("refreshes manager token when an existing manager is reused", async () => {
    createTestThreadBindingManager({
      accountId: "runtime",
      token: "token-old",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });
    const manager = createTestThreadBindingManager({
      accountId: "runtime",
      token: "token-new",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    hoisted.createThreadDiscord.mockClear();
    hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-token-refresh" });
    hoisted.createDiscordRestClient.mockClear();

    const bound = await manager.bindTarget({
      createThread: true,
      channelId: "parent-runtime",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:token-refresh",
      agentId: "main",
    });

    expectFields(bound, "bound thread", {
      threadId: "thread-created-token-refresh",
      targetSessionKey: "agent:main:subagent:token-refresh",
    });
    expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe(
      "parent-runtime",
    );
    expectThreadCreateOptionsWithoutArchiveOverride(
      mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
    );
    expectFields(
      mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
      "thread context",
      {
        accountId: "runtime",
        token: "token-new",
      },
    );
    const usedTokenNew = hoisted.createDiscordRestClient.mock.calls.some(
      (call) => (call?.[0] as { token?: string } | undefined)?.token === "token-new",
    );
    expect(usedTokenNew).toBe(true);
  });

  it("normalizes prefixed parentConversationId before creating child thread bindings", async () => {
    createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    hoisted.restGet.mockClear();
    hoisted.createThreadDiscord.mockClear();
    hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-parent-normalized" });

    const bound = await getSessionBindingService().bind({
      targetSessionKey: "agent:codex:acp:test-parent-normalized",
      targetKind: "session",
      conversation: {
        channel: "discord",
        accountId: "default",
        conversationId: "channel:1491611525914558668",
        parentConversationId: "channel:1491611525914558667",
      },
      placement: "child",
      metadata: {
        agentId: "codex",
        label: "Codex ACP bind test",
        threadName: "Codex ACP bind test",
      },
    });

    const boundConversation = requireRecord(
      requireRecord(bound, "bound session").conversation,
      "bound conversation",
    );
    expectFields(boundConversation, "bound conversation", {
      channel: "discord",
      accountId: "default",
      conversationId: "thread-created-parent-normalized",
    });
    expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe(
      "1491611525914558667",
    );
    expectThreadCreateOptionsWithoutArchiveOverride(
      mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
    );
    expectFields(
      mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
      "thread context",
      {
        accountId: "default",
      },
    );
    expect(hoisted.restGet).not.toHaveBeenCalled();
  });

  it("preserves prefixed current channel conversation ids as binding keys", async () => {
    createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    hoisted.restGet.mockClear();
    hoisted.restPost.mockClear();

    const service = getSessionBindingService();
    const bound = await service.bind({
      targetSessionKey: "agent:codex:acp:current-channel",
      targetKind: "session",
      conversation: {
        channel: "discord",
        accountId: "default",
        conversationId: "channel:1491611525914558667",
      },
      placement: "current",
      metadata: {
        agentId: "codex",
      },
    });

    const boundConversation = requireRecord(
      requireRecord(bound, "bound session").conversation,
      "bound conversation",
    );
    expectFields(boundConversation, "bound conversation", {
      channel: "discord",
      accountId: "default",
      conversationId: "channel:1491611525914558667",
    });
    expectFields(
      service.resolveByConversation({
        channel: "discord",
        accountId: "default",
        conversationId: "channel:1491611525914558667",
      }),
      "resolved binding",
      {
        targetSessionKey: "agent:codex:acp:current-channel",
      },
    );
    expect(
      service.resolveByConversation({
        channel: "discord",
        accountId: "default",
        conversationId: "1491611525914558667",
      }),
    ).toBeNull();
    expect(hoisted.restGet).not.toHaveBeenCalled();
    expect(hoisted.restPost).not.toHaveBeenCalled();
  });

  it("binds current Discord DMs as direct conversation bindings", async () => {
    createTestThreadBindingManager({
      accountId: "default",
      cfg: {
        agents: { list: [{ id: "codex", default: true }] },
      },
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    hoisted.restGet.mockClear();
    hoisted.restPost.mockClear();

    const bound = await getSessionBindingService().bind({
      targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm",
      targetKind: "session",
      conversation: {
        channel: "discord",
        accountId: "default",
        conversationId: "user:1177378744822943744",
      },
      placement: "current",
      metadata: {
        pluginBindingOwner: "plugin",
        pluginId: "openclaw-codex-app-server",
        pluginRoot: "/Users/huntharo/github/openclaw-app-server",
      },
    });

    const boundConversation = requireRecord(
      requireRecord(bound, "bound session").conversation,
      "bound conversation",
    );
    expectFields(boundConversation, "bound conversation", {
      channel: "discord",
      accountId: "default",
      conversationId: "user:1177378744822943744",
      parentConversationId: "user:1177378744822943744",
    });
    expectFields(requireRecord(bound, "bound session").metadata, "bound metadata", {
      agentId: "codex",
    });
    const resolved = requireRecord(
      getSessionBindingService().resolveByConversation({
        channel: "discord",
        accountId: "default",
        conversationId: "user:1177378744822943744",
      }),
      "resolved binding",
    );
    expect(requireRecord(resolved.conversation, "resolved conversation").conversationId).toBe(
      "user:1177378744822943744",
    );
    expect(hoisted.restGet).not.toHaveBeenCalled();
    expect(hoisted.restPost).not.toHaveBeenCalled();
  });

  it("preserves direct-binding metadata when rebinding the same conversation", async () => {
    createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    await getSessionBindingService().bind({
      targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm",
      targetKind: "session",
      conversation: {
        channel: "discord",
        accountId: "default",
        conversationId: "user:1177378744822943744",
      },
      placement: "current",
      metadata: {
        pluginBindingOwner: "plugin",
        pluginId: "openclaw-codex-app-server",
        pluginRoot: "/Users/huntharo/github/openclaw-app-server",
        agentId: "codex",
        boundBy: "system",
      },
    });

    await getSessionBindingService().bind({
      targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm",
      targetKind: "session",
      conversation: {
        channel: "discord",
        accountId: "default",
        conversationId: "user:1177378744822943744",
      },
      placement: "current",
      metadata: {
        label: "codex-dm",
      },
    });

    const resolved = requireRecord(
      getSessionBindingService().resolveByConversation({
        channel: "discord",
        accountId: "default",
        conversationId: "user:1177378744822943744",
      }),
      "resolved binding",
    );
    expectFields(requireRecord(resolved.metadata, "resolved metadata"), "resolved metadata", {
      pluginBindingOwner: "plugin",
      pluginId: "openclaw-codex-app-server",
      pluginRoot: "/Users/huntharo/github/openclaw-app-server",
      agentId: "codex",
      boundBy: "system",
      label: "codex-dm",
    });
    expect(hoisted.restGet).not.toHaveBeenCalled();
    expect(hoisted.restPost).not.toHaveBeenCalled();
  });

  it("keeps overlapping thread ids isolated per account", async () => {
    const a = createTestThreadBindingManager({
      accountId: "a",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });
    const b = createTestThreadBindingManager({
      accountId: "b",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    const aBinding = await a.bindTarget({
      threadId: "thread-1",
      channelId: "parent-1",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:a",
      agentId: "main",
    });
    const bBinding = await b.bindTarget({
      threadId: "thread-1",
      channelId: "parent-1",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:b",
      agentId: "main",
    });

    expect(aBinding?.accountId).toBe("a");
    expect(bBinding?.accountId).toBe("b");
    expect(a.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:a");
    expect(b.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:b");

    const removedA = a.unbindBySessionKey({
      targetSessionKey: "agent:main:subagent:a",
      sendFarewell: false,
    });
    expect(removedA).toHaveLength(1);
    expect(a.getByThreadId("thread-1")).toBeUndefined();
    expect(b.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:b");
  });

  it("removes stale ACP bindings during startup reconciliation", async () => {
    const manager = createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    await manager.bindTarget({
      threadId: "thread-acp-healthy",
      channelId: "parent-1",
      targetKind: "acp",
      targetSessionKey: "agent:codex:acp:healthy",
      agentId: "codex",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    });
    await manager.bindTarget({
      threadId: "thread-acp-stale",
      channelId: "parent-1",
      targetKind: "acp",
      targetSessionKey: "agent:codex:acp:stale",
      agentId: "codex",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    });
    await manager.bindTarget({
      threadId: "thread-subagent",
      channelId: "parent-1",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:child",
      agentId: "main",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    });

    hoisted.readAcpSessionEntry.mockImplementation((paramsUnknown: unknown) => {
      const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? "";
      if (sessionKey === "agent:codex:acp:healthy") {
        return {
          sessionKey,
          storeSessionKey: sessionKey,
          acp: {
            backend: "acpx",
            agent: "codex",
            runtimeSessionName: "runtime:healthy",
            mode: "persistent",
            state: "idle",
            lastActivityAt: Date.now(),
          },
        };
      }
      return {
        sessionKey,
        storeSessionKey: sessionKey,
        acp: undefined,
      };
    });

    const result = await reconcileAcpThreadBindingsOnStartup({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      accountId: "default",
    });

    expect(result.checked).toBe(2);
    expect(result.removed).toBe(1);
    expect(result.staleSessionKeys).toContain("agent:codex:acp:stale");
    expectFields(requireBinding(manager, "thread-acp-healthy"), "healthy binding", {
      threadId: "thread-acp-healthy",
      targetKind: "acp",
      targetSessionKey: "agent:codex:acp:healthy",
    });
    expect(manager.getByThreadId("thread-acp-stale")).toBeUndefined();
    expectFields(requireBinding(manager, "thread-subagent"), "subagent binding", {
      threadId: "thread-subagent",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:child",
    });
    expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled();
    expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
  });

  it("keeps ACP bindings when session store reads fail during startup reconciliation", async () => {
    const manager = createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    await manager.bindTarget({
      threadId: "thread-acp-uncertain",
      channelId: "parent-1",
      targetKind: "acp",
      targetSessionKey: "agent:codex:acp:uncertain",
      agentId: "codex",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    });

    hoisted.readAcpSessionEntry.mockReturnValue({
      sessionKey: "agent:codex:acp:uncertain",
      storeSessionKey: "agent:codex:acp:uncertain",
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      storePath: "/tmp/mock-sessions.json",
      storeReadFailed: true,
      entry: undefined,
      acp: undefined,
    });

    const result = await reconcileAcpThreadBindingsOnStartup({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      accountId: "default",
    });

    expect(result.checked).toBe(1);
    expect(result.removed).toBe(0);
    expect(result.staleSessionKeys).toStrictEqual([]);
    expectFields(requireBinding(manager, "thread-acp-uncertain"), "uncertain binding", {
      threadId: "thread-acp-uncertain",
      targetKind: "acp",
      targetSessionKey: "agent:codex:acp:uncertain",
    });
  });

  it("does not reconcile plugin-owned direct bindings as stale ACP sessions", async () => {
    const manager = createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    await manager.bindTarget({
      threadId: "user:1177378744822943744",
      channelId: "user:1177378744822943744",
      targetKind: "acp",
      targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm",
      agentId: "codex",
      metadata: {
        pluginBindingOwner: "plugin",
        pluginId: "openclaw-codex-app-server",
        pluginRoot: "/Users/huntharo/github/openclaw-app-server",
      },
    });

    hoisted.readAcpSessionEntry.mockReturnValue(null);

    const result = await reconcileAcpThreadBindingsOnStartup({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      accountId: "default",
    });

    expect(result.checked).toBe(0);
    expect(result.removed).toBe(0);
    expect(result.staleSessionKeys).toStrictEqual([]);
    const binding = expectFields(
      manager.getByThreadId("user:1177378744822943744"),
      "plugin direct binding",
      {
        threadId: "user:1177378744822943744",
      },
    );
    expectFields(requireRecord(binding.metadata, "binding metadata"), "binding metadata", {
      pluginBindingOwner: "plugin",
      pluginId: "openclaw-codex-app-server",
    });
  });

  it("removes ACP bindings when health probe marks running session as stale", async () => {
    const manager = createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    await manager.bindTarget({
      threadId: "thread-acp-running",
      channelId: "parent-1",
      targetKind: "acp",
      targetSessionKey: "agent:codex:acp:running",
      agentId: "codex",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    });

    hoisted.readAcpSessionEntry.mockReturnValue({
      sessionKey: "agent:codex:acp:running",
      storeSessionKey: "agent:codex:acp:running",
      acp: {
        backend: "acpx",
        agent: "codex",
        runtimeSessionName: "runtime:running",
        mode: "persistent",
        state: "running",
        lastActivityAt: Date.now() - 5 * 60 * 1000,
      },
    });

    const result = await reconcileAcpThreadBindingsOnStartup({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      accountId: "default",
      healthProbe: async () => ({ status: "stale", reason: "status-timeout-running-stale" }),
    });

    expect(result.checked).toBe(1);
    expect(result.removed).toBe(1);
    expect(result.staleSessionKeys).toContain("agent:codex:acp:running");
    expect(manager.getByThreadId("thread-acp-running")).toBeUndefined();
  });

  it("keeps running ACP bindings when health probe is uncertain", async () => {
    const manager = createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    await manager.bindTarget({
      threadId: "thread-acp-running-uncertain",
      channelId: "parent-1",
      targetKind: "acp",
      targetSessionKey: "agent:codex:acp:running-uncertain",
      agentId: "codex",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    });

    hoisted.readAcpSessionEntry.mockReturnValue({
      sessionKey: "agent:codex:acp:running-uncertain",
      storeSessionKey: "agent:codex:acp:running-uncertain",
      acp: {
        backend: "acpx",
        agent: "codex",
        runtimeSessionName: "runtime:running-uncertain",
        mode: "persistent",
        state: "running",
        lastActivityAt: Date.now(),
      },
    });

    const result = await reconcileAcpThreadBindingsOnStartup({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      accountId: "default",
      healthProbe: async () => ({ status: "uncertain", reason: "status-timeout" }),
    });

    expect(result.checked).toBe(1);
    expect(result.removed).toBe(0);
    expect(result.staleSessionKeys).toStrictEqual([]);
    expectFields(
      requireBinding(manager, "thread-acp-running-uncertain"),
      "running uncertain binding",
      {
        threadId: "thread-acp-running-uncertain",
        targetKind: "acp",
        targetSessionKey: "agent:codex:acp:running-uncertain",
      },
    );
  });

  it("keeps ACP bindings in stored error state when no explicit stale probe verdict exists", async () => {
    const manager = createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    await manager.bindTarget({
      threadId: "thread-acp-error",
      channelId: "parent-1",
      targetKind: "acp",
      targetSessionKey: "agent:codex:acp:error",
      agentId: "codex",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    });

    hoisted.readAcpSessionEntry.mockReturnValue({
      sessionKey: "agent:codex:acp:error",
      storeSessionKey: "agent:codex:acp:error",
      acp: {
        backend: "acpx",
        agent: "codex",
        runtimeSessionName: "runtime:error",
        mode: "persistent",
        state: "error",
        lastActivityAt: Date.now(),
      },
    });

    const result = await reconcileAcpThreadBindingsOnStartup({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      accountId: "default",
    });

    expect(result.checked).toBe(1);
    expect(result.removed).toBe(0);
    expect(result.staleSessionKeys).toStrictEqual([]);
    expectFields(requireBinding(manager, "thread-acp-error"), "error binding", {
      threadId: "thread-acp-error",
      targetKind: "acp",
      targetSessionKey: "agent:codex:acp:error",
    });
  });

  it("starts ACP health probes in parallel during startup reconciliation", async () => {
    const manager = createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    await manager.bindTarget({
      threadId: "thread-acp-probe-1",
      channelId: "parent-1",
      targetKind: "acp",
      targetSessionKey: "agent:codex:acp:probe-1",
      agentId: "codex",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    });
    await manager.bindTarget({
      threadId: "thread-acp-probe-2",
      channelId: "parent-1",
      targetKind: "acp",
      targetSessionKey: "agent:codex:acp:probe-2",
      agentId: "codex",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    });

    hoisted.readAcpSessionEntry.mockImplementation((paramsUnknown: unknown) => {
      const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? "";
      return {
        sessionKey,
        storeSessionKey: sessionKey,
        acp: {
          backend: "acpx",
          agent: "codex",
          runtimeSessionName: `runtime:${sessionKey}`,
          mode: "persistent",
          state: "running",
          lastActivityAt: Date.now(),
        },
      };
    });

    let resolveFirstProbe: ((value: { status: "healthy" }) => void) | undefined;
    const firstProbe = new Promise<{ status: "healthy" }>((resolve) => {
      resolveFirstProbe = resolve;
    });
    let probeCallCount = 0;
    let secondProbeStartedBeforeFirstResolved = false;

    const reconcilePromise = reconcileAcpThreadBindingsOnStartup({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      accountId: "default",
      healthProbe: async () => {
        probeCallCount += 1;
        if (probeCallCount === 1) {
          return await firstProbe;
        }
        secondProbeStartedBeforeFirstResolved = true;
        return { status: "healthy" as const };
      },
    });

    await vi.waitFor(() => {
      expect(probeCallCount).toBe(2);
    });
    const observedParallelStart = secondProbeStartedBeforeFirstResolved;

    resolveFirstProbe?.({ status: "healthy" });
    const result = await reconcilePromise;

    expect(observedParallelStart).toBe(true);
    expect(result.checked).toBe(2);
    expect(result.removed).toBe(0);
  });

  it("caps ACP startup health probe concurrency", async () => {
    const manager = createTestThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
      idleTimeoutMs: 24 * 60 * 60 * 1000,
      maxAgeMs: 0,
    });

    for (let index = 0; index < 12; index += 1) {
      const key = `agent:codex:acp:cap-${index}`;
      await manager.bindTarget({
        threadId: `thread-acp-cap-${index}`,
        channelId: "parent-1",
        targetKind: "acp",
        targetSessionKey: key,
        agentId: "codex",
        webhookId: "wh-1",
        webhookToken: "tok-1",
      });
    }

    hoisted.readAcpSessionEntry.mockImplementation((paramsUnknown: unknown) => {
      const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? "";
      return {
        sessionKey,
        storeSessionKey: sessionKey,
        acp: {
          backend: "acpx",
          agent: "codex",
          runtimeSessionName: `runtime:${sessionKey}`,
          mode: "persistent",
          state: "running",
          lastActivityAt: Date.now(),
        },
      };
    });

    const PROBE_LIMIT = 8;
    let probeCalls = 0;
    let inFlight = 0;
    let maxInFlight = 0;
    let releaseFirstWave: (() => void) | undefined;
    const firstWaveGate = new Promise<void>((resolve) => {
      releaseFirstWave = resolve;
    });

    const reconcilePromise = reconcileAcpThreadBindingsOnStartup({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      accountId: "default",
      healthProbe: async () => {
        probeCalls += 1;
        inFlight += 1;
        maxInFlight = Math.max(maxInFlight, inFlight);
        if (probeCalls <= PROBE_LIMIT) {
          await firstWaveGate;
        }
        inFlight -= 1;
        return { status: "healthy" as const };
      },
    });

    await vi.waitFor(() => {
      expect(probeCalls).toBe(PROBE_LIMIT);
    });
    expect(maxInFlight).toBe(PROBE_LIMIT);

    releaseFirstWave?.();
    const result = await reconcilePromise;
    expect(result.checked).toBe(12);
    expect(result.removed).toBe(0);
    expect(maxInFlight).toBeLessThanOrEqual(PROBE_LIMIT);
  });

  it("persists unbinds even when no manager is active", () => {
    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-"));
    process.env.OPENCLAW_STATE_DIR = stateDir;
    try {
      resetThreadBindingsForTests();
      const now = Date.now();
      const store = createPluginStateSyncKeyedStoreForTests("discord", {
        namespace: "thread-bindings",
        maxEntries: 10_000,
      });
      store.register("default:thread-1", {
        accountId: "default",
        channelId: "parent-1",
        threadId: "thread-1",
        targetKind: "subagent",
        targetSessionKey: "agent:main:subagent:child",
        agentId: "main",
        boundBy: "system",
        boundAt: now,
        lastActivityAt: now,
        idleTimeoutMs: 60_000,
        maxAgeMs: 0,
      });

      const removed = unbindThreadBindingsBySessionKey({
        targetSessionKey: "agent:main:subagent:child",
      });
      expect(removed).toHaveLength(1);
      expect(store.entries()).toStrictEqual([]);
    } finally {
      resetThreadBindingsForTests();
      if (previousStateDir === undefined) {
        delete process.env.OPENCLAW_STATE_DIR;
      } else {
        process.env.OPENCLAW_STATE_DIR = previousStateDir;
      }
      fs.rmSync(stateDir, { recursive: true, force: true });
    }
  });
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
