refactor(cron): centralize automation mutation options (#128373)

* refactor(cron): centralize automation mutation options

* chore(cron): keep release notes out of root changelog

* test(node-host): synchronize MCP refresh notifications explicitly

* test(node-host): preserve refresh synchronization tuple types
This commit is contained in:
Peter Steinberger
2026-08-23 16:22:32 -07:00
committed by GitHub
parent 0f57d3f797
commit 102f18446f
7 changed files with 193 additions and 179 deletions
+2
View File
@@ -75,6 +75,8 @@ Manage automations with the `openclaw automations` CLI; `openclaw cron` remains
| `on-exit` | `--on-exit` | Fire once when a watched command exits (event trigger; survives turn teardown; optional `--on-exit-cwd`) |
| `stream` | `--stream-command` | Fire from batched lines produced by a supervised long-lived command |
These schedule flags work with both `openclaw automations add` and `openclaw automations edit <job-id>`. For example, `openclaw automations edit <job-id> --on-exit "./watch.sh" --on-exit-cwd /srv/app` converts an existing job to an exit-triggered schedule.
Timestamps without a timezone are treated as UTC. Add `--tz America/New_York` to interpret an offset-less `--at` datetime, or to evaluate a cron expression, in that IANA timezone. Cron expressions without `--tz` use the Gateway host timezone. `--tz` is not valid with `--every` or `--on-exit`.
Recurring top-of-hour expressions (minute `0` with a wildcard hour field) are automatically staggered by up to 5 minutes to reduce load spikes. Use `--exact` to force precise timing, or `--stagger 30s` for an explicit window (cron schedules only).
+9 -94
View File
@@ -6,7 +6,6 @@ import {
} from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js";
import type { CronJob } from "../../cron/types.js";
import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js";
import { sanitizeAgentId } from "../../routing/session-key.js";
@@ -15,9 +14,9 @@ import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { listCronJobsFromGateway } from "./list-jobs.js";
import { createCronOutputCommand } from "./output-mode.js";
import { registerCronMutationOptions } from "./register.cron-options.js";
import { resolveCronCreateScheduleFromArgs } from "./schedule-options.js";
import {
getCronChannelOptions,
coerceCronDeliveryPreviews,
enrichCronJsonWithStatus,
handleCronCliError,
@@ -84,83 +83,15 @@ export function registerCronListCommand(cron: Command) {
export function registerCronAddCommand(cron: Command) {
addGatewayClientOptions(
createCronOutputCommand(cron, "add")
.description("Add an automation")
.argument("[scheduleOrName]", "Schedule string, or job name when using --at/--every/--cron")
.argument("[message]", "Agent message when using a positional schedule")
.option("--name <name>", "Job name")
registerCronMutationOptions(
createCronOutputCommand(cron, "add")
.description("Add an automation")
.argument("[scheduleOrName]", "Schedule string, or job name when using --at/--every/--cron")
.argument("[message]", "Agent message when using a positional schedule"),
"add",
)
.option("--declaration-key <key>", "Idempotent declaration identity key")
.option("--display-name <name>", "Human-readable declarative job label")
.option("--description <text>", "Optional description")
.option("--disabled", "Create job disabled", false)
.option("--delete-after-run", "Delete one-shot job after it succeeds", false)
.option("--keep-after-run", "Keep one-shot job after it succeeds", false)
.option("--agent <id>", "Agent id for this job")
.option("--session <target>", "Session target (main|isolated|current|session:<id>)")
.option("--session-key <key>", "Session key for job routing (e.g. agent:my-agent:my-session)")
.option("--wake <mode>", "Wake mode (now|next-heartbeat)", "now")
.option(
"--at <when>",
"Run once at time (ISO with offset, or +duration). Use --tz for offset-less datetimes",
)
.option("--every <duration>", "Run every duration (e.g. 10m, 1h)")
.option("--pacing-min <duration>", "Minimum delay accepted from a dynamic next check")
.option("--pacing-max <duration>", "Maximum delay accepted from a dynamic next check")
.option("--cron <expr>", "Cron expression (5-field or 6-field with seconds)")
.option(
"--on-exit <shell>",
"Fire once when this watched command exits (event trigger; survives turn teardown)",
)
.option("--on-exit-cwd <path>", "Working directory for the --on-exit watched command")
.option("--stream-command <json>", "Stream source argv as a JSON array of strings")
.option("--stream-cwd <path>", "Working directory for the stream source")
.option("--stream-mode <mode>", "Stream line selection mode (line|match)")
.option("--stream-match <regex>", "Regex source required for stream match mode")
.option("--stream-batch-ms <n>", "Quiet-window batch delay in milliseconds")
.option("--stream-max-batch-bytes <n>", "Maximum UTF-8 bytes per stream batch")
.option(
"--tz <iana>",
"Timezone for cron expressions (IANA; cron default: Gateway host local timezone)",
"",
)
.option("--stagger <duration>", "Cron stagger window (e.g. 30s, 5m)")
.option("--exact", "Disable cron staggering (set stagger to 0)", false)
.option("--trigger-script <path|->", "Condition script file, or - for stdin")
.option("--trigger-once", "Disable after the first successful triggered run", false)
.option("--system-event <text>", "System event payload (main session)")
.option("--message <text>", "Agent message payload")
.option("--script <file|->", "Headless script payload file, or - for stdin")
.option("--script-timeout-seconds <n>", "Script wall-clock timeout seconds")
.option("--script-tool-budget <n>", "Maximum script tool calls")
.option("--command <shell>", "Command payload run as sh -lc <shell> on the Gateway")
.option("--command-argv <json>", "Command payload argv as JSON array of strings")
.option("--command-cwd <path>", "Working directory for command payloads")
.option(
"--command-env <KEY=VALUE>",
"Environment override for command payloads (repeatable)",
(value: string, previous: string[] | undefined) => [...(previous ?? []), value],
)
.option("--command-input <text>", "stdin for command payloads")
.option("--thinking <level>", `Thinking level for agent jobs (${THINKING_LEVELS_HELP})`)
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
.option("--fallbacks <list>", "Fallback model list for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent or command jobs")
.option("--no-output-timeout-seconds <n>", "No-output timeout seconds for command jobs")
.option("--output-max-bytes <n>", "Maximum captured stdout/stderr bytes for command jobs")
.option("--light-context", "Use lightweight bootstrap context for agent jobs", false)
.option("--tools <list>", "Tool allow-list (e.g. exec,read,write or exec read write)")
.option("--announce", "Fallback-deliver final text to a chat", false)
.option("--deliver", "Deprecated (use --announce). Fallback-delivers final text to a chat.")
.option("--no-deliver", "Disable runner fallback delivery")
.option("--webhook <url>", "POST the finished payload to a webhook URL")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`, "last")
.option(
"--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
)
.option("--thread-id <id>", "Telegram forum topic thread id")
.option("--account <id>", "Channel account id for delivery (multi-account setups)")
.option("--best-effort-deliver", "Do not fail the job if delivery fails", false)
.action(
async (
nameArg: string | undefined,
@@ -176,23 +107,7 @@ export function registerCronAddCommand(cron: Command) {
typeof opts.onExit === "string" ||
typeof opts.streamCommand === "string";
const positionalSchedule = hasScheduleFlag ? undefined : nameArg;
const schedule = resolveCronCreateScheduleFromArgs({
at: opts.at,
cron: opts.cron,
every: opts.every,
onExit: opts.onExit,
onExitCwd: opts.onExitCwd,
streamCommand: opts.streamCommand,
streamCwd: opts.streamCwd,
streamMode: opts.streamMode,
streamMatch: opts.streamMatch,
streamBatchMs: opts.streamBatchMs,
streamMaxBatchBytes: opts.streamMaxBatchBytes,
exact: opts.exact,
positionalSchedule,
stagger: opts.stagger,
tz: opts.tz,
});
const schedule = resolveCronCreateScheduleFromArgs({ ...opts, positionalSchedule });
const wakeMode = normalizeOptionalString(opts.wake) ?? "now";
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
@@ -55,6 +55,9 @@ describe("cron edit command", () => {
expect(help).toContain("--best-effort-deliver");
expect(help).toContain("--display-name <name>");
expect(help).toContain("--clear-display-name");
expect(help).toContain("--on-exit <shell>");
expect(help).toContain("--on-exit-cwd <path>");
expect(help).toContain("main|isolated|current|session:<id>");
expect(help).toMatch(/also\s+implies --announce when used alone/);
});
+8 -83
View File
@@ -5,7 +5,6 @@ import {
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander";
import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js";
import type { CronJob } from "../../cron/types.js";
import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js";
import { danger } from "../../globals.js";
@@ -21,6 +20,7 @@ import { parseDurationMs } from "../parse-duration.js";
import { isUnknownCronGetMethodError, listCronJobsFromGateway } from "./list-jobs.js";
import { createCronOutputCommand } from "./output-mode.js";
import { resolveCronEditPayloadDeliveryPatch } from "./register.cron-edit-options.js";
import { registerCronMutationOptions } from "./register.cron-options.js";
import {
applyExistingCronSchedulePatch,
applyExistingStreamSchedulePatch,
@@ -57,98 +57,36 @@ async function readCronJobForEdit(opts: GatewayRpcOpts, id: string): Promise<Cro
export function registerCronEditCommand(cron: Command) {
addGatewayClientOptions(
createCronOutputCommand(cron, "edit")
.description("Edit an automation (patch fields)")
.argument("<id>", "Job id")
.option("--name <name>", "Set name")
.option("--display-name <name>", "Set human-readable display name")
registerCronMutationOptions(
createCronOutputCommand(cron, "edit")
.description("Edit an automation (patch fields)")
.argument("<id>", "Job id"),
"edit",
)
.option("--clear-display-name", "Restore the stable name in list and detail views", false)
.option("--description <text>", "Set description")
.option("--enable", "Enable job", false)
.option("--disable", "Disable job", false)
.option("--delete-after-run", "Delete one-shot job after it succeeds", false)
.option("--keep-after-run", "Keep one-shot job after it succeeds", false)
.option("--session <target>", "Session target (main|isolated)")
.option("--agent <id>", "Set agent id")
.option("--clear-agent", "Unset agent and use default", false)
.option("--session-key <key>", "Set session key for job routing")
.option("--clear-session-key", "Unset session key", false)
.option("--wake <mode>", "Wake mode (now|next-heartbeat)")
.option("--at <when>", "Set one-shot time (ISO, offset-less uses --tz) or duration like 20m")
.option("--every <duration>", "Set interval duration like 10m")
.option("--pacing-min <duration>", "Set minimum delay for a dynamic next check")
.option("--pacing-max <duration>", "Set maximum delay for a dynamic next check")
.option("--clear-pacing", "Remove dynamic-cadence bounds", false)
.option("--cron <expr>", "Set cron expression")
.option("--stream-command <json>", "Set stream source argv as a JSON array of strings")
.option("--stream-cwd <path>", "Set stream source working directory")
.option("--stream-mode <mode>", "Set stream selection mode (line|match)")
.option("--stream-match <regex>", "Set stream match regex source")
.option("--stream-batch-ms <n>", "Set stream quiet-window delay in milliseconds")
.option("--stream-max-batch-bytes <n>", "Set maximum UTF-8 bytes per stream batch")
.option(
"--tz <iana>",
"Timezone for cron expressions (IANA; cron default: Gateway host local timezone)",
)
.option("--stagger <duration>", "Cron stagger window (e.g. 30s, 5m)")
.option("--exact", "Disable cron staggering (set stagger to 0)")
.option("--trigger-script <path|->", "Set condition script from file, or - for stdin")
.option("--trigger-once", "Disable after the first successful triggered run", false)
.option("--clear-trigger", "Remove the condition trigger", false)
.option("--system-event <text>", "Set systemEvent payload")
.option("--message <text>", "Set agentTurn payload message")
.option("--script <file|->", "Set headless script payload from file, or - for stdin")
.option("--script-timeout-seconds <n>", "Set script wall-clock timeout seconds")
.option("--script-tool-budget <n>", "Set maximum script tool calls")
.option("--command <shell>", "Set command payload run as sh -lc <shell> on the Gateway")
.option("--command-argv <json>", "Set command payload argv as JSON array of strings")
.option("--command-cwd <path>", "Set command payload working directory")
.option(
"--command-env <KEY=VALUE>",
"Set command payload environment overrides (repeatable)",
(value: string, previous: string[] | undefined) => [...(previous ?? []), value],
)
.option("--command-input <text>", "Set command payload stdin")
.option("--thinking <level>", `Thinking level for agent jobs (${THINKING_LEVELS_HELP})`)
.option(
"--clear-thinking",
"Remove the per-job thinking override (restore normal cron thinking precedence)",
false,
)
.option("--model <model>", "Model override for agent jobs")
.option("--fallbacks <list>", "Fallback model list for agent jobs")
.option("--clear-fallbacks", "Remove per-job fallback override", false)
.option(
"--clear-model",
"Remove the per-job model override (restore normal cron model precedence)",
false,
)
.option("--timeout-seconds <n>", "Timeout seconds for agent or command jobs")
.option("--no-output-timeout-seconds <n>", "No-output timeout seconds for command jobs")
.option("--output-max-bytes <n>", "Maximum captured stdout/stderr bytes for command jobs")
.option("--light-context", "Enable lightweight bootstrap context for agent jobs")
.option("--no-light-context", "Disable lightweight bootstrap context for agent jobs")
.option("--tools <list>", "Tool allow-list (e.g. exec,read,write or exec read write)")
.option("--clear-tools", "Remove tool allow-list (use all tools)", false)
.option("--announce", "Fallback-deliver final text to a chat")
.option("--deliver", "Deprecated (use --announce). Fallback-delivers final text to a chat.")
.option("--no-deliver", "Disable runner fallback delivery")
.option("--webhook <url>", "POST the finished payload to a webhook URL")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
.option(
"--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
)
.option("--thread-id <id>", "Telegram forum topic thread id")
.option("--account <id>", "Channel account id for delivery (multi-account setups)")
.option("--clear-channel", "Unset the delivery channel", false)
.option("--clear-to", "Unset the delivery destination", false)
.option("--clear-thread-id", "Unset the Telegram forum topic thread id", false)
.option("--clear-account", "Unset the per-job delivery account override", false)
.option(
"--best-effort-deliver",
"Do not fail job if delivery fails (also implies --announce when used alone)",
)
.option("--no-best-effort-deliver", "Fail job when delivery fails")
.option("--failure-alert", "Enable failure alerts for this job")
.option("--no-failure-alert", "Disable failure alerts for this job")
@@ -369,20 +307,7 @@ export function registerCronEditCommand(cron: Command) {
patch.trigger = { ...existing.trigger, once: true };
}
const scheduleRequest = resolveCronEditScheduleRequest({
at: opts.at,
cron: opts.cron,
every: opts.every,
streamCommand: opts.streamCommand,
streamCwd: opts.streamCwd,
streamMode: opts.streamMode,
streamMatch: opts.streamMatch,
streamBatchMs: opts.streamBatchMs,
streamMaxBatchBytes: opts.streamMaxBatchBytes,
exact: opts.exact,
stagger: opts.stagger,
tz: opts.tz,
});
const scheduleRequest = resolveCronEditScheduleRequest(opts);
if (scheduleRequest.kind === "direct") {
if (scheduleRequest.schedule.kind === "stream") {
const existing = await readExistingCronJob();
@@ -0,0 +1,81 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../../runtime.js";
const callGatewayFromCli = vi.fn();
vi.mock("../gateway-rpc.js", async () => {
const actual = await vi.importActual<typeof import("../gateway-rpc.js")>("../gateway-rpc.js");
return {
...actual,
callGatewayFromCli: (...args: Parameters<typeof actual.callGatewayFromCli>) =>
callGatewayFromCli(...args),
};
});
const { registerCronAddCommand } = await import("./register.cron-add.js");
const { registerCronEditCommand } = await import("./register.cron-edit.js");
function createMutationProgram(): Command {
const program = new Command();
program.exitOverride();
registerCronAddCommand(program);
registerCronEditCommand(program);
return program;
}
describe("shared automation mutation options", () => {
beforeEach(() => {
callGatewayFromCli.mockReset();
callGatewayFromCli.mockResolvedValue({ ok: true });
});
it("updates an existing automation to an exit-triggered schedule", async () => {
await createMutationProgram().parseAsync(
["edit", "job-1", "--on-exit", "./watch.sh", "--on-exit-cwd", "/repo"],
{ from: "user" },
);
expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), {
id: "job-1",
patch: { schedule: { kind: "on-exit", command: "./watch.sh", cwd: "/repo" } },
});
});
it.each([
[["--on-exit-cwd", "/repo"], "--on-exit-cwd requires --on-exit"],
[["--on-exit", "./watch.sh", "--every", "5m"], "Choose at most one schedule change"],
])("rejects invalid exit-triggered schedule options", async (args, message) => {
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined);
try {
await createMutationProgram().parseAsync(["edit", "job-1", ...args], { from: "user" });
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(message));
expect(exitSpy).toHaveBeenCalledWith(1);
expect(callGatewayFromCli).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
exitSpy.mockRestore();
}
});
it("keeps creation defaults out of automation edit patches", () => {
const program = createMutationProgram();
const add = program.commands.find((command) => command.name() === "add")!;
const edit = program.commands.find((command) => command.name() === "edit")!;
const creationDefaults: Array<[string, string | boolean]> = [
["wake", "now"],
["tz", ""],
["exact", false],
["lightContext", false],
["announce", false],
["channel", "last"],
["bestEffortDeliver", false],
];
for (const [name, value] of creationDefaults) {
expect(add.getOptionValue(name)).toBe(value);
expect(edit.getOptionValue(name)).toBeUndefined();
}
});
});
+84
View File
@@ -0,0 +1,84 @@
import type { Command } from "commander";
import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js";
import { getCronChannelOptions } from "./shared.js";
export function registerCronMutationOptions(command: Command, mode: "add" | "edit"): Command {
const create = mode === "add";
return command
.option("--name <name>", "Job name")
.option("--display-name <name>", "Human-readable job label")
.option("--description <text>", "Job description")
.option("--delete-after-run", "Delete one-shot job after it succeeds", false)
.option("--keep-after-run", "Keep one-shot job after it succeeds", false)
.option("--agent <id>", "Agent id for this job")
.option("--session <target>", "Session target (main|isolated|current|session:<id>)")
.option("--session-key <key>", "Session key for job routing")
.option("--wake <mode>", "Wake mode (now|next-heartbeat)", create ? "now" : undefined)
.option("--at <when>", "One-shot time (ISO, offset-less uses --tz) or duration like 20m")
.option("--every <duration>", "Interval duration (e.g. 10m, 1h)")
.option("--pacing-min <duration>", "Minimum delay for a dynamic next check")
.option("--pacing-max <duration>", "Maximum delay for a dynamic next check")
.option("--cron <expr>", "Cron expression (5-field or 6-field with seconds)")
.option("--on-exit <shell>", "Fire once when the watched command exits")
.option("--on-exit-cwd <path>", "Working directory for the --on-exit watched command")
.option("--stream-command <json>", "Stream source argv as a JSON array of strings")
.option("--stream-cwd <path>", "Working directory for the stream source")
.option("--stream-mode <mode>", "Stream line selection mode (line|match)")
.option("--stream-match <regex>", "Regex source required for stream match mode")
.option("--stream-batch-ms <n>", "Quiet-window batch delay in milliseconds")
.option("--stream-max-batch-bytes <n>", "Maximum UTF-8 bytes per stream batch")
.option(
"--tz <iana>",
"Timezone for cron expressions (IANA; cron default: Gateway host local timezone)",
create ? "" : undefined,
)
.option("--stagger <duration>", "Cron stagger window (e.g. 30s, 5m)")
.option("--exact", "Disable cron staggering (set stagger to 0)", create ? false : undefined)
.option("--trigger-script <path|->", "Condition script file, or - for stdin")
.option("--trigger-once", "Disable after the first successful triggered run", false)
.option("--system-event <text>", "System event payload (main session)")
.option("--message <text>", "Agent message payload")
.option("--script <file|->", "Headless script payload file, or - for stdin")
.option("--script-timeout-seconds <n>", "Script wall-clock timeout seconds")
.option("--script-tool-budget <n>", "Maximum script tool calls")
.option("--command <shell>", "Command payload run as sh -lc <shell> on the Gateway")
.option("--command-argv <json>", "Command payload argv as JSON array of strings")
.option("--command-cwd <path>", "Working directory for command payloads")
.option(
"--command-env <KEY=VALUE>",
"Environment override for command payloads (repeatable)",
(value: string, previous: string[] | undefined) => [...(previous ?? []), value],
)
.option("--command-input <text>", "stdin for command payloads")
.option("--thinking <level>", `Thinking level for agent jobs (${THINKING_LEVELS_HELP})`)
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
.option("--fallbacks <list>", "Fallback model list for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent or command jobs")
.option("--no-output-timeout-seconds <n>", "No-output timeout seconds for command jobs")
.option("--output-max-bytes <n>", "Maximum captured stdout/stderr bytes for command jobs")
.option(
"--light-context",
"Use lightweight bootstrap context for agent jobs",
create ? false : undefined,
)
.option("--tools <list>", "Tool allow-list (e.g. exec,read,write or exec read write)")
.option("--announce", "Fallback-deliver final text to a chat", create ? false : undefined)
.option("--deliver", "Deprecated (use --announce). Fallback-delivers final text to a chat.")
.option("--no-deliver", "Disable runner fallback delivery")
.option("--webhook <url>", "POST the finished payload to a webhook URL")
.option(
"--channel <channel>",
`Delivery channel (${getCronChannelOptions()})`,
create ? "last" : undefined,
)
.option("--to <dest>", "Delivery destination (E.164, Telegram chatId, or Discord channel/user)")
.option("--thread-id <id>", "Telegram forum topic thread id")
.option("--account <id>", "Channel account id for delivery (multi-account setups)")
.option(
"--best-effort-deliver",
create
? "Do not fail the job if delivery fails"
: "Do not fail job if delivery fails (also implies --announce when used alone)",
create ? false : undefined,
);
}
+6 -2
View File
@@ -274,6 +274,7 @@ describe("node host MCP live lifecycle", () => {
let maxActiveLists = 0;
let listCount = 0;
const pending: Array<(value: { tools: Tool[] }) => void> = [];
const refreshStarted = [createDeferred(), createDeferred()] as const;
const client = createClient({
list: async () => {
listCount += 1;
@@ -285,6 +286,7 @@ describe("node host MCP live lifecycle", () => {
try {
return await new Promise<{ tools: Tool[] }>((resolve) => {
pending.push(resolve);
refreshStarted[listCount - 2]?.resolve();
});
} finally {
activeLists -= 1;
@@ -304,13 +306,15 @@ describe("node host MCP live lifecycle", () => {
);
notifyToolsChanged?.();
await vi.waitFor(() => expect(activeLists).toBe(1));
await refreshStarted[0].promise;
expect(activeLists).toBe(1);
for (let index = 0; index < 20; index += 1) {
notifyToolsChanged?.();
}
expect(client.request).toHaveBeenCalledTimes(2);
pending.shift()?.({ tools: [tool("middle")] });
await vi.waitFor(() => expect(client.request).toHaveBeenCalledTimes(3));
await refreshStarted[1].promise;
expect(client.request).toHaveBeenCalledTimes(3);
expect(maxActiveLists).toBe(1);
pending.shift()?.({ tools: [tool("final")] });
await vi.waitFor(() =>