// Run context lifecycle contract tests cover plugin run context setup and cleanup. import path from "node:path"; import { createPluginRegistryFixture, registerTestPlugin, } from "openclaw/plugin-sdk/plugin-test-contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { withTempConfig } from "../../gateway/test-temp-config.js"; import { emitAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js"; import { loadSessionStore, updateSessionStore } from "../../plugin-sdk/session-store-runtime.js"; import { createDeferredCore } from "../../shared/deferred.js"; import { createOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; import { runPluginHostCleanup } from "../host-hook-cleanup.js"; import { clearPluginHostRuntimeState, getPluginRunContext, dispatchPluginAgentEventSubscriptions, registerPluginSessionSchedulerJob, setPluginRunContext, } from "../host-hook-runtime.js"; import { listPluginSessionSchedulerJobs, PLUGIN_TERMINAL_EVENT_CLEANUP_WAIT_MS, } from "../host-hook-runtime.test-fixtures.js"; import { runPluginRegisterSyncInRegistry } from "../loader-module-runtime.js"; import { createEmptyPluginRegistry } from "../registry-empty.js"; import { captureActivePluginRegistrySnapshot, rollbackStagedPluginRegistry, setActivePluginRegistry, stageActivePluginRegistry, } from "../runtime.js"; import { createPluginRecord } from "../status.test-helpers.js"; import type { OpenClawPluginApi } from "../types.js"; const PLUGIN_HOST_CLEANUP_TIMEOUT_MS = 5_000; async function waitForPluginEventHandlers(): Promise { await new Promise((resolve) => { setImmediate(resolve); }); } function expectNoCleanupFailures(result: Awaited>): void { expect(result.failures).toEqual([]); } function requireFailureByHookId( result: Awaited>, hookId: string, ) { const failure = result.failures.find((entry) => entry.hookId === hookId); if (!failure) { throw new Error(`Expected cleanup failure for hook ${hookId}`); } return failure; } describe("plugin run context lifecycle", () => { afterEach(() => { vi.useRealTimers(); setActivePluginRegistry(createEmptyPluginRegistry()); clearPluginHostRuntimeState(); resetAgentEventsForTest(); }); it("keeps run-context APIs callable after registration closes", () => { const { config, registry } = createPluginRegistryFixture(); let capturedApi: OpenClawPluginApi | undefined; registerTestPlugin({ registry, config, record: createPluginRecord({ id: "late-run-context-plugin", name: "Late Run Context Plugin", }), register(api) { runPluginRegisterSyncInRegistry( (guardedApi) => { capturedApi = guardedApi; }, api, registry.registry, "late-run-context-plugin", ); }, }); setActivePluginRegistry(registry.registry); capturedApi?.registerGatewayMethod("late-run-context.blocked", () => {}); expect(Object.keys(registry.registry.gatewayHandlers)).not.toContain( "late-run-context.blocked", ); expect( capturedApi?.runContext.setRunContext({ runId: "late-run", namespace: "state", value: { available: true }, }), ).toBe(true); expect( capturedApi?.runContext.getRunContext({ runId: "late-run", namespace: "state", }), ).toEqual({ available: true }); capturedApi?.runContext.clearRunContext({ runId: "late-run", namespace: "state" }); expect( capturedApi?.runContext.getRunContext({ runId: "late-run", namespace: "state", }), ).toBeUndefined(); }); it("blocks stale plugin API run-context access after registry replacement", () => { const { config, registry } = createPluginRegistryFixture(); let capturedApi: OpenClawPluginApi | undefined; registerTestPlugin({ registry, config, record: createPluginRecord({ id: "stale-run-context-plugin", name: "Stale Run Context Plugin", }), register(api) { capturedApi = api; }, }); setActivePluginRegistry(registry.registry); setActivePluginRegistry(createEmptyPluginRegistry()); expect( capturedApi?.runContext?.setRunContext({ runId: "stale-run", namespace: "state", value: { stale: true }, }), ).toBe(false); expect( getPluginRunContext({ pluginId: "stale-run-context-plugin", get: { runId: "stale-run", namespace: "state" }, }), ).toBeUndefined(); expect( setPluginRunContext({ pluginId: "stale-run-context-plugin", patch: { runId: "stale-run", namespace: "state", value: { live: true } }, }), ).toBe(true); expect(capturedApi?.getRunContext({ runId: "stale-run", namespace: "state" })).toBeUndefined(); expect( capturedApi?.runContext.getRunContext({ runId: "stale-run", namespace: "state" }), ).toBeUndefined(); capturedApi?.runContext?.clearRunContext({ runId: "stale-run", namespace: "state" }); expect( getPluginRunContext({ pluginId: "stale-run-context-plugin", get: { runId: "stale-run", namespace: "state" }, }), ).toEqual({ live: true }); }); it("allows run-context mutations after a previous registry is restored active", () => { const { config, registry } = createPluginRegistryFixture(); let capturedApi: OpenClawPluginApi | undefined; registerTestPlugin({ registry, config, record: createPluginRecord({ id: "restored-run-context-plugin", name: "Restored Run Context Plugin", }), register(api) { capturedApi = api; }, }); setActivePluginRegistry(registry.registry); const snapshot = captureActivePluginRegistrySnapshot(); stageActivePluginRegistry(createEmptyPluginRegistry(), null, "default"); rollbackStagedPluginRegistry(snapshot); expect( capturedApi?.runContext?.setRunContext({ runId: "restored-run", namespace: "state", value: { restored: true }, }), ).toBe(true); expect( capturedApi?.runContext?.getRunContext({ runId: "restored-run", namespace: "state", }), ).toEqual({ restored: true }); }); it("allows run-context initialization during activating plugin registration", () => { const { config, registry } = createPluginRegistryFixture(); const record = createPluginRecord({ id: "registration-run-context-plugin", name: "Registration Run Context Plugin", }); const api = registry.createApi(record, { config }); runPluginRegisterSyncInRegistry( () => { expect(registry.registry.plugins).not.toContain(record); expect( api.setRunContext({ runId: "run-registration", namespace: "state", value: { initialized: true }, }), ).toBe(true); expect( getPluginRunContext({ pluginId: "registration-run-context-plugin", get: { runId: "run-registration", namespace: "state" }, }), ).toEqual({ initialized: true }); api.clearRunContext({ runId: "run-registration", namespace: "state" }); expect( getPluginRunContext({ pluginId: "registration-run-context-plugin", get: { runId: "run-registration", namespace: "state" }, }), ).toBeUndefined(); }, api, registry.registry, record.id, ); }); it("fences retired agent-event callbacks from successor run context", async () => { let releasePriorHandler: (() => void) | undefined; let markPriorHandlerStarted: (() => void) | undefined; let retiredHandlerSawContext: unknown; const priorHandlerStarted = new Promise((resolve) => { markPriorHandlerStarted = resolve; }); const priorHandlerRelease = new Promise((resolve) => { releasePriorHandler = resolve; }); const prior = createPluginRegistryFixture(); registerTestPlugin({ registry: prior.registry, config: prior.config, record: createPluginRecord({ id: "agent-event-generation", name: "Agent Event Generation", }), register(api) { api.registerAgentEventSubscription({ id: "prior", streams: ["tool"], async handle(event, ctx) { if (event.data.name !== "hold") { return; } markPriorHandlerStarted?.(); await priorHandlerRelease; retiredHandlerSawContext = ctx.getRunContext("state"); ctx.setRunContext("state", { generation: "A" }); ctx.clearRunContext("preserved"); }, }); }, }); const successor = createPluginRegistryFixture(); registerTestPlugin({ registry: successor.registry, config: successor.config, record: createPluginRecord({ id: "agent-event-generation", name: "Agent Event Generation", }), register(api) { api.registerAgentEventSubscription({ id: "successor", streams: ["tool"], handle(event, ctx) { if (event.data.name !== "successor") { return; } ctx.setRunContext("state", { generation: "B" }); ctx.setRunContext("preserved", { generation: "B" }); }, }); }, }); setActivePluginRegistry(prior.registry.registry); emitAgentEvent({ runId: "run-agent-event-generation", stream: "tool", data: { name: "hold" }, }); await priorHandlerStarted; setActivePluginRegistry(successor.registry.registry); emitAgentEvent({ runId: "run-agent-event-generation", stream: "tool", data: { name: "successor" }, }); await waitForPluginEventHandlers(); // Restoring the same registry object must not revive callbacks admitted before cutover. setActivePluginRegistry(prior.registry.registry); releasePriorHandler?.(); await waitForPluginEventHandlers(); expect(retiredHandlerSawContext).toBeUndefined(); expect( getPluginRunContext({ pluginId: "agent-event-generation", get: { runId: "run-agent-event-generation", namespace: "state" }, }), ).toEqual({ generation: "B" }); expect( getPluginRunContext({ pluginId: "agent-event-generation", get: { runId: "run-agent-event-generation", namespace: "preserved" }, }), ).toEqual({ generation: "B" }); }); it("does not let delayed non-terminal subscriptions resurrect closed run context", async () => { let releaseToolHandler: (() => void) | undefined; let delayedToolHandlerSawContext: unknown; 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) { ctx.setRunContext("before-terminal", { visible: true }); await new Promise((resolve) => { releaseToolHandler = resolve; }); delayedToolHandlerSawContext = ctx.getRunContext("before-terminal"); ctx.setRunContext("late", { resurrected: true }); }, }); }, }); setActivePluginRegistry(registry.registry); emitAgentEvent({ runId: "run-delayed-subscription", stream: "tool", data: { name: "tool" }, }); await Promise.resolve(); emitAgentEvent({ runId: "run-delayed-subscription", stream: "lifecycle", data: { phase: "end" }, }); await Promise.resolve(); expect( getPluginRunContext({ pluginId: "delayed-subscription", get: { runId: "run-delayed-subscription", namespace: "before-terminal" }, }), ).toEqual({ visible: true }); releaseToolHandler?.(); await waitForPluginEventHandlers(); expect(delayedToolHandlerSawContext).toEqual({ visible: true }); expect( getPluginRunContext({ pluginId: "delayed-subscription", get: { runId: "run-delayed-subscription", namespace: "late" }, }), ).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((resolve) => { releaseTerminalHandler = resolve; }); terminalHandlerSawContext = ctx.getRunContext("seen"); }, }); }, }); setActivePluginRegistry(registry.registry); emitAgentEvent({ runId: "run-async-terminal", stream: "tool", data: { name: "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("waits for terminal handlers added after the first terminal cleanup waiter starts", async () => { let releaseFirstTerminalHandler: (() => void) | undefined; let releaseSecondTerminalHandler: (() => void) | undefined; let firstTerminalHandlerSawContext: unknown; let secondTerminalHandlerSawContext: unknown; let terminalEventsSeen = 0; const { config, registry } = createPluginRegistryFixture(); registerTestPlugin({ registry, config, record: createPluginRecord({ id: "repeated-terminal-live-wait", name: "Repeated Terminal Live Wait", }), 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; } terminalEventsSeen += 1; if (terminalEventsSeen === 1) { await new Promise((resolve) => { releaseFirstTerminalHandler = resolve; }); firstTerminalHandlerSawContext = ctx.getRunContext("seen"); return; } await new Promise((resolve) => { releaseSecondTerminalHandler = resolve; }); secondTerminalHandlerSawContext = ctx.getRunContext("seen"); }, }); }, }); setActivePluginRegistry(registry.registry); emitAgentEvent({ runId: "run-repeated-terminal-live-wait", stream: "tool", data: { name: "tool" }, }); await waitForPluginEventHandlers(); emitAgentEvent({ runId: "run-repeated-terminal-live-wait", stream: "lifecycle", data: { phase: "end" }, }); await waitForPluginEventHandlers(); emitAgentEvent({ runId: "run-repeated-terminal-live-wait", stream: "lifecycle", data: { phase: "end" }, }); await waitForPluginEventHandlers(); releaseFirstTerminalHandler?.(); await waitForPluginEventHandlers(); expect(firstTerminalHandlerSawContext).toEqual({ runId: "run-repeated-terminal-live-wait" }); expect( getPluginRunContext({ pluginId: "repeated-terminal-live-wait", get: { runId: "run-repeated-terminal-live-wait", namespace: "seen" }, }), ).toEqual({ runId: "run-repeated-terminal-live-wait" }); releaseSecondTerminalHandler?.(); await waitForPluginEventHandlers(); await waitForPluginEventHandlers(); expect(secondTerminalHandlerSawContext).toEqual({ runId: "run-repeated-terminal-live-wait" }); expect( getPluginRunContext({ pluginId: "repeated-terminal-live-wait", get: { runId: "run-repeated-terminal-live-wait", namespace: "seen" }, }), ).toBeUndefined(); }); it("clears run context after the terminal subscription grace period", async () => { vi.useFakeTimers(); let releaseTerminalHandler: (() => void) | undefined; let terminalHandlerSawContext: unknown; let terminalHandlerWroteContext: unknown; const { config, registry } = createPluginRegistryFixture(); registerTestPlugin({ registry, config, record: createPluginRecord({ id: "slow-terminal-subscription", name: "Slow Terminal Subscription", }), register(api) { api.registerAgentEventSubscription({ id: "slow", streams: ["tool", "lifecycle"], async handle(event, ctx) { if (event.stream === "tool") { ctx.setRunContext("seen", { runId: event.runId }); return; } if (event.data?.phase === "end") { await new Promise((resolve) => { releaseTerminalHandler = resolve; }); terminalHandlerSawContext = ctx.getRunContext("seen"); ctx.setRunContext("terminal", { completed: true }); terminalHandlerWroteContext = ctx.getRunContext("terminal"); } }, }); }, }); setActivePluginRegistry(registry.registry); emitAgentEvent({ runId: "run-slow-terminal", stream: "tool", data: { name: "tool" }, }); await Promise.resolve(); emitAgentEvent({ runId: "run-slow-terminal", stream: "lifecycle", data: { phase: "end" }, }); await Promise.resolve(); await vi.advanceTimersByTimeAsync(PLUGIN_TERMINAL_EVENT_CLEANUP_WAIT_MS); expect( getPluginRunContext({ pluginId: "slow-terminal-subscription", get: { runId: "run-slow-terminal", namespace: "seen" }, }), ).toBeUndefined(); releaseTerminalHandler?.(); await vi.advanceTimersByTimeAsync(0); expect(terminalHandlerSawContext).toBeUndefined(); expect(terminalHandlerWroteContext).toBeUndefined(); expect( getPluginRunContext({ pluginId: "slow-terminal-subscription", get: { runId: "run-slow-terminal", namespace: "seen" }, }), ).toBeUndefined(); expect( getPluginRunContext({ pluginId: "slow-terminal-subscription", get: { runId: "run-slow-terminal", namespace: "terminal" }, }), ).toBeUndefined(); }); it("keeps the expired terminal marker across repeated terminal events", async () => { vi.useFakeTimers(); let releaseFirstTerminalHandler: (() => void) | undefined; let firstTerminalHandlerWroteContext: unknown; let secondTerminalHandlerWroteContext: unknown; let terminalEventsSeen = 0; const { config, registry } = createPluginRegistryFixture(); registerTestPlugin({ registry, config, record: createPluginRecord({ id: "repeated-terminal-subscription", name: "Repeated Terminal Subscription", }), register(api) { api.registerAgentEventSubscription({ id: "repeat-terminal", streams: ["lifecycle"], async handle(event, ctx) { if (event.data?.phase !== "end") { return; } terminalEventsSeen += 1; if (terminalEventsSeen === 1) { await new Promise((resolve) => { releaseFirstTerminalHandler = resolve; }); ctx.setRunContext("terminal", { from: "first" }); firstTerminalHandlerWroteContext = ctx.getRunContext("terminal"); return; } ctx.setRunContext("terminal", { from: "second" }); secondTerminalHandlerWroteContext = ctx.getRunContext("terminal"); }, }); }, }); setActivePluginRegistry(registry.registry); emitAgentEvent({ runId: "run-repeat-terminal", stream: "lifecycle", data: { phase: "end" }, }); await Promise.resolve(); await vi.advanceTimersByTimeAsync(PLUGIN_TERMINAL_EVENT_CLEANUP_WAIT_MS); emitAgentEvent({ runId: "run-repeat-terminal", stream: "lifecycle", data: { phase: "end" }, }); await vi.advanceTimersByTimeAsync(0); expect(secondTerminalHandlerWroteContext).toBeUndefined(); expect( getPluginRunContext({ pluginId: "repeated-terminal-subscription", get: { runId: "run-repeat-terminal", namespace: "terminal" }, }), ).toBeUndefined(); releaseFirstTerminalHandler?.(); await vi.advanceTimersByTimeAsync(0); expect(firstTerminalHandlerWroteContext).toBeUndefined(); expect( getPluginRunContext({ pluginId: "repeated-terminal-subscription", get: { runId: "run-repeat-terminal", namespace: "terminal" }, }), ).toBeUndefined(); }); it("preserves scheduler jobs instead of invoking stale cleanup callbacks", async () => { const cleanup = vi.fn(); registerPluginSessionSchedulerJob({ pluginId: "scheduler-plugin", pluginName: "Scheduler Plugin", job: { id: "job-preserved", sessionKey: "agent:main:main", kind: "session-turn", cleanup, }, }); expectNoCleanupFailures( await runPluginHostCleanup({ reason: "disable", pluginId: "scheduler-plugin", preserveSchedulerJobIds: new Set(["job-preserved"]), }), ); expect(cleanup).not.toHaveBeenCalled(); expect(listPluginSessionSchedulerJobs("scheduler-plugin")).toHaveLength(1); }); it("preserves plugin run context during restart cleanup", async () => { const registry = createEmptyPluginRegistry(); expect( setPluginRunContext({ pluginId: "restart-context-plugin", patch: { runId: "run-restart", namespace: "state", value: { keep: true } }, }), ).toBe(true); expectNoCleanupFailures( await runPluginHostCleanup({ registry, pluginId: "restart-context-plugin", reason: "restart", }), ); expect( getPluginRunContext({ pluginId: "restart-context-plugin", get: { runId: "run-restart", namespace: "state" }, }), ).toEqual({ keep: true }); expectNoCleanupFailures( await runPluginHostCleanup({ registry, pluginId: "restart-context-plugin", reason: "disable", }), ); expect( getPluginRunContext({ pluginId: "restart-context-plugin", get: { runId: "run-restart", namespace: "state" }, }), ).toBeUndefined(); }); 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 openClawState = await createOpenClawTestState({ layout: "state-only", prefix: "openclaw-run-context-restart-state-", }); const stateDir = openClawState.stateDir; const storePath = path.join(stateDir, "sessions.json"); const tempConfig = { session: { store: storePath }, }; try { 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; }); expectNoCleanupFailures( await runPluginHostCleanup({ cfg: tempConfig, registry: registry.registry, pluginId: "restart-state-fixture", reason: "restart", }), ); 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 { await openClawState.cleanup(); } }); it("rejects hung cleanup hooks with a bounded timeout", async () => { vi.useFakeTimers(); const cleanup = vi.fn(async () => { await new Promise(() => {}); }); registerPluginSessionSchedulerJob({ pluginId: "hung-cleanup-plugin", pluginName: "Hung Cleanup Plugin", job: { id: "job-hung", sessionKey: "agent:main:main", kind: "session-turn", cleanup, }, }); const resultPromise = runPluginHostCleanup({ reason: "disable", pluginId: "hung-cleanup-plugin", }); await vi.advanceTimersByTimeAsync(0); expect(cleanup).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(PLUGIN_HOST_CLEANUP_TIMEOUT_MS); const result = await resultPromise; expect(result.failures).toHaveLength(1); expect(result.failures[0]?.pluginId).toBe("hung-cleanup-plugin"); expect(result.failures[0]?.hookId).toBe("scheduler:job-hung"); }); it("bounds session, runtime, and scheduler cleanup callbacks so cleanup keeps moving", async () => { vi.useFakeTimers(); const { config, registry } = createPluginRegistryFixture(); const cleanupRelease = createDeferredCore(); registerTestPlugin({ registry, config, record: createPluginRecord({ id: "hanging-cleanup-fixture", name: "Hanging Cleanup Fixture", }), register(api) { api.registerSessionExtension({ namespace: "state", description: "hangs during cleanup", cleanup: () => cleanupRelease.promise, }); api.registerRuntimeLifecycle({ id: "runtime-cleanup", cleanup: () => cleanupRelease.promise, }); api.registerSessionSchedulerJob({ id: "scheduler-cleanup", sessionKey: "agent:main:main", kind: "monitor", cleanup: () => cleanupRelease.promise, }); }, }); setActivePluginRegistry(registry.registry); expect(listPluginSessionSchedulerJobs("hanging-cleanup-fixture")).toHaveLength(1); const cleanupPromise = runPluginHostCleanup({ cfg: config, registry: registry.registry, pluginId: "hanging-cleanup-fixture", reason: "delete", }); try { for (let index = 0; index < 3; index += 1) { await vi.advanceTimersByTimeAsync(PLUGIN_HOST_CLEANUP_TIMEOUT_MS + 1); } const result = await cleanupPromise; expect(result.failures).toHaveLength(3); for (const hookId of [ "session:state", "runtime:runtime-cleanup", "scheduler:scheduler-cleanup", ]) { const failure = requireFailureByHookId(result, hookId); expect(failure?.pluginId).toBe("hanging-cleanup-fixture"); } } finally { cleanupRelease.resolve(); await cleanupPromise; } }); it("blocks setting run context after a run is closed", () => { expect( setPluginRunContext({ pluginId: "closed-run-plugin", patch: { runId: "run-closed", namespace: "state", value: { before: true } }, }), ).toBe(true); dispatchPluginAgentEventSubscriptions({ registry: createEmptyPluginRegistry(), isLive: () => true, event: { runId: "run-closed", seq: 1, stream: "lifecycle", ts: Date.now(), data: { phase: "end" }, }, }); expect( setPluginRunContext({ pluginId: "closed-run-plugin", patch: { runId: "run-closed", namespace: "state", value: { after: true } }, }), ).toBe(false); }); });