import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import plugin from "./index.js";

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

const hoisted = vi.hoisted(() => {
  const sessionStore: Record<string, Record<string, unknown>> = {
    "agent:main:main": {
      sessionId: "s-main",
      updatedAt: 0,
    },
  };
  return {
    sessionStore,
    updateSessionStore: vi.fn(
      async (_storePath: string, updater: (store: Record<string, unknown>) => void) => {
        updater(sessionStore);
      },
    ),
  };
});

vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
  const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
    "openclaw/plugin-sdk/config-runtime",
  );
  return {
    ...actual,
    updateSessionStore: hoisted.updateSessionStore,
  };
});

describe("active-memory plugin", () => {
  const hooks: Record<string, Function> = {};
  const registeredCommands: Record<string, any> = {};
  const runEmbeddedPiAgent = vi.fn();
  let stateDir = "";
  let configFile: Record<string, unknown> = {};
  const api: any = {
    pluginConfig: {
      agents: ["main"],
      logging: true,
    },
    config: {},
    id: "active-memory",
    name: "Active Memory",
    logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
    runtime: {
      agent: {
        runEmbeddedPiAgent,
        session: {
          resolveStorePath: vi.fn(() => "/tmp/openclaw-session-store.json"),
          loadSessionStore: vi.fn(() => hoisted.sessionStore),
          saveSessionStore: vi.fn(async () => {}),
        },
      },
      state: {
        resolveStateDir: () => stateDir,
      },
      config: {
        loadConfig: () => configFile,
        writeConfigFile: vi.fn(async (nextConfig: Record<string, unknown>) => {
          configFile = nextConfig;
        }),
      },
    },
    registerCommand: vi.fn((command) => {
      registeredCommands[command.name] = command;
    }),
    on: vi.fn((hookName: string, handler: Function) => {
      hooks[hookName] = handler;
    }),
  };

  beforeEach(async () => {
    vi.clearAllMocks();
    stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-test-"));
    configFile = {
      plugins: {
        entries: {
          "active-memory": {
            enabled: true,
            config: {
              agents: ["main"],
            },
          },
        },
      },
    };
    api.pluginConfig = {
      agents: ["main"],
      logging: true,
    };
    api.config = {};
    hoisted.sessionStore["agent:main:main"] = {
      sessionId: "s-main",
      updatedAt: 0,
    };
    for (const key of Object.keys(hooks)) {
      delete hooks[key];
    }
    for (const key of Object.keys(registeredCommands)) {
      delete registeredCommands[key];
    }
    runEmbeddedPiAgent.mockResolvedValue({
      payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
    });
    await plugin.register(api as unknown as OpenClawPluginApi);
  });

  afterEach(async () => {
    vi.restoreAllMocks();
    if (stateDir) {
      await fs.rm(stateDir, { recursive: true, force: true });
      stateDir = "";
    }
  });

  it("registers a before_prompt_build hook", () => {
    expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function));
  });

  it("registers a session-scoped active-memory toggle command", async () => {
    const command = registeredCommands["active-memory"];
    const sessionKey = "agent:main:active-memory-toggle";
    hoisted.sessionStore[sessionKey] = {
      sessionId: "s-active-memory-toggle",
      updatedAt: 0,
    };
    expect(command).toMatchObject({
      name: "active-memory",
      acceptsArgs: true,
    });

    const offResult = await command.handler({
      channel: "webchat",
      isAuthorizedSender: true,
      sessionKey,
      args: "off",
      commandBody: "/active-memory off",
      config: {},
      requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
      detachConversationBinding: async () => ({ removed: false }),
      getCurrentConversationBinding: async () => null,
    });

    expect(offResult.text).toContain("off for this session");

    const statusResult = await command.handler({
      channel: "webchat",
      isAuthorizedSender: true,
      sessionKey,
      args: "status",
      commandBody: "/active-memory status",
      config: {},
      requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
      detachConversationBinding: async () => ({ removed: false }),
      getCurrentConversationBinding: async () => null,
    });

    expect(statusResult.text).toBe("Active Memory: off for this session.");

    const disabledResult = await hooks.before_prompt_build(
      { prompt: "what wings should i order? active memory toggle", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey,
        messageProvider: "webchat",
      },
    );

    expect(disabledResult).toBeUndefined();
    expect(runEmbeddedPiAgent).not.toHaveBeenCalled();

    const onResult = await command.handler({
      channel: "webchat",
      isAuthorizedSender: true,
      sessionKey,
      args: "on",
      commandBody: "/active-memory on",
      config: {},
      requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
      detachConversationBinding: async () => ({ removed: false }),
      getCurrentConversationBinding: async () => null,
    });

    expect(onResult.text).toContain("on for this session");

    await hooks.before_prompt_build(
      { prompt: "what wings should i order? active memory toggle", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey,
        messageProvider: "webchat",
      },
    );

    expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
  });

  it("supports an explicit global active-memory config toggle", async () => {
    const command = registeredCommands["active-memory"];

    const offResult = await command.handler({
      channel: "webchat",
      isAuthorizedSender: true,
      args: "off --global",
      commandBody: "/active-memory off --global",
      config: {},
      requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
      detachConversationBinding: async () => ({ removed: false }),
      getCurrentConversationBinding: async () => null,
    });

    expect(offResult.text).toBe("Active Memory: off globally.");
    expect(api.runtime.config.writeConfigFile).toHaveBeenCalledTimes(1);
    expect(configFile).toMatchObject({
      plugins: {
        entries: {
          "active-memory": {
            enabled: true,
            config: {
              enabled: false,
              agents: ["main"],
            },
          },
        },
      },
    });

    const statusOffResult = await command.handler({
      channel: "webchat",
      isAuthorizedSender: true,
      args: "status --global",
      commandBody: "/active-memory status --global",
      config: {},
      requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
      detachConversationBinding: async () => ({ removed: false }),
      getCurrentConversationBinding: async () => null,
    });

    expect(statusOffResult.text).toBe("Active Memory: off globally.");

    await hooks.before_prompt_build(
      { prompt: "what wings should i order while global active memory is off?", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:global-toggle",
        messageProvider: "webchat",
      },
    );

    expect(runEmbeddedPiAgent).not.toHaveBeenCalled();

    const onResult = await command.handler({
      channel: "webchat",
      isAuthorizedSender: true,
      args: "on --global",
      commandBody: "/active-memory on --global",
      config: {},
      requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
      detachConversationBinding: async () => ({ removed: false }),
      getCurrentConversationBinding: async () => null,
    });

    expect(onResult.text).toBe("Active Memory: on globally.");
    expect(configFile).toMatchObject({
      plugins: {
        entries: {
          "active-memory": {
            enabled: true,
            config: {
              enabled: true,
              agents: ["main"],
            },
          },
        },
      },
    });

    await hooks.before_prompt_build(
      { prompt: "what wings should i order after global active memory is back on?", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:global-toggle",
        messageProvider: "webchat",
      },
    );

    expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
  });

  it("does not run for agents that are not explicitly targeted", async () => {
    const result = await hooks.before_prompt_build(
      { prompt: "what wings should i order?", messages: [] },
      {
        agentId: "support",
        trigger: "user",
        sessionKey: "agent:support:main",
        messageProvider: "webchat",
      },
    );

    expect(result).toBeUndefined();
    expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
  });

  it("does not rewrite session state for skipped turns with no active-memory entry to clear", async () => {
    const result = await hooks.before_prompt_build(
      { prompt: "what wings should i order?", messages: [] },
      {
        agentId: "support",
        trigger: "user",
        sessionKey: "agent:support:main",
        messageProvider: "webchat",
      },
    );

    expect(result).toBeUndefined();
    expect(hoisted.updateSessionStore).not.toHaveBeenCalled();
  });

  it("does not run for non-interactive contexts", async () => {
    const result = await hooks.before_prompt_build(
      { prompt: "what wings should i order?", messages: [] },
      {
        agentId: "main",
        trigger: "heartbeat",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    expect(result).toBeUndefined();
    expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
  });

  it("defaults to direct-style sessions only", async () => {
    const result = await hooks.before_prompt_build(
      { prompt: "what wings should we order?", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:telegram:group:-100123",
        messageProvider: "telegram",
        channelId: "telegram",
      },
    );

    expect(result).toBeUndefined();
    expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
  });

  it("treats non-webchat main sessions as direct chats under the default dmScope", async () => {
    const result = await hooks.before_prompt_build(
      { prompt: "what wings should i order?", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "telegram",
        channelId: "telegram",
      },
    );

    expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
    expect(result).toEqual({
      prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
      appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
    });
  });

  it("treats non-default main session keys as direct chats", async () => {
    api.config = { session: { mainKey: "home" } };

    const result = await hooks.before_prompt_build(
      { prompt: "what wings should i order?", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:home",
        messageProvider: "telegram",
        channelId: "telegram",
      },
    );

    expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
    expect(result).toEqual({
      prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
      appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
    });
  });

  it("runs for group sessions when group chat types are explicitly allowed", async () => {
    api.pluginConfig = {
      agents: ["main"],
      allowedChatTypes: ["direct", "group"],
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    const result = await hooks.before_prompt_build(
      { prompt: "what wings should we order?", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:telegram:group:-100123",
        messageProvider: "telegram",
        channelId: "telegram",
      },
    );

    expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
    expect(result).toEqual({
      prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
      appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
    });
  });

  it("injects system context on a successful recall hit", async () => {
    const result = await hooks.before_prompt_build(
      {
        prompt: "what wings should i order?",
        messages: [
          { role: "user", content: "i want something greasy tonight" },
          { role: "assistant", content: "let's narrow it down" },
        ],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
    expect(result).toEqual({
      prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
      appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
    });
    expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
      "lemon pepper wings",
    );
    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
      provider: "github-copilot",
      model: "gpt-5.4-mini",
      sessionKey: expect.stringMatching(/^agent:main:main:active-memory:[a-f0-9]{12}$/),
    });
  });

  it("frames the blocking memory subagent as a memory search agent for another model", async () => {
    await hooks.before_prompt_build(
      {
        prompt: "What is my favorite food? strict-style-check",
        messages: [],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
    expect(runParams?.prompt).toContain("You are a memory search agent.");
    expect(runParams?.prompt).toContain("Another model is preparing the final user-facing answer.");
    expect(runParams?.prompt).toContain(
      "Your job is to search memory and return only the most relevant memory context for that model.",
    );
    expect(runParams?.prompt).toContain(
      "You receive conversation context, including the user's latest message.",
    );
    expect(runParams?.prompt).toContain("Use only memory_search and memory_get.");
    expect(runParams?.prompt).toContain(
      "If the user is directly asking about favorites, preferences, habits, routines, or personal facts, treat that as a strong recall signal.",
    );
    expect(runParams?.prompt).toContain(
      "Questions like 'what is my favorite food', 'do you remember my flight preferences', or 'what do i usually get' should normally return memory when relevant results exist.",
    );
    expect(runParams?.prompt).toContain("Return exactly one of these two forms:");
    expect(runParams?.prompt).toContain("1. NONE");
    expect(runParams?.prompt).toContain("2. one compact plain-text summary");
    expect(runParams?.prompt).toContain(
      "Write the summary as a memory note about the user, not as a reply to the user.",
    );
    expect(runParams?.prompt).toContain(
      "Do not return bullets, numbering, labels, XML, JSON, or markdown list formatting.",
    );
    expect(runParams?.prompt).toContain("Good examples:");
    expect(runParams?.prompt).toContain("Bad examples:");
    expect(runParams?.prompt).toContain(
      "Return: User's favorite food is ramen; tacos also come up often.",
    );
  });

  it("defaults prompt style by query mode when no promptStyle is configured", async () => {
    api.pluginConfig = {
      agents: ["main"],
      queryMode: "message",
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    await hooks.before_prompt_build(
      {
        prompt: "What is my favorite food? preference-style-check",
        messages: [],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
    expect(runParams?.prompt).toContain("Prompt style: strict.");
    expect(runParams?.prompt).toContain(
      "If the latest user message does not strongly call for memory, reply with NONE.",
    );
  });

  it("honors an explicit promptStyle override", async () => {
    api.pluginConfig = {
      agents: ["main"],
      queryMode: "message",
      promptStyle: "preference-only",
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    await hooks.before_prompt_build(
      {
        prompt: "What is my favorite food?",
        messages: [],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
    expect(runParams?.prompt).toContain("Prompt style: preference-only.");
    expect(runParams?.prompt).toContain(
      "Optimize for favorites, preferences, habits, routines, taste, and recurring personal facts.",
    );
  });

  it("keeps thinking off by default but allows an explicit thinking override", async () => {
    await hooks.before_prompt_build(
      {
        prompt: "What is my favorite food? default-thinking-check",
        messages: [],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
      thinkLevel: "off",
      reasoningLevel: "off",
    });

    api.pluginConfig = {
      agents: ["main"],
      thinking: "medium",
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    await hooks.before_prompt_build(
      {
        prompt: "What is my favorite food? thinking-override-check",
        messages: [],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
      thinkLevel: "medium",
      reasoningLevel: "off",
    });
  });

  it("allows appending extra prompt instructions without replacing the base prompt", async () => {
    api.pluginConfig = {
      agents: ["main"],
      promptAppend: "Prefer stable long-term preferences over one-off events.",
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    await hooks.before_prompt_build(
      {
        prompt: "What is my favorite food? prompt-append-check",
        messages: [],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt ?? "";
    expect(prompt).toContain("You are a memory search agent.");
    expect(prompt).toContain("Additional operator instructions:");
    expect(prompt).toContain("Prefer stable long-term preferences over one-off events.");
    expect(prompt).toContain("Conversation context:");
    expect(prompt).toContain("What is my favorite food? prompt-append-check");
  });

  it("allows replacing the base prompt while still appending conversation context", async () => {
    api.pluginConfig = {
      agents: ["main"],
      promptOverride: "Custom memory prompt. Return NONE or one user fact.",
      promptAppend: "Extra custom instruction.",
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    await hooks.before_prompt_build(
      {
        prompt: "What is my favorite food? prompt-override-check",
        messages: [],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt ?? "";
    expect(prompt).toContain("Custom memory prompt. Return NONE or one user fact.");
    expect(prompt).not.toContain("You are a memory search agent.");
    expect(prompt).toContain("Additional operator instructions:");
    expect(prompt).toContain("Extra custom instruction.");
    expect(prompt).toContain("Conversation context:");
    expect(prompt).toContain("What is my favorite food? prompt-override-check");
  });

  it("preserves leading digits in a plain-text summary", async () => {
    runEmbeddedPiAgent.mockResolvedValueOnce({
      payloads: [{ text: "2024 trip to tokyo and 2% milk both matter here." }],
    });

    const result = await hooks.before_prompt_build(
      {
        prompt: "what should i remember from my 2024 trip and should i buy 2% milk?",
        messages: [],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    expect(result).toEqual({
      prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
      appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
    });
    expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
      "2024 trip to tokyo",
    );
    expect((result as { appendSystemContext: string }).appendSystemContext).toContain("2% milk");
  });

  it("preserves canonical parent session scope in the blocking memory subagent session key", async () => {
    await hooks.before_prompt_build(
      { prompt: "what should i grab on the way?", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:telegram:direct:12345:thread:99",
        messageProvider: "telegram",
        channelId: "telegram",
      },
    );

    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
      /^agent:main:telegram:direct:12345:thread:99:active-memory:[a-f0-9]{12}$/,
    );
  });

  it("falls back to the current session model when no plugin model is configured", async () => {
    api.pluginConfig = {
      agents: ["main"],
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    await hooks.before_prompt_build(
      { prompt: "what wings should i order? temp transcript", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
        modelProviderId: "qwen",
        modelId: "glm-5",
      },
    );

    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
      provider: "qwen",
      model: "glm-5",
    });
  });

  it("can disable default remote model fallback", async () => {
    api.pluginConfig = {
      agents: ["main"],
      modelFallbackPolicy: "resolved-only",
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    const result = await hooks.before_prompt_build(
      { prompt: "what wings should i order? no fallback", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:resolved-only",
        messageProvider: "webchat",
      },
    );

    expect(result).toBeUndefined();
    expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
  });

  it("persists a readable debug summary alongside the status line", async () => {
    const sessionKey = "agent:main:debug";
    hoisted.sessionStore[sessionKey] = {
      sessionId: "s-main",
      updatedAt: 0,
    };
    runEmbeddedPiAgent.mockResolvedValueOnce({
      payloads: [{ text: "User prefers lemon pepper wings, and blue cheese still wins." }],
    });

    await hooks.before_prompt_build(
      {
        prompt: "what wings should i order?",
        messages: [],
      },
      { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
    );

    expect(hoisted.updateSessionStore).toHaveBeenCalled();
    const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
      | ((store: Record<string, Record<string, unknown>>) => void)
      | undefined;
    const store = {
      [sessionKey]: {
        sessionId: "s-main",
        updatedAt: 0,
      },
    } as Record<string, Record<string, unknown>>;
    updater?.(store);
    expect(store[sessionKey]?.pluginDebugEntries).toEqual([
      {
        pluginId: "active-memory",
        lines: expect.arrayContaining([
          expect.stringContaining("🧩 Active Memory: ok"),
          expect.stringContaining(
            "🔎 Active Memory Debug: User prefers lemon pepper wings, and blue cheese still wins.",
          ),
        ]),
      },
    ]);
  });

  it("replaces stale structured active-memory lines on a later empty run", async () => {
    const sessionKey = "agent:main:stale-active-memory-lines";
    hoisted.sessionStore[sessionKey] = {
      sessionId: "s-main",
      updatedAt: 0,
      pluginDebugEntries: [
        {
          pluginId: "active-memory",
          lines: [
            "🧩 Active Memory: ok 13.4s recent 34 chars",
            "🔎 Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
          ],
        },
        { pluginId: "other-plugin", lines: ["Other Plugin: keep me"] },
      ],
    };
    runEmbeddedPiAgent.mockResolvedValueOnce({
      payloads: [{ text: "NONE" }],
    });

    await hooks.before_prompt_build(
      { prompt: "what's up with you?", messages: [] },
      { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
    );

    const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
      | ((store: Record<string, Record<string, unknown>>) => void)
      | undefined;
    const store = {
      [sessionKey]: {
        sessionId: "s-main",
        updatedAt: 0,
        pluginDebugEntries: [
          {
            pluginId: "active-memory",
            lines: [
              "🧩 Active Memory: ok 13.4s recent 34 chars",
              "🔎 Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
            ],
          },
          { pluginId: "other-plugin", lines: ["Other Plugin: keep me"] },
        ],
      },
    } as Record<string, Record<string, unknown>>;
    updater?.(store);

    expect(store[sessionKey]?.pluginDebugEntries).toEqual([
      { pluginId: "other-plugin", lines: ["Other Plugin: keep me"] },
      {
        pluginId: "active-memory",
        lines: [expect.stringContaining("🧩 Active Memory: empty")],
      },
    ]);
  });

  it("returns nothing when the subagent says none", async () => {
    runEmbeddedPiAgent.mockResolvedValueOnce({
      payloads: [{ text: "NONE" }],
    });

    const result = await hooks.before_prompt_build(
      { prompt: "fair, okay gonna do them by throwing them in the garbage", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    expect(result).toBeUndefined();
  });

  it("does not cache timeout results", async () => {
    api.pluginConfig = {
      agents: ["main"],
      timeoutMs: 250,
      logging: true,
    };
    await plugin.register(api as unknown as OpenClawPluginApi);
    let lastAbortSignal: AbortSignal | undefined;
    runEmbeddedPiAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => {
      lastAbortSignal = params.abortSignal;
      return await new Promise((resolve, reject) => {
        const abortHandler = () => reject(new Error("aborted"));
        params.abortSignal?.addEventListener("abort", abortHandler, { once: true });
        setTimeout(() => {
          params.abortSignal?.removeEventListener("abort", abortHandler);
          resolve({ payloads: [] });
        }, 2_000);
      });
    });

    await hooks.before_prompt_build(
      { prompt: "what wings should i order? timeout test", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:timeout-test",
        messageProvider: "webchat",
      },
    );
    await hooks.before_prompt_build(
      { prompt: "what wings should i order? timeout test", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:timeout-test",
        messageProvider: "webchat",
      },
    );

    expect(hoisted.updateSessionStore).toHaveBeenCalledTimes(2);
    expect(lastAbortSignal?.aborted).toBe(true);
    const infoLines = vi
      .mocked(api.logger.info)
      .mock.calls.map((call: unknown[]) => String(call[0]));
    expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
  });

  it("does not share cached recall results across session-id-only contexts", async () => {
    api.pluginConfig = {
      agents: ["main"],
      logging: true,
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    await hooks.before_prompt_build(
      { prompt: "what wings should i order? session id cache", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionId: "session-a",
        messageProvider: "webchat",
      },
    );
    await hooks.before_prompt_build(
      { prompt: "what wings should i order? session id cache", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionId: "session-b",
        messageProvider: "webchat",
      },
    );

    expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
    const infoLines = vi
      .mocked(api.logger.info)
      .mock.calls.map((call: unknown[]) => String(call[0]));
    expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
  });

  it("uses a canonical agent session key when only sessionId is available", async () => {
    hoisted.sessionStore["agent:main:telegram:direct:12345"] = {
      sessionId: "session-a",
      updatedAt: 25,
    };

    await hooks.before_prompt_build(
      { prompt: "what wings should i order? session id only", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionId: "session-a",
        messageProvider: "webchat",
      },
    );

    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
      /^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/,
    );
    expect(hoisted.sessionStore["agent:main:telegram:direct:12345"]?.pluginDebugEntries).toEqual([
      {
        pluginId: "active-memory",
        lines: expect.arrayContaining([expect.stringContaining("🧩 Active Memory: ok")]),
      },
    ]);
  });

  it("uses the resolved canonical session key for non-webchat chat-type checks", async () => {
    hoisted.sessionStore["agent:main:telegram:direct:12345"] = {
      sessionId: "session-a",
      updatedAt: 25,
    };

    const result = await hooks.before_prompt_build(
      { prompt: "what wings should i order? session id only telegram", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionId: "session-a",
        messageProvider: "telegram",
        channelId: "telegram",
      },
    );

    expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
      /^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/,
    );
    expect(result).toEqual({
      prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
      appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
    });
  });

  it("clears stale status on skipped non-interactive turns even when agentId is missing", async () => {
    const sessionKey = "noncanonical-session";
    hoisted.sessionStore[sessionKey] = {
      sessionId: "s-main",
      updatedAt: 0,
      pluginDebugEntries: [
        { pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
      ],
    };

    const result = await hooks.before_prompt_build(
      { prompt: "what wings should i order?", messages: [] },
      { trigger: "heartbeat", sessionKey, messageProvider: "webchat" },
    );

    expect(result).toBeUndefined();
    const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
      | ((store: Record<string, Record<string, unknown>>) => void)
      | undefined;
    const store = {
      [sessionKey]: {
        sessionId: "s-main",
        updatedAt: 0,
        pluginDebugEntries: [
          { pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
        ],
      },
    } as Record<string, Record<string, unknown>>;
    updater?.(store);
    expect(store[sessionKey]?.pluginDebugEntries).toBeUndefined();
  });

  it("supports message mode by sending only the latest user message", async () => {
    api.pluginConfig = {
      agents: ["main"],
      queryMode: "message",
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    await hooks.before_prompt_build(
      {
        prompt: "what should i grab on the way?",
        messages: [
          { role: "user", content: "i have a flight tomorrow" },
          { role: "assistant", content: "got it" },
        ],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
    expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?");
    expect(prompt).not.toContain("Recent conversation tail:");
  });

  it("supports full mode by sending the whole conversation", async () => {
    api.pluginConfig = {
      agents: ["main"],
      queryMode: "full",
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    await hooks.before_prompt_build(
      {
        prompt: "what should i grab on the way?",
        messages: [
          { role: "user", content: "i have a flight tomorrow" },
          { role: "assistant", content: "got it" },
          { role: "user", content: "packing is annoying" },
        ],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
    expect(prompt).toContain("Full conversation context:");
    expect(prompt).toContain("user: i have a flight tomorrow");
    expect(prompt).toContain("assistant: got it");
    expect(prompt).toContain("user: packing is annoying");
  });

  it("strips prior memory/debug traces from assistant context before retrieval", async () => {
    api.pluginConfig = {
      agents: ["main"],
      queryMode: "recent",
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    await hooks.before_prompt_build(
      {
        prompt: "what should i grab on the way?",
        messages: [
          { role: "user", content: "i have a flight tomorrow" },
          {
            role: "assistant",
            content:
              "🧠 Memory Search: favorite food comfort food tacos sushi ramen\n🧩 Active Memory: ok 842ms recent 2 mem\n🔎 Active Memory Debug: spicy ramen; tacos\nSounds like you want something easy before the airport.",
          },
        ],
      },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
    expect(prompt).toContain("Treat the latest user message as the primary query.");
    expect(prompt).toContain(
      "Use recent conversation only to disambiguate what the latest user message means.",
    );
    expect(prompt).toContain(
      "Do not return memory just because it matched the broader recent topic; return memory only if it clearly helps with the latest user message itself.",
    );
    expect(prompt).toContain(
      "If recent context and the latest user message point to different memory domains, prefer the domain that best matches the latest user message.",
    );
    expect(prompt).toContain(
      "ignore that surfaced text unless the latest user message clearly requires re-checking it.",
    );
    expect(prompt).toContain(
      "Latest user message: I might see a movie while I wait for the flight.",
    );
    expect(prompt).toContain(
      "Return: User's favorite movie snack is buttery popcorn with extra salt.",
    );
    expect(prompt).toContain("assistant: Sounds like you want something easy before the airport.");
    expect(prompt).not.toContain("Memory Search:");
    expect(prompt).not.toContain("Active Memory:");
    expect(prompt).not.toContain("Active Memory Debug:");
    expect(prompt).not.toContain("spicy ramen; tacos");
  });

  it("trusts the subagent's relevance decision for explicit preference recall prompts", async () => {
    runEmbeddedPiAgent.mockResolvedValueOnce({
      payloads: [{ text: "User prefers aisle seats and extra buffer on connections." }],
    });

    const result = await hooks.before_prompt_build(
      { prompt: "u remember my flight preferences", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    expect(result).toEqual({
      prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
      appendSystemContext: expect.stringContaining("aisle seat"),
    });
    expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
      "extra buffer on connections",
    );
  });

  it("applies total summary truncation after normalizing the subagent reply", async () => {
    api.pluginConfig = {
      agents: ["main"],
      maxSummaryChars: 40,
    };
    await plugin.register(api as unknown as OpenClawPluginApi);
    runEmbeddedPiAgent.mockResolvedValueOnce({
      payloads: [
        {
          text: "alpha beta gamma delta epsilon zetalongword",
        },
      ],
    });

    const result = await hooks.before_prompt_build(
      { prompt: "what wings should i order? word-boundary-truncation-40", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    expect(result).toEqual({
      prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
      appendSystemContext: expect.stringContaining("alpha beta gamma"),
    });
    expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
      "alpha beta gamma delta epsilon",
    );
    expect((result as { appendSystemContext: string }).appendSystemContext).not.toContain("zetalo");
    expect((result as { appendSystemContext: string }).appendSystemContext).not.toContain(
      "zetalongword",
    );
  });

  it("uses the configured maxSummaryChars value in the subagent prompt", async () => {
    api.pluginConfig = {
      agents: ["main"],
      maxSummaryChars: 90,
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    await hooks.before_prompt_build(
      { prompt: "what wings should i order? prompt-count-check", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:prompt-count-check",
        messageProvider: "webchat",
      },
    );

    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt).toContain(
      "If something is useful, reply with one compact plain-text summary under 90 characters total.",
    );
  });

  it("keeps subagent transcripts off disk by default by using a temp session file", async () => {
    const mkdtempSpy = vi
      .spyOn(fs, "mkdtemp")
      .mockResolvedValue("/tmp/openclaw-active-memory-temp");
    const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);

    await hooks.before_prompt_build(
      { prompt: "what wings should i order? temp transcript path", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:main",
        messageProvider: "webchat",
      },
    );

    expect(mkdtempSpy).toHaveBeenCalled();
    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toBe(
      "/tmp/openclaw-active-memory-temp/session.jsonl",
    );
    expect(rmSpy).toHaveBeenCalledWith("/tmp/openclaw-active-memory-temp", {
      recursive: true,
      force: true,
    });
  });

  it("persists subagent transcripts in a separate directory when enabled", async () => {
    api.pluginConfig = {
      agents: ["main"],
      persistTranscripts: true,
      transcriptDir: "active-memory-subagents",
      logging: true,
    };
    await plugin.register(api as unknown as OpenClawPluginApi);
    const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
    const mkdtempSpy = vi.spyOn(fs, "mkdtemp");
    const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);

    const sessionKey = "agent:main:persist-transcript";
    await hooks.before_prompt_build(
      { prompt: "what wings should i order? persist transcript", messages: [] },
      { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
    );

    const expectedDir = path.join(
      stateDir,
      "plugins",
      "active-memory",
      "transcripts",
      "agents",
      "main",
      "active-memory-subagents",
    );
    expect(mkdirSpy).toHaveBeenCalledWith(expectedDir, { recursive: true, mode: 0o700 });
    expect(mkdtempSpy).not.toHaveBeenCalled();
    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
      new RegExp(
        `^${escapeRegExp(expectedDir)}${escapeRegExp(path.sep)}active-memory-[a-z0-9]+-[a-f0-9]{8}\\.jsonl$`,
      ),
    );
    expect(rmSpy).not.toHaveBeenCalled();
    expect(
      vi
        .mocked(api.logger.info)
        .mock.calls.some((call: unknown[]) =>
          String(call[0]).includes(`transcript=${expectedDir}${path.sep}`),
        ),
    ).toBe(true);
  });

  it("falls back to the default transcript directory when transcriptDir is unsafe", async () => {
    api.pluginConfig = {
      agents: ["main"],
      persistTranscripts: true,
      transcriptDir: "C:/temp/escape",
      logging: true,
    };
    await plugin.register(api as unknown as OpenClawPluginApi);
    const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);

    await hooks.before_prompt_build(
      { prompt: "what wings should i order? unsafe transcript dir", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:unsafe-transcript",
        messageProvider: "webchat",
      },
    );

    const expectedDir = path.join(
      stateDir,
      "plugins",
      "active-memory",
      "transcripts",
      "agents",
      "main",
      "active-memory",
    );
    expect(mkdirSpy).toHaveBeenCalledWith(expectedDir, { recursive: true, mode: 0o700 });
    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
      new RegExp(
        `^${escapeRegExp(expectedDir)}${escapeRegExp(path.sep)}active-memory-[a-z0-9]+-[a-f0-9]{8}\\.jsonl$`,
      ),
    );
  });

  it("scopes persisted subagent transcripts by agent", async () => {
    api.pluginConfig = {
      agents: ["main", "support/agent"],
      persistTranscripts: true,
      transcriptDir: "active-memory-subagents",
      logging: true,
    };
    await plugin.register(api as unknown as OpenClawPluginApi);
    const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);

    await hooks.before_prompt_build(
      { prompt: "what wings should i order? support agent transcript", messages: [] },
      {
        agentId: "support/agent",
        trigger: "user",
        sessionKey: "agent:support/agent:persist-transcript",
        messageProvider: "webchat",
      },
    );

    const expectedDir = path.join(
      stateDir,
      "plugins",
      "active-memory",
      "transcripts",
      "agents",
      "support%2Fagent",
      "active-memory-subagents",
    );
    expect(mkdirSpy).toHaveBeenCalledWith(expectedDir, { recursive: true, mode: 0o700 });
    expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
      new RegExp(
        `^${escapeRegExp(expectedDir)}${escapeRegExp(path.sep)}active-memory-[a-z0-9]+-[a-f0-9]{8}\\.jsonl$`,
      ),
    );
  });

  it("sanitizes control characters out of debug lines", async () => {
    const sessionKey = "agent:main:debug-sanitize";
    hoisted.sessionStore[sessionKey] = {
      sessionId: "s-main",
      updatedAt: 0,
    };
    runEmbeddedPiAgent.mockResolvedValueOnce({
      payloads: [{ text: "- spicy ramen\u001b[31m\n- fries\r\n- blue cheese\t" }],
    });

    await hooks.before_prompt_build(
      { prompt: "what should i order?", messages: [] },
      { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
    );

    const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
      | ((store: Record<string, Record<string, unknown>>) => void)
      | undefined;
    const store = {
      [sessionKey]: {
        sessionId: "s-main",
        updatedAt: 0,
      },
    } as Record<string, Record<string, unknown>>;
    updater?.(store);
    const lines =
      (store[sessionKey]?.pluginDebugEntries as Array<{ lines?: string[] }> | undefined)?.[0]
        ?.lines ?? [];
    expect(lines.some((line) => line.includes("\u001b"))).toBe(false);
    expect(lines.some((line) => line.includes("\r"))).toBe(false);
  });

  it("caps the active-memory cache size and evicts the oldest entries", async () => {
    api.pluginConfig = {
      agents: ["main"],
      logging: true,
    };
    await plugin.register(api as unknown as OpenClawPluginApi);

    for (let index = 0; index <= 1000; index += 1) {
      await hooks.before_prompt_build(
        { prompt: `cache pressure prompt ${index}`, messages: [] },
        {
          agentId: "main",
          trigger: "user",
          sessionKey: "agent:main:cache-cap",
          messageProvider: "webchat",
        },
      );
    }

    const callsBeforeReplay = runEmbeddedPiAgent.mock.calls.length;

    await hooks.before_prompt_build(
      { prompt: "cache pressure prompt 0", messages: [] },
      {
        agentId: "main",
        trigger: "user",
        sessionKey: "agent:main:cache-cap",
        messageProvider: "webchat",
      },
    );

    expect(runEmbeddedPiAgent.mock.calls.length).toBe(callsBeforeReplay + 1);
    const infoLines = vi
      .mocked(api.logger.info)
      .mock.calls.map((call: unknown[]) => String(call[0]));
    expect(
      infoLines.some(
        (line: string) => line.includes("cached status=ok") && line.includes("prompt 0"),
      ),
    ).toBe(false);
  });
});
