import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../../test/helpers/promise.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { CliBackendExecute, CliBackendExecuteContext, CliBackendLiveSessionHandle, CliBackendToolPermissionResult, } from "../../plugins/cli-backend.types.js"; import { callGatewayTool } from "../tools/gateway.js"; import { closeCliLiveSession, createCliLiveSessionCapability, } from "./cli-live-session-registry.js"; import { closePluginTestAdmissions, createExecution, requestNativeTool, runPlugin, SUCCESS_RESULT, waitUntilAborted, } from "./execute-plugin.test-support.js"; import type { PreparedCliRunContext } from "./types.js"; vi.mock("../tools/gateway.js", () => ({ callGatewayTool: vi.fn(), })); const mockCallGatewayTool = vi.mocked(callGatewayTool); const activeSessions = new Set(); function registerOwnerSession(context: PreparedCliRunContext, generation: string) { const capability = createCliLiveSessionCapability({ context, argv: ["/bin/sh", "-p", "--permission-mode", "bypassPermissions"], env: { PATH: "/bin:/usr/bin", OPENCLAW_TEST_MARKER: "host-owned" }, beginCapture: () => {}, abortSignal: new AbortController().signal, }); const close = vi.fn(() => capability.remove(session)); const session: CliBackendLiveSessionHandle = { generation, fingerprint: capability.fingerprint, isIdle: () => true, close, waitForExit: vi.fn(async () => {}), }; capability.register(session); activeSessions.add(session); return { handle: session, close }; } afterEach(() => { for (const session of activeSessions) { session.close("restart"); } activeSessions.clear(); closePluginTestAdmissions(); mockCallGatewayTool.mockReset(); vi.restoreAllMocks(); vi.useRealTimers(); }); describe("plugin-owned CLI execution host boundary", () => { it("streams plugin events through the canonical host output boundary", async () => { const { context } = await createExecution(); context.systemPrompt = ` Follow host policy.${SYSTEM_PROMPT_CACHE_BOUNDARY}Keep credentials private. `; context.promptContext = { prependContext: "private red prefix", appendContext: "private red suffix", }; const output: string[] = []; let observedExecution: CliBackendExecuteContext | undefined; const execute: CliBackendExecute = async function* (execution) { observedExecution = execution; yield { type: "system", subtype: "init", session_id: "sdk-session" }; yield SUCCESS_RESULT; }; await expect( runPlugin(context, execute, { consumeStdout: output.push.bind(output) }), ).resolves.toMatchObject({ reason: "exit", exitCode: 0, timedOut: false }); expect(output.map((line) => JSON.parse(line))).toEqual([ { type: "system", subtype: "init", session_id: "sdk-session" }, SUCCESS_RESULT, ]); expect(observedExecution).toEqual( expect.objectContaining({ command: "/bin/sh", cwd: "/tmp", prompt: "hello", promptContext: { prependContext: "private red prefix", appendContext: "private red suffix", }, modelId: "claude-sonnet-4-6", systemPrompt: "Follow host policy.\nKeep credentials private.", sessionId: "sdk-session", useResume: false, env: { PATH: "/bin:/usr/bin", OPENCLAW_TEST_MARKER: "host-owned" }, requestToolPermission: expect.any(Function), requestUserInput: expect.any(Function), }), ); }); it.each([false, true])( "runs plugin user questions with current caller authority (revoked=%s)", async (revoked) => { const { context } = await createExecution({ runId: "plugin-user-input", nativeTools: ["AskUserQuestion"], }); context.params.sessionKey = "main"; let callerCurrent = true; context.params.assertCurrent = () => { if (!callerCurrent) { throw new Error("caller revoked"); } }; context.params.runtimePolicySessionKey = "agent:main:telegram:default:direct:canonical-sender"; let promptDelivered = createDeferred(); const onBlockReply = vi.fn(async () => { promptDelivered.resolve(); }); context.params.onBlockReply = onBlockReply; const requests = new Map }>(); mockCallGatewayTool.mockImplementation(async (method, _opts, rawParams) => { const params = rawParams as { id: string; questions?: Array<{ questionId: string }>; sessionKey?: string; }; if (method === "question.request") { expect(params.sessionKey).toBe(context.params.sessionKey); requests.set(params.id, { questions: params.questions ?? [] }); return { id: params.id }; } if (method === "question.waitAnswer") { const request = requests.get(params.id); await promptDelivered.promise; promptDelivered = createDeferred(); callerCurrent = !revoked; return { status: "answered", answers: { answers: Object.fromEntries( (request?.questions ?? []).map((question) => [ question.questionId, [question.questionId], ]), ), }, }; } if (method === "question.resolve") { return { status: "cancelled" }; } throw new Error(`Unexpected Gateway method: ${method}`); }); let result: unknown; await runPlugin(context, async function* (execution) { result = await execution.requestUserInput({ toolName: "AskUserQuestion", toolCallId: "claude-question", questions: [ { id: "one", header: "One", question: "First question?", isOther: true, options: [{ label: "A" }, { label: "B" }], }, ], }); yield SUCCESS_RESULT; }); expect(result).toEqual( revoked ? expect.objectContaining({ status: "cancelled" }) : { status: "answered", answers: { one: ["one"], }, }, ); expect([...requests.keys()]).toEqual(["claude-question:0"]); expect(onBlockReply).toHaveBeenCalledOnce(); }, ); it.each(["caller", "admission"] as const)( "rejects %s revocation before restart or plugin execution", async (authority) => { const { context, admission } = await createExecution(); const session = registerOwnerSession(context, "dispatch-owner"); if (authority === "caller") { context.params.assertCurrent = () => { throw new Error("caller revoked"); }; } else { admission.close(); } const execute = vi.fn(async function* () { yield SUCCESS_RESULT; }); await expect( runPlugin(context, execute, { liveSession: true, forceNewSession: true }), ).rejects.toThrow(); expect(session.close).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); await expect(runPlugin(context, execute)).rejects.toThrow(); expect(execute).not.toHaveBeenCalled(); }, ); it("does not close a successor or execute after caller revocation during restart cleanup", async () => { const { context } = await createExecution(); const successor = await createExecution(); const session = registerOwnerSession(successor.context, "successor-during-restart"); const entered = createDeferred(); const held = createDeferred(); let callerCurrent = true; context.params.assertCurrent = () => { if (!callerCurrent) { throw new Error("caller revoked"); } }; context.preparedBackend.closeLiveSession = async () => { entered.resolve(); await held.promise; }; const execute = vi.fn(async function* () { yield SUCCESS_RESULT; }); const run = runPlugin(context, execute, { liveSession: true, forceNewSession: true }); const observed = run.catch((error: unknown) => error); try { await entered.promise; callerCurrent = false; } finally { held.resolve(); } expect(await observed).toEqual(new Error("caller revoked")); expect(session.close).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); await runPlugin( successor.context, async function* (execution) { expect(execution.liveSession?.current()).toBe(session.handle); yield SUCCESS_RESULT; }, { liveSession: true }, ); }); it("restarts true fresh sessions while preserving legitimate no-resume warm reuse", async () => { const reseed = await createExecution({ runId: "plugin-fresh-reseed" }); reseed.context.openClawHistoryPrompt = "Previously recorded bounded conversation."; const reseededSession = registerOwnerSession(reseed.context, "old-reseed-session"); await runPlugin( reseed.context, async function* (execution) { expect(execution.liveSession?.current()).toBeUndefined(); yield SUCCESS_RESULT; }, { liveSession: true, forceNewSession: true }, ); expect(reseededSession.close).toHaveBeenCalledWith("restart"); const resumeCapable = await createExecution({ runId: "plugin-resume-capable-fresh", resumeArgs: ["--resume", "{sessionId}"], }); const resumeSession = registerOwnerSession(resumeCapable.context, "resume-capable-session"); await runPlugin( resumeCapable.context, async function* () { yield SUCCESS_RESULT; }, { liveSession: true, useResume: false }, ); expect(resumeSession.close).toHaveBeenCalledWith("restart"); const noResume = await createExecution({ runId: "plugin-no-resume-warm", resumeArgs: [] }); const reusableSession = registerOwnerSession(noResume.context, "no-resume-session"); await runPlugin( noResume.context, async function* (execution) { expect(execution.liveSession?.current()).toBe(reusableSession.handle); yield SUCCESS_RESULT; }, { liveSession: true, useResume: false }, ); expect(reusableSession.close).not.toHaveBeenCalled(); }); it("rejects missing or replaced required generations but permits a deliberate cold recovery", async () => { const { context } = await createExecution({ runId: "plugin-required-generation" }); context.requiredClaudeLiveSessionGeneration = "original-generation"; const requireCurrentSession: CliBackendExecute = async function* (execution) { execution.liveSession?.current(); yield SUCCESS_RESULT; }; const resumedOptions = { liveSession: true, useResume: true, requiredGeneration: "original-generation", }; await expect(runPlugin(context, requireCurrentSession, resumedOptions)).rejects.toMatchObject({ reason: "session_expired", code: "cli_live_session_missing", }); const replacement = registerOwnerSession(context, "replacement-generation"); await expect(runPlugin(context, requireCurrentSession, resumedOptions)).rejects.toMatchObject({ reason: "session_expired", code: "cli_live_session_changed", }); expect(replacement.close).not.toHaveBeenCalled(); context.openClawHistoryPrompt = "Recovered conversation history."; await expect( runPlugin(context, requireCurrentSession, { liveSession: true, useResume: false, forceNewSession: true, }), ).resolves.toMatchObject({ reason: "exit" }); expect(replacement.close).toHaveBeenCalledWith("restart"); }); it.each([undefined, new Error("SDK stream closed after init")])( "recovers an invalidated control-only resume %#", async (streamError) => { const { context } = await createExecution(); const session = registerOwnerSession(context, "required-generation"); const run = runPlugin( context, async function* () { yield { type: "system", subtype: "init", session_id: "sdk-session" }; session.handle.close("abort"); if (streamError) { throw streamError; } }, { requiredGeneration: "required-generation", }, ); await expect(run).rejects.toMatchObject({ reason: "session_expired", code: "cli_live_session_missing", cause: streamError ?? expect.any(Error), }); }, ); it("does not replay an invalidated resume while native approval is pending", async () => { const { context } = await createExecution({ config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, nativeTools: ["WebFetch"], }); const session = registerOwnerSession(context, "required-generation"); const approval = createDeferred<{ id: string; decision: "deny" }>(); mockCallGatewayTool.mockReturnValueOnce(approval.promise); const streamError = new Error("SDK stream failed during approval"); let pending: Promise | undefined; const run = runPlugin( context, async function* (execution) { pending = requestNativeTool(execution, "WebFetch", { url: "https://example.com" }); await vi.waitFor(() => expect(mockCallGatewayTool).toHaveBeenCalledOnce()); yield { type: "system", subtype: "init", session_id: "sdk-session" }; session.handle.close("abort"); throw streamError; }, { requiredGeneration: "required-generation", }, ); await expect(run).rejects.toBe(streamError); approval.resolve({ id: "approval-pending", decision: "deny" }); await pending; }); it("does not replay an invalidated resume while operator input is pending", async () => { const { context } = await createExecution({ nativeTools: ["AskUserQuestion"] }); const session = registerOwnerSession(context, "required-generation"); const answer = createDeferred<{ status: "cancelled" }>(); mockCallGatewayTool.mockImplementation(async (method, _opts, rawParams) => { const params = rawParams as { id: string }; if (method === "question.request") { return { id: params.id }; } if (method === "question.waitAnswer") { return await answer.promise; } return { status: "cancelled" }; }); const streamError = new Error("SDK stream failed during operator input"); let pending: ReturnType | undefined; const run = runPlugin( context, async function* (execution) { pending = execution.requestUserInput({ toolName: "AskUserQuestion", questions: [{ id: "choice", header: "Continue", question: "Continue?" }], }); await vi.waitFor(() => expect(mockCallGatewayTool).toHaveBeenCalledTimes(2)); yield { type: "system", subtype: "init", session_id: "sdk-session" }; session.handle.close("abort"); throw streamError; }, { requiredGeneration: "required-generation", }, ); await expect(run).rejects.toBe(streamError); answer.resolve({ status: "cancelled" }); await pending; }); it("claims prepared resources only for the original process and cleans after its exit", async () => { const first = await createExecution({ runId: "plugin-prepared-resource-owner" }); const cleanup = vi.fn(async () => {}); first.context.preparedBackend.claimLiveSessionResources = vi.fn(() => cleanup); const exited = createDeferred(); let handle: CliBackendLiveSessionHandle | undefined; await runPlugin( first.context, async function* (execution) { const capability = execution.liveSession; if (!capability) { throw new Error("Expected a reusable plugin execution capability."); } const session: CliBackendLiveSessionHandle = { generation: "prepared-resource-process", fingerprint: capability.fingerprint, isIdle: () => true, close: vi.fn(() => capability.remove(session)), waitForExit: () => exited.promise, }; handle = session; capability.register(session); activeSessions.add(session); yield SUCCESS_RESULT; }, { liveSession: true }, ); expect(first.context.preparedBackend.claimLiveSessionResources).toHaveBeenCalledOnce(); expect(cleanup).not.toHaveBeenCalled(); const resumed = await createExecution({ runId: "plugin-prepared-resource-reuse" }); const unusedResourceClaim = vi.fn(() => vi.fn(async () => {})); resumed.context.preparedBackend.claimLiveSessionResources = unusedResourceClaim; await runPlugin( resumed.context, async function* (execution) { expect(execution.liveSession?.current()).toBe(handle); yield SUCCESS_RESULT; }, { liveSession: true }, ); expect(unusedResourceClaim).not.toHaveBeenCalled(); const closing = closeCliLiveSession(first.context, "restart"); await Promise.resolve(); expect(cleanup).not.toHaveBeenCalled(); exited.resolve(); await closing; expect(cleanup).toHaveBeenCalledOnce(); }); it.each([ { name: "full policy releases the exact original input", security: "full" as const, ask: "off" as const, behavior: "allow" as const, }, { name: "allowlist policy never silently prompts or grants", security: "allowlist" as const, ask: "off" as const, behavior: "deny" as const, }, ])("$name", async ({ security, ask, behavior }) => { const { context } = await createExecution({ config: { tools: { exec: { security, ask } } }, nativeTools: ["Read"], }); const input = { file_path: "/tmp/example.png", nested: { source: "exact" } }; let decision: CliBackendToolPermissionResult | undefined; await runPlugin(context, async function* (execution) { decision = await requestNativeTool(execution, "Read", input); yield SUCCESS_RESULT; }); expect(decision?.behavior).toBe(behavior); if (decision?.behavior === "allow") { expect(decision.updatedInput).toBe(input); } expect(mockCallGatewayTool).not.toHaveBeenCalled(); }); it("fails closed for unnamed and unavailable native tools before requesting approval", async () => { const { context } = await createExecution({ nativeTools: ["Read"] }); const decisions: CliBackendToolPermissionResult[] = []; await runPlugin(context, async function* (execution) { decisions.push(await requestNativeTool(execution, " ")); decisions.push(await requestNativeTool(execution, "Bash")); yield SUCCESS_RESULT; }); expect(decisions).toEqual([ expect.objectContaining({ behavior: "deny", message: expect.stringContaining("unnamed") }), expect.objectContaining({ behavior: "deny", message: expect.stringContaining("unavailable"), }), ]); expect(mockCallGatewayTool).not.toHaveBeenCalled(); }); it("retains safe standing approvals only for the exact live process and current turn policy", async () => { const config: OpenClawConfig = { tools: { exec: { security: "allowlist", ask: "on-miss" } } }; mockCallGatewayTool .mockResolvedValueOnce({ id: "approval-first", decision: "allow-always" }) .mockResolvedValueOnce({ id: "approval-second", decision: "allow-always" }); const first = await createExecution({ config, nativeTools: ["WebFetch"], runId: "plugin-approval-first", }); registerOwnerSession(first.context, "original-live-process"); const runApprovedTurn = async (context: PreparedCliRunContext, repeat: boolean) => { await runPlugin(context, async function* (execution) { await expect( requestNativeTool(execution, "WebFetch", { url: "https://example.com" }), ).resolves.toMatchObject({ behavior: "allow" }); if (repeat) { await expect( requestNativeTool(execution, "WebFetch", { url: "https://example.com/next" }), ).resolves.toMatchObject({ behavior: "allow" }); } yield SUCCESS_RESULT; }); }; await runApprovedTurn(first.context, true); const sameProcess = await createExecution({ config, nativeTools: ["WebFetch"], runId: "plugin-approval-second", }); await runApprovedTurn(sameProcess.context, false); expect(mockCallGatewayTool).toHaveBeenCalledOnce(); const restricted = await createExecution({ config, nativeTools: ["WebFetch"], runId: "plugin-approval-restricted", sessionEntry: { sessionId: "sdk-session", updatedAt: 1, permissionMode: "read-only" }, }); await runPlugin(restricted.context, async function* (execution) { await expect( requestNativeTool(execution, "WebFetch", { url: "https://example.com/restricted" }), ).resolves.toMatchObject({ behavior: "deny" }); yield SUCCESS_RESULT; }); expect(mockCallGatewayTool).toHaveBeenCalledOnce(); await closeCliLiveSession(first.context, "restart"); registerOwnerSession(first.context, "replacement-live-process"); const replacement = await createExecution({ config, nativeTools: ["WebFetch"], runId: "plugin-approval-replacement", }); await runApprovedTurn(replacement.context, false); expect(mockCallGatewayTool).toHaveBeenCalledTimes(2); }); it.each(["admission", "caller"] as const)( "denies approval when %s authority closes during the awaited decision", async (authority) => { const { admission, context } = await createExecution({ config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, nativeTools: ["WebFetch"], }); let callerCurrent = true; context.params.assertCurrent = () => { if (!callerCurrent) { throw new Error("caller revoked"); } }; mockCallGatewayTool.mockImplementationOnce(async () => { if (authority === "caller") { callerCurrent = false; } else { admission.close(); } return { id: "approval-closed", decision: "allow-once" }; }); let decision: CliBackendToolPermissionResult | undefined; await runPlugin(context, async function* (execution) { decision = await requestNativeTool(execution, "WebFetch", { url: "https://example.com" }); yield SUCCESS_RESULT; }); expect(decision).toEqual( expect.objectContaining({ behavior: "deny", message: expect.stringContaining("closed") }), ); }, ); it("cancels an in-flight native approval and never releases its late decision", async () => { const controller = new AbortController(); const { context } = await createExecution({ abortSignal: controller.signal, config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, nativeTools: ["WebFetch"], }); const approval = createDeferred<{ id: string; decision: "allow-always" }>(); mockCallGatewayTool.mockReturnValueOnce(approval.promise); const granted = vi.fn(); const closed = vi.fn(); const run = runPlugin(context, async function* (execution) { try { const decision = await requestNativeTool(execution, "WebFetch", { url: "https://example.com/canceled-approval", }); if (decision.behavior === "allow") { granted(); } yield SUCCESS_RESULT; } finally { closed(); } }); await vi.waitFor(() => expect(mockCallGatewayTool).toHaveBeenCalledOnce()); const approvalSignal = mockCallGatewayTool.mock.calls[0]?.[3]?.signal; controller.abort(); await expect(run).rejects.toMatchObject({ name: "AbortError" }); expect(approvalSignal?.aborted).toBe(true); expect(closed).toHaveBeenCalledOnce(); expect(granted).not.toHaveBeenCalled(); approval.resolve({ id: "canceled-approval", decision: "allow-always" }); await Promise.resolve(); expect(granted).not.toHaveBeenCalled(); }); it("fences a retained permission callback as soon as its turn finishes", async () => { const { context } = await createExecution(); let requestToolPermission: CliBackendExecuteContext["requestToolPermission"] | undefined; await runPlugin(context, async function* (execution) { requestToolPermission = execution.requestToolPermission; yield SUCCESS_RESULT; }); await expect( requestToolPermission?.({ toolName: "Bash", toolInput: { command: "echo stale" } }), ).resolves.toEqual( expect.objectContaining({ behavior: "deny", message: expect.stringContaining("no longer active"), }), ); expect(mockCallGatewayTool).not.toHaveBeenCalled(); }); it.each([ { name: "a 429 error-marked success", terminal: { type: "result", subtype: "success", is_error: true, api_error_status: 429, result: "Claude subscription rate limit reached.", }, }, { name: "a 529 provider-error subtype despite an unset error flag", terminal: { type: "result", subtype: "error_during_execution", is_error: false, api_error_status: 529, errors: ["Anthropic API overloaded (529)."], }, }, ])("preserves $name if the plugin throws while draining", async ({ terminal }) => { const { context } = await createExecution(); const output: string[] = []; await expect( runPlugin( context, async function* () { yield terminal; yield SUCCESS_RESULT; throw new Error("SDK stream closed after the provider error"); }, { consumeStdout: output.push.bind(output) }, ), ).resolves.toMatchObject({ reason: "exit", exitCode: 0 }); expect(output.map((line) => JSON.parse(line))).toEqual([terminal, SUCCESS_RESULT]); }); it.each([ { name: "a stream without a terminal result", async *execute() { yield { type: "system", subtype: "init" }; }, error: "without a terminal result", }, { name: "a plugin failure after an otherwise successful result", async *execute() { yield SUCCESS_RESULT; throw new Error("SDK stream failed after the result"); }, error: "SDK stream failed after the result", }, ])("rejects $name", async (testCase) => { const { context } = await createExecution(); await expect(runPlugin(context, () => testCase.execute())).rejects.toThrow(testCase.error); }); it("aborts a silent plugin stream through the host no-output watchdog", async () => { vi.useFakeTimers(); const { context } = await createExecution({ timeoutMs: 5_000 }); const streamStarted = createDeferred(); const run = runPlugin( context, async function* (execution) { streamStarted.resolve(); await waitUntilAborted(execution); yield SUCCESS_RESULT; }, { noOutputTimeoutMs: 100 }, ); await streamStarted.promise; await vi.advanceTimersByTimeAsync(100); await expect(run).resolves.toMatchObject({ reason: "no-output-timeout", exitCode: null, timedOut: true, noOutputTimedOut: true, }); }); it.each([ { name: "init-only resumed traffic remains safely retryable", event: { type: "system", subtype: "init", session_id: "sdk-session" }, code: "cli_no_output_timeout", }, { name: "actual SDK command lifecycle traffic remains safely retryable", event: { type: "command_lifecycle", subtype: "started", command: "resume", session_id: "sdk-session", }, code: "cli_no_output_timeout", }, { name: "substantive assistant output never becomes replay-safe", event: { type: "assistant", message: { content: [{ type: "text", text: "started" }] } }, code: undefined, }, ])("$name", async ({ event, code }) => { vi.useFakeTimers(); const { context } = await createExecution({ timeoutMs: 5_000 }); const output: string[] = []; const timeout = vi.fn(); const run = runPlugin( context, async function* (execution) { yield event; await waitUntilAborted(execution); yield SUCCESS_RESULT; }, { useResume: true, noOutputTimeoutMs: 100, consumeStdout: output.push.bind(output), onNoOutputTimeout: timeout, }, ); await vi.waitFor(() => expect(output).toHaveLength(1)); await vi.advanceTimersByTimeAsync(100); await expect(run).resolves.toMatchObject({ reason: "no-output-timeout" }); expect(timeout).toHaveBeenCalledOnce(); expect(timeout.mock.calls[0]?.[0]).toMatchObject({ reason: "timeout" }); expect(timeout.mock.calls[0]?.[0]?.code).toBe(code); }); it("keeps an active native approval alive beyond the ordinary no-output watchdog", async () => { vi.useFakeTimers(); const { context } = await createExecution({ config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, nativeTools: ["WebFetch"], }); const approval = createDeferred<{ id: string; decision: "allow-once" }>(); mockCallGatewayTool.mockReturnValueOnce(approval.promise); const outstandingWork = vi.fn(); let completed = false; const run = runPlugin( context, async function* (execution) { const decision = await requestNativeTool(execution, "WebFetch", { url: "https://example.com/approval", }); expect(decision.behavior).toBe("allow"); yield SUCCESS_RESULT; }, { noOutputTimeoutMs: 100, onOutstandingWorkChange: outstandingWork }, ).then((result) => { completed = true; return result; }); await vi.waitFor(() => expect(mockCallGatewayTool).toHaveBeenCalledOnce()); await vi.advanceTimersByTimeAsync(150); expect(completed).toBe(false); expect(outstandingWork).toHaveBeenLastCalledWith(true); approval.resolve({ id: "approval-pending", decision: "allow-once" }); await expect(run).resolves.toMatchObject({ reason: "exit", timedOut: false }); expect(outstandingWork).toHaveBeenLastCalledWith(false); }); it("keeps the overall deadline authoritative while a native approval is outstanding", async () => { vi.useFakeTimers(); const { context } = await createExecution({ config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, nativeTools: ["WebFetch"], timeoutMs: 150, }); const approval = createDeferred<{ id: string; decision: "allow-once" }>(); mockCallGatewayTool.mockReturnValueOnce(approval.promise); const run = runPlugin( context, async function* (execution) { await requestNativeTool(execution, "WebFetch", { url: "https://example.com/slow" }); yield SUCCESS_RESULT; }, { noOutputTimeoutMs: 100 }, ); await vi.waitFor(() => expect(mockCallGatewayTool).toHaveBeenCalledOnce()); const approvalSignal = mockCallGatewayTool.mock.calls[0]?.[3]?.signal; await vi.advanceTimersByTimeAsync(150); await expect(run).resolves.toMatchObject({ reason: "overall-timeout", timedOut: true, noOutputTimedOut: false, }); expect(approvalSignal?.aborted).toBe(true); approval.resolve({ id: "late-approval", decision: "allow-once" }); }); it("keeps tracked background work alive beyond the ordinary no-output watchdog", async () => { vi.useFakeTimers(); const { context } = await createExecution(); const backgroundFinished = createDeferred(); const received: string[] = []; let completed = false; const run = runPlugin( context, async function* () { yield { type: "system", subtype: "background_tasks_changed", tasks: [{ task_id: "background-agent", task_type: "local_agent" }], }; await backgroundFinished.promise; yield { type: "system", subtype: "background_tasks_changed", tasks: [] }; yield SUCCESS_RESULT; }, { noOutputTimeoutMs: 100, consumeStdout: received.push.bind(received) }, ).then((result) => { completed = true; return result; }); await vi.waitFor(() => expect(received).toHaveLength(1)); await vi.advanceTimersByTimeAsync(150); expect(completed).toBe(false); backgroundFinished.resolve(); await expect(run).resolves.toMatchObject({ reason: "exit", timedOut: false }); expect(received.map((event) => JSON.parse(event))).toHaveLength(3); }); it("keeps the overall deadline authoritative while background work remains active", async () => { vi.useFakeTimers(); const { context } = await createExecution({ timeoutMs: 150 }); const received: string[] = []; const run = runPlugin( context, async function* (execution) { yield { type: "system", subtype: "background_tasks_changed", tasks: [{ task_id: "background-agent", task_type: "local_agent" }], }; await waitUntilAborted(execution); yield SUCCESS_RESULT; }, { noOutputTimeoutMs: 100, consumeStdout: received.push.bind(received) }, ); await vi.waitFor(() => expect(received).toHaveLength(1)); await vi.advanceTimersByTimeAsync(150); await expect(run).resolves.toMatchObject({ reason: "overall-timeout", timedOut: true, noOutputTimedOut: false, }); }); it("propagates caller cancellation and closes the active plugin iterator", async () => { const controller = new AbortController(); const { context } = await createExecution({ abortSignal: controller.signal }); const streamStarted = createDeferred(); const streamClosed = vi.fn(); const run = runPlugin(context, async function* (execution) { try { streamStarted.resolve(); await waitUntilAborted(execution); yield SUCCESS_RESULT; } finally { streamClosed(); } }); await streamStarted.promise; controller.abort(); await expect(run).rejects.toMatchObject({ name: "AbortError" }); expect(streamClosed).toHaveBeenCalledOnce(); }); it.each([ { name: "AbortError", reason: "aborted" as const, abort: (controller: AbortController) => controller.abort(), }, { name: "the caller's TimeoutError", reason: "timeout" as const, abort: (controller: AbortController) => { const timeout = new Error("caller deadline exceeded"); timeout.name = "TimeoutError"; controller.abort(timeout); }, }, { name: "AbortError wrapping a TimeoutError", reason: "aborted" as const, abort: (controller: AbortController) => { const timeout = new Error("caller deadline exceeded"); timeout.name = "TimeoutError"; const cancellation = new Error("caller cancelled", { cause: timeout }); cancellation.name = "AbortError"; controller.abort(cancellation); }, }, ])("preserves streamed assistant output after $name", async ({ abort, reason }) => { const controller = new AbortController(); const { context } = await createExecution({ abortSignal: controller.signal }); const output: string[] = []; const preserveOutput = vi.fn(() => output.length > 0); const run = runPlugin( context, async function* (execution) { yield { type: "assistant", message: { content: [{ type: "text", text: "Here is the answer so far" }] }, }; await waitUntilAborted(execution); yield SUCCESS_RESULT; }, { consumeStdout: output.push.bind(output), onInterrupted: preserveOutput, }, ); await vi.waitFor(() => expect(output).toHaveLength(1)); abort(controller); await expect(run).resolves.toMatchObject({ reason: "manual-cancel", exitCode: null }); expect(preserveOutput).toHaveBeenCalledExactlyOnceWith(reason); expect(JSON.parse(output[0] ?? "{}")).toMatchObject({ message: { content: [{ text: "Here is the answer so far" }] }, }); }); });