openclaw / src /agents /cli-runner /execute-plugin.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
e249c6d verified
Raw
History Blame Contribute Delete
36.8 kB
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<CliBackendLiveSessionHandle>();
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<string, { questions: Array<{ questionId: string }> }>();
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<CliBackendToolPermissionResult> | 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<CliBackendExecuteContext["requestUserInput"]> | 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" }] },
});
});
});