import fs from "node:fs/promises";
import path from "node:path";
import {
  createPluginRegistryFixture,
  registerTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { afterEach, describe, expect, it } from "vitest";
import {
  validatePluginsUiDescriptorsParams,
  validateSessionsPluginPatchParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { loadSessionStore, updateSessionStore, type SessionEntry } from "../../config/sessions.js";
import { APPROVALS_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../../gateway/operator-scopes.js";
import { buildGatewaySessionRow } from "../../gateway/session-utils.js";
import { withTempConfig } from "../../gateway/test-temp-config.js";
import { emitAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { executePluginCommand, validatePluginCommandDefinition } from "../commands.js";
import { createHookRunner } from "../hooks.js";
import {
  cleanupReplacedPluginHostRegistry,
  clearPluginOwnedSessionState,
  runPluginHostCleanup,
} from "../host-hook-cleanup.js";
import {
  clearPluginHostRuntimeState,
  getPluginRunContext,
  listPluginSessionSchedulerJobs,
  setPluginRunContext,
} from "../host-hook-runtime.js";
import {
  drainPluginNextTurnInjections,
  enqueuePluginNextTurnInjection,
  patchPluginSessionExtension,
  projectPluginSessionExtensions,
  projectPluginSessionExtensionsSync,
} from "../host-hook-state.js";
import { buildPluginAgentTurnPrepareContext, isPluginJsonValue } from "../host-hooks.js";
import { createEmptyPluginRegistry } from "../registry-empty.js";
import { createPluginRegistry } from "../registry.js";
import { setActivePluginRegistry } from "../runtime.js";
import type { PluginRuntime } from "../runtime/types.js";
import { createPluginRecord } from "../status.test-helpers.js";
import { runTrustedToolPolicies } from "../trusted-tool-policy.js";
import { registerHostHookFixture, registerTrustedHostHookFixture } from "./host-hook-fixture.js";

async function waitForPluginEventHandlers(): Promise<void> {
  await new Promise<void>((resolve) => setImmediate(resolve));
}

function requireFirstCommandRegistration(
  registry: ReturnType<typeof createPluginRegistryFixture>["registry"]["registry"],
) {
  const registration = registry.commands[0];
  if (!registration) {
    throw new Error("expected first plugin command registration");
  }
  return registration;
}

function joinContextFragments(...fragments: Array<string | undefined>): string {
  const present: string[] = [];
  for (const fragment of fragments) {
    if (fragment) {
      present.push(fragment);
    }
  }
  return present.join("\n\n");
}

function diagnosticSummaries(diagnostics: readonly unknown[]) {
  return diagnostics.map((entry) => {
    const diagnostic = entry as { pluginId?: string; message?: string };
    return { pluginId: diagnostic.pluginId, message: diagnostic.message };
  });
}

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

describe("host-hook fixture plugin contract", () => {
  afterEach(() => {
    setActivePluginRegistry(createEmptyPluginRegistry());
    clearPluginHostRuntimeState();
    resetAgentEventsForTest();
  });

  it("registers generic SDK seams without Plan Mode business logic", () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "host-hook-fixture",
        name: "Host Hook Fixture",
        origin: "workspace",
        contracts: { tools: ["approval_fixture_tool"] },
      }),
      register: registerHostHookFixture,
    });

    expect(registry.registry.sessionExtensions ?? []).toHaveLength(1);
    expect(registry.registry.toolMetadata ?? []).toHaveLength(1);
    expect(registry.registry.controlUiDescriptors ?? []).toHaveLength(1);
    expect(registry.registry.runtimeLifecycles ?? []).toHaveLength(1);
    expect(registry.registry.agentEventSubscriptions ?? []).toHaveLength(1);
    expect(registry.registry.sessionSchedulerJobs ?? []).toHaveLength(1);
    expect(registry.registry.commands.map((entry) => entry.command.name)).toEqual([
      "host-hook-fixture",
    ]);
    expect(registry.registry.typedHooks.map((entry) => entry.hookName).toSorted()).toEqual([
      "agent_turn_prepare",
      "heartbeat_prompt_contribution",
    ]);
  });

  it("rejects external plugins from trusted policy and reserved command ownership", () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "external-policy",
        name: "External Policy",
        origin: "workspace",
      }),
      register(api) {
        api.registerTrustedToolPolicy({
          id: "deny",
          description: "Should not be accepted",
          evaluate: () => undefined,
        });
        api.registerCommand({
          name: "status",
          description: "Should not be accepted",
          ownership: "reserved",
          handler: async () => ({ text: "no" }),
        });
      },
    });

    expect(registry.registry.trustedToolPolicies ?? []).toHaveLength(0);
    expect(registry.registry.commands).toHaveLength(0);
    const diagnostics = diagnosticSummaries(registry.registry.diagnostics);
    expect(diagnostics).toHaveLength(2);
    expect(diagnostics[0]?.pluginId).toBe("external-policy");
    expect(diagnostics[0]?.message).toContain("only bundled plugins can register trusted tool");
    expect(diagnostics[1]?.pluginId).toBe("external-policy");
    expect(diagnostics[1]?.message).toContain("only bundled plugins can claim reserved command");
  });

  it("allows the official npm Codex plugin to keep /codex command ownership", () => {
    const { config, registry } = createPluginRegistryFixture();
    const codexRoot = path.join("/tmp", ".openclaw", "npm", "node_modules", "@openclaw", "codex");
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "codex",
        name: "Codex",
        origin: "global",
        rootDir: codexRoot,
        source: path.join(codexRoot, "index.ts"),
      }),
      register(api) {
        api.registerCommand({
          name: "codex",
          description: "Official npm Codex command",
          ownership: "reserved",
          handler: async () => ({ text: "ok" }),
        });
      },
    });

    expect(registry.registry.commands.map((entry) => entry.command.name)).toEqual(["codex"]);
    expect(
      diagnosticSummaries(registry.registry.diagnostics).some(
        (entry) =>
          entry.pluginId === "codex" &&
          entry.message?.includes("only bundled plugins can claim reserved command"),
      ),
    ).toBe(false);
  });

  it("allows the official ClawHub Codex plugin to keep /codex command ownership", () => {
    const { config, registry } = createPluginRegistryFixture();
    const codexRoot = path.join("/tmp", ".openclaw", "extensions", "codex");
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "codex",
        name: "Codex",
        packageName: "@openclaw/codex",
        origin: "global",
        rootDir: codexRoot,
        source: path.join(codexRoot, "dist", "index.js"),
      }),
      register(api) {
        api.registerCommand({
          name: "codex",
          description: "Official ClawHub Codex command",
          ownership: "reserved",
          handler: async () => ({ text: "ok" }),
        });
      },
    });

    expect(registry.registry.commands.map((entry) => entry.command.name)).toEqual(["codex"]);
    expect(
      diagnosticSummaries(registry.registry.diagnostics).some(
        (entry) =>
          entry.pluginId === "codex" &&
          entry.message?.includes("only bundled plugins can claim reserved command"),
      ),
    ).toBe(false);
  });

  it("rejects non-official global Codex plugins from /codex command ownership", () => {
    const { config, registry } = createPluginRegistryFixture();
    const codexRoot = path.join("/tmp", ".openclaw", "extensions", "codex");
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "codex",
        name: "Codex",
        origin: "global",
        rootDir: codexRoot,
        source: path.join(codexRoot, "dist", "index.js"),
      }),
      register(api) {
        api.registerCommand({
          name: "codex",
          description: "Impostor Codex command",
          ownership: "reserved",
          handler: async () => ({ text: "no" }),
        });
      },
    });

    expect(registry.registry.commands).toHaveLength(0);
    const diagnostics = diagnosticSummaries(registry.registry.diagnostics);
    expect(diagnostics).toHaveLength(1);
    expect(diagnostics[0]?.pluginId).toBe("codex");
    expect(diagnostics[0]?.message).toContain("only bundled plugins can claim reserved command");
  });

  it("rejects workspace Codex plugins that spoof the official package name", () => {
    const { config, registry } = createPluginRegistryFixture();
    const codexRoot = path.join("/tmp", "workspace", "codex");
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "codex",
        name: "Codex",
        packageName: "@openclaw/codex",
        origin: "workspace",
        rootDir: codexRoot,
        source: path.join(codexRoot, "dist", "index.js"),
      }),
      register(api) {
        api.registerCommand({
          name: "codex",
          description: "Workspace Codex command",
          ownership: "reserved",
          handler: async () => ({ text: "no" }),
        });
      },
    });

    expect(registry.registry.commands).toHaveLength(0);
    const diagnostics = diagnosticSummaries(registry.registry.diagnostics);
    expect(diagnostics).toHaveLength(1);
    expect(diagnostics[0]?.pluginId).toBe("codex");
    expect(diagnostics[0]?.message).toContain("only bundled plugins can claim reserved command");
  });

  it("rejects reserved command ownership for non-reserved bundled command names", () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "bundled-command",
        name: "Bundled Command",
        origin: "bundled",
      }),
      register(api) {
        api.registerCommand({
          name: "workflow",
          description: "Should not need reserved ownership",
          ownership: "reserved",
          handler: async () => ({ text: "no" }),
        });
      },
    });

    expect(registry.registry.commands).toHaveLength(0);
    expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
      {
        pluginId: "bundled-command",
        message: "reserved command ownership requires a reserved command name: workflow",
      },
    ]);
  });

  it("lets bundled fixture policies run before normal before_tool_call hooks", async () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "trusted-fixture",
        name: "Trusted Fixture",
        origin: "bundled",
      }),
      register: registerTrustedHostHookFixture,
    });
    setActivePluginRegistry(registry.registry);

    const policyResult = await runTrustedToolPolicies(
      { toolName: "blocked_fixture_tool", params: {} },
      { toolName: "blocked_fixture_tool" },
    );
    expectRecordFields(policyResult, {
      block: true,
      blockReason: "blocked by fixture policy",
    });
  });

  it("lets later trusted policy blocks override earlier approval requests", async () => {
    const registry = createEmptyPluginRegistry();
    registry.trustedToolPolicies = [
      {
        pluginId: "trusted-a",
        pluginName: "Trusted A",
        source: "test",
        policy: {
          id: "approval",
          description: "approval",
          evaluate: () => ({
            requireApproval: {
              title: "Review",
              description: "Review the call",
            },
          }),
        },
      },
      {
        pluginId: "trusted-b",
        pluginName: "Trusted B",
        source: "test",
        policy: {
          id: "block",
          description: "block",
          evaluate: () => ({ block: true, blockReason: "blocked by later policy" }),
        },
      },
    ];
    setActivePluginRegistry(registry);

    await expect(
      runTrustedToolPolicies({ toolName: "exec", params: {} }, { toolName: "exec" }),
    ).resolves.toEqual({
      block: true,
      blockReason: "blocked by later policy",
    });
  });

  it("passes adjusted trusted policy params to later trusted policies", async () => {
    const seenParams: Record<string, unknown>[] = [];
    const registry = createEmptyPluginRegistry();
    registry.trustedToolPolicies = [
      {
        pluginId: "trusted-a",
        pluginName: "Trusted A",
        source: "test",
        policy: {
          id: "params",
          description: "params",
          evaluate: () => ({ params: { command: "patched" } }),
        },
      },
      {
        pluginId: "trusted-b",
        pluginName: "Trusted B",
        source: "test",
        policy: {
          id: "inspect",
          description: "inspect",
          evaluate: (event) => {
            seenParams.push(event.params);
            return undefined;
          },
        },
      },
    ];
    setActivePluginRegistry(registry);

    await expect(
      runTrustedToolPolicies(
        { toolName: "exec", params: { command: "original" } },
        { toolName: "exec" },
      ),
    ).resolves.toEqual({ params: { command: "patched" } });
    expect(seenParams).toEqual([{ command: "patched" }]);
  });

  it("preserves trusted policy derived paths when params are unchanged", async () => {
    const seenDerivedPaths: unknown[] = [];
    const registry = createEmptyPluginRegistry();
    registry.trustedToolPolicies = [
      {
        pluginId: "trusted-inspector",
        pluginName: "Trusted Inspector",
        source: "test",
        policy: {
          id: "inspect",
          description: "inspect",
          evaluate: (event) => {
            seenDerivedPaths.push(event.derivedPaths);
            return undefined;
          },
        },
      },
    ];
    setActivePluginRegistry(registry);

    await expect(
      runTrustedToolPolicies(
        {
          toolName: "apply_patch",
          params: { input: "*** Update File: old.ts" },
          derivedPaths: ["old.ts"],
        },
        { toolName: "apply_patch" },
      ),
    ).resolves.toBeUndefined();
    expect(seenDerivedPaths).toEqual([["old.ts"]]);
  });

  it("ignores non-plain trusted policy params when re-deriving paths", async () => {
    const seenParams: unknown[] = [];
    const registry = createEmptyPluginRegistry();
    registry.trustedToolPolicies = [
      {
        pluginId: "trusted-bad",
        pluginName: "Trusted Bad",
        source: "test",
        policy: {
          id: "bad",
          description: "bad",
          evaluate: () => ({ params: "not-a-plain-object" as never }),
        },
      },
      {
        pluginId: "trusted-inspector",
        pluginName: "Trusted Inspector",
        source: "test",
        policy: {
          id: "inspect",
          description: "inspect",
          evaluate: (event) => {
            seenParams.push(event.params);
            return undefined;
          },
        },
      },
    ];
    setActivePluginRegistry(registry);

    await expect(
      runTrustedToolPolicies(
        { toolName: "apply_patch", params: { input: "*** Add File: old.ts" } },
        { toolName: "apply_patch" },
      ),
    ).resolves.toBeUndefined();
    expect(seenParams).toEqual([{ input: "*** Add File: old.ts" }]);
  });

  it("does not let trusted policies mutate derived paths for later policies", async () => {
    const seenDerivedPaths: unknown[] = [];
    let mutationRejected = false;
    const registry = createEmptyPluginRegistry();
    registry.trustedToolPolicies = [
      {
        pluginId: "trusted-a",
        pluginName: "Trusted A",
        source: "test",
        policy: {
          id: "mutate",
          description: "mutate",
          evaluate: (event) => {
            try {
              (event.derivedPaths as string[] | undefined)?.push("mutated.ts");
            } catch {
              mutationRejected = true;
            }
            return undefined;
          },
        },
      },
      {
        pluginId: "trusted-b",
        pluginName: "Trusted B",
        source: "test",
        policy: {
          id: "inspect",
          description: "inspect",
          evaluate: (event) => {
            seenDerivedPaths.push(event.derivedPaths);
            return undefined;
          },
        },
      },
    ];
    setActivePluginRegistry(registry);

    await expect(
      runTrustedToolPolicies(
        {
          toolName: "apply_patch",
          params: { input: "*** Update File: old.ts" },
          derivedPaths: ["old.ts"],
        },
        { toolName: "apply_patch" },
      ),
    ).resolves.toBeUndefined();
    expect(mutationRejected).toBe(true);
    expect(seenDerivedPaths).toEqual([["old.ts"]]);
  });

  it("clears stale derived paths when trusted policy rewrites remove targets", async () => {
    const seenDerivedPaths: unknown[] = [];
    const registry = createEmptyPluginRegistry();
    registry.trustedToolPolicies = [
      {
        pluginId: "trusted-a",
        pluginName: "Trusted A",
        source: "test",
        policy: {
          id: "params",
          description: "params",
          evaluate: () => ({ params: { input: "not a patch" } }),
        },
      },
      {
        pluginId: "trusted-b",
        pluginName: "Trusted B",
        source: "test",
        policy: {
          id: "inspect",
          description: "inspect",
          evaluate: (event) => {
            seenDerivedPaths.push(event.derivedPaths);
            return undefined;
          },
        },
      },
    ];
    setActivePluginRegistry(registry);

    await expect(
      runTrustedToolPolicies(
        {
          toolName: "apply_patch",
          params: { patch: "*** Update File: old.ts" },
          derivedPaths: ["old.ts"],
        },
        { toolName: "apply_patch" },
        {
          deriveEvent(params) {
            return typeof params.patch === "string" ? { derivedPaths: ["old.ts"] } : {};
          },
        },
      ),
    ).resolves.toEqual({ params: { input: "not a patch" } });
    expect(seenDerivedPaths).toEqual([undefined]);
  });

  it("does not let derived param callbacks override core trusted policy event fields", async () => {
    const seenEvents: Array<{ params: unknown; derivedPaths: unknown }> = [];
    const registry = createEmptyPluginRegistry();
    registry.trustedToolPolicies = [
      {
        pluginId: "trusted-a",
        pluginName: "Trusted A",
        source: "test",
        policy: {
          id: "params",
          description: "params",
          evaluate: () => ({ params: { input: "*** Update File: new.ts" } }),
        },
      },
      {
        pluginId: "trusted-b",
        pluginName: "Trusted B",
        source: "test",
        policy: {
          id: "inspect",
          description: "inspect",
          evaluate: (event) => {
            seenEvents.push({ params: event.params, derivedPaths: event.derivedPaths });
            return undefined;
          },
        },
      },
    ];
    setActivePluginRegistry(registry);

    await expect(
      runTrustedToolPolicies(
        {
          toolName: "apply_patch",
          params: { input: "*** Update File: old.ts" },
          derivedPaths: ["old.ts"],
        },
        { toolName: "apply_patch" },
        {
          deriveEvent() {
            return {
              params: { input: "malicious override" },
              derivedPaths: ["new.ts"],
            } as never;
          },
        },
      ),
    ).resolves.toEqual({ params: { input: "*** Update File: new.ts" } });
    expect(seenEvents).toEqual([
      {
        params: { input: "*** Update File: new.ts" },
        derivedPaths: ["new.ts"],
      },
    ]);
  });

  it("validates plugin-owned JSON values as plain JSON-compatible data", () => {
    expect(
      isPluginJsonValue({
        state: "waiting",
        attempts: 1,
        nested: [{ ok: true }, null],
      }),
    ).toBe(true);
    expect(isPluginJsonValue({ value: Number.NaN })).toBe(false);
    expect(isPluginJsonValue({ value: undefined })).toBe(false);
    expect(isPluginJsonValue(new Date(0))).toBe(false);
    expect(isPluginJsonValue(new Map([["state", "waiting"]]))).toBe(false);
    expect(isPluginJsonValue({ value: "x".repeat(70 * 1024) })).toBe(false);
  });

  it("rejects non-JSON descriptor schemas before projecting Control UI descriptors", () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "descriptor-fixture",
        name: "Descriptor Fixture",
      }),
      register(api) {
        api.registerControlUiDescriptor({
          id: "bad-schema",
          surface: "session",
          label: "Bad schema",
          schema: new Date(0) as never,
        });
      },
    });

    expect(registry.registry.controlUiDescriptors ?? []).toHaveLength(0);
    expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
      {
        pluginId: "descriptor-fixture",
        message: "control UI descriptor schema must be JSON-compatible: bad-schema",
      },
    ]);
  });

  it("projects registered session extensions into gateway session rows", () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "host-hook-fixture",
        name: "Host Hook Fixture",
      }),
      register: registerHostHookFixture,
    });
    setActivePluginRegistry(registry.registry);

    const row = buildGatewaySessionRow({
      cfg: config,
      storePath: "/tmp/sessions.json",
      store: {},
      key: "agent:main:main",
      entry: {
        sessionId: "session-1",
        updatedAt: 1,
        pluginExtensions: {
          "host-hook-fixture": {
            workflow: { state: "waiting" },
          },
        },
      },
    });

    expect(row.pluginExtensions).toEqual([
      {
        pluginId: "host-hook-fixture",
        namespace: "workflow",
        value: { state: "waiting" },
      },
    ]);
  });

  it("projects sync session extension projectors into gateway rows without exposing raw state", () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "projector-fixture",
        name: "Projector Fixture",
      }),
      register(api) {
        api.registerSessionExtension({
          namespace: "workflow",
          description: "Projected workflow state",
          project: ({ state }) => {
            if (!state || typeof state !== "object" || Array.isArray(state)) {
              return undefined;
            }
            const workflowState = (state as { state?: unknown }).state;
            return typeof workflowState === "string" ? { state: workflowState } : undefined;
          },
        });
      },
    });
    setActivePluginRegistry(registry.registry);

    const entry: SessionEntry = {
      sessionId: "session-1",
      updatedAt: 1,
      pluginExtensions: {
        "projector-fixture": {
          workflow: { state: "waiting", privateToken: "secret" },
        },
      },
    };
    expect(projectPluginSessionExtensionsSync({ sessionKey: "agent:main:main", entry })).toEqual([
      {
        pluginId: "projector-fixture",
        namespace: "workflow",
        value: { state: "waiting" },
      },
    ]);

    const row = buildGatewaySessionRow({
      cfg: config,
      storePath: "/tmp/sessions.json",
      store: {},
      key: "agent:main:main",
      entry,
    });
    expect(row.pluginExtensions).toEqual([
      {
        pluginId: "projector-fixture",
        namespace: "workflow",
        value: { state: "waiting" },
      },
    ]);
  });

  it("rejects async session extension projectors because gateway rows are synchronous", () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "async-projector-fixture",
        name: "Async Projector Fixture",
      }),
      register(api) {
        api.registerSessionExtension({
          namespace: "workflow",
          description: "Async workflow state",
          project: (async () => ({ state: "late" })) as unknown as () => undefined,
        });
      },
    });

    expect(registry.registry.sessionExtensions ?? []).toHaveLength(0);
    expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
      {
        pluginId: "async-projector-fixture",
        message: "session extension projector must be synchronous",
      },
    ]);
  });

  it("reports specific diagnostics for malformed session extension callbacks", () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "bad-session-extension-fixture",
        name: "Bad Session Extension Fixture",
      }),
      register(api) {
        api.registerSessionExtension({
          namespace: "projector",
          description: "Bad projector",
          project: "not-a-function" as never,
        });
        api.registerSessionExtension({
          namespace: "cleanup",
          description: "Bad cleanup",
          cleanup: "not-a-function" as never,
        });
      },
    });

    expect(registry.registry.sessionExtensions ?? []).toHaveLength(0);
    expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
      {
        pluginId: "bad-session-extension-fixture",
        message: "session extension projector must be a function",
      },
      {
        pluginId: "bad-session-extension-fixture",
        message: "session extension cleanup must be a function",
      },
    ]);
  });

  it("rejects duplicate runtime lifecycle and agent event subscription ids", () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "duplicate-host-hook-fixture",
        name: "Duplicate Host Hook Fixture",
      }),
      register(api) {
        api.registerRuntimeLifecycle({ id: "cleanup", cleanup: () => undefined });
        api.registerRuntimeLifecycle({ id: "cleanup", cleanup: () => undefined });
        api.registerRuntimeLifecycle({
          id: "bad-cleanup",
          cleanup: "not-a-function" as never,
        });
        api.registerAgentEventSubscription({
          id: "events",
          streams: ["tool"],
          handle: () => undefined,
        });
        api.registerAgentEventSubscription({
          id: "events",
          streams: ["error"],
          handle: () => undefined,
        });
        api.registerAgentEventSubscription({
          id: "missing-handler",
          streams: ["tool"],
          handle: "not-a-function" as never,
        });
        api.registerAgentEventSubscription({
          id: "bad-streams",
          streams: { length: 1, 0: "tool" } as never,
          handle: () => undefined,
        });
        api.registerSessionSchedulerJob({
          id: "bad-scheduler-cleanup",
          sessionKey: "agent:main:main",
          kind: "monitor",
          cleanup: "not-a-function" as never,
        });
      },
    });

    expect(registry.registry.runtimeLifecycles ?? []).toHaveLength(1);
    expect(registry.registry.agentEventSubscriptions ?? []).toHaveLength(1);
    expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([
      {
        pluginId: "duplicate-host-hook-fixture",
        message: "runtime lifecycle already registered: cleanup",
      },
      {
        pluginId: "duplicate-host-hook-fixture",
        message: "runtime lifecycle cleanup must be a function: bad-cleanup",
      },
      {
        pluginId: "duplicate-host-hook-fixture",
        message: "agent event subscription already registered: events",
      },
      {
        pluginId: "duplicate-host-hook-fixture",
        message: "agent event subscription registration requires id and handle",
      },
      {
        pluginId: "duplicate-host-hook-fixture",
        message: "agent event subscription streams must be an array of strings: bad-streams",
      },
      {
        pluginId: "duplicate-host-hook-fixture",
        message: "session scheduler job cleanup must be a function: bad-scheduler-cleanup",
      },
    ]);
  });

  it("defensively ignores promise-like session projections from untyped plugins", async () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "promise-projector-fixture",
        name: "Promise Projector Fixture",
      }),
      register(api) {
        api.registerSessionExtension({
          namespace: "workflow",
          description: "Promise workflow state",
          project: (() =>
            Promise.reject(
              new Error("projectors must be synchronous"),
            )) as unknown as () => undefined,
        });
      },
    });
    setActivePluginRegistry(registry.registry);
    const entry: SessionEntry = {
      sessionId: "session-1",
      updatedAt: 1,
      pluginExtensions: {
        "promise-projector-fixture": {
          workflow: { state: "waiting" },
        },
      },
    };

    expect(projectPluginSessionExtensionsSync({ sessionKey: "agent:main:main", entry })).toEqual(
      [],
    );
    await expect(
      projectPluginSessionExtensions({ sessionKey: "agent:main:main", entry }),
    ).resolves.toStrictEqual([]);
  });

  it("skips throwing session extension projectors without losing other projections", () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "throwing-projector-fixture",
        name: "Throwing Projector Fixture",
      }),
      register(api) {
        api.registerSessionExtension({
          namespace: "workflow",
          description: "Throwing workflow state",
          project: () => {
            throw new Error("projection failed");
          },
        });
      },
    });
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "healthy-projector-fixture",
        name: "Healthy Projector Fixture",
      }),
      register(api) {
        api.registerSessionExtension({
          namespace: "workflow",
          description: "Healthy workflow state",
          project: ({ state }) => state,
        });
      },
    });
    setActivePluginRegistry(registry.registry);
    const entry: SessionEntry = {
      sessionId: "session-1",
      updatedAt: 1,
      pluginExtensions: {
        "throwing-projector-fixture": {
          workflow: { state: "hidden" },
        },
        "healthy-projector-fixture": {
          workflow: { state: "visible" },
        },
      },
    };

    expect(projectPluginSessionExtensionsSync({ sessionKey: "agent:main:main", entry })).toEqual([
      {
        pluginId: "healthy-projector-fixture",
        namespace: "workflow",
        value: { state: "visible" },
      },
    ]);
    const row = buildGatewaySessionRow({
      cfg: config,
      storePath: "/tmp/sessions.json",
      store: {},
      key: "agent:main:main",
      entry,
    });
    expect(row.pluginExtensions).toEqual([
      {
        pluginId: "healthy-projector-fixture",
        namespace: "workflow",
        value: { state: "visible" },
      },
    ]);
  });

  it("requires explicit unset to remove plugin session extension state", async () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "patch-fixture",
        name: "Patch Fixture",
      }),
      register(api) {
        api.registerSessionExtension({
          namespace: "workflow",
          description: "Patch workflow state",
        });
      },
    });
    setActivePluginRegistry(registry.registry);

    const stateDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-patch-"),
    );
    const storePath = path.join(stateDir, "sessions.json");
    const tempConfig = {
      session: { store: storePath },
    };
    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    try {
      process.env.OPENCLAW_STATE_DIR = stateDir;
      await withTempConfig({
        cfg: tempConfig,
        run: async () => {
          await updateSessionStore(storePath, (store) => {
            store["agent:main:main"] = {
              sessionId: "session-1",
              updatedAt: Date.now(),
              pluginExtensions: {
                "patch-fixture": { workflow: { state: "waiting" } },
              },
            };
            return undefined;
          });

          await expect(
            patchPluginSessionExtension({
              cfg: tempConfig,
              sessionKey: "agent:main:main",
              pluginId: "patch-fixture",
              namespace: "workflow",
            }),
          ).resolves.toEqual({
            ok: false,
            error: "plugin session extension value is required unless unset is true",
          });
          expect(
            loadSessionStore(storePath)["agent:main:main"]?.pluginExtensions?.["patch-fixture"]
              ?.workflow,
          ).toEqual({ state: "waiting" });

          await expect(
            patchPluginSessionExtension({
              cfg: tempConfig,
              sessionKey: "agent:main:main",
              pluginId: "patch-fixture",
              namespace: "workflow",
              value: { state: "ambiguous" },
              unset: true,
            }),
          ).resolves.toEqual({
            ok: false,
            error: "plugin session extension cannot specify both unset and value",
          });
          expect(
            loadSessionStore(storePath)["agent:main:main"]?.pluginExtensions?.["patch-fixture"]
              ?.workflow,
          ).toEqual({ state: "waiting" });

          await expect(
            patchPluginSessionExtension({
              cfg: tempConfig,
              sessionKey: "agent:main:main",
              pluginId: "patch-fixture",
              namespace: "workflow",
              value: { state: "approved" },
            }),
          ).resolves.toEqual({
            ok: true,
            key: "agent:main:main",
            value: { state: "approved" },
          });

          await expect(
            patchPluginSessionExtension({
              cfg: tempConfig,
              sessionKey: "agent:main:main",
              pluginId: "patch-fixture",
              namespace: "workflow",
              unset: true,
            }),
          ).resolves.toEqual({
            ok: true,
            key: "agent:main:main",
            value: undefined,
          });
          expect(loadSessionStore(storePath)["agent:main:main"]?.pluginExtensions).toBeUndefined();
        },
      });
    } finally {
      if (previousStateDir === undefined) {
        delete process.env.OPENCLAW_STATE_DIR;
      } else {
        process.env.OPENCLAW_STATE_DIR = previousStateDir;
      }
      await fs.rm(stateDir, { recursive: true, force: true });
    }
  });

  it("models queued next-turn injections and agent_turn_prepare as one prompt context", async () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "host-hook-fixture",
        name: "Host Hook Fixture",
      }),
      register: registerHostHookFixture,
    });
    const runner = createHookRunner(registry.registry);
    const queuedContext = buildPluginAgentTurnPrepareContext({
      queuedInjections: [
        {
          id: "approval",
          pluginId: "approval-plugin",
          text: "approval workflow resumed",
          placement: "prepend_context",
          createdAt: 1,
        },
        {
          id: "budget",
          pluginId: "budget-plugin",
          text: "budget policy summary",
          placement: "append_context",
          createdAt: 1,
        },
      ],
    });
    const hookContext = await runner.runAgentTurnPrepare(
      {
        prompt: "continue",
        messages: [],
        queuedInjections: [],
      },
      { sessionKey: "agent:main:main" },
    );

    expect(
      joinContextFragments(
        queuedContext.prependContext,
        queuedContext.appendContext,
        hookContext?.prependContext,
      ),
    ).toContain("approval workflow resumed");
    expect(hookContext?.prependContext).toBe("fixture turn context");
  });

  it("skips malformed persisted next-turn injection records during prompt assembly", () => {
    const queuedContext = buildPluginAgentTurnPrepareContext({
      queuedInjections: [
        {
          id: "bad-text",
          pluginId: "approval-plugin",
          text: 123,
          placement: "prepend_context",
          createdAt: 1,
        } as never,
        {
          id: "bad-placement",
          pluginId: "approval-plugin",
          text: "wrong placement",
          placement: "middle_context",
          createdAt: 1,
        } as never,
        {
          id: "valid",
          pluginId: "approval-plugin",
          text: "  approval workflow resumed  ",
          placement: "append_context",
          createdAt: 1,
        },
      ],
    });

    expect(queuedContext).toEqual({ appendContext: "approval workflow resumed" });
  });

  it("rejects malformed next-turn injection input before persisting records", async () => {
    await expect(
      enqueuePluginNextTurnInjection({
        cfg: {},
        pluginId: "approval-fixture",
        injection: {
          sessionKey: "agent:main:main",
          text: "invalid placement",
          placement: "middle_context",
        } as never,
      }),
    ).resolves.toEqual({ enqueued: false, id: "", sessionKey: "agent:main:main" });

    await expect(
      enqueuePluginNextTurnInjection({
        cfg: {},
        pluginId: "approval-fixture",
        injection: {
          sessionKey: "agent:main:main",
          text: "invalid ttl",
          ttlMs: Number.POSITIVE_INFINITY,
        },
      }),
    ).resolves.toEqual({ enqueued: false, id: "", sessionKey: "agent:main:main" });

    await expect(
      enqueuePluginNextTurnInjection({
        cfg: {},
        pluginId: "approval-fixture",
        injection: {
          sessionKey: "agent:main:main",
          text: "negative ttl",
          ttlMs: -1,
        },
      }),
    ).resolves.toEqual({ enqueued: false, id: "", sessionKey: "agent:main:main" });
  });

  it("reports duplicate next-turn injections as not newly enqueued", async () => {
    const stateDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-injection-"),
    );
    const storePath = path.join(stateDir, "sessions.json");
    const tempConfig = {
      session: { store: storePath },
    };
    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    try {
      process.env.OPENCLAW_STATE_DIR = stateDir;
      await withTempConfig({
        cfg: tempConfig,
        run: async () => {
          await updateSessionStore(storePath, (store) => {
            store["agent:main:main"] = {
              sessionId: "session-1",
              updatedAt: Date.now(),
            };
            return undefined;
          });
          const now = Date.now();

          const first = await enqueuePluginNextTurnInjection({
            cfg: tempConfig,
            pluginId: "approval-fixture",
            injection: {
              sessionKey: "agent:main:main",
              text: "resume approval workflow",
              placement: "prepend_context",
              idempotencyKey: "approval:resume",
            },
            now,
          });
          const duplicate = await enqueuePluginNextTurnInjection({
            cfg: tempConfig,
            pluginId: "approval-fixture",
            injection: {
              sessionKey: "agent:main:main",
              text: "resume approval workflow again",
              placement: "prepend_context",
              idempotencyKey: "approval:resume",
            },
            now: now + 1,
          });

          expect(first.enqueued).toBe(true);
          expect(duplicate).toEqual({
            enqueued: false,
            id: first.id,
            sessionKey: "agent:main:main",
          });
          const stored = loadSessionStore(storePath, { skipCache: true });
          expect(
            stored["agent:main:main"]?.pluginNextTurnInjections?.["approval-fixture"],
          ).toHaveLength(1);
        },
      });
    } finally {
      if (previousStateDir === undefined) {
        delete process.env.OPENCLAW_STATE_DIR;
      } else {
        process.env.OPENCLAW_STATE_DIR = previousStateDir;
      }
      await fs.rm(stateDir, { recursive: true, force: true });
    }
  });

  it("suppresses stale next-turn injections from plugins that are no longer loaded", async () => {
    const registry = createEmptyPluginRegistry();
    registry.plugins.push(
      createPluginRecord({
        id: "active-injector",
        name: "Active Injector",
        status: "loaded",
      }),
      createPluginRecord({
        id: "disabled-injector",
        name: "Disabled Injector",
        status: "disabled",
      }),
      createPluginRecord({
        id: "policy-blocked-injector",
        name: "Policy Blocked Injector",
        status: "loaded",
      }),
    );
    setActivePluginRegistry(registry);
    const stateDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-stale-"),
    );
    const storePath = path.join(stateDir, "sessions.json");
    const tempConfig = {
      session: { store: storePath },
      plugins: {
        entries: {
          "policy-blocked-injector": {
            hooks: { allowPromptInjection: false },
          },
        },
      },
    };
    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    try {
      process.env.OPENCLAW_STATE_DIR = stateDir;
      await withTempConfig({
        cfg: tempConfig,
        run: async () => {
          await updateSessionStore(storePath, (store) => {
            store["agent:main:main"] = {
              sessionId: "session-1",
              updatedAt: Date.now(),
              pluginNextTurnInjections: {
                "active-injector": [
                  {
                    id: "active",
                    pluginId: "active-injector",
                    text: "active prompt contribution",
                    placement: "append_context",
                    createdAt: 1,
                  },
                ],
                "disabled-injector": [
                  {
                    id: "stale",
                    pluginId: "disabled-injector",
                    text: "stale prompt contribution",
                    placement: "prepend_context",
                    createdAt: 1,
                  },
                ],
                "policy-blocked-injector": [
                  {
                    id: "policy-blocked",
                    pluginId: "policy-blocked-injector",
                    text: "policy blocked prompt contribution",
                    placement: "prepend_context",
                    createdAt: 1,
                  },
                ],
              },
            };
            return undefined;
          });

          const drained = await drainPluginNextTurnInjections({
            cfg: tempConfig,
            sessionKey: "agent:main:main",
            now: 2,
          });
          expect(drained).toHaveLength(1);
          expectRecordFields(drained[0], {
            id: "active",
            pluginId: "active-injector",
            text: "active prompt contribution",
          });
          const stored = loadSessionStore(storePath, { skipCache: true });
          expect(stored["agent:main:main"]?.pluginNextTurnInjections).toBeUndefined();
        },
      });
    } finally {
      if (previousStateDir === undefined) {
        delete process.env.OPENCLAW_STATE_DIR;
      } else {
        process.env.OPENCLAW_STATE_DIR = previousStateDir;
      }
      await fs.rm(stateDir, { recursive: true, force: true });
    }
  });

  it("preserves global enqueue order when draining live next-turn injections", async () => {
    const registry = createEmptyPluginRegistry();
    registry.plugins.push(
      createPluginRecord({
        id: "injector-a",
        name: "Injector A",
        status: "loaded",
      }),
      createPluginRecord({
        id: "injector-b",
        name: "Injector B",
        status: "loaded",
      }),
    );
    setActivePluginRegistry(registry);
    const stateDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-order-"),
    );
    const storePath = path.join(stateDir, "sessions.json");
    const tempConfig = {
      session: { store: storePath },
    };
    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    try {
      process.env.OPENCLAW_STATE_DIR = stateDir;
      await withTempConfig({
        cfg: tempConfig,
        run: async () => {
          await updateSessionStore(storePath, (store) => {
            store["agent:main:main"] = {
              sessionId: "session-1",
              updatedAt: Date.now(),
              pluginNextTurnInjections: {
                "injector-a": [
                  {
                    id: "a1",
                    pluginId: "injector-a",
                    text: "first",
                    placement: "append_context",
                    createdAt: 1,
                  },
                  {
                    id: "a2",
                    pluginId: "injector-a",
                    text: "third",
                    placement: "append_context",
                    createdAt: 3,
                  },
                ],
                "injector-b": [
                  {
                    id: "b1",
                    pluginId: "injector-b",
                    text: "second",
                    placement: "append_context",
                    createdAt: 2,
                  },
                ],
              },
            };
            return undefined;
          });

          const drained = await drainPluginNextTurnInjections({
            cfg: tempConfig,
            sessionKey: "agent:main:main",
            now: 4,
          });
          expect(drained).toHaveLength(3);
          expectRecordFields(drained[0], { id: "a1", text: "first" });
          expectRecordFields(drained[1], { id: "b1", text: "second" });
          expectRecordFields(drained[2], { id: "a2", text: "third" });
        },
      });
    } finally {
      if (previousStateDir === undefined) {
        delete process.env.OPENCLAW_STATE_DIR;
      } else {
        process.env.OPENCLAW_STATE_DIR = previousStateDir;
      }
      await fs.rm(stateDir, { recursive: true, force: true });
    }
  });

  it("validates gateway protocol envelopes for plugin patch and UI descriptors", () => {
    expect(
      validateSessionsPluginPatchParams({
        key: "agent:main:main",
        pluginId: "approval-plugin",
        namespace: "workflow",
        value: { state: "waiting" },
      }),
    ).toBe(true);
    expect(
      validateSessionsPluginPatchParams({
        key: "agent:main:main",
        pluginId: "approval-plugin",
        namespace: "workflow",
        value: { state: "waiting" },
        accidentalPlanModeRootField: true,
      }),
    ).toBe(false);
    expect(validatePluginsUiDescriptorsParams({})).toBe(true);
    expect(validatePluginsUiDescriptorsParams({ pluginId: "host-hook-fixture" })).toBe(false);
  });

  it("enforces command requiredScopes for gateway clients and command owners", async () => {
    const handlerCalls: string[] = [];
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "approval-command-fixture",
        name: "Approval Command Fixture",
      }),
      register(api) {
        api.registerCommand({
          name: "approval-fixture",
          description: "Continue the agent after approval.",
          requiredScopes: [APPROVALS_SCOPE],
          acceptsArgs: true,
          handler: async (ctx) => {
            handlerCalls.push(ctx.args ?? "");
            return { text: "approval queued", continueAgent: true };
          },
        });
      },
    });
    const registration = requireFirstCommandRegistration(registry.registry);
    const command = {
      ...registration.command,
      pluginId: registration.pluginId,
      pluginName: registration.pluginName,
      pluginRoot: registration.rootDir,
    };
    expect(
      validatePluginCommandDefinition({
        name: "invalid-scopes-fixture",
        description: "Invalid scopes.",
        requiredScopes: "operator.approvals" as never,
        handler: () => ({ text: "unused" }),
      }),
    ).toBe("Command requiredScopes must be an array of operator scopes");
    expect(
      validatePluginCommandDefinition({
        name: "unknown-scopes-fixture",
        description: "Unknown scopes.",
        requiredScopes: ["operator.unknown" as never],
        handler: () => ({ text: "unused" }),
      }),
    ).toBe("Command requiredScopes contains unknown operator scope: operator.unknown");
    expect(
      validatePluginCommandDefinition({
        name: "invalid-owner-status-fixture",
        description: "Invalid owner status exposure.",
        exposeSenderIsOwner: "yes" as never,
        handler: () => ({ text: "unused" }),
      }),
    ).toBe("Command exposeSenderIsOwner must be a boolean");

    await expect(
      executePluginCommand({
        command,
        args: "resume-text",
        senderId: "owner",
        channel: "whatsapp",
        isAuthorizedSender: true,
        senderIsOwner: true,
        sessionKey: "agent:main:main",
        commandBody: "/approval-fixture resume-text",
        config,
      }),
    ).resolves.toEqual({ text: "approval queued", continueAgent: true });
    expect(handlerCalls).toEqual(["resume-text"]);

    await expect(
      executePluginCommand({
        command,
        args: "resume",
        senderId: "owner",
        channel: "whatsapp",
        isAuthorizedSender: true,
        gatewayClientScopes: [READ_SCOPE, WRITE_SCOPE],
        sessionKey: "agent:main:main",
        commandBody: "/approval-fixture resume",
        config,
      }),
    ).resolves.toEqual({
      text: `⚠️ This command requires gateway scope: ${APPROVALS_SCOPE}.`,
    });
    expect(handlerCalls).toEqual(["resume-text"]);

    await expect(
      executePluginCommand({
        command,
        args: "resume",
        senderId: "owner",
        channel: "whatsapp",
        isAuthorizedSender: true,
        gatewayClientScopes: [APPROVALS_SCOPE],
        sessionKey: "agent:main:main",
        commandBody: "/approval-fixture resume",
        config,
      }),
    ).resolves.toEqual({ text: "approval queued", continueAgent: true });
    expect(handlerCalls).toEqual(["resume-text", "resume"]);
  });

  it("dispatches sanitized agent events and clears plugin run context on run end", async () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "host-hook-fixture",
        name: "Host Hook Fixture",
      }),
      register: registerHostHookFixture,
    });
    setActivePluginRegistry(registry.registry);

    emitAgentEvent({
      runId: "run-1",
      stream: "tool",
      data: { name: "approval_fixture_tool" },
    });
    await Promise.resolve();

    expect(
      getPluginRunContext({
        pluginId: "host-hook-fixture",
        get: { runId: "run-1", namespace: "lastToolEvent" },
      }),
    ).toEqual({ runId: "run-1", seen: true });

    emitAgentEvent({
      runId: "run-1",
      stream: "lifecycle",
      data: { phase: "end" },
    });
    await waitForPluginEventHandlers();

    expect(
      getPluginRunContext({
        pluginId: "host-hook-fixture",
        get: { runId: "run-1", namespace: "lastToolEvent" },
      }),
    ).toBeUndefined();
  });

  it("clears run context on terminal events even when no plugin subscribes to agent events", async () => {
    setActivePluginRegistry(createEmptyPluginRegistry());
    expect(
      setPluginRunContext({
        pluginId: "context-only-plugin",
        patch: { runId: "run-no-subscribers", namespace: "state", value: { ok: true } },
      }),
    ).toBe(true);

    emitAgentEvent({
      runId: "run-no-subscribers",
      stream: "lifecycle",
      data: { phase: "end" },
    });
    await waitForPluginEventHandlers();

    expect(
      getPluginRunContext({
        pluginId: "context-only-plugin",
        get: { runId: "run-no-subscribers", namespace: "state" },
      }),
    ).toBeUndefined();
  });

  it("does not let delayed non-terminal subscriptions resurrect closed run context", async () => {
    let releaseToolHandler: (() => void) | undefined;
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "delayed-subscription",
        name: "Delayed Subscription",
      }),
      register(api) {
        api.registerAgentEventSubscription({
          id: "delayed",
          streams: ["tool"],
          async handle(eventValue, ctx) {
            await new Promise<void>((resolve) => {
              releaseToolHandler = resolve;
            });
            ctx.setRunContext("late", { resurrected: true });
          },
        });
      },
    });
    setActivePluginRegistry(registry.registry);

    emitAgentEvent({
      runId: "run-delayed-subscription",
      stream: "tool",
      data: { name: "approval_fixture_tool" },
    });
    await Promise.resolve();

    emitAgentEvent({
      runId: "run-delayed-subscription",
      stream: "lifecycle",
      data: { phase: "end" },
    });
    releaseToolHandler?.();
    await waitForPluginEventHandlers();

    expect(
      getPluginRunContext({
        pluginId: "delayed-subscription",
        get: { runId: "run-delayed-subscription", namespace: "late" },
      }),
    ).toBeUndefined();
  });

  it("continues agent event dispatch and terminal cleanup when one subscription throws", async () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "throwing-subscription",
        name: "Throwing Subscription",
      }),
      register(api) {
        api.registerAgentEventSubscription({
          id: "throws",
          streams: ["tool"],
          handle() {
            throw new Error("subscription failed");
          },
        });
      },
    });
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "healthy-subscription",
        name: "Healthy Subscription",
      }),
      register(api) {
        api.registerAgentEventSubscription({
          id: "records",
          streams: ["tool"],
          handle(event, ctx) {
            ctx.setRunContext("seen", { runId: event.runId });
          },
        });
      },
    });
    setActivePluginRegistry(registry.registry);

    emitAgentEvent({
      runId: "run-throws",
      stream: "tool",
      data: { name: "approval_fixture_tool" },
    });
    await Promise.resolve();

    expect(
      getPluginRunContext({
        pluginId: "healthy-subscription",
        get: { runId: "run-throws", namespace: "seen" },
      }),
    ).toEqual({ runId: "run-throws" });

    emitAgentEvent({
      runId: "run-throws",
      stream: "lifecycle",
      data: { phase: "end" },
    });
    await waitForPluginEventHandlers();

    expect(
      getPluginRunContext({
        pluginId: "healthy-subscription",
        get: { runId: "run-throws", namespace: "seen" },
      }),
    ).toBeUndefined();
  });

  it("preserves run context until async terminal event subscriptions settle", async () => {
    let releaseTerminalHandler: (() => void) | undefined;
    let terminalHandlerSawContext: unknown;
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "async-terminal-subscription",
        name: "Async Terminal Subscription",
      }),
      register(api) {
        api.registerAgentEventSubscription({
          id: "records",
          streams: ["tool", "lifecycle"],
          async handle(event, ctx) {
            if (event.stream === "tool") {
              ctx.setRunContext("seen", { runId: event.runId });
              return;
            }
            if (event.data?.phase !== "end") {
              return;
            }
            await new Promise<void>((resolve) => {
              releaseTerminalHandler = resolve;
            });
            terminalHandlerSawContext = ctx.getRunContext("seen");
          },
        });
      },
    });
    setActivePluginRegistry(registry.registry);

    emitAgentEvent({
      runId: "run-async-terminal",
      stream: "tool",
      data: { name: "approval_fixture_tool" },
    });
    await Promise.resolve();

    emitAgentEvent({
      runId: "run-async-terminal",
      stream: "lifecycle",
      data: { phase: "end" },
    });
    await Promise.resolve();

    expect(
      getPluginRunContext({
        pluginId: "async-terminal-subscription",
        get: { runId: "run-async-terminal", namespace: "seen" },
      }),
    ).toEqual({ runId: "run-async-terminal" });

    releaseTerminalHandler?.();
    await waitForPluginEventHandlers();

    expect(terminalHandlerSawContext).toEqual({ runId: "run-async-terminal" });
    expect(
      getPluginRunContext({
        pluginId: "async-terminal-subscription",
        get: { runId: "run-async-terminal", namespace: "seen" },
      }),
    ).toBeUndefined();
  });

  it("covers the non-Plan plugin archetypes promised by the host-hook fixture", () => {
    const archetypes = [
      {
        name: "approval workflow",
        seams: [
          "session extension",
          "command continuation",
          "next-turn injection",
          "UI descriptor",
        ],
      },
      {
        name: "budget/workspace policy gate",
        seams: ["trusted tool policy", "tool metadata", "session projection"],
      },
      {
        name: "background lifecycle monitor",
        seams: ["agent event subscription", "scheduler cleanup", "heartbeat prompt contribution"],
      },
    ];

    expect(archetypes.map((entry) => entry.name)).toEqual([
      "approval workflow",
      "budget/workspace policy gate",
      "background lifecycle monitor",
    ]);
    expect(archetypes.flatMap((entry) => entry.seams)).toEqual([
      "session extension",
      "command continuation",
      "next-turn injection",
      "UI descriptor",
      "trusted tool policy",
      "tool metadata",
      "session projection",
      "agent event subscription",
      "scheduler cleanup",
      "heartbeat prompt contribution",
    ]);
  });

  it("proves every #71676 Plan Mode entry-point class has a generic host seam", () => {
    const parityMap = [
      ["session state + sessions.patch", "session extensions + sessions.pluginPatch"],
      [
        "pending injections + approval resumes",
        "durable next-turn injections + agent_turn_prepare",
      ],
      ["mutation gates around tools", "trusted tool policy before before_tool_call"],
      ["slash/native command continuations", "requiredScopes + reserved ownership + continueAgent"],
      ["Control UI mode/cards/status", "Control UI descriptor projection"],
      [
        "plan snapshots, nudges, subagent follow-ups, heartbeat",
        "agent events + run context + scheduler cleanup + heartbeat contribution",
      ],
      ["tool catalog display metadata", "plugin tool metadata projection"],
      ["disable/reset/delete/restart cleanup", "runtime lifecycle cleanup"],
    ];

    expect(parityMap).toHaveLength(8);
    for (const [entryPoint, seam] of parityMap) {
      expect(entryPoint).not.toBe("");
      expect(seam).not.toBe("");
      expect(seam).not.toContain("Plan Mode");
    }
  });

  it("cleans plugin-owned session state and lifecycle resources on reset/disable", async () => {
    const cleanupEvents: string[] = [];
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "cleanup-fixture",
        name: "Cleanup Fixture",
      }),
      register(api) {
        api.registerSessionExtension({
          namespace: "workflow",
          description: "cleanup test",
          cleanup: ({ reason, sessionKey }) => {
            cleanupEvents.push(`session:${reason}:${sessionKey ?? ""}`);
          },
        });
        api.registerRuntimeLifecycle({
          id: "monitor",
          cleanup: ({ reason, sessionKey }) => {
            cleanupEvents.push(`runtime:${reason}:${sessionKey ?? ""}`);
          },
        });
        api.registerSessionSchedulerJob({
          id: "nudge",
          sessionKey: "agent:main:main",
          kind: "monitor",
          cleanup: ({ reason, sessionKey }) => {
            cleanupEvents.push(`scheduler:${reason}:${sessionKey}`);
          },
        });
      },
    });

    const entry: SessionEntry = {
      sessionId: "session-1",
      updatedAt: 1,
      pluginExtensions: {
        "cleanup-fixture": { workflow: { state: "waiting" } },
        "other-plugin": { workflow: { state: "keep" } },
      },
      pluginNextTurnInjections: {
        "cleanup-fixture": [
          {
            id: "resume",
            pluginId: "cleanup-fixture",
            text: "resume",
            placement: "prepend_context" as const,
            createdAt: 1,
          },
        ],
        "other-plugin": [
          {
            id: "keep",
            pluginId: "other-plugin",
            text: "keep",
            placement: "append_context" as const,
            createdAt: 1,
          },
        ],
      },
    };
    clearPluginOwnedSessionState(entry, "cleanup-fixture");
    expect(entry.pluginExtensions).toEqual({
      "other-plugin": { workflow: { state: "keep" } },
    });
    expect(entry.pluginNextTurnInjections).toEqual({
      "other-plugin": [
        {
          id: "keep",
          pluginId: "other-plugin",
          text: "keep",
          placement: "append_context",
          createdAt: 1,
        },
      ],
    });

    const stateDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-state-"),
    );
    const tempConfig = {
      session: { store: path.join(stateDir, "sessions.json") },
    };
    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    try {
      process.env.OPENCLAW_STATE_DIR = stateDir;
      await withTempConfig({
        cfg: tempConfig,
        run: async () => {
          await runPluginHostCleanup({
            cfg: tempConfig,
            registry: registry.registry,
            pluginId: "cleanup-fixture",
            reason: "reset",
            sessionKey: "agent:main:main",
          });
          await cleanupReplacedPluginHostRegistry({
            cfg: tempConfig,
            previousRegistry: registry.registry,
            nextRegistry: createEmptyPluginRegistry(),
          });
        },
      });
    } finally {
      if (previousStateDir === undefined) {
        delete process.env.OPENCLAW_STATE_DIR;
      } else {
        process.env.OPENCLAW_STATE_DIR = previousStateDir;
      }
      await fs.rm(stateDir, { recursive: true, force: true });
    }

    expect(cleanupEvents).toEqual([
      "session:reset:agent:main:main",
      "runtime:reset:agent:main:main",
      "scheduler:reset:agent:main:main",
      "session:disable:",
      "runtime:disable:",
    ]);
    expect(listPluginSessionSchedulerJobs("cleanup-fixture")).toStrictEqual([]);
  });

  it("keeps scheduler job records when cleanup fails so cleanup can retry", async () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "cleanup-failure-fixture",
        name: "Cleanup Failure Fixture",
      }),
      register(api) {
        api.registerSessionSchedulerJob({
          id: "retryable-job",
          sessionKey: "agent:main:main",
          kind: "monitor",
          cleanup: () => {
            throw new Error("cleanup failed");
          },
        });
      },
    });

    const cleanupResult = await runPluginHostCleanup({
      cfg: config,
      registry: registry.registry,
      pluginId: "cleanup-failure-fixture",
      reason: "disable",
    });
    expect(cleanupResult.failures).toHaveLength(1);
    expectRecordFields(cleanupResult.failures[0], {
      pluginId: "cleanup-failure-fixture",
      hookId: "scheduler:retryable-job",
    });
    expect(listPluginSessionSchedulerJobs("cleanup-failure-fixture")).toEqual([
      {
        id: "retryable-job",
        pluginId: "cleanup-failure-fixture",
        sessionKey: "agent:main:main",
        kind: "monitor",
      },
    ]);
  });

  it("preserves restarted scheduler jobs while cleaning the replaced registry", async () => {
    const cleanupEvents: string[] = [];
    const previous = createEmptyPluginRegistry();
    previous.plugins.push(
      createPluginRecord({
        id: "restart-fixture",
        name: "Restart Fixture",
        status: "loaded",
      }),
    );
    previous.sessionSchedulerJobs = [
      {
        pluginId: "restart-fixture",
        pluginName: "Restart Fixture",
        job: {
          id: "shared-job",
          sessionKey: "agent:main:main",
          kind: "monitor",
          cleanup: ({ reason, jobId }) => {
            cleanupEvents.push(`${reason}:${jobId}`);
          },
        },
        source: "/virtual/restart-fixture/index.ts",
        rootDir: "/virtual/restart-fixture",
      },
    ];
    const next = createEmptyPluginRegistry();
    next.plugins.push(
      createPluginRecord({
        id: "restart-fixture",
        name: "Restart Fixture",
        status: "loaded",
      }),
    );
    next.sessionSchedulerJobs = [
      {
        pluginId: "restart-fixture",
        pluginName: "Restart Fixture",
        job: {
          id: "shared-job",
          sessionKey: "agent:main:main",
          kind: "monitor",
          cleanup: () => undefined,
        },
        source: "/virtual/restart-fixture/index.ts",
        rootDir: "/virtual/restart-fixture",
      },
    ];
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "restart-fixture",
        name: "Restart Fixture",
      }),
      register(api) {
        api.registerSessionSchedulerJob({
          id: "shared-job",
          sessionKey: "agent:main:main",
          kind: "monitor",
        });
      },
    });

    const cleanupResult = await cleanupReplacedPluginHostRegistry({
      cfg: config,
      previousRegistry: previous,
      nextRegistry: next,
    });
    expect(cleanupResult.failures).toEqual([]);
    expect(cleanupEvents).toStrictEqual([]);
    expect(listPluginSessionSchedulerJobs("restart-fixture")).toEqual([
      {
        id: "shared-job",
        pluginId: "restart-fixture",
        sessionKey: "agent:main:main",
        kind: "monitor",
      },
    ]);
  });

  it("does not invoke old scheduler cleanup for a preserved newer generation", async () => {
    const cleanupEvents: string[] = [];
    const previousFixture = createPluginRegistryFixture();
    registerTestPlugin({
      registry: previousFixture.registry,
      config: previousFixture.config,
      record: createPluginRecord({
        id: "scheduler-preserve",
        name: "Scheduler Preserve",
      }),
      register(api) {
        api.registerSessionSchedulerJob({
          id: "shared-job",
          sessionKey: "agent:main:main",
          kind: "monitor",
          cleanup: ({ reason, jobId }) => {
            cleanupEvents.push(`${reason}:${jobId}`);
          },
        });
      },
    });

    const replacementFixture = createPluginRegistryFixture();
    registerTestPlugin({
      registry: replacementFixture.registry,
      config: replacementFixture.config,
      record: createPluginRecord({
        id: "scheduler-preserve",
        name: "Scheduler Preserve",
      }),
      register(api) {
        api.registerSessionSchedulerJob({
          id: "shared-job",
          sessionKey: "agent:main:main",
          kind: "monitor",
        });
      },
    });

    await expect(
      cleanupReplacedPluginHostRegistry({
        cfg: previousFixture.config,
        previousRegistry: previousFixture.registry.registry,
        nextRegistry: replacementFixture.registry.registry,
      }),
    ).resolves.toEqual({ cleanupCount: 0, failures: [] });
    expect(cleanupEvents).toEqual([]);
    expect(listPluginSessionSchedulerJobs("scheduler-preserve")).toEqual([
      {
        id: "shared-job",
        pluginId: "scheduler-preserve",
        sessionKey: "agent:main:main",
        kind: "monitor",
      },
    ]);
  });

  it("does not let stale scheduler cleanup delete a newer job generation", async () => {
    let releaseCleanup: (() => void) | undefined;
    let markCleanupStarted: (() => void) | undefined;
    const cleanupStartedPromise = new Promise<void>((resolve) => {
      markCleanupStarted = resolve;
    });
    const previousFixture = createPluginRegistryFixture();
    registerTestPlugin({
      registry: previousFixture.registry,
      config: previousFixture.config,
      record: createPluginRecord({
        id: "scheduler-race",
        name: "Scheduler Race",
      }),
      register(api) {
        api.registerSessionSchedulerJob({
          id: "shared-job",
          sessionKey: "agent:main:main",
          kind: "monitor",
          cleanup: async () => {
            if (!markCleanupStarted) {
              throw new Error("Expected scheduler cleanup start callback to be initialized");
            }
            markCleanupStarted();
            await new Promise<void>((resolve) => {
              releaseCleanup = resolve;
            });
          },
        });
      },
    });

    const cleanupPromise = cleanupReplacedPluginHostRegistry({
      cfg: previousFixture.config,
      previousRegistry: previousFixture.registry.registry,
      nextRegistry: createEmptyPluginRegistry(),
    });
    await cleanupStartedPromise;

    const replacementFixture = createPluginRegistryFixture();
    registerTestPlugin({
      registry: replacementFixture.registry,
      config: replacementFixture.config,
      record: createPluginRecord({
        id: "scheduler-race",
        name: "Scheduler Race",
      }),
      register(api) {
        api.registerSessionSchedulerJob({
          id: "shared-job",
          sessionKey: "agent:main:main",
          kind: "monitor",
        });
      },
    });

    if (!releaseCleanup) {
      throw new Error("Expected scheduler cleanup release callback to be initialized");
    }
    releaseCleanup();
    const cleanupResult = await cleanupPromise;
    expect(cleanupResult.failures).toEqual([]);
    expect(listPluginSessionSchedulerJobs("scheduler-race")).toEqual([
      {
        id: "shared-job",
        pluginId: "scheduler-race",
        sessionKey: "agent:main:main",
        kind: "monitor",
      },
    ]);
  });

  it("does not register scheduler jobs globally during non-activating registry loads", () => {
    const registry = createPluginRegistry({
      logger: {
        info() {},
        warn() {},
        error() {},
        debug() {},
      },
      runtime: {} as PluginRuntime,
      activateGlobalSideEffects: false,
    });
    const config = {};
    let handle:
      | {
          id: string;
          pluginId: string;
          sessionKey: string;
          kind: string;
        }
      | undefined;
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "snapshot-fixture",
        name: "Snapshot Fixture",
      }),
      register(api) {
        handle = api.registerSessionSchedulerJob({
          id: "snapshot-job",
          sessionKey: "agent:main:main",
          kind: "monitor",
        });
      },
    });

    expect(handle).toEqual({
      id: "snapshot-job",
      pluginId: "snapshot-fixture",
      sessionKey: "agent:main:main",
      kind: "monitor",
    });
    const schedulerJobs = registry.registry.sessionSchedulerJobs ?? [];
    expect(schedulerJobs).toHaveLength(1);
    const schedulerJob = schedulerJobs[0];
    expect(schedulerJob?.pluginId).toBe("snapshot-fixture");
    expectRecordFields(schedulerJob?.job, {
      id: "snapshot-job",
      sessionKey: "agent:main:main",
      kind: "monitor",
    });
    expect(listPluginSessionSchedulerJobs("snapshot-fixture")).toStrictEqual([]);
  });

  it("removes persistent plugin-owned session state and pending injections during cleanup", async () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "cleanup-fixture",
        name: "Cleanup Fixture",
      }),
      register(api) {
        api.registerSessionExtension({
          namespace: "workflow",
          description: "cleanup test",
        });
      },
    });

    const stateDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-store-"),
    );
    const storePath = path.join(stateDir, "sessions.json");
    const tempConfig = {
      session: { store: storePath },
    };
    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    try {
      process.env.OPENCLAW_STATE_DIR = stateDir;
      await withTempConfig({
        cfg: tempConfig,
        run: async () => {
          await updateSessionStore(storePath, (store) => {
            store["agent:main:main"] = {
              sessionId: "session-1",
              updatedAt: Date.now(),
              pluginExtensions: {
                "cleanup-fixture": { workflow: { state: "waiting" } },
                "other-plugin": { workflow: { state: "keep" } },
              },
              pluginNextTurnInjections: {
                "cleanup-fixture": [
                  {
                    id: "resume",
                    pluginId: "cleanup-fixture",
                    text: "resume",
                    placement: "prepend_context",
                    createdAt: 1,
                  },
                ],
                "other-plugin": [
                  {
                    id: "keep",
                    pluginId: "other-plugin",
                    text: "keep",
                    placement: "append_context",
                    createdAt: 1,
                  },
                ],
              },
            };
            return undefined;
          });

          const cleanupResult = await runPluginHostCleanup({
            cfg: tempConfig,
            registry: registry.registry,
            pluginId: "cleanup-fixture",
            reason: "disable",
          });
          expect(cleanupResult.failures).toEqual([]);

          const stored = loadSessionStore(storePath, { skipCache: true });
          expectRecordFields(stored["agent:main:main"], {
            pluginExtensions: {
              "other-plugin": { workflow: { state: "keep" } },
            },
            pluginNextTurnInjections: {
              "other-plugin": [
                {
                  id: "keep",
                  pluginId: "other-plugin",
                  text: "keep",
                  placement: "append_context",
                  createdAt: 1,
                },
              ],
            },
          });
        },
      });
    } finally {
      if (previousStateDir === undefined) {
        delete process.env.OPENCLAW_STATE_DIR;
      } else {
        process.env.OPENCLAW_STATE_DIR = previousStateDir;
      }
      await fs.rm(stateDir, { recursive: true, force: true });
    }
  });

  it("does not clear unrelated run context during session-scoped cleanup", async () => {
    const registry = createEmptyPluginRegistry();
    expect(
      setPluginRunContext({
        pluginId: "plugin-a",
        patch: { runId: "run-a", namespace: "state", value: { keep: "a" } },
      }),
    ).toBe(true);
    expect(
      setPluginRunContext({
        pluginId: "plugin-b",
        patch: { runId: "run-b", namespace: "state", value: { keep: "b" } },
      }),
    ).toBe(true);

    const stateDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-run-context-"),
    );
    const tempConfig = {
      session: { store: path.join(stateDir, "sessions.json") },
    };
    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    try {
      process.env.OPENCLAW_STATE_DIR = stateDir;
      await withTempConfig({
        cfg: tempConfig,
        run: async () => {
          await runPluginHostCleanup({
            cfg: tempConfig,
            registry,
            reason: "reset",
            sessionKey: "agent:main:main",
          });
        },
      });
    } finally {
      if (previousStateDir === undefined) {
        delete process.env.OPENCLAW_STATE_DIR;
      } else {
        process.env.OPENCLAW_STATE_DIR = previousStateDir;
      }
      await fs.rm(stateDir, { recursive: true, force: true });
    }

    expect(
      getPluginRunContext({
        pluginId: "plugin-a",
        get: { runId: "run-a", namespace: "state" },
      }),
    ).toEqual({ keep: "a" });
    expect(
      getPluginRunContext({
        pluginId: "plugin-b",
        get: { runId: "run-b", namespace: "state" },
      }),
    ).toEqual({ keep: "b" });
  });

  it("preserves durable plugin session state during plugin restart cleanup", async () => {
    const { config, registry } = createPluginRegistryFixture();
    registerTestPlugin({
      registry,
      config,
      record: createPluginRecord({
        id: "restart-state-fixture",
        name: "Restart State Fixture",
      }),
      register(api) {
        api.registerSessionExtension({
          namespace: "workflow",
          description: "restart state test",
        });
      },
    });

    const stateDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-restart-state-"),
    );
    const storePath = path.join(stateDir, "sessions.json");
    const tempConfig = {
      session: { store: storePath },
    };
    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    try {
      process.env.OPENCLAW_STATE_DIR = stateDir;
      await withTempConfig({
        cfg: tempConfig,
        run: async () => {
          await updateSessionStore(storePath, (store) => {
            store["agent:main:main"] = {
              sessionId: "session-1",
              updatedAt: Date.now(),
              pluginExtensions: {
                "restart-state-fixture": { workflow: { state: "waiting" } },
              },
              pluginNextTurnInjections: {
                "restart-state-fixture": [
                  {
                    id: "resume",
                    pluginId: "restart-state-fixture",
                    text: "resume",
                    placement: "prepend_context",
                    createdAt: 1,
                  },
                ],
              },
            };
            return undefined;
          });

          const cleanupResult = await runPluginHostCleanup({
            cfg: tempConfig,
            registry: registry.registry,
            pluginId: "restart-state-fixture",
            reason: "restart",
          });
          expect(cleanupResult.failures).toEqual([]);

          const stored = loadSessionStore(storePath, { skipCache: true });
          expect(stored["agent:main:main"]?.pluginExtensions).toEqual({
            "restart-state-fixture": { workflow: { state: "waiting" } },
          });
          expect(stored["agent:main:main"]?.pluginNextTurnInjections).toEqual({
            "restart-state-fixture": [
              {
                id: "resume",
                pluginId: "restart-state-fixture",
                text: "resume",
                placement: "prepend_context",
                createdAt: 1,
              },
            ],
          });
        },
      });
    } finally {
      if (previousStateDir === undefined) {
        delete process.env.OPENCLAW_STATE_DIR;
      } else {
        process.env.OPENCLAW_STATE_DIR = previousStateDir;
      }
      await fs.rm(stateDir, { recursive: true, force: true });
    }
  });

  it("cleans pending injections for plugins that registered no host-hook callbacks", async () => {
    const previousRegistry = createEmptyPluginRegistry();
    previousRegistry.plugins.push(
      createPluginRecord({
        id: "injection-only-fixture",
        name: "Injection Only Fixture",
        status: "loaded",
      }),
    );
    const stateDir = await fs.mkdtemp(
      path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-injection-only-"),
    );
    const storePath = path.join(stateDir, "sessions.json");
    const tempConfig = {
      session: { store: storePath },
    };
    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    try {
      process.env.OPENCLAW_STATE_DIR = stateDir;
      await withTempConfig({
        cfg: tempConfig,
        run: async () => {
          await updateSessionStore(storePath, (store) => {
            store["agent:main:main"] = {
              sessionId: "session-1",
              updatedAt: Date.now(),
              pluginNextTurnInjections: {
                "injection-only-fixture": [
                  {
                    id: "resume",
                    pluginId: "injection-only-fixture",
                    text: "resume",
                    placement: "prepend_context",
                    createdAt: 1,
                  },
                ],
              },
            };
            return undefined;
          });

          const cleanupResult = await cleanupReplacedPluginHostRegistry({
            cfg: tempConfig,
            previousRegistry,
            nextRegistry: createEmptyPluginRegistry(),
          });
          expect(cleanupResult.failures).toEqual([]);

          const stored = loadSessionStore(storePath, { skipCache: true });
          expect(stored["agent:main:main"]?.pluginNextTurnInjections).toBeUndefined();
        },
      });
    } finally {
      if (previousStateDir === undefined) {
        delete process.env.OPENCLAW_STATE_DIR;
      } else {
        process.env.OPENCLAW_STATE_DIR = previousStateDir;
      }
      await fs.rm(stateDir, { recursive: true, force: true });
    }
  });
});
