mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-04 22:51:08 +00:00
Merge pull request #2731 from QwenLM/feat/in-session-cron-loops
feat(cron): add in-session loop scheduling with cron tools
This commit is contained in:
commit
76d64c9464
60 changed files with 3110 additions and 41 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -134,7 +134,9 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
npm run preflight
|
npm run preflight
|
||||||
npm run test:integration:cli:sandbox:none
|
npm run test:integration:cli:sandbox:none
|
||||||
|
npm run test:integration:interactive:sandbox:none
|
||||||
npm run test:integration:cli:sandbox:docker
|
npm run test:integration:cli:sandbox:docker
|
||||||
|
npm run test:integration:interactive:sandbox:docker
|
||||||
env:
|
env:
|
||||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,5 @@ export default {
|
||||||
sandbox: 'Sandboxing',
|
sandbox: 'Sandboxing',
|
||||||
language: 'i18n',
|
language: 'i18n',
|
||||||
hooks: 'Hooks',
|
hooks: 'Hooks',
|
||||||
|
'scheduled-tasks': 'Scheduled Tasks',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
139
docs/users/features/scheduled-tasks.md
Normal file
139
docs/users/features/scheduled-tasks.md
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# Run Prompts on a Schedule
|
||||||
|
|
||||||
|
> Use `/loop` and the cron scheduling tools to run prompts repeatedly, poll for status, or set one-time reminders within a Qwen Code session.
|
||||||
|
|
||||||
|
Scheduled tasks let Qwen Code re-run a prompt automatically on an interval. Use them to poll a deployment, babysit a PR, check back on a long-running build, or remind yourself to do something later in the session.
|
||||||
|
|
||||||
|
Tasks are session-scoped: they live in the current Qwen Code process and are gone when you exit. Nothing is written to disk.
|
||||||
|
|
||||||
|
> **Note:** Scheduled tasks are an experimental feature. Enable them with `experimental.cron: true` in your [settings](../configuration/settings.md), or set `QWEN_CODE_ENABLE_CRON=1` in your environment.
|
||||||
|
|
||||||
|
## Schedule a recurring prompt with /loop
|
||||||
|
|
||||||
|
The `/loop` [bundled skill](skills.md) is the quickest way to schedule a recurring prompt. Pass an optional interval and a prompt, and Qwen Code sets up a cron job that fires in the background while the session stays open.
|
||||||
|
|
||||||
|
```text
|
||||||
|
/loop 5m check if the deployment finished and tell me what happened
|
||||||
|
```
|
||||||
|
|
||||||
|
Qwen Code parses the interval, converts it to a cron expression, schedules the job, and confirms the cadence and job ID. It then immediately executes the prompt once — you don't have to wait for the first cron fire.
|
||||||
|
|
||||||
|
### Interval syntax
|
||||||
|
|
||||||
|
Intervals are optional. You can lead with them, trail with them, or leave them out entirely.
|
||||||
|
|
||||||
|
| Form | Example | Parsed interval |
|
||||||
|
| :---------------------- | :------------------------------------ | :--------------------------- |
|
||||||
|
| Leading token | `/loop 30m check the build` | every 30 minutes |
|
||||||
|
| Trailing `every` clause | `/loop check the build every 2 hours` | every 2 hours |
|
||||||
|
| No interval | `/loop check the build` | defaults to every 10 minutes |
|
||||||
|
|
||||||
|
Supported units are `s` for seconds, `m` for minutes, `h` for hours, and `d` for days. Seconds are rounded up to the nearest minute since cron has one-minute granularity. Intervals that don't divide evenly into their unit, such as `7m` or `90m`, are rounded to the nearest clean interval and Qwen Code tells you what it picked.
|
||||||
|
|
||||||
|
### Loop over another command
|
||||||
|
|
||||||
|
The scheduled prompt can itself be a command or skill invocation. This is useful for re-running a workflow you've already packaged.
|
||||||
|
|
||||||
|
```text
|
||||||
|
/loop 20m /review-pr 1234
|
||||||
|
```
|
||||||
|
|
||||||
|
Each time the job fires, Qwen Code runs `/review-pr 1234` as if you had typed it.
|
||||||
|
|
||||||
|
### Manage loops
|
||||||
|
|
||||||
|
`/loop` also supports two subcommands for managing existing jobs:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/loop list
|
||||||
|
```
|
||||||
|
|
||||||
|
Lists all scheduled jobs with their IDs and cron expressions.
|
||||||
|
|
||||||
|
```text
|
||||||
|
/loop clear
|
||||||
|
```
|
||||||
|
|
||||||
|
Cancels all scheduled jobs at once.
|
||||||
|
|
||||||
|
## Set a one-time reminder
|
||||||
|
|
||||||
|
For one-shot reminders, describe what you want in natural language instead of using `/loop`. Qwen Code schedules a single-fire task that deletes itself after running.
|
||||||
|
|
||||||
|
```text
|
||||||
|
remind me at 3pm to push the release branch
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
in 45 minutes, check whether the integration tests passed
|
||||||
|
```
|
||||||
|
|
||||||
|
Qwen Code pins the fire time to a specific minute and hour using a cron expression and confirms when it will fire.
|
||||||
|
|
||||||
|
## Manage scheduled tasks
|
||||||
|
|
||||||
|
Ask Qwen Code in natural language to list or cancel tasks, or reference the underlying tools directly.
|
||||||
|
|
||||||
|
```text
|
||||||
|
what scheduled tasks do I have?
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
cancel the deploy check job
|
||||||
|
```
|
||||||
|
|
||||||
|
Under the hood, Qwen Code uses these tools:
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| :----------- | :-------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `CronCreate` | Schedule a new task. Accepts a 5-field cron expression, the prompt to run, and whether it recurs or fires once. |
|
||||||
|
| `CronList` | List all scheduled tasks with their IDs, schedules, and prompts. |
|
||||||
|
| `CronDelete` | Cancel a task by ID. |
|
||||||
|
|
||||||
|
Each scheduled task has an 8-character ID you can pass to `CronDelete`. A session can hold up to 50 scheduled tasks at once.
|
||||||
|
|
||||||
|
## How scheduled tasks run
|
||||||
|
|
||||||
|
The scheduler checks every second for due tasks and enqueues them when the session is idle. A scheduled prompt fires between your turns, not while Qwen Code is mid-response. If Qwen Code is busy when a task comes due, the prompt waits until the current turn ends.
|
||||||
|
|
||||||
|
All times are interpreted in your local timezone. A cron expression like `0 9 * * *` means 9am wherever you're running Qwen Code, not UTC.
|
||||||
|
|
||||||
|
### Jitter
|
||||||
|
|
||||||
|
To avoid every session hitting the API at the same wall-clock moment, the scheduler adds a small deterministic offset to fire times:
|
||||||
|
|
||||||
|
- **Recurring tasks** fire up to 10% of their period late, capped at 15 minutes. An hourly job might fire anywhere from `:00` to `:06`.
|
||||||
|
- **One-shot tasks** scheduled for the top or bottom of the hour (minute `:00` or `:30`) fire up to 90 seconds early.
|
||||||
|
|
||||||
|
The offset is derived from the task ID, so the same task always gets the same offset. If exact timing matters, pick a minute that is not `:00` or `:30`, for example `3 9 * * *` instead of `0 9 * * *`, and the one-shot jitter will not apply.
|
||||||
|
|
||||||
|
### Three-day expiry
|
||||||
|
|
||||||
|
Recurring tasks automatically expire 3 days after creation. The task fires one final time, then deletes itself. This bounds how long a forgotten loop can run. If you need a recurring task to last longer, cancel and recreate it before it expires.
|
||||||
|
|
||||||
|
One-shot tasks do not expire on a timer — they simply delete themselves after firing once.
|
||||||
|
|
||||||
|
## Cron expression reference
|
||||||
|
|
||||||
|
`CronCreate` accepts standard 5-field cron expressions: `minute hour day-of-month month day-of-week`. All fields support wildcards (`*`), single values (`5`), steps (`*/15`), ranges (`1-5`), and comma-separated lists (`1,15,30`).
|
||||||
|
|
||||||
|
| Example | Meaning |
|
||||||
|
| :------------- | :--------------------------- |
|
||||||
|
| `*/5 * * * *` | Every 5 minutes |
|
||||||
|
| `0 * * * *` | Every hour on the hour |
|
||||||
|
| `7 * * * *` | Every hour at 7 minutes past |
|
||||||
|
| `0 9 * * *` | Every day at 9am local |
|
||||||
|
| `0 9 * * 1-5` | Weekdays at 9am local |
|
||||||
|
| `30 14 15 3 *` | March 15 at 2:30pm local |
|
||||||
|
|
||||||
|
Day-of-week uses `0` or `7` for Sunday through `6` for Saturday. When both day-of-month and day-of-week are constrained (neither is `*`), a date matches if either field matches — this follows standard vixie-cron semantics.
|
||||||
|
|
||||||
|
Extended syntax like `L`, `W`, `?`, and name aliases such as `MON` or `JAN` is not supported.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
Session-scoped scheduling has inherent constraints:
|
||||||
|
|
||||||
|
- Tasks only fire while Qwen Code is running and idle. Closing the terminal or letting the session exit cancels everything.
|
||||||
|
- No catch-up for missed fires. If a task's scheduled time passes while Qwen Code is busy on a long-running request, it fires once when Qwen Code becomes idle, not once per missed interval.
|
||||||
|
- No persistence across restarts. Restarting Qwen Code clears all session-scoped tasks.
|
||||||
380
integration-tests/cli/acp-cron.test.ts
Normal file
380
integration-tests/cli/acp-cron.test.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ACP integration tests for in-session cron/loop scheduling.
|
||||||
|
*
|
||||||
|
* These verify that cron jobs created during an ACP session fire correctly
|
||||||
|
* and stream results back to the client via sessionUpdate notifications,
|
||||||
|
* even after the originating prompt has already returned.
|
||||||
|
*
|
||||||
|
* The two tests share one ACP session to stay within 2 minutes total:
|
||||||
|
* 1. Fast smoke test — cron tools available (no cron fire needed)
|
||||||
|
* 2. Combined test — create job, verify session responsive, wait for
|
||||||
|
* cron fire, check content + _meta.source, then clean up
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TestRig } from '../test-helper.js';
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
const IS_SANDBOX =
|
||||||
|
process.env['QWEN_SANDBOX'] &&
|
||||||
|
process.env['QWEN_SANDBOX']!.toLowerCase() !== 'false';
|
||||||
|
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (reason: Error) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionUpdateNotification = {
|
||||||
|
sessionId?: string;
|
||||||
|
update?: {
|
||||||
|
sessionUpdate?: string;
|
||||||
|
content?: {
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
title?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
status?: string;
|
||||||
|
_meta?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type PermissionRequest = {
|
||||||
|
id: number;
|
||||||
|
sessionId?: string;
|
||||||
|
toolCall?: {
|
||||||
|
toolCallId: string;
|
||||||
|
title: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
options?: Array<{
|
||||||
|
optionId: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up an ACP test environment with cron support enabled.
|
||||||
|
*/
|
||||||
|
function setupAcpCronTest(rig: TestRig) {
|
||||||
|
const pending = new Map<number, PendingRequest>();
|
||||||
|
let nextRequestId = 1;
|
||||||
|
const sessionUpdates: (SessionUpdateNotification & {
|
||||||
|
receivedAt: number;
|
||||||
|
})[] = [];
|
||||||
|
const stderr: string[] = [];
|
||||||
|
|
||||||
|
const agent = spawn(
|
||||||
|
'node',
|
||||||
|
[rig.bundlePath, '--acp', '--no-chat-recording'],
|
||||||
|
{
|
||||||
|
cwd: rig.testDir!,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
QWEN_CODE_ENABLE_CRON: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
agent.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
stderr.push(chunk.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
const rl = createInterface({ input: agent.stdout });
|
||||||
|
|
||||||
|
const send = (json: unknown) => {
|
||||||
|
agent.stdin.write(`${JSON.stringify(json)}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendResponse = (id: number, result: unknown) => {
|
||||||
|
send({ jsonrpc: '2.0', id, result });
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendRequest = (method: string, params?: unknown) =>
|
||||||
|
new Promise<unknown>((resolve, reject) => {
|
||||||
|
const id = nextRequestId++;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pending.delete(id);
|
||||||
|
reject(new Error(`Request ${id} (${method}) timed out`));
|
||||||
|
}, REQUEST_TIMEOUT_MS);
|
||||||
|
pending.set(id, { resolve, reject, timeout });
|
||||||
|
send({ jsonrpc: '2.0', id, method, params });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleResponse = (msg: {
|
||||||
|
id: number;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message?: string };
|
||||||
|
}) => {
|
||||||
|
const waiter = pending.get(msg.id);
|
||||||
|
if (!waiter) return;
|
||||||
|
clearTimeout(waiter.timeout);
|
||||||
|
pending.delete(msg.id);
|
||||||
|
if (msg.error) {
|
||||||
|
const error = new Error(msg.error.message ?? 'Unknown error');
|
||||||
|
(error as Error & { response?: unknown }).response = msg.error;
|
||||||
|
waiter.reject(error);
|
||||||
|
} else {
|
||||||
|
waiter.resolve(msg.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessage = (msg: {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: SessionUpdateNotification & {
|
||||||
|
path?: string;
|
||||||
|
content?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
toolCall?: PermissionRequest['toolCall'];
|
||||||
|
options?: PermissionRequest['options'];
|
||||||
|
};
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message?: string };
|
||||||
|
}) => {
|
||||||
|
if (typeof msg.id !== 'undefined' && ('result' in msg || 'error' in msg)) {
|
||||||
|
handleResponse(
|
||||||
|
msg as {
|
||||||
|
id: number;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message?: string };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.method === 'session/update') {
|
||||||
|
sessionUpdates.push({
|
||||||
|
sessionId: msg.params?.sessionId,
|
||||||
|
update: msg.params?.update,
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
msg.method === 'session/request_permission' &&
|
||||||
|
typeof msg.id === 'number'
|
||||||
|
) {
|
||||||
|
sendResponse(msg.id, {
|
||||||
|
outcome: { optionId: 'proceed_once', outcome: 'selected' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.method === 'fs/read_text_file' && typeof msg.id === 'number') {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(msg.params?.path ?? '', 'utf8');
|
||||||
|
sendResponse(msg.id, { content });
|
||||||
|
} catch (e) {
|
||||||
|
sendResponse(msg.id, { content: `ERROR: ${(e as Error).message}` });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.method === 'fs/write_text_file' && typeof msg.id === 'number') {
|
||||||
|
try {
|
||||||
|
writeFileSync(
|
||||||
|
msg.params?.path ?? '',
|
||||||
|
msg.params?.content ?? '',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
sendResponse(msg.id, null);
|
||||||
|
} catch (e) {
|
||||||
|
sendResponse(msg.id, { message: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rl.on('line', (line: string) => {
|
||||||
|
if (!line.trim()) return;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line);
|
||||||
|
handleMessage(msg);
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON output
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls sessionUpdates until a notification matching the predicate appears,
|
||||||
|
* or the timeout expires.
|
||||||
|
*/
|
||||||
|
const waitForSessionUpdate = async (
|
||||||
|
predicate: (
|
||||||
|
update: SessionUpdateNotification & { receivedAt: number },
|
||||||
|
) => boolean,
|
||||||
|
description: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<SessionUpdateNotification & { receivedAt: number }> => {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const match = sessionUpdates.find(predicate);
|
||||||
|
if (match) return match;
|
||||||
|
await delay(500);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for sessionUpdate: ${description} (after ${timeoutMs}ms, ` +
|
||||||
|
`saw ${sessionUpdates.length} updates: ` +
|
||||||
|
`[${sessionUpdates.map((u) => u.update?.sessionUpdate).join(', ')}])`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForExit = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (agent.exitCode !== null || agent.signalCode) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
agent.once('exit', () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
rl.close();
|
||||||
|
agent.kill();
|
||||||
|
pending.forEach(({ timeout }) => clearTimeout(timeout));
|
||||||
|
pending.clear();
|
||||||
|
await waitForExit();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendRequest,
|
||||||
|
cleanup,
|
||||||
|
stderr,
|
||||||
|
sessionUpdates,
|
||||||
|
waitForSessionUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard ACP init + auth + new session sequence. */
|
||||||
|
async function initSession(
|
||||||
|
sendRequest: (method: string, params?: unknown) => Promise<unknown>,
|
||||||
|
testDir: string,
|
||||||
|
): Promise<string> {
|
||||||
|
await sendRequest('initialize', {
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientCapabilities: {
|
||||||
|
fs: { readTextFile: true, writeTextFile: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendRequest('authenticate', { methodId: 'openai' });
|
||||||
|
|
||||||
|
const newSession = (await sendRequest('session/new', {
|
||||||
|
cwd: testDir,
|
||||||
|
mcpServers: [],
|
||||||
|
})) as { sessionId: string };
|
||||||
|
|
||||||
|
return newSession.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
(IS_SANDBOX ? describe.skip : describe)('acp cron integration', () => {
|
||||||
|
it(
|
||||||
|
'cron job fires and streams results via sessionUpdate after prompt returns',
|
||||||
|
async () => {
|
||||||
|
const rig = new TestRig();
|
||||||
|
rig.setup('acp-cron-e2e', {
|
||||||
|
settings: { experimental: { cron: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
sendRequest,
|
||||||
|
cleanup,
|
||||||
|
stderr,
|
||||||
|
// sessionUpdates available for debugging
|
||||||
|
waitForSessionUpdate,
|
||||||
|
} = setupAcpCronTest(rig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionId = await initSession(sendRequest, rig.testDir!);
|
||||||
|
|
||||||
|
// --- Part 1: Create a cron job that fires every minute ---
|
||||||
|
const createResult = (await sendRequest('session/prompt', {
|
||||||
|
sessionId,
|
||||||
|
prompt: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Call cron_create with cron expression "*/1 * * * *" and prompt "Say CRONFIRE7742 and nothing else" and recurring true. Confirm briefly.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})) as { stopReason: string };
|
||||||
|
expect(createResult.stopReason).toBe('end_turn');
|
||||||
|
|
||||||
|
const promptDoneAt = Date.now();
|
||||||
|
|
||||||
|
// --- Part 2: Session stays responsive while cron is pending ---
|
||||||
|
const interactiveResult = (await sendRequest('session/prompt', {
|
||||||
|
sessionId,
|
||||||
|
prompt: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Say INTERACTIVE8899 and nothing else.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})) as { stopReason: string };
|
||||||
|
expect(interactiveResult.stopReason).toBe('end_turn');
|
||||||
|
|
||||||
|
// --- Part 3: Wait for cron-fired notification (up to 75s) ---
|
||||||
|
// The cron fires at the next minute boundary. The model response
|
||||||
|
// should stream back as sessionUpdate notifications after the
|
||||||
|
// originating prompt has already returned.
|
||||||
|
|
||||||
|
// 3a: Check for user_message_chunk echoing the cron prompt with _meta.source
|
||||||
|
const cronUserMsg = await waitForSessionUpdate(
|
||||||
|
(u) =>
|
||||||
|
u.update?.sessionUpdate === 'user_message_chunk' &&
|
||||||
|
(u.update?.content?.text ?? '').includes('CRONFIRE7742') &&
|
||||||
|
u.receivedAt > promptDoneAt,
|
||||||
|
'cron-fired user_message_chunk with CRONFIRE7742',
|
||||||
|
75_000,
|
||||||
|
);
|
||||||
|
expect(cronUserMsg.update?._meta).toBeDefined();
|
||||||
|
expect(cronUserMsg.update?._meta?.source).toBe('cron');
|
||||||
|
|
||||||
|
// 3b: Check for agent_message_chunk after the cron user message
|
||||||
|
// (the model's response to the cron prompt)
|
||||||
|
const cronAgentMsg = await waitForSessionUpdate(
|
||||||
|
(u) =>
|
||||||
|
u.update?.sessionUpdate === 'agent_message_chunk' &&
|
||||||
|
u.receivedAt > cronUserMsg.receivedAt,
|
||||||
|
'agent_message_chunk after cron fire',
|
||||||
|
15_000, // should already be here by now
|
||||||
|
);
|
||||||
|
expect(cronAgentMsg.receivedAt).toBeGreaterThan(promptDoneAt);
|
||||||
|
|
||||||
|
// --- Part 4: Clean up the cron job ---
|
||||||
|
await sendRequest('session/prompt', {
|
||||||
|
sessionId,
|
||||||
|
prompt: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Delete all cron jobs using cron_delete.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 120_000, retry: 0 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -9,7 +9,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { createInterface } from 'node:readline';
|
import { createInterface } from 'node:readline';
|
||||||
import { setTimeout as delay } from 'node:timers/promises';
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { TestRig } from './test-helper.js';
|
import { TestRig } from '../test-helper.js';
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 60_000;
|
const REQUEST_TIMEOUT_MS = 60_000;
|
||||||
const INITIAL_PROMPT = 'Create a quick note (smoke test).';
|
const INITIAL_PROMPT = 'Create a quick note (smoke test).';
|
||||||
131
integration-tests/cli/cron-tools.test.ts
Normal file
131
integration-tests/cli/cron-tools.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
TestRig,
|
||||||
|
printDebugInfo,
|
||||||
|
validateModelOutput,
|
||||||
|
} from '../test-helper.js';
|
||||||
|
|
||||||
|
describe('cron-tools', () => {
|
||||||
|
let rig: TestRig;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (rig) {
|
||||||
|
await rig.cleanup();
|
||||||
|
}
|
||||||
|
// Clean up env vars
|
||||||
|
delete process.env['QWEN_CODE_ENABLE_CRON'];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have cron tools registered when enabled via settings', async () => {
|
||||||
|
rig = new TestRig();
|
||||||
|
await rig.setup('cron-tools-registered', {
|
||||||
|
settings: { experimental: { cron: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await rig.run(
|
||||||
|
'Do you have access to tools called cron_create, cron_list, and cron_delete? Reply with just "yes" or "no".',
|
||||||
|
);
|
||||||
|
|
||||||
|
validateModelOutput(result, null, 'cron tools registered');
|
||||||
|
expect(result.toLowerCase()).toContain('yes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have cron tools registered when enabled via env var', async () => {
|
||||||
|
rig = new TestRig();
|
||||||
|
await rig.setup('cron-tools-env-var');
|
||||||
|
|
||||||
|
process.env['QWEN_CODE_ENABLE_CRON'] = '1';
|
||||||
|
|
||||||
|
const result = await rig.run(
|
||||||
|
'Do you have access to tools called cron_create, cron_list, and cron_delete? Reply with just "yes" or "no".',
|
||||||
|
);
|
||||||
|
|
||||||
|
validateModelOutput(result, null, 'cron tools via env var');
|
||||||
|
expect(result.toLowerCase()).toContain('yes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT have cron tools by default', async () => {
|
||||||
|
rig = new TestRig();
|
||||||
|
await rig.setup('cron-tools-disabled-by-default');
|
||||||
|
|
||||||
|
const result = await rig.run(
|
||||||
|
'Do you have access to a tool called cron_create? Reply with just "yes" or "no".',
|
||||||
|
);
|
||||||
|
|
||||||
|
validateModelOutput(result, null, 'cron disabled by default');
|
||||||
|
expect(result.toLowerCase()).toContain('no');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create, list, and delete a cron job in a single turn', async () => {
|
||||||
|
rig = new TestRig();
|
||||||
|
await rig.setup('cron-create-list-delete', {
|
||||||
|
settings: { experimental: { cron: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await rig.run(
|
||||||
|
'Call cron_create with cron "*/5 * * * *", prompt "test ping", recurring true. Then call cron_list. Then delete that job using cron_delete. Then call cron_list again. How many jobs remain? Reply with just the number.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const foundCreate = await rig.waitForToolCall('cron_create');
|
||||||
|
const foundList = await rig.waitForToolCall('cron_list');
|
||||||
|
const foundDelete = await rig.waitForToolCall('cron_delete');
|
||||||
|
|
||||||
|
if (!foundCreate || !foundList || !foundDelete) {
|
||||||
|
printDebugInfo(rig, result, {
|
||||||
|
'cron_create found': foundCreate,
|
||||||
|
'cron_list found': foundList,
|
||||||
|
'cron_delete found': foundDelete,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(foundCreate, 'Expected cron_create tool call').toBeTruthy();
|
||||||
|
expect(foundList, 'Expected cron_list tool call').toBeTruthy();
|
||||||
|
expect(foundDelete, 'Expected cron_delete tool call').toBeTruthy();
|
||||||
|
|
||||||
|
validateModelOutput(result, '0', 'cron create-list-delete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a one-shot (non-recurring) job', async () => {
|
||||||
|
rig = new TestRig();
|
||||||
|
await rig.setup('cron-one-shot', {
|
||||||
|
settings: { experimental: { cron: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await rig.run(
|
||||||
|
'Do these steps: (1) Call cron_create with cron "*/5 * * * *", prompt "one-shot test", recurring false. (2) Call cron_list. Is the job marked as recurring or one-shot? Remember the answer. (3) Delete all cron jobs. Reply with just "recurring" or "one-shot".',
|
||||||
|
);
|
||||||
|
|
||||||
|
const foundCreate = await rig.waitForToolCall('cron_create');
|
||||||
|
const foundList = await rig.waitForToolCall('cron_list');
|
||||||
|
|
||||||
|
if (!foundCreate || !foundList) {
|
||||||
|
printDebugInfo(rig, result, {
|
||||||
|
'cron_create found': foundCreate,
|
||||||
|
'cron_list found': foundList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(foundCreate, 'Expected cron_create tool call').toBeTruthy();
|
||||||
|
expect(foundList, 'Expected cron_list tool call').toBeTruthy();
|
||||||
|
|
||||||
|
validateModelOutput(result, 'one-shot', 'cron one-shot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exit normally in -p mode when no cron jobs are created', async () => {
|
||||||
|
rig = new TestRig();
|
||||||
|
await rig.setup('cron-no-jobs-exit', {
|
||||||
|
settings: { experimental: { cron: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// A normal -p call without cron should still exit quickly
|
||||||
|
const result = await rig.run('What is 2+2? Reply with just the number.');
|
||||||
|
|
||||||
|
validateModelOutput(result, '4', 'no cron exit');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
import {
|
||||||
|
TestRig,
|
||||||
|
printDebugInfo,
|
||||||
|
validateModelOutput,
|
||||||
|
} from '../test-helper.js';
|
||||||
|
|
||||||
describe('edit', () => {
|
describe('edit', () => {
|
||||||
it('should be able to edit content in a file', async () => {
|
it('should be able to edit content in a file', async () => {
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect, test } from 'vitest';
|
import { expect, test } from 'vitest';
|
||||||
import { TestRig } from './test-helper.js';
|
import { TestRig } from '../test-helper.js';
|
||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
|
@ -5,7 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
import {
|
||||||
|
TestRig,
|
||||||
|
printDebugInfo,
|
||||||
|
validateModelOutput,
|
||||||
|
} from '../test-helper.js';
|
||||||
|
|
||||||
describe('file-system', () => {
|
describe('file-system', () => {
|
||||||
it('should be able to read a file', async () => {
|
it('should be able to read a file', async () => {
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||||
import { TestRig } from './test-helper.js';
|
import { TestRig } from '../test-helper.js';
|
||||||
|
|
||||||
describe('JSON output', () => {
|
describe('JSON output', () => {
|
||||||
let rig: TestRig;
|
let rig: TestRig;
|
||||||
|
|
@ -5,7 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
import {
|
||||||
|
TestRig,
|
||||||
|
printDebugInfo,
|
||||||
|
validateModelOutput,
|
||||||
|
} from '../test-helper.js';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { TestRig } from './test-helper.js';
|
import { TestRig } from '../test-helper.js';
|
||||||
|
|
||||||
// Create a minimal MCP server that doesn't require external dependencies
|
// Create a minimal MCP server that doesn't require external dependencies
|
||||||
// This implements the MCP protocol directly using Node.js built-ins
|
// This implements the MCP protocol directly using Node.js built-ins
|
||||||
|
|
@ -5,7 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
import {
|
||||||
|
TestRig,
|
||||||
|
printDebugInfo,
|
||||||
|
validateModelOutput,
|
||||||
|
} from '../test-helper.js';
|
||||||
|
|
||||||
describe('read_many_files', () => {
|
describe('read_many_files', () => {
|
||||||
it.skip('should be able to read multiple files', async () => {
|
it.skip('should be able to read multiple files', async () => {
|
||||||
|
|
@ -5,7 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
import {
|
||||||
|
TestRig,
|
||||||
|
printDebugInfo,
|
||||||
|
validateModelOutput,
|
||||||
|
} from '../test-helper.js';
|
||||||
|
|
||||||
describe('run_shell_command', () => {
|
describe('run_shell_command', () => {
|
||||||
it('should be able to run a shell command', async () => {
|
it('should be able to run a shell command', async () => {
|
||||||
|
|
@ -5,7 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
import {
|
||||||
|
TestRig,
|
||||||
|
printDebugInfo,
|
||||||
|
validateModelOutput,
|
||||||
|
} from '../test-helper.js';
|
||||||
|
|
||||||
describe('save_memory', () => {
|
describe('save_memory', () => {
|
||||||
// Skipped due to flaky model behavior - the model sometimes answers the question
|
// Skipped due to flaky model behavior - the model sometimes answers the question
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { TestRig } from './test-helper.js';
|
import { TestRig } from '../test-helper.js';
|
||||||
import { writeFileSync, readFileSync } from 'node:fs';
|
import { writeFileSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
// Import settings fixtures from unified workspace file
|
// Import settings fixtures from unified workspace file
|
||||||
import workspacesSettings from './fixtures/settings-migration/workspaces.json' with { type: 'json' };
|
import workspacesSettings from '../fixtures/settings-migration/workspaces.json' with { type: 'json' };
|
||||||
|
|
||||||
const {
|
const {
|
||||||
v1Settings,
|
v1Settings,
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, beforeAll, expect } from 'vitest';
|
import { describe, it, beforeAll, expect } from 'vitest';
|
||||||
import { TestRig, validateModelOutput } from './test-helper.js';
|
import { TestRig, validateModelOutput } from '../test-helper.js';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
|
@ -5,7 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
import {
|
||||||
|
TestRig,
|
||||||
|
printDebugInfo,
|
||||||
|
validateModelOutput,
|
||||||
|
} from '../test-helper.js';
|
||||||
|
|
||||||
describe.skip('stdin context', () => {
|
describe.skip('stdin context', () => {
|
||||||
it('should be able to use stdin as context for a prompt', async () => {
|
it('should be able to use stdin as context for a prompt', async () => {
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TestRig } from './test-helper.js';
|
import { TestRig } from '../test-helper.js';
|
||||||
|
|
||||||
describe('telemetry', () => {
|
describe('telemetry', () => {
|
||||||
it('should emit a metric and a log event', async () => {
|
it('should emit a metric and a log event', async () => {
|
||||||
|
|
@ -5,7 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
import {
|
||||||
|
TestRig,
|
||||||
|
printDebugInfo,
|
||||||
|
validateModelOutput,
|
||||||
|
} from '../test-helper.js';
|
||||||
|
|
||||||
describe('todo_write', () => {
|
describe('todo_write', () => {
|
||||||
it('should be able to create and manage a todo list', async () => {
|
it('should be able to create and manage a todo list', async () => {
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import { writeFileSync, readFileSync } from 'node:fs';
|
import { writeFileSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { TestRig } from './test-helper.js';
|
import { TestRig } from '../test-helper.js';
|
||||||
|
|
||||||
// Windows skip (Option A: avoid infra scope)
|
// Windows skip (Option A: avoid infra scope)
|
||||||
const d = process.platform === 'win32' ? describe.skip : describe;
|
const d = process.platform === 'win32' ? describe.skip : describe;
|
||||||
|
|
@ -5,7 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
import {
|
||||||
|
TestRig,
|
||||||
|
printDebugInfo,
|
||||||
|
validateModelOutput,
|
||||||
|
} from '../test-helper.js';
|
||||||
|
|
||||||
describe('web_search', () => {
|
describe('web_search', () => {
|
||||||
it('should be able to search the web', async () => {
|
it('should be able to search the web', async () => {
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
createToolCallErrorMessage,
|
createToolCallErrorMessage,
|
||||||
printDebugInfo,
|
printDebugInfo,
|
||||||
validateModelOutput,
|
validateModelOutput,
|
||||||
} from './test-helper.js';
|
} from '../test-helper.js';
|
||||||
|
|
||||||
describe('write_file', () => {
|
describe('write_file', () => {
|
||||||
it('should be able to write a file', async () => {
|
it('should be able to write a file', async () => {
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||||
import { TestRig, type } from './test-helper.js';
|
import { TestRig, type } from '../test-helper.js';
|
||||||
|
|
||||||
describe('Interactive Mode', () => {
|
describe('Interactive Mode', () => {
|
||||||
let rig: TestRig;
|
let rig: TestRig;
|
||||||
140
integration-tests/interactive/cron-interactive.test.ts
Normal file
140
integration-tests/interactive/cron-interactive.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-session cron/loop interactive E2E tests.
|
||||||
|
*
|
||||||
|
* These drive the full interactive TUI via InteractiveSession (node-pty +
|
||||||
|
* @xterm/headless) and read the rendered terminal screen. No browser needed.
|
||||||
|
*
|
||||||
|
* Ported from the standalone script at
|
||||||
|
* terminal-capture/test-cron-interactive-e2e.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { InteractiveSession } from './interactive-session.js';
|
||||||
|
|
||||||
|
function makeEnv(): NodeJS.ProcessEnv {
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env['NO_COLOR'];
|
||||||
|
return {
|
||||||
|
...env,
|
||||||
|
QWEN_CODE_ENABLE_CRON: '1',
|
||||||
|
FORCE_COLOR: '1',
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
NODE_NO_WARNINGS: '1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cron interactive', () => {
|
||||||
|
let session: InteractiveSession | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (session) {
|
||||||
|
await session.close();
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'loop fires inline in conversation',
|
||||||
|
async () => {
|
||||||
|
session = await InteractiveSession.start({
|
||||||
|
env: makeEnv(),
|
||||||
|
args: ['--approval-mode', 'yolo'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.send(
|
||||||
|
'Call cron_create with expression "*/1 * * * *" and prompt "PONG7742" and recurring true. Confirm briefly.',
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.waitForScreen(
|
||||||
|
(scr) => scr.split('\n').some((l) => l.trim() === '> PONG7742'),
|
||||||
|
'cron-injected prompt "> PONG7742"',
|
||||||
|
90_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.idle(5000);
|
||||||
|
const finalScreen = await session.screen();
|
||||||
|
const afterPrompt = finalScreen.slice(
|
||||||
|
finalScreen.lastIndexOf('> PONG7742'),
|
||||||
|
);
|
||||||
|
expect(afterPrompt).toContain('✦');
|
||||||
|
},
|
||||||
|
{ timeout: 180_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'user input takes priority over cron',
|
||||||
|
async () => {
|
||||||
|
session = await InteractiveSession.start({
|
||||||
|
env: makeEnv(),
|
||||||
|
args: ['--approval-mode', 'yolo'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.send(
|
||||||
|
'Call cron_create with expression "*/1 * * * *" and prompt "CRONTICK99" and recurring true. Confirm briefly.',
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.waitForScreen(
|
||||||
|
(scr) => scr.split('\n').some((l) => l.trim() === '> CRONTICK99'),
|
||||||
|
'first cron fire "> CRONTICK99"',
|
||||||
|
90_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.idle(5000);
|
||||||
|
await session.send('Reply with exactly USERPRIORITY77 nothing else');
|
||||||
|
|
||||||
|
await session.waitForScreen(
|
||||||
|
(scr) => scr.includes('USERPRIORITY77'),
|
||||||
|
'model response containing USERPRIORITY77',
|
||||||
|
);
|
||||||
|
|
||||||
|
const screen = await session.screen();
|
||||||
|
expect(screen).toContain('Type your message');
|
||||||
|
},
|
||||||
|
{ timeout: 180_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'error during cron turn does not kill the loop',
|
||||||
|
async () => {
|
||||||
|
session = await InteractiveSession.start({
|
||||||
|
env: makeEnv(),
|
||||||
|
args: ['--approval-mode', 'yolo'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.send(
|
||||||
|
'Call cron_create with expression "*/1 * * * *" and prompt "Read the file /tmp/nonexistent_e2e_99.txt and report its contents. If it does not exist say FILEERR88." and recurring true. Confirm briefly.',
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.waitForScreen(
|
||||||
|
(scr) => scr.includes('FILEERR88'),
|
||||||
|
'model reporting FILEERR88 from cron prompt',
|
||||||
|
90_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.idle(5000);
|
||||||
|
await session.send('Reply with exactly ALIVE99 nothing else');
|
||||||
|
await session.waitForScreen(
|
||||||
|
(scr) => scr.includes('ALIVE99'),
|
||||||
|
'model response ALIVE99',
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.send(
|
||||||
|
'Call cron_list and tell me how many jobs exist. Say "COUNT: N"',
|
||||||
|
);
|
||||||
|
await session.idle(8000);
|
||||||
|
const screen = await session.screen();
|
||||||
|
expect(
|
||||||
|
screen.includes('COUNT: 1') ||
|
||||||
|
screen.includes('1 job') ||
|
||||||
|
screen.includes('Active cron jobs (1)'),
|
||||||
|
).toBe(true);
|
||||||
|
},
|
||||||
|
{ timeout: 180_000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TestRig } from './test-helper.js';
|
import { TestRig } from '../test-helper.js';
|
||||||
|
|
||||||
describe('Ctrl+C exit', () => {
|
describe('Ctrl+C exit', () => {
|
||||||
// (#9782) Temporarily disabling on windows because it is failing on main and every
|
// (#9782) Temporarily disabling on windows because it is failing on main and every
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||||
import { TestRig, type, printDebugInfo } from './test-helper.js';
|
import { TestRig, type, printDebugInfo } from '../test-helper.js';
|
||||||
|
|
||||||
describe('Interactive file system', () => {
|
describe('Interactive file system', () => {
|
||||||
let rig: TestRig;
|
let rig: TestRig;
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { TestRig } from './test-helper.js';
|
import { TestRig } from '../test-helper.js';
|
||||||
|
|
||||||
describe('/hooks command', () => {
|
describe('/hooks command', () => {
|
||||||
let rig: TestRig;
|
let rig: TestRig;
|
||||||
213
integration-tests/interactive/interactive-session.ts
Normal file
213
integration-tests/interactive/interactive-session.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InteractiveSession — lightweight terminal session driver for interactive
|
||||||
|
* integration tests.
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* node-pty (pseudo-terminal)
|
||||||
|
* ↓ raw ANSI byte stream
|
||||||
|
* @xterm/headless (pure Node.js terminal emulator)
|
||||||
|
* ↓ proper ANSI processing: cursor movement, line clearing, scrollback
|
||||||
|
* buffer.active.getLine() → rendered screen text
|
||||||
|
*
|
||||||
|
* No browser, no Playwright — runs entirely in Node.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as pty from '@lydell/node-pty';
|
||||||
|
import stripAnsi from 'strip-ansi';
|
||||||
|
// @xterm/headless is CJS — use default import + destructure
|
||||||
|
import xtermHeadless from '@xterm/headless';
|
||||||
|
const { Terminal } = xtermHeadless;
|
||||||
|
type Terminal = InstanceType<typeof Terminal>;
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
export interface InteractiveSessionOptions {
|
||||||
|
/** Terminal columns, default 100 */
|
||||||
|
cols?: number;
|
||||||
|
/** Terminal rows, default 40 */
|
||||||
|
rows?: number;
|
||||||
|
/** Working directory, default project root */
|
||||||
|
cwd?: string;
|
||||||
|
/** Environment variables */
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
/** Extra CLI arguments (e.g. ['--approval-mode', 'yolo']) */
|
||||||
|
args?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InteractiveSession {
|
||||||
|
private ptyProcess: pty.IPty;
|
||||||
|
private terminal: Terminal;
|
||||||
|
private rawOutput = '';
|
||||||
|
private pendingWrite: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
private constructor(ptyProcess: pty.IPty, terminal: Terminal) {
|
||||||
|
this.ptyProcess = ptyProcess;
|
||||||
|
this.terminal = terminal;
|
||||||
|
|
||||||
|
ptyProcess.onData((data) => {
|
||||||
|
this.rawOutput += data;
|
||||||
|
// Chain writes so flush() can await all pending data
|
||||||
|
this.pendingWrite = this.pendingWrite.then(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
terminal.write(data, resolve);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for all pending PTY data to be processed by xterm. */
|
||||||
|
private async flush(): Promise<void> {
|
||||||
|
await this.pendingWrite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new interactive session with the CLI.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const session = await InteractiveSession.start({
|
||||||
|
* env: { QWEN_CODE_ENABLE_CRON: '1' },
|
||||||
|
* args: ['--approval-mode', 'yolo'],
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
static async start(
|
||||||
|
options?: InteractiveSessionOptions,
|
||||||
|
): Promise<InteractiveSession> {
|
||||||
|
const cols = options?.cols ?? 100;
|
||||||
|
const rows = options?.rows ?? 40;
|
||||||
|
const cwd = options?.cwd ?? join(__dirname, '..', '..');
|
||||||
|
const args = options?.args ?? [];
|
||||||
|
|
||||||
|
const baseEnv = { ...process.env };
|
||||||
|
delete baseEnv['NO_COLOR'];
|
||||||
|
const env = options?.env ?? baseEnv;
|
||||||
|
|
||||||
|
const terminal = new Terminal({
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
scrollback: 1000,
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bundlePath = join(__dirname, '..', '..', 'dist/cli.js');
|
||||||
|
const ptyProcess = pty.spawn('node', [bundlePath, ...args], {
|
||||||
|
name: 'xterm-256color',
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
cwd,
|
||||||
|
env: env as Record<string, string>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = new InteractiveSession(ptyProcess, terminal);
|
||||||
|
await session.waitFor('Type your message', 30_000);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send text followed by Enter. */
|
||||||
|
async send(text: string): Promise<void> {
|
||||||
|
// Type character by character to avoid paste detection
|
||||||
|
for (const char of text) {
|
||||||
|
this.ptyProcess.write(char);
|
||||||
|
await sleep(5);
|
||||||
|
}
|
||||||
|
await sleep(300);
|
||||||
|
this.ptyProcess.write('\r');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for text to appear in raw output. */
|
||||||
|
async waitFor(text: string, timeout = 120_000): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
if (
|
||||||
|
stripAnsi(this.rawOutput).toLowerCase().includes(text.toLowerCase())
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Timeout (${timeout}ms) waiting for text: "${text}"\n` +
|
||||||
|
`Last 500 chars: ${stripAnsi(this.rawOutput).slice(-500)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for output to stabilize (no new output for `stableMs`). */
|
||||||
|
async idle(stableMs = 5000, timeout = 120_000): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastLength = this.rawOutput.length;
|
||||||
|
let lastChangeTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
await sleep(100);
|
||||||
|
if (this.rawOutput.length !== lastLength) {
|
||||||
|
lastLength = this.rawOutput.length;
|
||||||
|
lastChangeTime = Date.now();
|
||||||
|
} else if (Date.now() - lastChangeTime >= stableMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the rendered terminal screen — what a user would actually see.
|
||||||
|
* Uses @xterm/headless buffer to get properly processed output,
|
||||||
|
* handling cursor movement, line clearing, and scrollback.
|
||||||
|
*/
|
||||||
|
async screen(): Promise<string> {
|
||||||
|
await this.flush();
|
||||||
|
const buf = this.terminal.buffer.active;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
const line = buf.getLine(i);
|
||||||
|
lines.push(line ? line.translateToString(true) : '');
|
||||||
|
}
|
||||||
|
// Trim trailing empty lines
|
||||||
|
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
||||||
|
lines.pop();
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll the screen until `predicate` returns true.
|
||||||
|
* Returns the screen text when matched.
|
||||||
|
*/
|
||||||
|
async waitForScreen(
|
||||||
|
predicate: (screen: string) => boolean,
|
||||||
|
description: string,
|
||||||
|
timeout = 120_000,
|
||||||
|
): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
await sleep(3000);
|
||||||
|
const s = await this.screen();
|
||||||
|
if (predicate(s)) return s;
|
||||||
|
}
|
||||||
|
const finalScreen = await this.screen();
|
||||||
|
throw new Error(
|
||||||
|
`Timeout (${timeout}ms) waiting for: ${description}\n` +
|
||||||
|
`Screen (last 600):\n${finalScreen.slice(-600)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill the PTY process and dispose the terminal. */
|
||||||
|
async close(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.ptyProcess.kill();
|
||||||
|
} catch {
|
||||||
|
// Process may have already exited
|
||||||
|
}
|
||||||
|
this.terminal.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TestRig } from './test-helper.js';
|
import { TestRig } from '../test-helper.js';
|
||||||
|
|
||||||
describe('mixed input crash prevention', () => {
|
describe('mixed input crash prevention', () => {
|
||||||
it('should not crash when using mixed prompt inputs', async () => {
|
it('should not crash when using mixed prompt inputs', async () => {
|
||||||
17
integration-tests/terminal-capture/scenarios/cron-loop.ts
Normal file
17
integration-tests/terminal-capture/scenarios/cron-loop.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demonstrates the /loop skill and cron scheduling tools.
|
||||||
|
* Creates a recurring job, lists it, then clears all jobs.
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
name: 'cron-loop',
|
||||||
|
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||||
|
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||||
|
flow: [
|
||||||
|
{ type: 'hi' },
|
||||||
|
{ type: '/loop 1m say hi to me' },
|
||||||
|
{ type: '/loop list' },
|
||||||
|
{ type: '/loop clear' },
|
||||||
|
],
|
||||||
|
} satisfies ScenarioConfig;
|
||||||
|
|
@ -640,6 +640,46 @@ export class TerminalCapture {
|
||||||
return this.rawOutput;
|
return this.rawOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current rendered terminal screen text from xterm.js.
|
||||||
|
*
|
||||||
|
* Unlike getOutput() which returns the accumulated raw PTY stream (with
|
||||||
|
* duplicates from Ink TUI redraws), this returns the actual screen content
|
||||||
|
* as rendered by xterm.js — what a user would see right now.
|
||||||
|
*
|
||||||
|
* Includes scrollback buffer content.
|
||||||
|
*/
|
||||||
|
async getScreenText(): Promise<string> {
|
||||||
|
if (!this.page) throw new Error('Not initialized');
|
||||||
|
await this.flush();
|
||||||
|
return this.page.evaluate(() => {
|
||||||
|
const W = window as unknown as Record<string, unknown>;
|
||||||
|
const term = W['term'] as {
|
||||||
|
buffer: {
|
||||||
|
active: {
|
||||||
|
length: number;
|
||||||
|
getLine: (
|
||||||
|
i: number,
|
||||||
|
) =>
|
||||||
|
| { translateToString: (trimRight?: boolean) => string }
|
||||||
|
| undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const buf = term.buffer.active;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
const line = buf.getLine(i);
|
||||||
|
lines.push(line ? line.translateToString(true) : '');
|
||||||
|
}
|
||||||
|
// Trim trailing empty lines
|
||||||
|
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
||||||
|
lines.pop();
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Cleanup ──────────────────────────────
|
// ── Cleanup ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -26,6 +26,7 @@
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitest/coverage-v8": "^3.1.1",
|
"@vitest/coverage-v8": "^3.1.1",
|
||||||
"@vitest/eslint-plugin": "^1.3.4",
|
"@vitest/eslint-plugin": "^1.3.4",
|
||||||
|
"@xterm/headless": "^5.5.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,10 @@
|
||||||
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
|
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
|
||||||
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
|
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
|
||||||
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
|
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
|
||||||
"test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
"test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests cli",
|
||||||
"test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
"test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests cli",
|
||||||
|
"test:integration:interactive:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests interactive",
|
||||||
|
"test:integration:interactive:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests interactive",
|
||||||
"test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests",
|
"test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests",
|
||||||
"test:terminal-bench:oracle": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'oracle'",
|
"test:terminal-bench:oracle": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'oracle'",
|
||||||
"test:terminal-bench:qwen": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'qwen'",
|
"test:terminal-bench:qwen": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'qwen'",
|
||||||
|
|
@ -84,6 +86,7 @@
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitest/coverage-v8": "^3.1.1",
|
"@vitest/coverage-v8": "^3.1.1",
|
||||||
"@vitest/eslint-plugin": "^1.3.4",
|
"@vitest/eslint-plugin": "^1.3.4",
|
||||||
|
"@xterm/headless": "^5.5.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ describe('Session', () => {
|
||||||
getTargetDir: vi.fn().mockReturnValue(process.cwd()),
|
getTargetDir: vi.fn().mockReturnValue(process.cwd()),
|
||||||
getDebugMode: vi.fn().mockReturnValue(false),
|
getDebugMode: vi.fn().mockReturnValue(false),
|
||||||
getAuthType: vi.fn().mockImplementation(() => currentAuthType),
|
getAuthType: vi.fn().mockImplementation(() => currentAuthType),
|
||||||
|
isCronEnabled: vi.fn().mockReturnValue(false),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
mockClient = {
|
mockClient = {
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,12 @@ export class Session implements SessionContext {
|
||||||
private turn: number = 0;
|
private turn: number = 0;
|
||||||
private readonly runtimeBaseDir: string;
|
private readonly runtimeBaseDir: string;
|
||||||
|
|
||||||
|
// Cron scheduling state
|
||||||
|
private cronQueue: string[] = [];
|
||||||
|
private cronProcessing = false;
|
||||||
|
private cronAbortController: AbortController | null = null;
|
||||||
|
private cronCompletion: Promise<void> | null = null;
|
||||||
|
|
||||||
// Modular components
|
// Modular components
|
||||||
private readonly historyReplayer: HistoryReplayer;
|
private readonly historyReplayer: HistoryReplayer;
|
||||||
private readonly toolCallEmitter: ToolCallEmitter;
|
private readonly toolCallEmitter: ToolCallEmitter;
|
||||||
|
|
@ -155,12 +161,37 @@ export class Session implements SessionContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelPendingPrompt(): Promise<void> {
|
async cancelPendingPrompt(): Promise<void> {
|
||||||
if (!this.pendingPrompt) {
|
const hadPrompt = !!this.pendingPrompt;
|
||||||
|
const hadCron = !!this.cronAbortController;
|
||||||
|
|
||||||
|
if (!hadPrompt && !hadCron) {
|
||||||
throw new Error('Not currently generating');
|
throw new Error('Not currently generating');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pendingPrompt.abort();
|
if (this.pendingPrompt) {
|
||||||
this.pendingPrompt = null;
|
this.pendingPrompt.abort();
|
||||||
|
this.pendingPrompt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any in-progress cron execution
|
||||||
|
if (this.cronAbortController) {
|
||||||
|
this.cronAbortController.abort();
|
||||||
|
this.cronAbortController = null;
|
||||||
|
this.cronQueue = [];
|
||||||
|
this.cronProcessing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop scheduler and emit exit summary
|
||||||
|
const scheduler = this.config.isCronEnabled()
|
||||||
|
? this.config.getCronScheduler()
|
||||||
|
: null;
|
||||||
|
if (scheduler) {
|
||||||
|
const summary = scheduler.getExitSummary();
|
||||||
|
scheduler.stop();
|
||||||
|
if (summary) {
|
||||||
|
await this.messageEmitter.emitAgentMessage(summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||||
|
|
@ -170,6 +201,22 @@ export class Session implements SessionContext {
|
||||||
const pendingSend = new AbortController();
|
const pendingSend = new AbortController();
|
||||||
this.pendingPrompt = pendingSend;
|
this.pendingPrompt = pendingSend;
|
||||||
|
|
||||||
|
// Abort any in-progress cron execution (user prompt takes priority)
|
||||||
|
if (this.cronAbortController) {
|
||||||
|
this.cronAbortController.abort();
|
||||||
|
this.cronAbortController = null;
|
||||||
|
this.cronQueue = [];
|
||||||
|
this.cronProcessing = false;
|
||||||
|
}
|
||||||
|
if (this.cronCompletion) {
|
||||||
|
try {
|
||||||
|
await this.cronCompletion;
|
||||||
|
} catch {
|
||||||
|
// Expected: cron was aborted
|
||||||
|
}
|
||||||
|
this.cronCompletion = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for the previous prompt to finish so chat history is consistent.
|
// Wait for the previous prompt to finish so chat history is consistent.
|
||||||
if (this.pendingPromptCompletion) {
|
if (this.pendingPromptCompletion) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -191,7 +238,12 @@ export class Session implements SessionContext {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.#executePrompt(params, pendingSend);
|
const result = await this.#executePrompt(params, pendingSend);
|
||||||
|
this.pendingPrompt = null;
|
||||||
|
this.#startCronSchedulerIfNeeded();
|
||||||
|
// Drain any cron prompts that queued while the prompt was active
|
||||||
|
void this.#drainCronQueue();
|
||||||
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
resolveCompletion();
|
resolveCompletion();
|
||||||
}
|
}
|
||||||
|
|
@ -376,6 +428,169 @@ export class Session implements SessionContext {
|
||||||
await this.client.sessionUpdate(params);
|
await this.client.sessionUpdate(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the cron scheduler if cron is enabled and jobs exist.
|
||||||
|
* The scheduler runs in the background, pushing fired prompts into
|
||||||
|
* `cronQueue` and triggering `#drainCronQueue`.
|
||||||
|
*/
|
||||||
|
#startCronSchedulerIfNeeded(): void {
|
||||||
|
if (!this.config.isCronEnabled()) return;
|
||||||
|
const scheduler = this.config.getCronScheduler();
|
||||||
|
if (scheduler.size === 0) return;
|
||||||
|
|
||||||
|
scheduler.start((job: { prompt: string }) => {
|
||||||
|
this.cronQueue.push(job.prompt);
|
||||||
|
void this.#drainCronQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes queued cron prompts one at a time. Uses `cronProcessing`
|
||||||
|
* as a mutex to prevent concurrent access to the chat.
|
||||||
|
*/
|
||||||
|
async #drainCronQueue(): Promise<void> {
|
||||||
|
if (this.cronProcessing) return;
|
||||||
|
// Don't process cron while a user prompt is active — the queue will be
|
||||||
|
// drained after the prompt completes (see end of prompt()).
|
||||||
|
if (this.pendingPrompt) return;
|
||||||
|
this.cronProcessing = true;
|
||||||
|
|
||||||
|
let resolveCompletion!: () => void;
|
||||||
|
this.cronCompletion = new Promise<void>((resolve) => {
|
||||||
|
resolveCompletion = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (this.cronQueue.length > 0) {
|
||||||
|
const prompt = this.cronQueue.shift()!;
|
||||||
|
await this.#executeCronPrompt(prompt);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.cronProcessing = false;
|
||||||
|
resolveCompletion();
|
||||||
|
this.cronCompletion = null;
|
||||||
|
|
||||||
|
// Stop scheduler if all jobs were deleted during execution
|
||||||
|
if (this.config.isCronEnabled()) {
|
||||||
|
const scheduler = this.config.getCronScheduler();
|
||||||
|
if (scheduler.size === 0) {
|
||||||
|
scheduler.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a single cron-fired prompt: echoes it as a user message with
|
||||||
|
* `_meta.source='cron'`, streams the model response, and handles tool calls.
|
||||||
|
*/
|
||||||
|
async #executeCronPrompt(prompt: string): Promise<void> {
|
||||||
|
return Storage.runWithRuntimeBaseDir(
|
||||||
|
this.runtimeBaseDir,
|
||||||
|
this.config.getWorkingDir(),
|
||||||
|
async () => {
|
||||||
|
const ac = new AbortController();
|
||||||
|
this.cronAbortController = ac;
|
||||||
|
const promptId =
|
||||||
|
this.config.getSessionId() + '########cron' + Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Echo the cron prompt as a user message so the client sees it
|
||||||
|
await this.sendUpdate({
|
||||||
|
sessionUpdate: 'user_message_chunk',
|
||||||
|
content: { type: 'text', text: prompt },
|
||||||
|
_meta: { source: 'cron' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextMessage: Content | null = {
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: prompt }],
|
||||||
|
};
|
||||||
|
|
||||||
|
while (nextMessage !== null) {
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
|
||||||
|
const functionCalls: FunctionCall[] = [];
|
||||||
|
let usageMetadata: GenerateContentResponseUsageMetadata | null =
|
||||||
|
null;
|
||||||
|
const streamStartTime = Date.now();
|
||||||
|
|
||||||
|
const responseStream = await this.chat.sendMessageStream(
|
||||||
|
this.config.getModel(),
|
||||||
|
{
|
||||||
|
message: nextMessage.parts ?? [],
|
||||||
|
config: { abortSignal: ac.signal },
|
||||||
|
},
|
||||||
|
promptId,
|
||||||
|
);
|
||||||
|
nextMessage = null;
|
||||||
|
|
||||||
|
for await (const resp of responseStream) {
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
resp.type === StreamEventType.CHUNK &&
|
||||||
|
resp.value.candidates &&
|
||||||
|
resp.value.candidates.length > 0
|
||||||
|
) {
|
||||||
|
const candidate = resp.value.candidates[0];
|
||||||
|
for (const part of candidate.content?.parts ?? []) {
|
||||||
|
if (!part.text) continue;
|
||||||
|
this.messageEmitter.emitMessage(
|
||||||
|
part.text,
|
||||||
|
'assistant',
|
||||||
|
part.thought,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
resp.type === StreamEventType.CHUNK &&
|
||||||
|
resp.value.usageMetadata
|
||||||
|
) {
|
||||||
|
usageMetadata = resp.value.usageMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
resp.type === StreamEventType.CHUNK &&
|
||||||
|
resp.value.functionCalls
|
||||||
|
) {
|
||||||
|
functionCalls.push(...resp.value.functionCalls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usageMetadata) {
|
||||||
|
const durationMs = Date.now() - streamStartTime;
|
||||||
|
await this.messageEmitter.emitUsageMetadata(
|
||||||
|
usageMetadata,
|
||||||
|
'',
|
||||||
|
durationMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (functionCalls.length > 0) {
|
||||||
|
const toolResponseParts: Part[] = [];
|
||||||
|
for (const fc of functionCalls) {
|
||||||
|
const response = await this.runTool(ac.signal, promptId, fc);
|
||||||
|
toolResponseParts.push(...response);
|
||||||
|
}
|
||||||
|
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
debugLogger.error('Error processing cron prompt:', error);
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
await this.messageEmitter.emitAgentMessage(`[cron error] ${msg}`);
|
||||||
|
} finally {
|
||||||
|
if (this.cronAbortController === ac) {
|
||||||
|
this.cronAbortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async sendAvailableCommandsUpdate(): Promise<void> {
|
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1082,6 +1082,7 @@ export async function loadCliConfig(
|
||||||
maxSessionTurns:
|
maxSessionTurns:
|
||||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||||
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
|
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
|
||||||
|
cronEnabled: settings.experimental?.cron ?? false,
|
||||||
listExtensions: argv.listExtensions || false,
|
listExtensions: argv.listExtensions || false,
|
||||||
overrideExtensions: overrideExtensions || argv.extensions,
|
overrideExtensions: overrideExtensions || argv.extensions,
|
||||||
noBrowser: !!process.env['NO_BROWSER'],
|
noBrowser: !!process.env['NO_BROWSER'],
|
||||||
|
|
|
||||||
|
|
@ -1571,9 +1571,20 @@ const SETTINGS_SCHEMA = {
|
||||||
category: 'Experimental',
|
category: 'Experimental',
|
||||||
requiresRestart: true,
|
requiresRestart: true,
|
||||||
default: {},
|
default: {},
|
||||||
description: 'Setting to enable experimental features',
|
description: 'Settings to enable experimental features.',
|
||||||
showInDialog: false,
|
showInDialog: false,
|
||||||
properties: {},
|
properties: {
|
||||||
|
cron: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Enable Cron/Loop Tools',
|
||||||
|
category: 'Experimental',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Enable in-session cron/loop tools (experimental). When enabled, the model can create recurring prompts using cron_create, cron_list, and cron_delete tools. Can also be enabled via QWEN_CODE_ENABLE_CRON=1 environment variable.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const satisfies SettingsSchema;
|
} as const satisfies SettingsSchema;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,8 @@ describe('runNonInteractive', () => {
|
||||||
}),
|
}),
|
||||||
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
||||||
isInteractive: vi.fn().mockReturnValue(false),
|
isInteractive: vi.fn().mockReturnValue(false),
|
||||||
|
isCronEnabled: vi.fn().mockReturnValue(false),
|
||||||
|
getCronScheduler: vi.fn().mockReturnValue(null),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
mockSettings = {
|
mockSettings = {
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,138 @@ export async function runNonInteractive(
|
||||||
}
|
}
|
||||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||||
} else {
|
} else {
|
||||||
|
// No more tool calls — check if cron jobs are keeping us alive
|
||||||
|
const scheduler = !config.isCronEnabled()
|
||||||
|
? null
|
||||||
|
: config.getCronScheduler();
|
||||||
|
if (scheduler && scheduler.size > 0) {
|
||||||
|
// Start the scheduler and wait for all jobs to complete or be deleted.
|
||||||
|
// Each fired prompt is processed as a new turn through the same loop.
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const cronQueue: string[] = [];
|
||||||
|
let processing = false;
|
||||||
|
|
||||||
|
const checkDone = () => {
|
||||||
|
if (scheduler.size === 0 && !processing) {
|
||||||
|
scheduler.stop();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drainQueue = async () => {
|
||||||
|
if (processing) return;
|
||||||
|
processing = true;
|
||||||
|
try {
|
||||||
|
while (cronQueue.length > 0) {
|
||||||
|
const cronPrompt = cronQueue.shift()!;
|
||||||
|
turnCount++;
|
||||||
|
let cronMessages: Content[] = [
|
||||||
|
{ role: 'user', parts: [{ text: cronPrompt }] },
|
||||||
|
];
|
||||||
|
let cronIsFirstTurn = true;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const cronToolCallRequests: ToolCallRequestInfo[] = [];
|
||||||
|
const cronApiStartTime = Date.now();
|
||||||
|
const cronStream = geminiClient.sendMessageStream(
|
||||||
|
cronMessages[0]?.parts || [],
|
||||||
|
abortController.signal,
|
||||||
|
prompt_id,
|
||||||
|
{
|
||||||
|
type: cronIsFirstTurn
|
||||||
|
? SendMessageType.Cron
|
||||||
|
: SendMessageType.ToolResult,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cronIsFirstTurn = false;
|
||||||
|
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
|
||||||
|
for await (const event of cronStream) {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
const summary = scheduler.getExitSummary();
|
||||||
|
scheduler.stop();
|
||||||
|
if (summary) {
|
||||||
|
process.stderr.write(summary + '\n');
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
adapter.processEvent(event);
|
||||||
|
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||||
|
cronToolCallRequests.push(event.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
totalApiDurationMs += Date.now() - cronApiStartTime;
|
||||||
|
|
||||||
|
if (cronToolCallRequests.length > 0) {
|
||||||
|
const cronToolResponseParts: Part[] = [];
|
||||||
|
|
||||||
|
for (const requestInfo of cronToolCallRequests) {
|
||||||
|
const isAgentTool = requestInfo.name === 'agent';
|
||||||
|
const { handler: outputUpdateHandler } = isAgentTool
|
||||||
|
? createAgentToolProgressHandler(
|
||||||
|
config,
|
||||||
|
requestInfo.callId,
|
||||||
|
adapter,
|
||||||
|
)
|
||||||
|
: createToolProgressHandler(requestInfo, adapter);
|
||||||
|
|
||||||
|
const toolResponse = await executeToolCall(
|
||||||
|
config,
|
||||||
|
requestInfo,
|
||||||
|
abortController.signal,
|
||||||
|
{ outputUpdateHandler },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (toolResponse.error) {
|
||||||
|
handleToolError(
|
||||||
|
requestInfo.name,
|
||||||
|
toolResponse.error,
|
||||||
|
config,
|
||||||
|
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||||
|
typeof toolResponse.resultDisplay === 'string'
|
||||||
|
? toolResponse.resultDisplay
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter.emitToolResult(requestInfo, toolResponse);
|
||||||
|
|
||||||
|
if (toolResponse.responseParts) {
|
||||||
|
cronToolResponseParts.push(
|
||||||
|
...toolResponse.responseParts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cronMessages = [
|
||||||
|
{ role: 'user', parts: cronToolResponseParts },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error('Error processing cron prompt:', error);
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
checkDone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduler.start((job: { prompt: string }) => {
|
||||||
|
cronQueue.push(job.prompt);
|
||||||
|
void drainQueue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also check immediately in case jobs were already deleted
|
||||||
|
checkDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const metrics = uiTelemetryService.getMetrics();
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
const usage = computeUsageFromMetrics(metrics);
|
const usage = computeUsageFromMetrics(metrics);
|
||||||
// Get stats for JSON format output
|
// Get stats for JSON format output
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,8 @@ describe('useGeminiStream', () => {
|
||||||
.mockReturnValue(contentGeneratorConfig),
|
.mockReturnValue(contentGeneratorConfig),
|
||||||
getMaxSessionTurns: vi.fn(() => 50),
|
getMaxSessionTurns: vi.fn(() => 50),
|
||||||
getArenaAgentClient: vi.fn(() => null),
|
getArenaAgentClient: vi.fn(() => null),
|
||||||
|
isCronEnabled: vi.fn(() => false),
|
||||||
|
getCronScheduler: vi.fn(() => null),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
mockOnDebugMessage = vi.fn();
|
mockOnDebugMessage = vi.fn();
|
||||||
mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
|
mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
|
||||||
|
|
|
||||||
|
|
@ -1236,7 +1236,10 @@ export const useGeminiStream = (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check image format support for non-continuations
|
// Check image format support for non-continuations
|
||||||
if (submitType === SendMessageType.UserQuery) {
|
if (
|
||||||
|
submitType === SendMessageType.UserQuery ||
|
||||||
|
submitType === SendMessageType.Cron
|
||||||
|
) {
|
||||||
const formatCheck = checkImageFormatsSupport(queryToSend);
|
const formatCheck = checkImageFormatsSupport(queryToSend);
|
||||||
if (formatCheck.hasUnsupportedFormats) {
|
if (formatCheck.hasUnsupportedFormats) {
|
||||||
addItem(
|
addItem(
|
||||||
|
|
@ -1253,7 +1256,10 @@ export const useGeminiStream = (
|
||||||
lastPromptRef.current = finalQueryToSend;
|
lastPromptRef.current = finalQueryToSend;
|
||||||
lastPromptErroredRef.current = false;
|
lastPromptErroredRef.current = false;
|
||||||
|
|
||||||
if (submitType === SendMessageType.UserQuery) {
|
if (
|
||||||
|
submitType === SendMessageType.UserQuery ||
|
||||||
|
submitType === SendMessageType.Cron
|
||||||
|
) {
|
||||||
// trigger new prompt event for session stats in CLI
|
// trigger new prompt event for session stats in CLI
|
||||||
startNewPrompt();
|
startNewPrompt();
|
||||||
|
|
||||||
|
|
@ -1698,6 +1704,38 @@ export const useGeminiStream = (
|
||||||
storage,
|
storage,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ─── Cron scheduler integration ─────────────────────────
|
||||||
|
const cronQueueRef = useRef<string[]>([]);
|
||||||
|
const [cronTrigger, setCronTrigger] = useState(0);
|
||||||
|
|
||||||
|
// Start the scheduler on mount, stop on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config.isCronEnabled()) return;
|
||||||
|
const scheduler = config.getCronScheduler();
|
||||||
|
scheduler.start((job: { prompt: string }) => {
|
||||||
|
cronQueueRef.current.push(job.prompt);
|
||||||
|
setCronTrigger((n) => n + 1);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
const summary = scheduler.getExitSummary();
|
||||||
|
scheduler.stop();
|
||||||
|
if (summary) {
|
||||||
|
process.stderr.write(summary + '\n');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// When idle, drain the cron queue one prompt at a time
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
streamingState === StreamingState.Idle &&
|
||||||
|
cronQueueRef.current.length > 0
|
||||||
|
) {
|
||||||
|
const prompt = cronQueueRef.current.shift()!;
|
||||||
|
submitQuery(prompt, SendMessageType.Cron);
|
||||||
|
}
|
||||||
|
}, [streamingState, submitQuery, cronTrigger]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
streamingState,
|
streamingState,
|
||||||
submitQuery,
|
submitQuery,
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import type {
|
||||||
import { type AgentEventEmitter, AgentEventType } from './agent-events.js';
|
import { type AgentEventEmitter, AgentEventType } from './agent-events.js';
|
||||||
import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js';
|
import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js';
|
||||||
import { AgentTool } from '../../tools/agent.js';
|
import { AgentTool } from '../../tools/agent.js';
|
||||||
|
import { ToolNames } from '../../tools/tool-names.js';
|
||||||
import { DEFAULT_QWEN_MODEL } from '../../config/models.js';
|
import { DEFAULT_QWEN_MODEL } from '../../config/models.js';
|
||||||
import { type ContextState, templateString } from './agent-headless.js';
|
import { type ContextState, templateString } from './agent-headless.js';
|
||||||
|
|
||||||
|
|
@ -273,6 +274,15 @@ export class AgentCore {
|
||||||
const toolRegistry = this.runtimeContext.getToolRegistry();
|
const toolRegistry = this.runtimeContext.getToolRegistry();
|
||||||
const toolsList: FunctionDeclaration[] = [];
|
const toolsList: FunctionDeclaration[] = [];
|
||||||
|
|
||||||
|
// Tools excluded from subagents: AgentTool (prevent recursion) and
|
||||||
|
// cron tools (session-scoped, should only be used by the main session).
|
||||||
|
const excludedFromSubagents = new Set<string>([
|
||||||
|
AgentTool.Name,
|
||||||
|
ToolNames.CRON_CREATE,
|
||||||
|
ToolNames.CRON_LIST,
|
||||||
|
ToolNames.CRON_DELETE,
|
||||||
|
]);
|
||||||
|
|
||||||
if (this.toolConfig) {
|
if (this.toolConfig) {
|
||||||
const asStrings = this.toolConfig.tools.filter(
|
const asStrings = this.toolConfig.tools.filter(
|
||||||
(t): t is string => typeof t === 'string',
|
(t): t is string => typeof t === 'string',
|
||||||
|
|
@ -286,11 +296,13 @@ export class AgentCore {
|
||||||
toolsList.push(
|
toolsList.push(
|
||||||
...toolRegistry
|
...toolRegistry
|
||||||
.getFunctionDeclarations()
|
.getFunctionDeclarations()
|
||||||
.filter((t) => t.name !== AgentTool.Name),
|
.filter((t) => !(t.name && excludedFromSubagents.has(t.name))),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toolsList.push(
|
toolsList.push(
|
||||||
...toolRegistry.getFunctionDeclarationsFiltered(asStrings),
|
...toolRegistry.getFunctionDeclarationsFiltered(
|
||||||
|
asStrings.filter((name) => !excludedFromSubagents.has(name)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
toolsList.push(...onlyInlineDecls);
|
toolsList.push(...onlyInlineDecls);
|
||||||
|
|
@ -299,7 +311,7 @@ export class AgentCore {
|
||||||
toolsList.push(
|
toolsList.push(
|
||||||
...toolRegistry
|
...toolRegistry
|
||||||
.getFunctionDeclarations()
|
.getFunctionDeclarations()
|
||||||
.filter((t) => t.name !== AgentTool.Name),
|
.filter((t) => !(t.name && excludedFromSubagents.has(t.name))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import {
|
||||||
type FileEncodingType,
|
type FileEncodingType,
|
||||||
} from '../services/fileSystemService.js';
|
} from '../services/fileSystemService.js';
|
||||||
import { GitService } from '../services/gitService.js';
|
import { GitService } from '../services/gitService.js';
|
||||||
|
import { CronScheduler } from '../services/cronScheduler.js';
|
||||||
|
|
||||||
// Tools
|
// Tools
|
||||||
import { AskUserQuestionTool } from '../tools/askUserQuestion.js';
|
import { AskUserQuestionTool } from '../tools/askUserQuestion.js';
|
||||||
|
|
@ -63,6 +64,9 @@ import { WebFetchTool } from '../tools/web-fetch.js';
|
||||||
import { WebSearchTool } from '../tools/web-search/index.js';
|
import { WebSearchTool } from '../tools/web-search/index.js';
|
||||||
import { WriteFileTool } from '../tools/write-file.js';
|
import { WriteFileTool } from '../tools/write-file.js';
|
||||||
import { LspTool } from '../tools/lsp.js';
|
import { LspTool } from '../tools/lsp.js';
|
||||||
|
import { CronCreateTool } from '../tools/cron-create.js';
|
||||||
|
import { CronListTool } from '../tools/cron-list.js';
|
||||||
|
import { CronDeleteTool } from '../tools/cron-delete.js';
|
||||||
import type { LspClient } from '../lsp/types.js';
|
import type { LspClient } from '../lsp/types.js';
|
||||||
|
|
||||||
// Other modules
|
// Other modules
|
||||||
|
|
@ -368,6 +372,7 @@ export interface ConfigParameters {
|
||||||
maxSessionTurns?: number;
|
maxSessionTurns?: number;
|
||||||
sessionTokenLimit?: number;
|
sessionTokenLimit?: number;
|
||||||
experimentalZedIntegration?: boolean;
|
experimentalZedIntegration?: boolean;
|
||||||
|
cronEnabled?: boolean;
|
||||||
listExtensions?: boolean;
|
listExtensions?: boolean;
|
||||||
overrideExtensions?: string[];
|
overrideExtensions?: string[];
|
||||||
allowedMcpServers?: string[];
|
allowedMcpServers?: string[];
|
||||||
|
|
@ -530,6 +535,7 @@ export class Config {
|
||||||
private readonly usageStatisticsEnabled: boolean;
|
private readonly usageStatisticsEnabled: boolean;
|
||||||
private geminiClient!: GeminiClient;
|
private geminiClient!: GeminiClient;
|
||||||
private baseLlmClient!: BaseLlmClient;
|
private baseLlmClient!: BaseLlmClient;
|
||||||
|
private cronScheduler: CronScheduler | null = null;
|
||||||
private readonly fileFiltering: {
|
private readonly fileFiltering: {
|
||||||
respectGitIgnore: boolean;
|
respectGitIgnore: boolean;
|
||||||
respectQwenIgnore: boolean;
|
respectQwenIgnore: boolean;
|
||||||
|
|
@ -557,6 +563,7 @@ export class Config {
|
||||||
|
|
||||||
private readonly cliVersion?: string;
|
private readonly cliVersion?: string;
|
||||||
private readonly experimentalZedIntegration: boolean = false;
|
private readonly experimentalZedIntegration: boolean = false;
|
||||||
|
private readonly cronEnabled: boolean = false;
|
||||||
private readonly chatRecordingEnabled: boolean;
|
private readonly chatRecordingEnabled: boolean;
|
||||||
private readonly loadMemoryFromIncludeDirectories: boolean = false;
|
private readonly loadMemoryFromIncludeDirectories: boolean = false;
|
||||||
private readonly importFormat: 'tree' | 'flat';
|
private readonly importFormat: 'tree' | 'flat';
|
||||||
|
|
@ -679,6 +686,7 @@ export class Config {
|
||||||
this.sessionTokenLimit = params.sessionTokenLimit ?? -1;
|
this.sessionTokenLimit = params.sessionTokenLimit ?? -1;
|
||||||
this.experimentalZedIntegration =
|
this.experimentalZedIntegration =
|
||||||
params.experimentalZedIntegration ?? false;
|
params.experimentalZedIntegration ?? false;
|
||||||
|
this.cronEnabled = params.cronEnabled ?? false;
|
||||||
this.listExtensions = params.listExtensions ?? false;
|
this.listExtensions = params.listExtensions ?? false;
|
||||||
this.overrideExtensions = params.overrideExtensions;
|
this.overrideExtensions = params.overrideExtensions;
|
||||||
this.noBrowser = params.noBrowser ?? false;
|
this.noBrowser = params.noBrowser ?? false;
|
||||||
|
|
@ -1687,6 +1695,19 @@ export class Config {
|
||||||
return this.geminiClient;
|
return this.geminiClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCronScheduler(): CronScheduler {
|
||||||
|
if (!this.cronScheduler) {
|
||||||
|
this.cronScheduler = new CronScheduler();
|
||||||
|
}
|
||||||
|
return this.cronScheduler;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCronEnabled(): boolean {
|
||||||
|
// Cron is experimental and opt-in: enabled via settings or env var
|
||||||
|
if (process.env['QWEN_CODE_ENABLE_CRON'] === '1') return true;
|
||||||
|
return this.cronEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
getEnableRecursiveFileSearch(): boolean {
|
getEnableRecursiveFileSearch(): boolean {
|
||||||
return this.fileFiltering.enableRecursiveFileSearch;
|
return this.fileFiltering.enableRecursiveFileSearch;
|
||||||
}
|
}
|
||||||
|
|
@ -2204,6 +2225,13 @@ export class Config {
|
||||||
await registerCoreTool(LspTool, this);
|
await registerCoreTool(LspTool, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register cron tools unless disabled
|
||||||
|
if (this.isCronEnabled()) {
|
||||||
|
await registerCoreTool(CronCreateTool, this);
|
||||||
|
await registerCoreTool(CronListTool, this);
|
||||||
|
await registerCoreTool(CronDeleteTool, this);
|
||||||
|
}
|
||||||
|
|
||||||
if (!options?.skipDiscovery) {
|
if (!options?.skipDiscovery) {
|
||||||
await registry.discoverAllTools();
|
await registry.discoverAllTools();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,8 @@ export enum SendMessageType {
|
||||||
ToolResult = 'toolResult',
|
ToolResult = 'toolResult',
|
||||||
Retry = 'retry',
|
Retry = 'retry',
|
||||||
Hook = 'hook',
|
Hook = 'hook',
|
||||||
|
/** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */
|
||||||
|
Cron = 'cron',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageOptions {
|
export interface SendMessageOptions {
|
||||||
|
|
@ -472,6 +474,7 @@ export class GeminiClient {
|
||||||
const messageBus = this.config.getMessageBus();
|
const messageBus = this.config.getMessageBus();
|
||||||
if (
|
if (
|
||||||
messageType !== SendMessageType.Retry &&
|
messageType !== SendMessageType.Retry &&
|
||||||
|
messageType !== SendMessageType.Cron &&
|
||||||
hooksEnabled &&
|
hooksEnabled &&
|
||||||
messageBus &&
|
messageBus &&
|
||||||
this.config.hasHooksForEvent('UserPromptSubmit')
|
this.config.hasHooksForEvent('UserPromptSubmit')
|
||||||
|
|
@ -516,7 +519,10 @@ export class GeminiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageType === SendMessageType.UserQuery) {
|
if (
|
||||||
|
messageType === SendMessageType.UserQuery ||
|
||||||
|
messageType === SendMessageType.Cron
|
||||||
|
) {
|
||||||
this.loopDetector.reset(prompt_id);
|
this.loopDetector.reset(prompt_id);
|
||||||
this.lastPromptId = prompt_id;
|
this.lastPromptId = prompt_id;
|
||||||
|
|
||||||
|
|
@ -614,7 +620,10 @@ export class GeminiClient {
|
||||||
|
|
||||||
// append system reminders to the request
|
// append system reminders to the request
|
||||||
let requestToSent = await flatMapTextParts(request, async (text) => [text]);
|
let requestToSent = await flatMapTextParts(request, async (text) => [text]);
|
||||||
if (messageType === SendMessageType.UserQuery) {
|
if (
|
||||||
|
messageType === SendMessageType.UserQuery ||
|
||||||
|
messageType === SendMessageType.Cron
|
||||||
|
) {
|
||||||
const systemReminders = [];
|
const systemReminders = [];
|
||||||
|
|
||||||
// add subagent system reminder if there are subagents
|
// add subagent system reminder if there are subagents
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,16 @@ export * from './tools/tool-registry.js';
|
||||||
export * from './tools/web-fetch.js';
|
export * from './tools/web-fetch.js';
|
||||||
export * from './tools/web-search/index.js';
|
export * from './tools/web-search/index.js';
|
||||||
export * from './tools/write-file.js';
|
export * from './tools/write-file.js';
|
||||||
|
export * from './tools/cron-create.js';
|
||||||
|
export * from './tools/cron-list.js';
|
||||||
|
export * from './tools/cron-delete.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Services
|
// Services
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export * from './services/chatRecordingService.js';
|
export * from './services/chatRecordingService.js';
|
||||||
|
export * from './services/cronScheduler.js';
|
||||||
export * from './services/fileDiscoveryService.js';
|
export * from './services/fileDiscoveryService.js';
|
||||||
export * from './services/fileSystemService.js';
|
export * from './services/fileSystemService.js';
|
||||||
export * from './services/gitService.js';
|
export * from './services/gitService.js';
|
||||||
|
|
|
||||||
286
packages/core/src/services/cronScheduler.test.ts
Normal file
286
packages/core/src/services/cronScheduler.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { CronScheduler, type CronJob } from './cronScheduler.js';
|
||||||
|
|
||||||
|
describe('CronScheduler', () => {
|
||||||
|
let scheduler: CronScheduler;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scheduler = new CronScheduler();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
scheduler.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('creates a job with valid fields', () => {
|
||||||
|
const job = scheduler.create('*/5 * * * *', 'test prompt', true);
|
||||||
|
expect(job.id).toHaveLength(8);
|
||||||
|
expect(job.cronExpr).toBe('*/5 * * * *');
|
||||||
|
expect(job.prompt).toBe('test prompt');
|
||||||
|
expect(job.recurring).toBe(true);
|
||||||
|
expect(job.createdAt).toBeGreaterThan(0);
|
||||||
|
expect(job.expiresAt).toBeGreaterThan(job.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates one-shot jobs with zero jitter', () => {
|
||||||
|
const job = scheduler.create('*/1 * * * *', 'once', false);
|
||||||
|
expect(job.jitterMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces max 50 jobs', () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
scheduler.create('*/1 * * * *', `job-${i}`, true);
|
||||||
|
}
|
||||||
|
expect(() => scheduler.create('*/1 * * * *', 'job-51', true)).toThrow(
|
||||||
|
'Maximum number of cron jobs (50) reached',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates unique IDs', () => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const job = scheduler.create('*/1 * * * *', `job-${i}`, true);
|
||||||
|
ids.add(job.id);
|
||||||
|
}
|
||||||
|
expect(ids.size).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('removes an existing job', () => {
|
||||||
|
const job = scheduler.create('*/1 * * * *', 'test', true);
|
||||||
|
expect(scheduler.delete(job.id)).toBe(true);
|
||||||
|
expect(scheduler.list()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-existent job', () => {
|
||||||
|
expect(scheduler.delete('nonexistent')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('returns empty array when no jobs', () => {
|
||||||
|
expect(scheduler.list()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all jobs', () => {
|
||||||
|
scheduler.create('*/1 * * * *', 'a', true);
|
||||||
|
scheduler.create('*/2 * * * *', 'b', false);
|
||||||
|
const jobs = scheduler.list();
|
||||||
|
expect(jobs).toHaveLength(2);
|
||||||
|
expect(jobs.map((j) => j.prompt).sort()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('size', () => {
|
||||||
|
it('tracks job count', () => {
|
||||||
|
expect(scheduler.size).toBe(0);
|
||||||
|
const job = scheduler.create('*/1 * * * *', 'a', true);
|
||||||
|
expect(scheduler.size).toBe(1);
|
||||||
|
scheduler.delete(job.id);
|
||||||
|
expect(scheduler.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tick', () => {
|
||||||
|
it('fires callback when a job matches', () => {
|
||||||
|
const fired: CronJob[] = [];
|
||||||
|
scheduler.start((job) => fired.push(job));
|
||||||
|
|
||||||
|
// Use every-minute cron so jitter is tiny (max ~6s for 1-min period)
|
||||||
|
scheduler.create('*/1 * * * *', 'match', true);
|
||||||
|
|
||||||
|
// Tick at 10:30:59 — past any jitter for a 1-min period job
|
||||||
|
const date = new Date(2025, 0, 15, 10, 30, 59);
|
||||||
|
scheduler.tick(date);
|
||||||
|
|
||||||
|
expect(fired).toHaveLength(1);
|
||||||
|
expect(fired[0]!.prompt).toBe('match');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire when no match', () => {
|
||||||
|
const fired: CronJob[] = [];
|
||||||
|
scheduler.start((job) => fired.push(job));
|
||||||
|
|
||||||
|
scheduler.create('30 10 * * *', 'no match', true);
|
||||||
|
|
||||||
|
// Tick at 10:31 — should not fire
|
||||||
|
scheduler.tick(new Date(2025, 0, 15, 10, 31, 0));
|
||||||
|
expect(fired).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not double-fire in same minute', () => {
|
||||||
|
const fired: CronJob[] = [];
|
||||||
|
scheduler.start((job) => fired.push(job));
|
||||||
|
|
||||||
|
scheduler.create('*/1 * * * *', 'once per minute', true);
|
||||||
|
|
||||||
|
// Both ticks in second 59 — past jitter for a 1-min period job
|
||||||
|
const date1 = new Date(2025, 0, 15, 10, 30, 59);
|
||||||
|
const date2 = new Date(2025, 0, 15, 10, 30, 59, 500);
|
||||||
|
scheduler.tick(date1);
|
||||||
|
scheduler.tick(date2);
|
||||||
|
|
||||||
|
expect(fired).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes one-shot jobs after firing', () => {
|
||||||
|
const fired: CronJob[] = [];
|
||||||
|
scheduler.start((job) => fired.push(job));
|
||||||
|
|
||||||
|
// One-shot: jitter is 0, so second 1 is fine
|
||||||
|
scheduler.create('30 10 * * *', 'one-shot', false);
|
||||||
|
|
||||||
|
scheduler.tick(new Date(2025, 0, 15, 10, 30, 1));
|
||||||
|
expect(fired).toHaveLength(1);
|
||||||
|
expect(scheduler.list()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps recurring jobs after firing', () => {
|
||||||
|
const fired: CronJob[] = [];
|
||||||
|
scheduler.start((job) => fired.push(job));
|
||||||
|
|
||||||
|
scheduler.create('*/1 * * * *', 'recurring', true);
|
||||||
|
|
||||||
|
// Tick at second 59 — past any jitter for a 1-min period job
|
||||||
|
scheduler.tick(new Date(2025, 0, 15, 10, 30, 59));
|
||||||
|
expect(fired).toHaveLength(1);
|
||||||
|
expect(scheduler.list()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes expired jobs', () => {
|
||||||
|
scheduler.start(() => {});
|
||||||
|
|
||||||
|
const job = scheduler.create('*/1 * * * *', 'expire me', true);
|
||||||
|
// Tick far in the future (past expiry)
|
||||||
|
const farFuture = new Date(job.expiresAt + 1000);
|
||||||
|
scheduler.tick(farFuture);
|
||||||
|
|
||||||
|
expect(scheduler.list()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires in next minute after first fire', () => {
|
||||||
|
const fired: CronJob[] = [];
|
||||||
|
scheduler.start((job) => fired.push(job));
|
||||||
|
|
||||||
|
// Every minute
|
||||||
|
scheduler.create('* * * * *', 'every minute', true);
|
||||||
|
|
||||||
|
scheduler.tick(new Date(2025, 0, 15, 10, 30, 59));
|
||||||
|
expect(fired).toHaveLength(1);
|
||||||
|
|
||||||
|
// Next minute
|
||||||
|
scheduler.tick(new Date(2025, 0, 15, 10, 31, 59));
|
||||||
|
expect(fired).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires recurring jobs after the matching minute when positive jitter delays them', () => {
|
||||||
|
const fired: CronJob[] = [];
|
||||||
|
scheduler.start((job) => fired.push(job));
|
||||||
|
|
||||||
|
const job = scheduler.create('0 * * * *', 'hourly delayed', true);
|
||||||
|
job.jitterMs = 6 * 60 * 1000;
|
||||||
|
|
||||||
|
scheduler.tick(new Date(2025, 0, 15, 10, 5, 59));
|
||||||
|
expect(fired).toHaveLength(0);
|
||||||
|
|
||||||
|
scheduler.tick(new Date(2025, 0, 15, 10, 6, 0));
|
||||||
|
expect(fired).toHaveLength(1);
|
||||||
|
expect(fired[0]!.prompt).toBe('hourly delayed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires one-shot jobs before the matching minute when negative jitter advances them', () => {
|
||||||
|
const fired: CronJob[] = [];
|
||||||
|
scheduler.start((job) => fired.push(job));
|
||||||
|
|
||||||
|
const job = scheduler.create('30 10 * * *', 'oneshot early', false);
|
||||||
|
job.jitterMs = -30 * 1000;
|
||||||
|
|
||||||
|
scheduler.tick(new Date(2025, 0, 15, 10, 29, 29));
|
||||||
|
expect(fired).toHaveLength(0);
|
||||||
|
|
||||||
|
scheduler.tick(new Date(2025, 0, 15, 10, 29, 30));
|
||||||
|
expect(fired).toHaveLength(1);
|
||||||
|
expect(fired[0]!.prompt).toBe('oneshot early');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('start/stop', () => {
|
||||||
|
it('starts and stops without error', () => {
|
||||||
|
scheduler.start(() => {});
|
||||||
|
expect(scheduler.running).toBe(true);
|
||||||
|
scheduler.stop();
|
||||||
|
expect(scheduler.running).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire after stop', () => {
|
||||||
|
const fired: CronJob[] = [];
|
||||||
|
scheduler.start((job) => fired.push(job));
|
||||||
|
scheduler.stop();
|
||||||
|
|
||||||
|
scheduler.create('30 10 * * *', 'no fire', true);
|
||||||
|
scheduler.tick(new Date(2025, 0, 15, 10, 30, 1));
|
||||||
|
|
||||||
|
// tick still works manually, but onFire is cleared
|
||||||
|
expect(fired).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('start is idempotent', () => {
|
||||||
|
scheduler.start(() => {});
|
||||||
|
scheduler.start(() => {}); // should not throw or create duplicate timers
|
||||||
|
expect(scheduler.running).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getExitSummary', () => {
|
||||||
|
it('returns null when no jobs', () => {
|
||||||
|
expect(scheduler.getExitSummary()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns summary with single job', () => {
|
||||||
|
scheduler.create('*/5 * * * *', 'check the build', true);
|
||||||
|
const summary = scheduler.getExitSummary()!;
|
||||||
|
expect(summary).toContain('1 active loop cancelled:');
|
||||||
|
expect(summary).toContain('Every 5 minutes');
|
||||||
|
expect(summary).toContain('check the build');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns summary with multiple jobs', () => {
|
||||||
|
scheduler.create('*/5 * * * *', 'check the build', true);
|
||||||
|
scheduler.create('*/30 * * * *', 'check PR reviews', true);
|
||||||
|
const summary = scheduler.getExitSummary()!;
|
||||||
|
expect(summary).toContain('2 active loops cancelled:');
|
||||||
|
expect(summary).toContain('check the build');
|
||||||
|
expect(summary).toContain('check PR reviews');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates long prompts', () => {
|
||||||
|
const longPrompt = 'a'.repeat(100);
|
||||||
|
scheduler.create('*/1 * * * *', longPrompt, true);
|
||||||
|
const summary = scheduler.getExitSummary()!;
|
||||||
|
expect(summary).toContain('...');
|
||||||
|
// Should not contain the full 100-char prompt
|
||||||
|
expect(summary).not.toContain(longPrompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null after all jobs are deleted', () => {
|
||||||
|
const job = scheduler.create('*/1 * * * *', 'temp', true);
|
||||||
|
scheduler.delete(job.id);
|
||||||
|
expect(scheduler.getExitSummary()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('stops and clears all jobs', () => {
|
||||||
|
scheduler.create('*/1 * * * *', 'a', true);
|
||||||
|
scheduler.create('*/2 * * * *', 'b', true);
|
||||||
|
scheduler.start(() => {});
|
||||||
|
|
||||||
|
scheduler.destroy();
|
||||||
|
|
||||||
|
expect(scheduler.running).toBe(false);
|
||||||
|
expect(scheduler.list()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
282
packages/core/src/services/cronScheduler.ts
Normal file
282
packages/core/src/services/cronScheduler.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
/**
|
||||||
|
* In-session cron scheduler. Jobs live in memory and are gone when the
|
||||||
|
* process exits. Ticks every second, fires callbacks when jobs are due.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { matches, nextFireTime } from '../utils/cronParser.js';
|
||||||
|
import { humanReadableCron } from '../utils/cronDisplay.js';
|
||||||
|
|
||||||
|
const MAX_JOBS = 50;
|
||||||
|
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
|
||||||
|
// Recurring: up to 10% of period, capped at 15 minutes.
|
||||||
|
const MAX_RECURRING_JITTER_MS = 15 * 60 * 1000;
|
||||||
|
// One-shot: up to 90s early for jobs landing on :00 or :30.
|
||||||
|
const MAX_ONESHOT_JITTER_MS = 90 * 1000;
|
||||||
|
|
||||||
|
export interface CronJob {
|
||||||
|
id: string;
|
||||||
|
cronExpr: string;
|
||||||
|
prompt: string;
|
||||||
|
recurring: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
lastFiredAt?: number;
|
||||||
|
jitterMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic hash from a string ID, returned as a positive integer.
|
||||||
|
*/
|
||||||
|
function hashId(id: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) {
|
||||||
|
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a deterministic jitter offset from a job ID and its cron period.
|
||||||
|
* Recurring jobs: up to 10% of period, capped at 15 minutes (added after fire time).
|
||||||
|
* One-shot jobs landing on :00 or :30: up to 90s early (subtracted before fire time).
|
||||||
|
* Other one-shot jobs: 0 jitter.
|
||||||
|
*/
|
||||||
|
function computeJitter(
|
||||||
|
id: string,
|
||||||
|
cronExpr: string,
|
||||||
|
recurring: boolean,
|
||||||
|
): number {
|
||||||
|
const hash = hashId(id);
|
||||||
|
|
||||||
|
if (recurring) {
|
||||||
|
// Estimate period by computing two consecutive fire times
|
||||||
|
const now = new Date();
|
||||||
|
try {
|
||||||
|
const first = nextFireTime(cronExpr, now);
|
||||||
|
const second = nextFireTime(cronExpr, first);
|
||||||
|
const periodMs = second.getTime() - first.getTime();
|
||||||
|
const tenPercent = periodMs * 0.1;
|
||||||
|
const maxJitter = Math.min(tenPercent, MAX_RECURRING_JITTER_MS);
|
||||||
|
return hash % Math.max(1, Math.floor(maxJitter));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot: apply up to 90s early jitter only when minute is :00 or :30
|
||||||
|
try {
|
||||||
|
const fields = cronExpr.trim().split(/\s+/);
|
||||||
|
const minuteField = fields[0] ?? '';
|
||||||
|
const minuteVal = parseInt(minuteField, 10);
|
||||||
|
if (!isNaN(minuteVal) && (minuteVal === 0 || minuteVal === 30)) {
|
||||||
|
// Negative jitter = fire early
|
||||||
|
return -(hash % MAX_ONESHOT_JITTER_MS);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let id = '';
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
id += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CronScheduler {
|
||||||
|
private jobs = new Map<string, CronJob>();
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private onFire: ((job: CronJob) => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new cron job. Returns the created job.
|
||||||
|
* Throws if the max job limit is reached.
|
||||||
|
*/
|
||||||
|
create(cronExpr: string, prompt: string, recurring: boolean): CronJob {
|
||||||
|
if (this.jobs.size >= MAX_JOBS) {
|
||||||
|
throw new Error(
|
||||||
|
`Maximum number of cron jobs (${MAX_JOBS}) reached. Delete some jobs first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = generateId();
|
||||||
|
const now = Date.now();
|
||||||
|
const jitterMs = computeJitter(id, cronExpr, recurring);
|
||||||
|
|
||||||
|
const job: CronJob = {
|
||||||
|
id,
|
||||||
|
cronExpr,
|
||||||
|
prompt,
|
||||||
|
recurring,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: recurring ? now + THREE_DAYS_MS : Infinity,
|
||||||
|
jitterMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.jobs.set(id, job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a job by ID. Returns true if the job existed.
|
||||||
|
*/
|
||||||
|
delete(id: string): boolean {
|
||||||
|
return this.jobs.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all active jobs.
|
||||||
|
*/
|
||||||
|
list(): CronJob[] {
|
||||||
|
return [...this.jobs.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of active jobs.
|
||||||
|
*/
|
||||||
|
get size(): number {
|
||||||
|
return this.jobs.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the scheduler tick. Calls `onFire` when a job is due.
|
||||||
|
* Only fires when called — does not auto-fire missed intervals.
|
||||||
|
*/
|
||||||
|
start(onFire: (job: CronJob) => void): void {
|
||||||
|
this.onFire = onFire;
|
||||||
|
if (this.timer) return; // already running
|
||||||
|
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.tick();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the scheduler. Does not clear jobs — they remain queryable.
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
this.onFire = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the scheduler is running.
|
||||||
|
*/
|
||||||
|
get running(): boolean {
|
||||||
|
return this.timer !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual tick — checks all jobs against the current time and fires those
|
||||||
|
* that are due. Exported for testing.
|
||||||
|
*/
|
||||||
|
tick(now?: Date): void {
|
||||||
|
const currentDate = now ?? new Date();
|
||||||
|
const currentMs = currentDate.getTime();
|
||||||
|
|
||||||
|
for (const job of this.jobs.values()) {
|
||||||
|
// Check expiry
|
||||||
|
if (currentMs >= job.expiresAt) {
|
||||||
|
this.jobs.delete(job.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the cron minute whose jittered fire time we might be in.
|
||||||
|
// For positive jitter (recurring) the fire time is *after* the matching
|
||||||
|
// minute, so we look backwards. For negative jitter (one-shot :00/:30)
|
||||||
|
// the fire time is *before* the matching minute, so we look at the
|
||||||
|
// current minute.
|
||||||
|
//
|
||||||
|
// We scan a window of minutes around `now` equal to the absolute jitter
|
||||||
|
// so that a +6 min jitter on an hourly job still finds the :00 match.
|
||||||
|
const absJitter = Math.abs(job.jitterMs);
|
||||||
|
const windowMinutes = Math.ceil(absJitter / 60_000);
|
||||||
|
|
||||||
|
// Build the candidate minute-start at the beginning of the current minute
|
||||||
|
const nowMinuteStart = new Date(currentDate);
|
||||||
|
nowMinuteStart.setSeconds(0, 0);
|
||||||
|
const nowMinuteMs = nowMinuteStart.getTime();
|
||||||
|
|
||||||
|
let matchedMinuteMs: number | null = null;
|
||||||
|
|
||||||
|
// Scan from (now - windowMinutes) to (now) for positive jitter,
|
||||||
|
// or (now) to (now + windowMinutes) for negative jitter.
|
||||||
|
// In practice we scan both directions to keep the code simple.
|
||||||
|
for (let offset = -windowMinutes; offset <= windowMinutes; offset++) {
|
||||||
|
const candidateMs = nowMinuteMs + offset * 60_000;
|
||||||
|
const candidateDate = new Date(candidateMs);
|
||||||
|
if (!matches(job.cronExpr, candidateDate)) continue;
|
||||||
|
|
||||||
|
const fireTimeMs = candidateMs + job.jitterMs;
|
||||||
|
if (currentMs >= fireTimeMs) {
|
||||||
|
// This candidate's jittered fire time has passed — it's a match.
|
||||||
|
// Pick the latest matching minute to avoid re-triggering old ones.
|
||||||
|
if (matchedMinuteMs === null || candidateMs > matchedMinuteMs) {
|
||||||
|
matchedMinuteMs = candidateMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedMinuteMs === null) {
|
||||||
|
continue; // No matching minute whose jittered time has arrived
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent double-firing: compare against the cron minute we last fired for
|
||||||
|
if (
|
||||||
|
job.lastFiredAt !== undefined &&
|
||||||
|
job.lastFiredAt === matchedMinuteMs
|
||||||
|
) {
|
||||||
|
continue; // Already fired for this cron minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire! Record the matched cron minute (not wall-clock time) so the
|
||||||
|
// double-fire guard works when jitter pushes the fire into a later minute.
|
||||||
|
job.lastFiredAt = matchedMinuteMs;
|
||||||
|
|
||||||
|
if (!job.recurring) {
|
||||||
|
this.jobs.delete(job.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.onFire) {
|
||||||
|
this.onFire(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable summary of active jobs for display on session
|
||||||
|
* exit. Returns null if there are no active jobs.
|
||||||
|
*/
|
||||||
|
getExitSummary(): string | null {
|
||||||
|
if (this.jobs.size === 0) return null;
|
||||||
|
|
||||||
|
const count = this.jobs.size;
|
||||||
|
const lines = [
|
||||||
|
`Session ending. ${count} active loop${count === 1 ? '' : 's'} cancelled:`,
|
||||||
|
];
|
||||||
|
for (const job of this.jobs.values()) {
|
||||||
|
const schedule = humanReadableCron(job.cronExpr);
|
||||||
|
// Truncate long prompts
|
||||||
|
const prompt =
|
||||||
|
job.prompt.length > 60 ? job.prompt.slice(0, 57) + '...' : job.prompt;
|
||||||
|
lines.push(` - [${job.id}] ${schedule}: ${prompt}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all jobs and stops the scheduler.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.stop();
|
||||||
|
this.jobs.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
61
packages/core/src/skills/bundled/loop/SKILL.md
Normal file
61
packages/core/src/skills/bundled/loop/SKILL.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
name: loop
|
||||||
|
description: Create a recurring loop that runs a prompt on a schedule. Usage - /loop 5m check the build, /loop check the PR every 30m, /loop run tests (defaults to 10m). /loop list to show jobs, /loop clear to cancel all.
|
||||||
|
allowedTools:
|
||||||
|
- cron_create
|
||||||
|
- cron_list
|
||||||
|
- cron_delete
|
||||||
|
---
|
||||||
|
|
||||||
|
# /loop — schedule a recurring prompt
|
||||||
|
|
||||||
|
## Subcommands
|
||||||
|
|
||||||
|
If the input (after stripping the `/loop` prefix) is exactly one of these keywords, run the subcommand instead of scheduling:
|
||||||
|
|
||||||
|
- **`list`** — call CronList and display the results. Done.
|
||||||
|
- **`clear`** — call CronList, then call CronDelete for every job returned. Confirm how many were cancelled. Done.
|
||||||
|
|
||||||
|
Otherwise, parse the input below into `[interval] <prompt…>` and schedule it with CronCreate.
|
||||||
|
|
||||||
|
## Parsing (in priority order)
|
||||||
|
|
||||||
|
1. **Leading token**: if the first whitespace-delimited token matches `^\d+[smhd]$` (e.g. `5m`, `2h`), that's the interval; the rest is the prompt.
|
||||||
|
2. **Trailing "every" clause**: otherwise, if the input ends with `every <N><unit>` or `every <N> <unit-word>` (e.g. `every 20m`, `every 5 minutes`, `every 2 hours`), extract that as the interval and strip it from the prompt. Only match when what follows "every" is a time expression — `check every PR` has no interval.
|
||||||
|
3. **Default**: otherwise, interval is `10m` and the entire input is the prompt.
|
||||||
|
|
||||||
|
If the resulting prompt is empty, show usage `/loop [interval] <prompt>` and stop — do not call CronCreate.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `5m /babysit-prs` → interval `5m`, prompt `/babysit-prs` (rule 1)
|
||||||
|
- `check the deploy every 20m` → interval `20m`, prompt `check the deploy` (rule 2)
|
||||||
|
- `run tests every 5 minutes` → interval `5m`, prompt `run tests` (rule 2)
|
||||||
|
- `check the deploy` → interval `10m`, prompt `check the deploy` (rule 3)
|
||||||
|
- `check every PR` → interval `10m`, prompt `check every PR` (rule 3 — "every" not followed by time)
|
||||||
|
- `5m` → empty prompt → show usage
|
||||||
|
|
||||||
|
## Interval → cron
|
||||||
|
|
||||||
|
Supported suffixes: `s` (seconds, rounded up to nearest minute, min 1), `m` (minutes), `h` (hours), `d` (days). Convert:
|
||||||
|
|
||||||
|
| Interval pattern | Cron expression | Notes |
|
||||||
|
| ----------------- | ---------------------- | ----------------------------------------- |
|
||||||
|
| `Nm` where N ≤ 59 | `*/N * * * *` | every N minutes |
|
||||||
|
| `Nm` where N ≥ 60 | `0 */H * * *` | round to hours (H = N/60, must divide 24) |
|
||||||
|
| `Nh` where N ≤ 23 | `0 */N * * *` | every N hours |
|
||||||
|
| `Nd` | `0 0 */N * *` | every N days at midnight local |
|
||||||
|
| `Ns` | treat as `ceil(N/60)m` | cron minimum granularity is 1 minute |
|
||||||
|
|
||||||
|
**If the interval doesn't cleanly divide its unit** (e.g. `7m` → `*/7 * * * *` gives uneven gaps at :56→:00; `90m` → 1.5h which cron can't express), pick the nearest clean interval and tell the user what you rounded to before scheduling.
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
1. Call CronCreate with:
|
||||||
|
- `cron`: the expression from the table above
|
||||||
|
- `prompt`: the parsed prompt from above, verbatim (slash commands are passed through unchanged)
|
||||||
|
- `recurring`: `true`
|
||||||
|
2. Briefly confirm: what's scheduled, the cron expression, the human-readable cadence, that recurring tasks auto-expire after 3 days, and that they can cancel sooner with CronDelete (include the job ID).
|
||||||
|
3. **Then immediately execute the parsed prompt now** — don't wait for the first cron fire. If it's a slash command, invoke it via the Skill tool; otherwise act on it directly.
|
||||||
|
|
||||||
|
## Input
|
||||||
68
packages/core/src/tools/cron-create.test.ts
Normal file
68
packages/core/src/tools/cron-create.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { CronCreateTool } from './cron-create.js';
|
||||||
|
import { CronScheduler } from '../services/cronScheduler.js';
|
||||||
|
|
||||||
|
function makeConfig() {
|
||||||
|
const scheduler = new CronScheduler();
|
||||||
|
return {
|
||||||
|
getCronScheduler: () => scheduler,
|
||||||
|
_scheduler: scheduler,
|
||||||
|
} as unknown as import('../config/config.js').Config & {
|
||||||
|
_scheduler: CronScheduler;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CronCreateTool', () => {
|
||||||
|
let config: ReturnType<typeof makeConfig>;
|
||||||
|
let tool: CronCreateTool;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
config = makeConfig();
|
||||||
|
tool = new CronCreateTool(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the correct name', () => {
|
||||||
|
expect(tool.name).toBe('cron_create');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a recurring job by default', async () => {
|
||||||
|
const invocation = tool.build({
|
||||||
|
cron: '*/5 * * * *',
|
||||||
|
prompt: 'check status',
|
||||||
|
});
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.llmContent).toContain('Scheduled recurring job');
|
||||||
|
expect(result.llmContent).toContain('Auto-expires after 3 days');
|
||||||
|
expect(config._scheduler.list()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a one-shot job when recurring=false', async () => {
|
||||||
|
const invocation = tool.build({
|
||||||
|
cron: '*/1 * * * *',
|
||||||
|
prompt: 'once',
|
||||||
|
recurring: false,
|
||||||
|
});
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.llmContent).toContain('Scheduled one-shot task');
|
||||||
|
expect(result.llmContent).toContain('fire once then auto-delete');
|
||||||
|
const jobs = config._scheduler.list();
|
||||||
|
expect(jobs).toHaveLength(1);
|
||||||
|
expect(jobs[0]!.recurring).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error for invalid cron expression', async () => {
|
||||||
|
const invocation = tool.build({
|
||||||
|
cron: 'bad cron',
|
||||||
|
prompt: 'fail',
|
||||||
|
});
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
expect(result.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required params', () => {
|
||||||
|
expect(() => tool.build({ cron: '*/1 * * * *' } as never)).toThrow();
|
||||||
|
expect(() => tool.build({ prompt: 'test' } as never)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
137
packages/core/src/tools/cron-create.ts
Normal file
137
packages/core/src/tools/cron-create.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
/**
|
||||||
|
* cron_create tool — creates a new in-session cron job.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||||
|
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||||
|
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
|
import { parseCron } from '../utils/cronParser.js';
|
||||||
|
import { humanReadableCron } from '../utils/cronDisplay.js';
|
||||||
|
|
||||||
|
export interface CronCreateParams {
|
||||||
|
cron: string;
|
||||||
|
prompt: string;
|
||||||
|
recurring?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CronCreateInvocation extends BaseToolInvocation<
|
||||||
|
CronCreateParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
params: CronCreateParams,
|
||||||
|
) {
|
||||||
|
super(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return `${this.params.cron}: ${this.params.prompt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<ToolResult> {
|
||||||
|
const scheduler = this.config.getCronScheduler();
|
||||||
|
const recurring = this.params.recurring !== false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate cron expression before creating the job
|
||||||
|
parseCron(this.params.cron);
|
||||||
|
|
||||||
|
const job = scheduler.create(
|
||||||
|
this.params.cron,
|
||||||
|
this.params.prompt,
|
||||||
|
recurring,
|
||||||
|
);
|
||||||
|
|
||||||
|
const display = humanReadableCron(job.cronExpr);
|
||||||
|
const returnDisplay = `Scheduled ${job.id} (${display})`;
|
||||||
|
|
||||||
|
let llmContent: string;
|
||||||
|
if (recurring) {
|
||||||
|
llmContent =
|
||||||
|
`Scheduled recurring job ${job.id} (${job.cronExpr}). ` +
|
||||||
|
'Session-only (not written to disk, dies when Qwen Code exits). ' +
|
||||||
|
'Auto-expires after 3 days. Use CronDelete to cancel sooner.';
|
||||||
|
} else {
|
||||||
|
llmContent =
|
||||||
|
`Scheduled one-shot task ${job.id} (${job.cronExpr}). ` +
|
||||||
|
'Session-only (not written to disk, dies when Qwen Code exits). ' +
|
||||||
|
'It will fire once then auto-delete.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { llmContent, returnDisplay };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
llmContent: `Error creating cron job: ${message}`,
|
||||||
|
returnDisplay: message,
|
||||||
|
error: { message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CronCreateTool extends BaseDeclarativeTool<
|
||||||
|
CronCreateParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
static readonly Name = ToolNames.CRON_CREATE;
|
||||||
|
|
||||||
|
constructor(private config: Config) {
|
||||||
|
super(
|
||||||
|
CronCreateTool.Name,
|
||||||
|
ToolDisplayNames.CRON_CREATE,
|
||||||
|
'Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.\n\n' +
|
||||||
|
'Uses standard 5-field cron in the user\'s local timezone: minute hour day-of-month month day-of-week. "0 9 * * *" means 9am local — no timezone conversion needed.\n\n' +
|
||||||
|
'## One-shot tasks (recurring: false)\n\n' +
|
||||||
|
'For "remind me at X" or "at <time>, do Y" requests — fire once then auto-delete.\n' +
|
||||||
|
'Pin minute/hour/day-of-month/month to specific values:\n' +
|
||||||
|
' "remind me at 2:30pm today to check the deploy" → cron: "30 14 <today_dom> <today_month> *", recurring: false\n' +
|
||||||
|
' "tomorrow morning, run the smoke test" → cron: "57 8 <tomorrow_dom> <tomorrow_month> *", recurring: false\n\n' +
|
||||||
|
'## Recurring jobs (recurring: true, the default)\n\n' +
|
||||||
|
'For "every N minutes" / "every hour" / "weekdays at 9am" requests:\n' +
|
||||||
|
' "*/5 * * * *" (every 5 min), "0 * * * *" (hourly), "0 9 * * 1-5" (weekdays at 9am local)\n\n' +
|
||||||
|
'## Avoid the :00 and :30 minute marks when the task allows it\n\n' +
|
||||||
|
'Every user who asks for "9am" gets `0 9`, and every user who asks for "hourly" gets `0 *` — which means requests from across the planet land on the API at the same instant. When the user\'s request is approximate, pick a minute that is NOT 0 or 30:\n' +
|
||||||
|
' "every morning around 9" → "57 8 * * *" or "3 9 * * *" (not "0 9 * * *")\n' +
|
||||||
|
' "hourly" → "7 * * * *" (not "0 * * * *")\n' +
|
||||||
|
' "in an hour or so, remind me to..." → pick whatever minute you land on, don\'t round\n\n' +
|
||||||
|
'Only use minute 0 or 30 when the user names that exact time and clearly means it ("at 9:00 sharp", "at half past", coordinating with a meeting). When in doubt, nudge a few minutes early or late — the user will not notice, and the fleet will.\n\n' +
|
||||||
|
'## Session-only\n\n' +
|
||||||
|
'Jobs live only in this Qwen Code session — nothing is written to disk, and the job is gone when Qwen Code exits.\n\n' +
|
||||||
|
'## Runtime behavior\n\n' +
|
||||||
|
'Jobs only fire while the REPL is idle (not mid-query). The scheduler adds a small deterministic jitter on top of whatever you pick: recurring tasks fire up to 10% of their period late (max 15 min); one-shot tasks landing on :00 or :30 fire up to 90 s early. Picking an off-minute is still the bigger lever.\n\n' +
|
||||||
|
'Recurring tasks auto-expire after 3 days — they fire one final time, then are deleted. This bounds session lifetime. Tell the user about the 3-day limit when scheduling recurring jobs.\n\n' +
|
||||||
|
'Returns a job ID you can pass to CronDelete.',
|
||||||
|
Kind.Other,
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
cron: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes, "30 14 28 2 *" = Feb 28 at 2:30pm local once).',
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The prompt to enqueue at each fire time.',
|
||||||
|
},
|
||||||
|
recurring: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'true (default) = fire on every cron match until deleted or auto-expired after 3 days. false = fire once at the next match, then auto-delete. Use false for "remind me at X" one-shot requests with pinned minute/hour/dom/month.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['cron', 'prompt'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createInvocation(
|
||||||
|
params: CronCreateParams,
|
||||||
|
): ToolInvocation<CronCreateParams, ToolResult> {
|
||||||
|
return new CronCreateInvocation(this.config, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
packages/core/src/tools/cron-delete.test.ts
Normal file
48
packages/core/src/tools/cron-delete.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { CronDeleteTool } from './cron-delete.js';
|
||||||
|
import { CronScheduler } from '../services/cronScheduler.js';
|
||||||
|
|
||||||
|
function makeConfig() {
|
||||||
|
const scheduler = new CronScheduler();
|
||||||
|
return {
|
||||||
|
getCronScheduler: () => scheduler,
|
||||||
|
_scheduler: scheduler,
|
||||||
|
} as unknown as import('../config/config.js').Config & {
|
||||||
|
_scheduler: CronScheduler;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CronDeleteTool', () => {
|
||||||
|
let config: ReturnType<typeof makeConfig>;
|
||||||
|
let tool: CronDeleteTool;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
config = makeConfig();
|
||||||
|
tool = new CronDeleteTool(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the correct name', () => {
|
||||||
|
expect(tool.name).toBe('cron_delete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes an existing job', async () => {
|
||||||
|
const job = config._scheduler.create('*/1 * * * *', 'test', true);
|
||||||
|
|
||||||
|
const invocation = tool.build({ id: job.id });
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.llmContent).toContain('Cancelled job');
|
||||||
|
expect(config._scheduler.list()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error for non-existent job', async () => {
|
||||||
|
const invocation = tool.build({ id: 'nonexist' });
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
expect(result.error).toBeDefined();
|
||||||
|
expect(result.llmContent).toContain('not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required params', () => {
|
||||||
|
expect(() => tool.build({} as never)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
79
packages/core/src/tools/cron-delete.ts
Normal file
79
packages/core/src/tools/cron-delete.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* cron_delete tool — deletes an in-session cron job by ID.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||||
|
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||||
|
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
|
|
||||||
|
export interface CronDeleteParams {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CronDeleteInvocation extends BaseToolInvocation<
|
||||||
|
CronDeleteParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
params: CronDeleteParams,
|
||||||
|
) {
|
||||||
|
super(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return this.params.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<ToolResult> {
|
||||||
|
const scheduler = this.config.getCronScheduler();
|
||||||
|
const deleted = scheduler.delete(this.params.id);
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
const llmContent = `Cancelled job ${this.params.id}.`;
|
||||||
|
const returnDisplay = `Cancelled ${this.params.id}`;
|
||||||
|
return { llmContent, returnDisplay };
|
||||||
|
} else {
|
||||||
|
const result = `Job ${this.params.id} not found.`;
|
||||||
|
return {
|
||||||
|
llmContent: result,
|
||||||
|
returnDisplay: result,
|
||||||
|
error: { message: result },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CronDeleteTool extends BaseDeclarativeTool<
|
||||||
|
CronDeleteParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
static readonly Name = ToolNames.CRON_DELETE;
|
||||||
|
|
||||||
|
constructor(private config: Config) {
|
||||||
|
super(
|
||||||
|
CronDeleteTool.Name,
|
||||||
|
ToolDisplayNames.CRON_DELETE,
|
||||||
|
'Cancel a cron job previously scheduled with CronCreate. Removes it from the in-memory session store.',
|
||||||
|
Kind.Other,
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Job ID returned by CronCreate.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createInvocation(
|
||||||
|
params: CronDeleteParams,
|
||||||
|
): ToolInvocation<CronDeleteParams, ToolResult> {
|
||||||
|
return new CronDeleteInvocation(this.config, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
packages/core/src/tools/cron-list.test.ts
Normal file
49
packages/core/src/tools/cron-list.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { CronListTool } from './cron-list.js';
|
||||||
|
import { CronScheduler } from '../services/cronScheduler.js';
|
||||||
|
|
||||||
|
function makeConfig() {
|
||||||
|
const scheduler = new CronScheduler();
|
||||||
|
return {
|
||||||
|
getCronScheduler: () => scheduler,
|
||||||
|
_scheduler: scheduler,
|
||||||
|
} as unknown as import('../config/config.js').Config & {
|
||||||
|
_scheduler: CronScheduler;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CronListTool', () => {
|
||||||
|
let config: ReturnType<typeof makeConfig>;
|
||||||
|
let tool: CronListTool;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
config = makeConfig();
|
||||||
|
tool = new CronListTool(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the correct name', () => {
|
||||||
|
expect(tool.name).toBe('cron_list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty message when no jobs', async () => {
|
||||||
|
const invocation = tool.build({});
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.llmContent).toContain('No active cron jobs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists created jobs', async () => {
|
||||||
|
config._scheduler.create('*/5 * * * *', 'check build', true);
|
||||||
|
config._scheduler.create('*/1 * * * *', 'ping', false);
|
||||||
|
|
||||||
|
const invocation = tool.build({});
|
||||||
|
const result = await invocation.execute(new AbortController().signal);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.llmContent).toContain(
|
||||||
|
'(recurring) [session-only]: check build',
|
||||||
|
);
|
||||||
|
expect(result.llmContent).toContain('(one-shot) [session-only]: ping');
|
||||||
|
// Two lines, one per job
|
||||||
|
expect(String(result.llmContent).split('\n')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
packages/core/src/tools/cron-list.ts
Normal file
75
packages/core/src/tools/cron-list.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
/**
|
||||||
|
* cron_list tool — lists all active in-session cron jobs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||||
|
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||||
|
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
|
import { humanReadableCron } from '../utils/cronDisplay.js';
|
||||||
|
|
||||||
|
export type CronListParams = Record<string, never>;
|
||||||
|
|
||||||
|
class CronListInvocation extends BaseToolInvocation<
|
||||||
|
CronListParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
params: CronListParams,
|
||||||
|
) {
|
||||||
|
super(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<ToolResult> {
|
||||||
|
const scheduler = this.config.getCronScheduler();
|
||||||
|
const jobs = scheduler.list();
|
||||||
|
|
||||||
|
if (jobs.length === 0) {
|
||||||
|
const result = 'No active cron jobs.';
|
||||||
|
return { llmContent: result, returnDisplay: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
const llmLines = jobs.map((job) => {
|
||||||
|
const type = job.recurring ? 'recurring' : 'one-shot';
|
||||||
|
return `${job.id} — ${job.cronExpr} (${type}) [session-only]: ${job.prompt}`;
|
||||||
|
});
|
||||||
|
const llmContent = llmLines.join('\n');
|
||||||
|
|
||||||
|
const displayLines = jobs.map((job) => `${job.id} ${humanReadableCron(job.cronExpr)}`);
|
||||||
|
const returnDisplay = displayLines.join('\n');
|
||||||
|
|
||||||
|
return { llmContent, returnDisplay };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CronListTool extends BaseDeclarativeTool<
|
||||||
|
CronListParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
static readonly Name = ToolNames.CRON_LIST;
|
||||||
|
|
||||||
|
constructor(private config: Config) {
|
||||||
|
super(
|
||||||
|
CronListTool.Name,
|
||||||
|
ToolDisplayNames.CRON_LIST,
|
||||||
|
'List all cron jobs scheduled via CronCreate in this session.',
|
||||||
|
Kind.Other,
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createInvocation(
|
||||||
|
params: CronListParams,
|
||||||
|
): ToolInvocation<CronListParams, ToolResult> {
|
||||||
|
return new CronListInvocation(this.config, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,9 @@ export const ToolNames = {
|
||||||
LS: 'list_directory',
|
LS: 'list_directory',
|
||||||
LSP: 'lsp',
|
LSP: 'lsp',
|
||||||
ASK_USER_QUESTION: 'ask_user_question',
|
ASK_USER_QUESTION: 'ask_user_question',
|
||||||
|
CRON_CREATE: 'cron_create',
|
||||||
|
CRON_LIST: 'cron_list',
|
||||||
|
CRON_DELETE: 'cron_delete',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,6 +53,9 @@ export const ToolDisplayNames = {
|
||||||
LS: 'ListFiles',
|
LS: 'ListFiles',
|
||||||
LSP: 'Lsp',
|
LSP: 'Lsp',
|
||||||
ASK_USER_QUESTION: 'AskUserQuestion',
|
ASK_USER_QUESTION: 'AskUserQuestion',
|
||||||
|
CRON_CREATE: 'CronCreate',
|
||||||
|
CRON_LIST: 'CronList',
|
||||||
|
CRON_DELETE: 'CronDelete',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Migration from old tool names to new tool names
|
// Migration from old tool names to new tool names
|
||||||
|
|
|
||||||
54
packages/core/src/utils/cronDisplay.ts
Normal file
54
packages/core/src/utils/cronDisplay.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* Human-readable cron display for common recurring patterns.
|
||||||
|
* Falls back to the raw expression for anything non-trivial.
|
||||||
|
*/
|
||||||
|
export function humanReadableCron(cronExpr: string): string {
|
||||||
|
const parts = cronExpr.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) return cronExpr;
|
||||||
|
|
||||||
|
const [min, hour, dom, mon, dow] = parts;
|
||||||
|
|
||||||
|
// */N * * * * → Every N minutes
|
||||||
|
if (
|
||||||
|
min!.startsWith('*/') &&
|
||||||
|
hour === '*' &&
|
||||||
|
dom === '*' &&
|
||||||
|
mon === '*' &&
|
||||||
|
dow === '*'
|
||||||
|
) {
|
||||||
|
const n = parseInt(min!.slice(2), 10);
|
||||||
|
if (!isNaN(n)) {
|
||||||
|
return n === 1 ? 'Every minute' : `Every ${n} minutes`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 */N * * * → Every N hours (or single minute with */N hours)
|
||||||
|
if (
|
||||||
|
/^\d+$/.test(min!) &&
|
||||||
|
hour!.startsWith('*/') &&
|
||||||
|
dom === '*' &&
|
||||||
|
mon === '*' &&
|
||||||
|
dow === '*'
|
||||||
|
) {
|
||||||
|
const n = parseInt(hour!.slice(2), 10);
|
||||||
|
if (!isNaN(n)) {
|
||||||
|
return n === 1 ? 'Every hour' : `Every ${n} hours`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// M H */N * * → Every N days
|
||||||
|
if (
|
||||||
|
/^\d+$/.test(min!) &&
|
||||||
|
/^\d+$/.test(hour!) &&
|
||||||
|
dom!.startsWith('*/') &&
|
||||||
|
mon === '*' &&
|
||||||
|
dow === '*'
|
||||||
|
) {
|
||||||
|
const n = parseInt(dom!.slice(2), 10);
|
||||||
|
if (!isNaN(n)) {
|
||||||
|
return n === 1 ? 'Every day' : `Every ${n} days`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cronExpr;
|
||||||
|
}
|
||||||
174
packages/core/src/utils/cronParser.test.ts
Normal file
174
packages/core/src/utils/cronParser.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { matches, nextFireTime, parseCron } from './cronParser.js';
|
||||||
|
|
||||||
|
describe('parseCron', () => {
|
||||||
|
it('parses wildcard fields', () => {
|
||||||
|
const fields = parseCron('* * * * *');
|
||||||
|
expect(fields.minute.size).toBe(60);
|
||||||
|
expect(fields.hour.size).toBe(24);
|
||||||
|
expect(fields.dayOfMonth.size).toBe(31);
|
||||||
|
expect(fields.month.size).toBe(12);
|
||||||
|
expect(fields.dayOfWeek.size).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses single values', () => {
|
||||||
|
const fields = parseCron('5 14 1 6 3');
|
||||||
|
expect([...fields.minute]).toEqual([5]);
|
||||||
|
expect([...fields.hour]).toEqual([14]);
|
||||||
|
expect([...fields.dayOfMonth]).toEqual([1]);
|
||||||
|
expect([...fields.month]).toEqual([6]);
|
||||||
|
expect([...fields.dayOfWeek]).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses ranges', () => {
|
||||||
|
const fields = parseCron('1-5 * * * *');
|
||||||
|
expect([...fields.minute].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses comma lists', () => {
|
||||||
|
const fields = parseCron('0,15,30,45 * * * *');
|
||||||
|
expect([...fields.minute].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses steps', () => {
|
||||||
|
const fields = parseCron('*/15 * * * *');
|
||||||
|
expect([...fields.minute].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses range with step', () => {
|
||||||
|
const fields = parseCron('1-10/3 * * * *');
|
||||||
|
expect([...fields.minute].sort((a, b) => a - b)).toEqual([1, 4, 7, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on wrong number of fields', () => {
|
||||||
|
expect(() => parseCron('* * *')).toThrow('must have exactly 5 fields');
|
||||||
|
expect(() => parseCron('* * * * * *')).toThrow(
|
||||||
|
'must have exactly 5 fields',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on out-of-range values', () => {
|
||||||
|
expect(() => parseCron('60 * * * *')).toThrow('out of bounds');
|
||||||
|
expect(() => parseCron('* 25 * * *')).toThrow('out of bounds');
|
||||||
|
expect(() => parseCron('* * 0 * *')).toThrow('out of bounds');
|
||||||
|
expect(() => parseCron('* * * 13 *')).toThrow('out of bounds');
|
||||||
|
expect(() => parseCron('* * * * 8')).toThrow('out of bounds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts 7 as Sunday and normalizes to 0', () => {
|
||||||
|
const fields = parseCron('* * * * 7');
|
||||||
|
expect(fields.dayOfWeek.has(0)).toBe(true);
|
||||||
|
expect(fields.dayOfWeek.has(7)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on invalid step', () => {
|
||||||
|
expect(() => parseCron('*/0 * * * *')).toThrow('Invalid step');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matches', () => {
|
||||||
|
it('matches every-minute cron', () => {
|
||||||
|
const date = new Date(2025, 0, 15, 10, 30); // Jan 15 2025, 10:30
|
||||||
|
expect(matches('* * * * *', date)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches specific minute', () => {
|
||||||
|
const date = new Date(2025, 0, 15, 10, 30);
|
||||||
|
expect(matches('30 * * * *', date)).toBe(true);
|
||||||
|
expect(matches('31 * * * *', date)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches specific hour and minute', () => {
|
||||||
|
const date = new Date(2025, 0, 15, 14, 0);
|
||||||
|
expect(matches('0 14 * * *', date)).toBe(true);
|
||||||
|
expect(matches('0 13 * * *', date)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches day of week', () => {
|
||||||
|
// Jan 15 2025 is a Wednesday (day 3)
|
||||||
|
const date = new Date(2025, 0, 15, 10, 0);
|
||||||
|
expect(matches('* * * * 3', date)).toBe(true);
|
||||||
|
expect(matches('* * * * 1', date)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses OR logic when both day-of-month and day-of-week are constrained', () => {
|
||||||
|
// Jan 15 2025 is a Wednesday (day 3), day-of-month 15
|
||||||
|
const date = new Date(2025, 0, 15, 10, 0);
|
||||||
|
// dom=1 (no match), dow=3 (match) → should match via OR
|
||||||
|
expect(matches('0 10 1 * 3', date)).toBe(true);
|
||||||
|
// dom=15 (match), dow=1 (no match) → should match via OR
|
||||||
|
expect(matches('0 10 15 * 1', date)).toBe(true);
|
||||||
|
// dom=1 (no match), dow=1 (no match) → no match
|
||||||
|
expect(matches('0 10 1 * 1', date)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses AND logic when only one day field is constrained', () => {
|
||||||
|
// Jan 15 2025 is a Wednesday (day 3)
|
||||||
|
const date = new Date(2025, 0, 15, 10, 0);
|
||||||
|
// dom=1, dow=* → AND, dom doesn't match
|
||||||
|
expect(matches('0 10 1 * *', date)).toBe(false);
|
||||||
|
// dom=*, dow=1 → AND, dow doesn't match
|
||||||
|
expect(matches('0 10 * * 1', date)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches every-N-minutes pattern', () => {
|
||||||
|
const date0 = new Date(2025, 0, 15, 10, 0);
|
||||||
|
const date5 = new Date(2025, 0, 15, 10, 5);
|
||||||
|
const date3 = new Date(2025, 0, 15, 10, 3);
|
||||||
|
expect(matches('*/5 * * * *', date0)).toBe(true);
|
||||||
|
expect(matches('*/5 * * * *', date5)).toBe(true);
|
||||||
|
expect(matches('*/5 * * * *', date3)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextFireTime', () => {
|
||||||
|
it('finds next minute for * * * * *', () => {
|
||||||
|
const now = new Date(2025, 0, 15, 10, 30, 15); // 10:30:15
|
||||||
|
const next = nextFireTime('* * * * *', now);
|
||||||
|
expect(next.getHours()).toBe(10);
|
||||||
|
expect(next.getMinutes()).toBe(31);
|
||||||
|
expect(next.getSeconds()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds next match for specific minute', () => {
|
||||||
|
const now = new Date(2025, 0, 15, 10, 30, 0);
|
||||||
|
const next = nextFireTime('45 * * * *', now);
|
||||||
|
expect(next.getHours()).toBe(10);
|
||||||
|
expect(next.getMinutes()).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls to next hour when no match in current hour', () => {
|
||||||
|
const now = new Date(2025, 0, 15, 10, 50, 0);
|
||||||
|
const next = nextFireTime('15 * * * *', now);
|
||||||
|
expect(next.getHours()).toBe(11);
|
||||||
|
expect(next.getMinutes()).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds next match for every-5-minutes', () => {
|
||||||
|
const now = new Date(2025, 0, 15, 10, 31, 0);
|
||||||
|
const next = nextFireTime('*/5 * * * *', now);
|
||||||
|
expect(next.getMinutes()).toBe(35);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds next match for specific hour', () => {
|
||||||
|
const now = new Date(2025, 0, 15, 10, 30, 0);
|
||||||
|
const next = nextFireTime('0 14 * * *', now);
|
||||||
|
expect(next.getHours()).toBe(14);
|
||||||
|
expect(next.getMinutes()).toBe(0);
|
||||||
|
expect(next.getDate()).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls to next day for past time', () => {
|
||||||
|
const now = new Date(2025, 0, 15, 15, 0, 0); // 3pm
|
||||||
|
const next = nextFireTime('0 9 * * *', now); // 9am daily
|
||||||
|
expect(next.getDate()).toBe(16);
|
||||||
|
expect(next.getHours()).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns time strictly after the input', () => {
|
||||||
|
// Even if `after` is exactly on a match minute, next should be the following match
|
||||||
|
const now = new Date(2025, 0, 15, 10, 0, 0); // exactly 10:00:00
|
||||||
|
const next = nextFireTime('*/5 * * * *', now);
|
||||||
|
expect(next.getTime()).toBeGreaterThan(now.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
186
packages/core/src/utils/cronParser.ts
Normal file
186
packages/core/src/utils/cronParser.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
/**
|
||||||
|
* Minimal 5-field cron expression parser.
|
||||||
|
*
|
||||||
|
* Fields: minute (0-59), hour (0-23), day-of-month (1-31), month (1-12), day-of-week (0-7, 0 and 7=Sun)
|
||||||
|
* Supports: *, single values, steps (asterisk/N), ranges (a-b), comma lists (a,b,c)
|
||||||
|
* No extended syntax (L, W, ?, name aliases).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CronFields {
|
||||||
|
minute: Set<number>;
|
||||||
|
hour: Set<number>;
|
||||||
|
dayOfMonth: Set<number>;
|
||||||
|
month: Set<number>;
|
||||||
|
dayOfWeek: Set<number>;
|
||||||
|
/** True when the day-of-month field was literally '*' (unrestricted). */
|
||||||
|
domIsWild: boolean;
|
||||||
|
/** True when the day-of-week field was literally '*' (unrestricted). */
|
||||||
|
dowIsWild: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_RANGES: Array<[number, number]> = [
|
||||||
|
[0, 59], // minute
|
||||||
|
[0, 23], // hour
|
||||||
|
[1, 31], // day of month
|
||||||
|
[1, 12], // month
|
||||||
|
[0, 7], // day of week (0 and 7 both mean Sunday)
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a single cron field into a set of matching values.
|
||||||
|
* Supports: star, single values, steps (star/N), ranges (a-b), comma lists.
|
||||||
|
*/
|
||||||
|
function parseField(field: string, min: number, max: number): Set<number> {
|
||||||
|
const values = new Set<number>();
|
||||||
|
|
||||||
|
for (const part of field.split(',')) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error(`Empty field segment in "${field}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle step: */N or range/N or value/N
|
||||||
|
const stepParts = trimmed.split('/');
|
||||||
|
if (stepParts.length > 2) {
|
||||||
|
throw new Error(`Invalid step expression: "${trimmed}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rangeStart: number;
|
||||||
|
let rangeEnd: number;
|
||||||
|
const base = stepParts[0]!;
|
||||||
|
|
||||||
|
if (base === '*') {
|
||||||
|
rangeStart = min;
|
||||||
|
rangeEnd = max;
|
||||||
|
} else if (base.includes('-')) {
|
||||||
|
const [startStr, endStr] = base.split('-');
|
||||||
|
rangeStart = parseInt(startStr!, 10);
|
||||||
|
rangeEnd = parseInt(endStr!, 10);
|
||||||
|
if (isNaN(rangeStart) || isNaN(rangeEnd)) {
|
||||||
|
throw new Error(`Invalid range: "${base}"`);
|
||||||
|
}
|
||||||
|
if (rangeStart < min || rangeEnd > max || rangeStart > rangeEnd) {
|
||||||
|
throw new Error(`Range ${base} out of bounds [${min}-${max}]`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const val = parseInt(base, 10);
|
||||||
|
if (isNaN(val) || val < min || val > max) {
|
||||||
|
throw new Error(`Value "${base}" out of bounds [${min}-${max}]`);
|
||||||
|
}
|
||||||
|
rangeStart = val;
|
||||||
|
rangeEnd = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = stepParts.length === 2 ? parseInt(stepParts[1]!, 10) : 1;
|
||||||
|
if (isNaN(step) || step <= 0) {
|
||||||
|
throw new Error(`Invalid step: "${stepParts[1]}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = rangeStart; i <= rangeEnd; i += step) {
|
||||||
|
values.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a 5-field cron expression into structured fields.
|
||||||
|
* Throws on invalid expressions.
|
||||||
|
*/
|
||||||
|
export function parseCron(cronExpr: string): CronFields {
|
||||||
|
const parts = cronExpr.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) {
|
||||||
|
throw new Error(
|
||||||
|
`Cron expression must have exactly 5 fields, got ${parts.length}: "${cronExpr}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse day-of-week with range 0-7, then normalize 7 → 0 (both mean Sunday)
|
||||||
|
const dayOfWeek = parseField(
|
||||||
|
parts[4]!,
|
||||||
|
FIELD_RANGES[4]![0],
|
||||||
|
FIELD_RANGES[4]![1],
|
||||||
|
);
|
||||||
|
if (dayOfWeek.has(7)) {
|
||||||
|
dayOfWeek.delete(7);
|
||||||
|
dayOfWeek.add(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minute: parseField(parts[0]!, FIELD_RANGES[0]![0], FIELD_RANGES[0]![1]),
|
||||||
|
hour: parseField(parts[1]!, FIELD_RANGES[1]![0], FIELD_RANGES[1]![1]),
|
||||||
|
dayOfMonth: parseField(parts[2]!, FIELD_RANGES[2]![0], FIELD_RANGES[2]![1]),
|
||||||
|
month: parseField(parts[3]!, FIELD_RANGES[3]![0], FIELD_RANGES[3]![1]),
|
||||||
|
dayOfWeek,
|
||||||
|
domIsWild: parts[2]!.trim() === '*',
|
||||||
|
dowIsWild: parts[4]!.trim() === '*',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given date matches the cron expression.
|
||||||
|
*
|
||||||
|
* Follows vixie-cron day semantics: when both day-of-month and day-of-week
|
||||||
|
* are constrained (neither is `*`), the date matches if EITHER field matches.
|
||||||
|
* When only one is constrained, it must match.
|
||||||
|
*/
|
||||||
|
export function matches(cronExpr: string, date: Date): boolean {
|
||||||
|
const fields = parseCron(cronExpr);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!fields.minute.has(date.getMinutes()) ||
|
||||||
|
!fields.hour.has(date.getHours()) ||
|
||||||
|
!fields.month.has(date.getMonth() + 1)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domMatch = fields.dayOfMonth.has(date.getDate());
|
||||||
|
const dowMatch = fields.dayOfWeek.has(date.getDay());
|
||||||
|
|
||||||
|
// Vixie-cron: if both day-of-month and day-of-week are restricted,
|
||||||
|
// match if EITHER is satisfied. Otherwise use AND.
|
||||||
|
if (!fields.domIsWild && !fields.dowIsWild) {
|
||||||
|
return domMatch || dowMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return domMatch && dowMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next fire time after `after` for the given cron expression.
|
||||||
|
* Scans forward minute-by-minute (up to ~4 years) to find the next match.
|
||||||
|
*/
|
||||||
|
export function nextFireTime(cronExpr: string, after: Date): Date {
|
||||||
|
const fields = parseCron(cronExpr);
|
||||||
|
|
||||||
|
// Start at the next whole minute after `after`
|
||||||
|
const candidate = new Date(after.getTime());
|
||||||
|
candidate.setSeconds(0, 0);
|
||||||
|
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||||
|
|
||||||
|
// Scan up to 4 years (~2.1M minutes) to avoid infinite loops
|
||||||
|
const maxIterations = 4 * 366 * 24 * 60;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxIterations; i++) {
|
||||||
|
const minuteOk = fields.minute.has(candidate.getMinutes());
|
||||||
|
const hourOk = fields.hour.has(candidate.getHours());
|
||||||
|
const monthOk = fields.month.has(candidate.getMonth() + 1);
|
||||||
|
const domOk = fields.dayOfMonth.has(candidate.getDate());
|
||||||
|
const dowOk = fields.dayOfWeek.has(candidate.getDay());
|
||||||
|
|
||||||
|
// Vixie-cron day semantics: OR when both constrained, AND otherwise
|
||||||
|
const dayOk =
|
||||||
|
!fields.domIsWild && !fields.dowIsWild ? domOk || dowOk : domOk && dowOk;
|
||||||
|
|
||||||
|
if (minuteOk && hourOk && monthOk && dayOk) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`No matching fire time found within 4 years for: "${cronExpr}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1432,9 +1432,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"experimental": {
|
"experimental": {
|
||||||
"description": "Setting to enable experimental features",
|
"description": "Settings to enable experimental features.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"properties": {
|
||||||
|
"cron": {
|
||||||
|
"description": "Enable in-session cron/loop tools (experimental). When enabled, the model can create recurring prompts using cron_create, cron_list, and cron_delete tools. Can also be enabled via QWEN_CODE_ENABLE_CRON=1 environment variable.",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"$version": {
|
"$version": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue