diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 617cf9553..b26fdb88d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,7 +134,9 @@ jobs: run: | npm run preflight 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:interactive:sandbox:docker env: OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts index cb083c35a..76a789b00 100644 --- a/docs/users/features/_meta.ts +++ b/docs/users/features/_meta.ts @@ -14,4 +14,5 @@ export default { sandbox: 'Sandboxing', language: 'i18n', hooks: 'Hooks', + 'scheduled-tasks': 'Scheduled Tasks', }; diff --git a/docs/users/features/scheduled-tasks.md b/docs/users/features/scheduled-tasks.md new file mode 100644 index 000000000..ed884e7e1 --- /dev/null +++ b/docs/users/features/scheduled-tasks.md @@ -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. diff --git a/integration-tests/cli/acp-cron.test.ts b/integration-tests/cli/acp-cron.test.ts new file mode 100644 index 000000000..84eb71a01 --- /dev/null +++ b/integration-tests/cli/acp-cron.test.ts @@ -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; + [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(); + 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((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 => { + 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((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, + testDir: string, +): Promise { + 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 }, + ); +}); diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/cli/acp-integration.test.ts similarity index 99% rename from integration-tests/acp-integration.test.ts rename to integration-tests/cli/acp-integration.test.ts index 0f7770e6c..98a056700 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/cli/acp-integration.test.ts @@ -9,7 +9,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { createInterface } from 'node:readline'; import { setTimeout as delay } from 'node:timers/promises'; 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 INITIAL_PROMPT = 'Create a quick note (smoke test).'; diff --git a/integration-tests/cli/cron-tools.test.ts b/integration-tests/cli/cron-tools.test.ts new file mode 100644 index 000000000..a6cd1e349 --- /dev/null +++ b/integration-tests/cli/cron-tools.test.ts @@ -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'); + }); +}); diff --git a/integration-tests/edit.test.ts b/integration-tests/cli/edit.test.ts similarity index 98% rename from integration-tests/edit.test.ts rename to integration-tests/cli/edit.test.ts index 175f0d85a..670d00c17 100644 --- a/integration-tests/edit.test.ts +++ b/integration-tests/cli/edit.test.ts @@ -5,7 +5,11 @@ */ 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', () => { it('should be able to edit content in a file', async () => { diff --git a/integration-tests/extensions-install.test.ts b/integration-tests/cli/extensions-install.test.ts similarity index 96% rename from integration-tests/extensions-install.test.ts rename to integration-tests/cli/extensions-install.test.ts index 935d0ac54..58afd4024 100644 --- a/integration-tests/extensions-install.test.ts +++ b/integration-tests/cli/extensions-install.test.ts @@ -5,7 +5,7 @@ */ import { expect, test } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; diff --git a/integration-tests/file-system.test.ts b/integration-tests/cli/file-system.test.ts similarity index 98% rename from integration-tests/file-system.test.ts rename to integration-tests/cli/file-system.test.ts index f4c60edd7..8b564b954 100644 --- a/integration-tests/file-system.test.ts +++ b/integration-tests/cli/file-system.test.ts @@ -5,7 +5,11 @@ */ 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', () => { it('should be able to read a file', async () => { diff --git a/integration-tests/json-output.test.ts b/integration-tests/cli/json-output.test.ts similarity index 99% rename from integration-tests/json-output.test.ts rename to integration-tests/cli/json-output.test.ts index 37dca8678..27806f7b8 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/cli/json-output.test.ts @@ -5,7 +5,7 @@ */ import { expect, describe, it, beforeEach, afterEach } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; describe('JSON output', () => { let rig: TestRig; diff --git a/integration-tests/list_directory.test.ts b/integration-tests/cli/list_directory.test.ts similarity index 95% rename from integration-tests/list_directory.test.ts rename to integration-tests/cli/list_directory.test.ts index a60945ba4..38a4351f1 100644 --- a/integration-tests/list_directory.test.ts +++ b/integration-tests/cli/list_directory.test.ts @@ -5,7 +5,11 @@ */ 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 { join } from 'node:path'; diff --git a/integration-tests/mcp_server_cyclic_schema.test.ts b/integration-tests/cli/mcp_server_cyclic_schema.test.ts similarity index 99% rename from integration-tests/mcp_server_cyclic_schema.test.ts rename to integration-tests/cli/mcp_server_cyclic_schema.test.ts index 40963a240..feed4693b 100644 --- a/integration-tests/mcp_server_cyclic_schema.test.ts +++ b/integration-tests/cli/mcp_server_cyclic_schema.test.ts @@ -24,7 +24,7 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; 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 // This implements the MCP protocol directly using Node.js built-ins diff --git a/integration-tests/read_many_files.test.ts b/integration-tests/cli/read_many_files.test.ts similarity index 94% rename from integration-tests/read_many_files.test.ts rename to integration-tests/cli/read_many_files.test.ts index 396732439..23f54f5a8 100644 --- a/integration-tests/read_many_files.test.ts +++ b/integration-tests/cli/read_many_files.test.ts @@ -5,7 +5,11 @@ */ 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', () => { it.skip('should be able to read multiple files', async () => { diff --git a/integration-tests/run_shell_command.test.ts b/integration-tests/cli/run_shell_command.test.ts similarity index 97% rename from integration-tests/run_shell_command.test.ts rename to integration-tests/cli/run_shell_command.test.ts index 4b0b99677..890ad0082 100644 --- a/integration-tests/run_shell_command.test.ts +++ b/integration-tests/cli/run_shell_command.test.ts @@ -5,7 +5,11 @@ */ 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', () => { it('should be able to run a shell command', async () => { diff --git a/integration-tests/save_memory.test.ts b/integration-tests/cli/save_memory.test.ts similarity index 94% rename from integration-tests/save_memory.test.ts rename to integration-tests/cli/save_memory.test.ts index 40ede6835..1ffb6beef 100644 --- a/integration-tests/save_memory.test.ts +++ b/integration-tests/cli/save_memory.test.ts @@ -5,7 +5,11 @@ */ 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', () => { // Skipped due to flaky model behavior - the model sometimes answers the question diff --git a/integration-tests/settings-migration.test.ts b/integration-tests/cli/settings-migration.test.ts similarity index 99% rename from integration-tests/settings-migration.test.ts rename to integration-tests/cli/settings-migration.test.ts index fa5446c17..3be7cee24 100644 --- a/integration-tests/settings-migration.test.ts +++ b/integration-tests/cli/settings-migration.test.ts @@ -5,12 +5,12 @@ */ 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 { join } from 'node:path'; // 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 { v1Settings, diff --git a/integration-tests/simple-mcp-server.test.ts b/integration-tests/cli/simple-mcp-server.test.ts similarity index 98% rename from integration-tests/simple-mcp-server.test.ts rename to integration-tests/cli/simple-mcp-server.test.ts index cdb9ee21a..3e50174d2 100644 --- a/integration-tests/simple-mcp-server.test.ts +++ b/integration-tests/cli/simple-mcp-server.test.ts @@ -11,7 +11,7 @@ */ 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 { writeFileSync } from 'node:fs'; diff --git a/integration-tests/stdin-context.test.ts b/integration-tests/cli/stdin-context.test.ts similarity index 97% rename from integration-tests/stdin-context.test.ts rename to integration-tests/cli/stdin-context.test.ts index 3ec681000..2dd4aca74 100644 --- a/integration-tests/stdin-context.test.ts +++ b/integration-tests/cli/stdin-context.test.ts @@ -5,7 +5,11 @@ */ 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', () => { it('should be able to use stdin as context for a prompt', async () => { diff --git a/integration-tests/telemetry.test.ts b/integration-tests/cli/telemetry.test.ts similarity index 94% rename from integration-tests/telemetry.test.ts rename to integration-tests/cli/telemetry.test.ts index 111f24c86..ac63e1ca0 100644 --- a/integration-tests/telemetry.test.ts +++ b/integration-tests/cli/telemetry.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; describe('telemetry', () => { it('should emit a metric and a log event', async () => { diff --git a/integration-tests/todo_write.test.ts b/integration-tests/cli/todo_write.test.ts similarity index 95% rename from integration-tests/todo_write.test.ts rename to integration-tests/cli/todo_write.test.ts index 5c63e3c48..5bc28125f 100644 --- a/integration-tests/todo_write.test.ts +++ b/integration-tests/cli/todo_write.test.ts @@ -5,7 +5,11 @@ */ 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', () => { it('should be able to create and manage a todo list', async () => { diff --git a/integration-tests/utf-bom-encoding.test.ts b/integration-tests/cli/utf-bom-encoding.test.ts similarity index 99% rename from integration-tests/utf-bom-encoding.test.ts rename to integration-tests/cli/utf-bom-encoding.test.ts index 31dd41522..be34f8eb0 100644 --- a/integration-tests/utf-bom-encoding.test.ts +++ b/integration-tests/cli/utf-bom-encoding.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; // Windows skip (Option A: avoid infra scope) const d = process.platform === 'win32' ? describe.skip : describe; diff --git a/integration-tests/web_search.test.ts b/integration-tests/cli/web_search.test.ts similarity index 97% rename from integration-tests/web_search.test.ts rename to integration-tests/cli/web_search.test.ts index 680a1ffdf..5ab0b4364 100644 --- a/integration-tests/web_search.test.ts +++ b/integration-tests/cli/web_search.test.ts @@ -5,7 +5,11 @@ */ 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', () => { it('should be able to search the web', async () => { diff --git a/integration-tests/write_file.test.ts b/integration-tests/cli/write_file.test.ts similarity index 98% rename from integration-tests/write_file.test.ts rename to integration-tests/cli/write_file.test.ts index 7dd6445d9..2440c5931 100644 --- a/integration-tests/write_file.test.ts +++ b/integration-tests/cli/write_file.test.ts @@ -10,7 +10,7 @@ import { createToolCallErrorMessage, printDebugInfo, validateModelOutput, -} from './test-helper.js'; +} from '../test-helper.js'; describe('write_file', () => { it('should be able to write a file', async () => { diff --git a/integration-tests/context-compress-interactive.test.ts b/integration-tests/interactive/context-compress-interactive.test.ts similarity index 98% rename from integration-tests/context-compress-interactive.test.ts rename to integration-tests/interactive/context-compress-interactive.test.ts index 378575f4f..1018c846b 100644 --- a/integration-tests/context-compress-interactive.test.ts +++ b/integration-tests/interactive/context-compress-interactive.test.ts @@ -5,7 +5,7 @@ */ 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', () => { let rig: TestRig; diff --git a/integration-tests/interactive/cron-interactive.test.ts b/integration-tests/interactive/cron-interactive.test.ts new file mode 100644 index 000000000..545a810a9 --- /dev/null +++ b/integration-tests/interactive/cron-interactive.test.ts @@ -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 }, + ); +}); diff --git a/integration-tests/ctrl-c-exit.test.ts b/integration-tests/interactive/ctrl-c-exit.test.ts similarity index 98% rename from integration-tests/ctrl-c-exit.test.ts rename to integration-tests/interactive/ctrl-c-exit.test.ts index 3f5b011fa..e0718b466 100644 --- a/integration-tests/ctrl-c-exit.test.ts +++ b/integration-tests/interactive/ctrl-c-exit.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; describe('Ctrl+C exit', () => { // (#9782) Temporarily disabling on windows because it is failing on main and every diff --git a/integration-tests/file-system-interactive.test.ts b/integration-tests/interactive/file-system-interactive.test.ts similarity index 97% rename from integration-tests/file-system-interactive.test.ts rename to integration-tests/interactive/file-system-interactive.test.ts index 31a9feb15..a583ce1ae 100644 --- a/integration-tests/file-system-interactive.test.ts +++ b/integration-tests/interactive/file-system-interactive.test.ts @@ -5,7 +5,7 @@ */ 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', () => { let rig: TestRig; diff --git a/integration-tests/hooks-command.test.ts b/integration-tests/interactive/hooks-command.test.ts similarity index 97% rename from integration-tests/hooks-command.test.ts rename to integration-tests/interactive/hooks-command.test.ts index 0fb67f00f..9d07d0d22 100644 --- a/integration-tests/hooks-command.test.ts +++ b/integration-tests/interactive/hooks-command.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; describe('/hooks command', () => { let rig: TestRig; diff --git a/integration-tests/interactive/interactive-session.ts b/integration-tests/interactive/interactive-session.ts new file mode 100644 index 000000000..0065cebea --- /dev/null +++ b/integration-tests/interactive/interactive-session.ts @@ -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; +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 = 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((resolve) => { + terminal.write(data, resolve); + }), + ); + }); + } + + /** Wait for all pending PTY data to be processed by xterm. */ + private async flush(): Promise { + 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 { + 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, + }); + + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + try { + this.ptyProcess.kill(); + } catch { + // Process may have already exited + } + this.terminal.dispose(); + } +} diff --git a/integration-tests/mixed-input-crash.test.ts b/integration-tests/interactive/mixed-input-crash.test.ts similarity index 97% rename from integration-tests/mixed-input-crash.test.ts rename to integration-tests/interactive/mixed-input-crash.test.ts index e2db64731..5ac33eddf 100644 --- a/integration-tests/mixed-input-crash.test.ts +++ b/integration-tests/interactive/mixed-input-crash.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; describe('mixed input crash prevention', () => { it('should not crash when using mixed prompt inputs', async () => { diff --git a/integration-tests/terminal-capture/scenarios/cron-loop.ts b/integration-tests/terminal-capture/scenarios/cron-loop.ts new file mode 100644 index 000000000..a1c71236c --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/cron-loop.ts @@ -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; diff --git a/integration-tests/terminal-capture/terminal-capture.ts b/integration-tests/terminal-capture/terminal-capture.ts index ebfddd523..8d427f472 100644 --- a/integration-tests/terminal-capture/terminal-capture.ts +++ b/integration-tests/terminal-capture/terminal-capture.ts @@ -640,6 +640,46 @@ export class TerminalCapture { 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 { + if (!this.page) throw new Error('Not initialized'); + await this.flush(); + return this.page.evaluate(() => { + const W = window as unknown as Record; + 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 ────────────────────────────── /** diff --git a/package-lock.json b/package-lock.json index 68a6cb6c9..a00014e09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", + "@xterm/headless": "^5.5.0", "@xterm/xterm": "^6.0.0", "cross-env": "^7.0.3", "esbuild": "^0.25.0", diff --git a/package.json b/package.json index 44f8438d2..28c5f6179 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,10 @@ "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: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: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: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 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: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'", @@ -84,6 +86,7 @@ "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", + "@xterm/headless": "^5.5.0", "@xterm/xterm": "^6.0.0", "cross-env": "^7.0.3", "esbuild": "^0.25.0", diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 03c1f9016..4eb8093ad 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -75,6 +75,7 @@ describe('Session', () => { getTargetDir: vi.fn().mockReturnValue(process.cwd()), getDebugMode: vi.fn().mockReturnValue(false), getAuthType: vi.fn().mockImplementation(() => currentAuthType), + isCronEnabled: vi.fn().mockReturnValue(false), } as unknown as Config; mockClient = { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 6c32b6d5d..b92ea56a1 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -112,6 +112,12 @@ export class Session implements SessionContext { private turn: number = 0; private readonly runtimeBaseDir: string; + // Cron scheduling state + private cronQueue: string[] = []; + private cronProcessing = false; + private cronAbortController: AbortController | null = null; + private cronCompletion: Promise | null = null; + // Modular components private readonly historyReplayer: HistoryReplayer; private readonly toolCallEmitter: ToolCallEmitter; @@ -155,12 +161,37 @@ export class Session implements SessionContext { } async cancelPendingPrompt(): Promise { - if (!this.pendingPrompt) { + const hadPrompt = !!this.pendingPrompt; + const hadCron = !!this.cronAbortController; + + if (!hadPrompt && !hadCron) { throw new Error('Not currently generating'); } - this.pendingPrompt.abort(); - this.pendingPrompt = null; + if (this.pendingPrompt) { + 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 { @@ -170,6 +201,22 @@ export class Session implements SessionContext { const pendingSend = new AbortController(); 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. if (this.pendingPromptCompletion) { try { @@ -191,7 +238,12 @@ export class Session implements SessionContext { }); 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 { resolveCompletion(); } @@ -376,6 +428,169 @@ export class Session implements SessionContext { 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 { + 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((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 { + 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 { const abortController = new AbortController(); try { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e1762254d..0086510e3 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1082,6 +1082,7 @@ export async function loadCliConfig( maxSessionTurns: argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, experimentalZedIntegration: argv.acp || argv.experimentalAcp || false, + cronEnabled: settings.experimental?.cron ?? false, listExtensions: argv.listExtensions || false, overrideExtensions: overrideExtensions || argv.extensions, noBrowser: !!process.env['NO_BROWSER'], diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 4e6fd7c1a..80f110138 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1571,9 +1571,20 @@ const SETTINGS_SCHEMA = { category: 'Experimental', requiresRestart: true, default: {}, - description: 'Setting to enable experimental features', + description: 'Settings to enable experimental features.', 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; diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index af3c93113..8bd34ca22 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -144,6 +144,8 @@ describe('runNonInteractive', () => { }), getExperimentalZedIntegration: vi.fn().mockReturnValue(false), isInteractive: vi.fn().mockReturnValue(false), + isCronEnabled: vi.fn().mockReturnValue(false), + getCronScheduler: vi.fn().mockReturnValue(null), } as unknown as Config; mockSettings = { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 4ae0a5759..cb5c23c5c 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -371,6 +371,138 @@ export async function runNonInteractive( } currentMessages = [{ role: 'user', parts: toolResponseParts }]; } 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((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 usage = computeUsageFromMetrics(metrics); // Get stats for JSON format output diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index f0fcb1ef9..9c0c54ee1 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -204,6 +204,8 @@ describe('useGeminiStream', () => { .mockReturnValue(contentGeneratorConfig), getMaxSessionTurns: vi.fn(() => 50), getArenaAgentClient: vi.fn(() => null), + isCronEnabled: vi.fn(() => false), + getCronScheduler: vi.fn(() => null), } as unknown as Config; mockOnDebugMessage = vi.fn(); mockHandleSlashCommand = vi.fn().mockResolvedValue(false); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 0460490f3..ed3eee3b8 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1236,7 +1236,10 @@ export const useGeminiStream = ( } // Check image format support for non-continuations - if (submitType === SendMessageType.UserQuery) { + if ( + submitType === SendMessageType.UserQuery || + submitType === SendMessageType.Cron + ) { const formatCheck = checkImageFormatsSupport(queryToSend); if (formatCheck.hasUnsupportedFormats) { addItem( @@ -1253,7 +1256,10 @@ export const useGeminiStream = ( lastPromptRef.current = finalQueryToSend; lastPromptErroredRef.current = false; - if (submitType === SendMessageType.UserQuery) { + if ( + submitType === SendMessageType.UserQuery || + submitType === SendMessageType.Cron + ) { // trigger new prompt event for session stats in CLI startNewPrompt(); @@ -1698,6 +1704,38 @@ export const useGeminiStream = ( storage, ]); + // ─── Cron scheduler integration ───────────────────────── + const cronQueueRef = useRef([]); + 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 { streamingState, submitQuery, diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 2a4b29d79..fa5e753be 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -58,6 +58,7 @@ import type { import { type AgentEventEmitter, AgentEventType } from './agent-events.js'; import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js'; import { AgentTool } from '../../tools/agent.js'; +import { ToolNames } from '../../tools/tool-names.js'; import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; import { type ContextState, templateString } from './agent-headless.js'; @@ -273,6 +274,15 @@ export class AgentCore { const toolRegistry = this.runtimeContext.getToolRegistry(); 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([ + AgentTool.Name, + ToolNames.CRON_CREATE, + ToolNames.CRON_LIST, + ToolNames.CRON_DELETE, + ]); + if (this.toolConfig) { const asStrings = this.toolConfig.tools.filter( (t): t is string => typeof t === 'string', @@ -286,11 +296,13 @@ export class AgentCore { toolsList.push( ...toolRegistry .getFunctionDeclarations() - .filter((t) => t.name !== AgentTool.Name), + .filter((t) => !(t.name && excludedFromSubagents.has(t.name))), ); } else { toolsList.push( - ...toolRegistry.getFunctionDeclarationsFiltered(asStrings), + ...toolRegistry.getFunctionDeclarationsFiltered( + asStrings.filter((name) => !excludedFromSubagents.has(name)), + ), ); } toolsList.push(...onlyInlineDecls); @@ -299,7 +311,7 @@ export class AgentCore { toolsList.push( ...toolRegistry .getFunctionDeclarations() - .filter((t) => t.name !== AgentTool.Name), + .filter((t) => !(t.name && excludedFromSubagents.has(t.name))), ); } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4d496ad59..c54b55705 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -41,6 +41,7 @@ import { type FileEncodingType, } from '../services/fileSystemService.js'; import { GitService } from '../services/gitService.js'; +import { CronScheduler } from '../services/cronScheduler.js'; // Tools 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 { WriteFileTool } from '../tools/write-file.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'; // Other modules @@ -368,6 +372,7 @@ export interface ConfigParameters { maxSessionTurns?: number; sessionTokenLimit?: number; experimentalZedIntegration?: boolean; + cronEnabled?: boolean; listExtensions?: boolean; overrideExtensions?: string[]; allowedMcpServers?: string[]; @@ -530,6 +535,7 @@ export class Config { private readonly usageStatisticsEnabled: boolean; private geminiClient!: GeminiClient; private baseLlmClient!: BaseLlmClient; + private cronScheduler: CronScheduler | null = null; private readonly fileFiltering: { respectGitIgnore: boolean; respectQwenIgnore: boolean; @@ -557,6 +563,7 @@ export class Config { private readonly cliVersion?: string; private readonly experimentalZedIntegration: boolean = false; + private readonly cronEnabled: boolean = false; private readonly chatRecordingEnabled: boolean; private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly importFormat: 'tree' | 'flat'; @@ -679,6 +686,7 @@ export class Config { this.sessionTokenLimit = params.sessionTokenLimit ?? -1; this.experimentalZedIntegration = params.experimentalZedIntegration ?? false; + this.cronEnabled = params.cronEnabled ?? false; this.listExtensions = params.listExtensions ?? false; this.overrideExtensions = params.overrideExtensions; this.noBrowser = params.noBrowser ?? false; @@ -1687,6 +1695,19 @@ export class Config { 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 { return this.fileFiltering.enableRecursiveFileSearch; } @@ -2204,6 +2225,13 @@ export class Config { 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) { await registry.discoverAllTools(); } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 07217d598..afc447366 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -92,6 +92,8 @@ export enum SendMessageType { ToolResult = 'toolResult', Retry = 'retry', Hook = 'hook', + /** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */ + Cron = 'cron', } export interface SendMessageOptions { @@ -472,6 +474,7 @@ export class GeminiClient { const messageBus = this.config.getMessageBus(); if ( messageType !== SendMessageType.Retry && + messageType !== SendMessageType.Cron && hooksEnabled && messageBus && 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.lastPromptId = prompt_id; @@ -614,7 +620,10 @@ export class GeminiClient { // append system reminders to the request let requestToSent = await flatMapTextParts(request, async (text) => [text]); - if (messageType === SendMessageType.UserQuery) { + if ( + messageType === SendMessageType.UserQuery || + messageType === SendMessageType.Cron + ) { const systemReminders = []; // add subagent system reminder if there are subagents diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 83ab203ca..9fbf78002 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -97,12 +97,16 @@ export * from './tools/tool-registry.js'; export * from './tools/web-fetch.js'; export * from './tools/web-search/index.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 // ============================================================================ export * from './services/chatRecordingService.js'; +export * from './services/cronScheduler.js'; export * from './services/fileDiscoveryService.js'; export * from './services/fileSystemService.js'; export * from './services/gitService.js'; diff --git a/packages/core/src/services/cronScheduler.test.ts b/packages/core/src/services/cronScheduler.test.ts new file mode 100644 index 000000000..9b1f5e9d2 --- /dev/null +++ b/packages/core/src/services/cronScheduler.test.ts @@ -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(); + 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); + }); + }); +}); diff --git a/packages/core/src/services/cronScheduler.ts b/packages/core/src/services/cronScheduler.ts new file mode 100644 index 000000000..9685dde5b --- /dev/null +++ b/packages/core/src/services/cronScheduler.ts @@ -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(); + private timer: ReturnType | 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(); + } +} diff --git a/packages/core/src/skills/bundled/loop/SKILL.md b/packages/core/src/skills/bundled/loop/SKILL.md new file mode 100644 index 000000000..ea9ae7cf3 --- /dev/null +++ b/packages/core/src/skills/bundled/loop/SKILL.md @@ -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] ` 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 ` or `every ` (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] ` 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 diff --git a/packages/core/src/tools/cron-create.test.ts b/packages/core/src/tools/cron-create.test.ts new file mode 100644 index 000000000..b689aec3e --- /dev/null +++ b/packages/core/src/tools/cron-create.test.ts @@ -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; + 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(); + }); +}); diff --git a/packages/core/src/tools/cron-create.ts b/packages/core/src/tools/cron-create.ts new file mode 100644 index 000000000..6013e3dd1 --- /dev/null +++ b/packages/core/src/tools/cron-create.ts @@ -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 { + 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