mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
* feat(scheduled-tasks): add isolated run mode via create_sub_session tool
Introduce a new `create_sub_session` tool (daemon-only) that spawns a
fresh top-level sub-session with its own clean context and transcript.
Wire it into the cron scheduler as an `isolated` run mode so each
scheduled fire dispatches its prompt into a fresh sub-session instead
of accumulating in one shared transcript.
- Add `create_sub_session` tool with `first-turn` and `sent` completion modes
- Add `SubSessionLauncher` in cli/serve with concurrency cap, timeout, and truncation
- Extend ACP bridge `extMethod` dispatch for child→daemon sub-session requests
- Add `runMode` field (`shared`|`isolated`) to DurableCronTask, CronJob, and API types
- Add run-mode radio picker to ScheduledTasksDialog UI
- Fix AuthMessage hardcoded placeholder to use i18n key
* fix(scheduled-tasks): dispatch isolated fires daemon-side, not via the model
An `isolated` fire was relayed through the model: the fired prompt was
wrapped with an instruction to call `create_sub_session`. That tool's
default permission is `'ask'`, so under `ApprovalMode.DEFAULT` an
unattended fire reached `client.requestPermission`, found no SSE
subscriber, and was cancelled by the daemon's 5-minute permission
timeout. The task never ran, and the cancel was booked as a successful
run — the headline use case of a scheduled task was broken.
Route isolated fires straight to the daemon instead: the cron `onFire`
handler in `Session` calls the sub-session spawner directly, with no
model relay and no tool-permission gate. The prompt was already approved
when the task was created; laundering it back through the model only
re-opened that gate. `create_sub_session` keeps `'ask'` for
model-initiated calls, and the attended "Run now" button keeps its relay
(a user is present to answer the prompt).
Also fix orphan-session cleanup in the launcher. `closeSession` was
guarded only by `.catch()`, which covers an async rejection but not a
synchronous throw; because the call sits inside the launcher's own
`catch (err)` block, a sync throw escaped and replaced the real launch
error. Guard both shapes.
Tests:
- Cover isolated routing: dispatch, in-session fallback with no spawner,
missed one-shot, dispatch failure (dropped, never run inline), and
shared mode.
- Cover the orphan close, including a `closeSession` that throws.
- Replace the sent-mode concurrency test, which only asserted the slot
was eventually released (moving the release to the drain's *start*
kept it green) with one that asserts the slot is HELD while the drain
runs, plus one that asserts it is released at `turn_complete`.
* fix(scheduled-tasks): honor the caller's AbortSignal and harden the spawn boundary
Four findings from review, all in the model-initiated `create_sub_session`
path (the scheduled `isolated` dispatch reaches none of them).
`execute()` took no parameters, so it silently dropped the parent turn's
`AbortSignal`. `Session.ts` awaits `invocation.execute(signal)` without
racing the abort itself, so cancelling a turn with a `first-turn`
sub-session in flight pinned the caller's tool loop until the daemon's
5-minute ceiling. Accept the signal and return as soon as it fires.
The sub-session is deliberately NOT cancelled and deliberately KEEPS its
concurrency slot: `sendPrompt` has no abort seam, so the sub-session runs
on. Releasing its slot on cancel — as the review suggested — would let the
caller over-admit against sub-sessions that are still consuming a bridge
session and model quota.
`handleCreateSubSession` trusted the child-supplied `callerSessionId`
verbatim, and that id keys the launcher's per-caller concurrency bucket: a
fabricated id starts a fresh bucket at zero (cap evasion) and a victim's id
burns their slots (DoS). Validate it with the connection's existing
`ownsSession` seam.
Every daemon session wires a spawner, sub-sessions included, and each gets
its own cap-sized bucket — so one prompt could fan out 5ⁿ sub-sessions until
`maxSessions` ran dry. Gate nesting at one level: the launcher remembers the
sessions it spawned and refuses to spawn from them. With `callerSessionId`
now authenticated, the gate cannot be sidestepped.
Cap the prompt at 100,000 chars (matching the scheduled-task REST route) and
the display name at 200, both at the bridge trust boundary and, for the
prompt, in the tool's own validation so the model gets an actionable error.
Not changed: `create_sub_session` stays in `PermissionManager.CORE_TOOLS`.
Membership there SUBJECTS a tool to the `coreTools` allowlist; it does not
exempt it. Removing it — as the review suggested — is what would let the
tool bypass a user's allowlist, the way `agent` and `send_message` do today.
* fix(core): do not spawn a sub-session for an already-cancelled turn
`raceCancellation(spawner({…}), signal)` evaluated the spawner as an
argument, so the spawn started before the abort was ever checked. A turn
cancelled before `execute()` ran still created a sub-session on the daemon
— and it kept a concurrency slot — while the tool reported itself
cancelled.
Take a thunk instead, so the pre-abort check happens before any daemon work
is started. Track whether the spawn actually began, and say so: "cancelled
before it started, no sub-session was created" is a different fact from
"a sub-session may already have been created and is not cancelled".
Regression test asserts the spawner is never called for a pre-aborted
signal; it fails against the eager-argument form.
* fix(serve): require callerSessionId and stop misreporting an early stream close
Two findings from review.
`awaitFirstTurn`'s `'incomplete'` stopReason was unreachable. The cleanup
`finally` calls `ac.abort()` unconditionally to tear the subscription down,
so by the time the stopReason ternary read `ac.signal.aborted` it was always
true. An event stream that closed before the turn finished (bridge teardown,
WS drop) was reported as a 5-minute wall-clock `'timeout'` — indistinguishable
from a real one. Track the timer firing in its own flag.
`callerSessionId` was validated only when present. Omitting it handed the
launcher `undefined`, which minted an `anon:<uuid>` bucket — a fresh
concurrency bucket per call, so no cap — and skipped the depth-1 nesting gate
(`info.callerSessionId !== undefined && …`). Authenticating the id closed
forgery but not omission. It is now required at the bridge boundary, and
required in `CreateSubSessionInfo`, so the launcher's anonymous fallback and
the gate's presence check are both gone. Every real caller has a session id —
the tool only ever runs inside a session's turn.
* fix(serve): surface dropped fires and drain timeouts; bound sub-sessions per workspace
Three findings from review.
A dropped `isolated` scheduled fire left no trace. `debugLogger.warn` writes
nothing unless a debug log session is active, and the scheduler persists the
fire as a run before dispatch — so a nightly task could fail forever while its
history claimed it ran. It now also writes to stderr, which the daemon forwards
from the child.
A sent-mode drain that hit its 30-minute ceiling was equally silent: the catch
saw `drainAc.signal.aborted` and skipped logging, the `finally` freed the
concurrency slot, and the sub-session — which the abort does not cancel — kept
burning a bridge session and model quota. The timer now records its own firing
(the controller cannot: `finally` aborts it on every exit path) and the timeout
is written to stderr. The drain ceiling is injectable for tests, mirroring
`firstTurnTimeoutMs`.
The per-caller concurrency cap trusts `callerSessionId`, and the bridge can only
authenticate that id as "a session on this channel". Every session of a
workspace shares one child process, so nothing at the transport can prove which
of them issued the call — and a per-session secret would be readable by the
whole process anyway. Rather than pretend otherwise, add a workspace-wide
ceiling on concurrent sub-sessions that holds no matter which bucket a launch is
charged to.
528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
// @vitest-environment jsdom
|
||
/**
|
||
* @license
|
||
* Copyright 2025 Qwen Team
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { act } from 'react';
|
||
import { createRoot, type Root } from 'react-dom/client';
|
||
|
||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||
|
||
if (!Element.prototype.scrollIntoView) {
|
||
Element.prototype.scrollIntoView = () => {};
|
||
}
|
||
|
||
interface MockTask {
|
||
id: string;
|
||
name: string | null;
|
||
cron: string;
|
||
prompt: string;
|
||
recurring: boolean;
|
||
enabled: boolean;
|
||
createdAt: number;
|
||
lastFiredAt: number | null;
|
||
nextRunAt: number | null;
|
||
sessionId: string | null;
|
||
runMode?: 'shared' | 'isolated';
|
||
runs: Array<{
|
||
at: number;
|
||
kind?: 'scheduled' | 'catch-up';
|
||
sessionId?: string;
|
||
}>;
|
||
}
|
||
|
||
const { actions } = vi.hoisted(() => ({
|
||
actions: {
|
||
listScheduledTasks: vi.fn(),
|
||
createScheduledTask: vi.fn(),
|
||
updateScheduledTask: vi.fn(),
|
||
runScheduledTask: vi.fn(),
|
||
deleteScheduledTask: vi.fn(),
|
||
},
|
||
}));
|
||
|
||
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
||
useWorkspaceActions: () => actions,
|
||
}));
|
||
|
||
const { ScheduledTasksDialog } = await import('./ScheduledTasksDialog');
|
||
const { I18nProvider } = await import('../../i18n');
|
||
|
||
let container: HTMLDivElement | null = null;
|
||
let root: Root | null = null;
|
||
|
||
async function mount(
|
||
tasks: MockTask[],
|
||
opts: {
|
||
onOpenSession?: (sessionId: string) => void;
|
||
onRunPrompt?: (
|
||
prompt: string,
|
||
sessionId: string | null,
|
||
) => void | Promise<void>;
|
||
onError?: (error: unknown, message: string) => void;
|
||
} = {},
|
||
) {
|
||
actions.listScheduledTasks.mockResolvedValue(tasks);
|
||
actions.updateScheduledTask.mockResolvedValue(tasks[0]);
|
||
actions.runScheduledTask.mockResolvedValue(tasks[0]);
|
||
container = document.createElement('div');
|
||
document.body.appendChild(container);
|
||
root = createRoot(container);
|
||
await act(async () => {
|
||
root!.render(
|
||
<I18nProvider language="en">
|
||
<ScheduledTasksDialog
|
||
onRunPrompt={opts.onRunPrompt ?? vi.fn()}
|
||
onCreateViaChat={vi.fn()}
|
||
onOpenSession={opts.onOpenSession}
|
||
onError={opts.onError ?? vi.fn()}
|
||
/>
|
||
</I18nProvider>,
|
||
);
|
||
});
|
||
await flush();
|
||
}
|
||
|
||
// Flush the async list load (and any post-action reload) so state settles.
|
||
async function flush() {
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
}
|
||
|
||
function click(el: Element | null | undefined) {
|
||
if (!el) throw new Error('click target not found');
|
||
act(() => {
|
||
el.dispatchEvent(
|
||
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
||
);
|
||
});
|
||
}
|
||
|
||
function findButton(label: string): HTMLButtonElement | undefined {
|
||
return Array.from(document.querySelectorAll('button')).find(
|
||
(b) => b.textContent?.trim() === label,
|
||
);
|
||
}
|
||
|
||
// Run mode is a radio group; the frequency picker is the (only) <select>.
|
||
function findRunModeRadio(
|
||
value: 'shared' | 'isolated',
|
||
): HTMLInputElement | undefined {
|
||
return Array.from(
|
||
document.querySelectorAll<HTMLInputElement>(
|
||
'input[type="radio"][name="runMode"]',
|
||
),
|
||
).find((r) => r.value === value);
|
||
}
|
||
|
||
function findFrequencySelect(): HTMLSelectElement | undefined {
|
||
return Array.from(document.querySelectorAll('select')).find(
|
||
(s) => !!s.querySelector('option[value="weekdays"]'),
|
||
);
|
||
}
|
||
|
||
afterEach(() => {
|
||
act(() => root?.unmount());
|
||
container?.remove();
|
||
root = null;
|
||
container = null;
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
const baseTask = (over: Partial<MockTask>): MockTask => ({
|
||
id: 't1',
|
||
name: 'Digest',
|
||
cron: '30 12 * * 1-5',
|
||
prompt: 'summarize the day',
|
||
recurring: true,
|
||
enabled: true,
|
||
createdAt: 1_700_000_000_000,
|
||
lastFiredAt: null,
|
||
nextRunAt: null,
|
||
sessionId: null,
|
||
runs: [],
|
||
...over,
|
||
});
|
||
|
||
describe('ScheduledTasksDialog editing', () => {
|
||
it('prefills the form from the task and saves via updateScheduledTask', async () => {
|
||
await mount([baseTask({})]);
|
||
|
||
// Open the edit form for the (only) task.
|
||
click(document.querySelector('[aria-label="Edit"]'));
|
||
|
||
// The cron reverses onto the structured pickers (weekdays @ 12:30) and the
|
||
// name/prompt are prefilled — not left blank as they would be for create.
|
||
const name = document.querySelector<HTMLInputElement>('input[type="text"]');
|
||
const prompt = document.querySelector<HTMLTextAreaElement>('textarea');
|
||
const frequency = findFrequencySelect();
|
||
const time = document.querySelector<HTMLInputElement>('input[type="time"]');
|
||
expect(name?.value).toBe('Digest');
|
||
expect(prompt?.value).toBe('summarize the day');
|
||
expect(frequency?.value).toBe('weekdays');
|
||
expect(time?.value).toBe('12:30');
|
||
|
||
// Saving routes through update (not create), sending only the editable
|
||
// fields so recurring/enabled are left untouched.
|
||
click(findButton('Save'));
|
||
await flush();
|
||
|
||
expect(actions.updateScheduledTask).toHaveBeenCalledWith('t1', {
|
||
cron: '30 12 * * 1-5',
|
||
prompt: 'summarize the day',
|
||
name: 'Digest',
|
||
// A task with no runMode prefills + saves as the default 'shared'.
|
||
runMode: 'shared',
|
||
});
|
||
expect(actions.createScheduledTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('an unrepresentable cron lands in the custom field, losslessly', async () => {
|
||
await mount([baseTask({ cron: '0 9 * * 1,3,5' })]); // day-of-week list
|
||
|
||
click(document.querySelector('[aria-label="Edit"]'));
|
||
const frequency = findFrequencySelect();
|
||
expect(frequency?.value).toBe('custom');
|
||
|
||
click(findButton('Save'));
|
||
await flush();
|
||
// The raw expression round-trips unchanged rather than being rewritten.
|
||
expect(actions.updateScheduledTask).toHaveBeenCalledWith(
|
||
't1',
|
||
expect.objectContaining({ cron: '0 9 * * 1,3,5' }),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('ScheduledTasksDialog run mode', () => {
|
||
it('offers run-mode radios defaulting to shared in the create form', async () => {
|
||
await mount([]);
|
||
click(findButton('New scheduled task'));
|
||
// Both modes are offered as radios; shared is selected by default.
|
||
expect(findRunModeRadio('shared')?.checked).toBe(true);
|
||
expect(findRunModeRadio('isolated')?.checked).toBe(false);
|
||
});
|
||
|
||
it('prefills and saves runMode for an isolated task', async () => {
|
||
await mount([baseTask({ runMode: 'isolated', sessionId: 'anchor-1' })]);
|
||
click(document.querySelector('[aria-label="Edit"]'));
|
||
|
||
expect(findRunModeRadio('isolated')?.checked).toBe(true);
|
||
expect(findRunModeRadio('shared')?.checked).toBe(false);
|
||
|
||
click(findButton('Save'));
|
||
await flush();
|
||
expect(actions.updateScheduledTask).toHaveBeenCalledWith(
|
||
't1',
|
||
expect.objectContaining({ runMode: 'isolated' }),
|
||
);
|
||
});
|
||
|
||
it('opens the bound session for an isolated task, same as shared', async () => {
|
||
const onOpenSession = vi.fn();
|
||
await mount(
|
||
[
|
||
baseTask({
|
||
runMode: 'isolated',
|
||
sessionId: 'anchor-1',
|
||
runs: [
|
||
{ at: 1_700_000_100_000, kind: 'scheduled', sessionId: 'anchor-1' },
|
||
],
|
||
}),
|
||
],
|
||
{ onOpenSession },
|
||
);
|
||
// Isolated uses the SAME bound-session history button as shared — its
|
||
// transcript shows the model dispatching each run into a sub-session.
|
||
click(findButton('View conversation (1)'));
|
||
expect(onOpenSession).toHaveBeenCalledWith('anchor-1');
|
||
});
|
||
});
|
||
|
||
describe('ScheduledTasksDialog run history', () => {
|
||
it('toggles the run list open, newest-first, tagging late fires', async () => {
|
||
await mount([
|
||
baseTask({
|
||
runs: [
|
||
{ at: 1_700_000_100_000, kind: 'scheduled' },
|
||
{ at: 1_700_000_200_000, kind: 'catch-up' },
|
||
],
|
||
}),
|
||
]);
|
||
|
||
// Collapsed by default — no list items rendered.
|
||
expect(document.querySelectorAll('li').length).toBe(0);
|
||
const toggle = document.querySelector<HTMLButtonElement>(
|
||
'button[aria-expanded]',
|
||
);
|
||
expect(toggle?.textContent).toBe('Run history (2)');
|
||
|
||
click(toggle);
|
||
const items = Array.from(document.querySelectorAll('li'));
|
||
expect(items).toHaveLength(2);
|
||
// Newest first: the catch-up fire (later timestamp) leads and is tagged.
|
||
expect(items[0]!.textContent).toContain('late');
|
||
expect(items[1]!.textContent).not.toContain('late');
|
||
});
|
||
|
||
it('shows no run-history toggle for a task that has never fired', async () => {
|
||
await mount([baseTask({ runs: [] })]);
|
||
expect(document.querySelector('button[aria-expanded]')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('ScheduledTasksDialog run now', () => {
|
||
it('records the run and executes the prompt in the task’s bound session', async () => {
|
||
const onRunPrompt = vi.fn();
|
||
await mount([baseTask({ sessionId: 'sess-9', prompt: 'do it' })], {
|
||
onRunPrompt,
|
||
});
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
// Server-side run record (updates last-run) + client run in the bound session.
|
||
expect(actions.runScheduledTask).toHaveBeenCalledWith('t1');
|
||
expect(onRunPrompt).toHaveBeenCalledWith('do it', 'sess-9');
|
||
});
|
||
|
||
it('passes a null sessionId through for an unbound task', async () => {
|
||
const onRunPrompt = vi.fn();
|
||
await mount([baseTask({ sessionId: null, prompt: 'legacy' })], {
|
||
onRunPrompt,
|
||
});
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(onRunPrompt).toHaveBeenCalledWith('legacy', null);
|
||
});
|
||
|
||
it('does not run a DISABLED task (button disabled, prompt never enqueued)', async () => {
|
||
// The server /run guard only refuses the record — the prompt must not even
|
||
// be enqueued, or a disabled task would execute unrecorded.
|
||
const onRunPrompt = vi.fn();
|
||
await mount([baseTask({ enabled: false, sessionId: 'sess-9' })], {
|
||
onRunPrompt,
|
||
});
|
||
const btn = document.querySelector(
|
||
'[aria-label="Run now"]',
|
||
) as HTMLButtonElement;
|
||
expect(btn.disabled).toBe(true);
|
||
click(btn); // disabled + handler guard → no-op
|
||
await flush();
|
||
expect(onRunPrompt).not.toHaveBeenCalled();
|
||
expect(actions.runScheduledTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('does NOT record the run when the bound session fails to open', async () => {
|
||
// onRunPrompt rejects (session archived/deleted): the prompt never ran, so
|
||
// the run must not be recorded — otherwise history shows a phantom run.
|
||
const onRunPrompt = vi.fn().mockRejectedValue(new Error('archived'));
|
||
const onError = vi.fn();
|
||
await mount([baseTask({ sessionId: 'sess-9', prompt: 'do it' })], {
|
||
onRunPrompt,
|
||
onError,
|
||
});
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(onRunPrompt).toHaveBeenCalledWith('do it', 'sess-9');
|
||
expect(actions.runScheduledTask).not.toHaveBeenCalled(); // no phantom record
|
||
expect(onError).toHaveBeenCalled();
|
||
});
|
||
|
||
it('re-checks server state and does NOT enqueue a task disabled after load', async () => {
|
||
// The dialog loaded the task as enabled, but another tab/API disabled it
|
||
// since. The server-authoritative re-check must catch that BEFORE enqueuing,
|
||
// or a disabled task's prompt runs unrecorded.
|
||
const onRunPrompt = vi.fn();
|
||
await mount(
|
||
[baseTask({ enabled: true, sessionId: 'sess-9', prompt: 'do it' })],
|
||
{
|
||
onRunPrompt,
|
||
},
|
||
);
|
||
// Server now reports it disabled (the re-check reload sees this).
|
||
actions.listScheduledTasks.mockResolvedValue([
|
||
baseTask({ enabled: false, sessionId: 'sess-9', prompt: 'do it' }),
|
||
]);
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(onRunPrompt).not.toHaveBeenCalled(); // never executed
|
||
expect(actions.runScheduledTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('consumes a bound ONE-SHOT before enqueuing (record → enqueue)', async () => {
|
||
// /run deletes a one-shot (its single fire). Consuming it BEFORE the run
|
||
// means a failed enqueue leaves a recoverable "recorded but never ran", not
|
||
// a silent double execution at the task's own slot.
|
||
const order: string[] = [];
|
||
const onRunPrompt = vi.fn(() => {
|
||
order.push('enqueue');
|
||
});
|
||
await mount(
|
||
[baseTask({ recurring: false, enabled: true, sessionId: 'sess-9' })],
|
||
{ onRunPrompt },
|
||
);
|
||
actions.runScheduledTask.mockImplementation(async () => {
|
||
order.push('record');
|
||
return baseTask({});
|
||
});
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(order).toEqual(['record', 'enqueue']);
|
||
});
|
||
|
||
it('surfaces a consumed-but-failed error when a ONE-SHOT delivery fails', async () => {
|
||
// The one-shot was deleted before the run; if delivery then rejects it is
|
||
// gone AND un-run, so the error must say so — not the generic "run failed",
|
||
// which would hide that the task no longer exists.
|
||
const onRunPrompt = vi.fn().mockRejectedValue(new Error('switch timeout'));
|
||
const onError = vi.fn();
|
||
await mount(
|
||
[
|
||
baseTask({
|
||
recurring: false,
|
||
enabled: true,
|
||
sessionId: 'sess-9',
|
||
prompt: 'do it',
|
||
}),
|
||
],
|
||
{ onRunPrompt, onError },
|
||
);
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(actions.runScheduledTask).toHaveBeenCalledWith('t1'); // consumed
|
||
expect(onRunPrompt).toHaveBeenCalledWith('do it', 'sess-9');
|
||
expect(onError).toHaveBeenCalledWith(
|
||
expect.any(Error),
|
||
expect.stringContaining('never ran'),
|
||
);
|
||
});
|
||
|
||
it('records a RECURRING task after enqueuing (enqueue → record)', async () => {
|
||
const order: string[] = [];
|
||
const onRunPrompt = vi.fn(() => {
|
||
order.push('enqueue');
|
||
});
|
||
await mount(
|
||
[baseTask({ recurring: true, enabled: true, sessionId: 'sess-9' })],
|
||
{ onRunPrompt },
|
||
);
|
||
actions.runScheduledTask.mockImplementation(async () => {
|
||
order.push('record');
|
||
return baseTask({});
|
||
});
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(order).toEqual(['enqueue', 'record']);
|
||
});
|
||
|
||
it('serializes run now: a second click while one is pending is ignored', async () => {
|
||
// Hold the first run pending (session still switching), then click again —
|
||
// the button is disabled + the handler guards, so no second prompt/record.
|
||
let resolveRun: (() => void) | undefined;
|
||
const onRunPrompt = vi.fn().mockImplementation(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
resolveRun = resolve;
|
||
}),
|
||
);
|
||
await mount([baseTask({ sessionId: 'sess-9', prompt: 'do it' })], {
|
||
onRunPrompt,
|
||
});
|
||
const btn = document.querySelector('[aria-label="Run now"]');
|
||
click(btn);
|
||
await flush(); // first run pending → button disabled
|
||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||
click(btn); // ignored while pending
|
||
await flush();
|
||
expect(onRunPrompt).toHaveBeenCalledTimes(1);
|
||
resolveRun?.();
|
||
await flush();
|
||
expect(actions.runScheduledTask).toHaveBeenCalledTimes(1); // one record
|
||
});
|
||
});
|
||
|
||
describe('ScheduledTasksDialog far-future countdown', () => {
|
||
it('clamps the reload timer so a months-away schedule cannot overflow setTimeout', async () => {
|
||
const spy = vi.spyOn(window, 'setTimeout');
|
||
try {
|
||
// ~100 days out: the raw delay exceeds setTimeout's 32-bit ceiling, which
|
||
// would fire immediately and spin a reload loop. It must be clamped.
|
||
const farFuture = Date.now() + 100 * 86_400_000;
|
||
await mount([baseTask({ nextRunAt: farFuture })]);
|
||
const delays = spy.mock.calls.map((c) => Number(c[1] ?? 0));
|
||
expect(delays.every((d) => d <= 2_147_483_647)).toBe(true);
|
||
expect(delays).toContain(2_147_483_647); // clamped to the ceiling
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('ScheduledTasksDialog view-history (bound session)', () => {
|
||
it('opens the bound session when its history control is clicked', async () => {
|
||
const onOpenSession = vi.fn();
|
||
await mount(
|
||
[
|
||
baseTask({
|
||
sessionId: 'sess-42',
|
||
runs: [{ at: 1_700_000_100_000, kind: 'scheduled' }],
|
||
}),
|
||
],
|
||
{ onOpenSession },
|
||
);
|
||
// A bound task shows a "View conversation" control (with a run count) that
|
||
// opens its session transcript — not the inline expand toggle.
|
||
const btn = findButton('View conversation (1)');
|
||
expect(btn).toBeDefined();
|
||
expect(document.querySelector('button[aria-expanded]')).toBeNull();
|
||
click(btn);
|
||
expect(onOpenSession).toHaveBeenCalledWith('sess-42');
|
||
});
|
||
|
||
it('shows the view-history control even before the first run (empty state)', async () => {
|
||
const onOpenSession = vi.fn();
|
||
await mount([baseTask({ sessionId: 'sess-42', runs: [] })], {
|
||
onOpenSession,
|
||
});
|
||
// Discoverable even with zero runs — the original "can't find history" pain.
|
||
const btn = findButton('View conversation');
|
||
expect(btn).toBeDefined();
|
||
click(btn);
|
||
expect(onOpenSession).toHaveBeenCalledWith('sess-42');
|
||
});
|
||
|
||
it('falls back to the inline toggle when session opening is not wired', async () => {
|
||
// No onOpenSession (e.g. a minimal embed): a bound task with runs still gets
|
||
// the inline timestamp list rather than a dead open button.
|
||
await mount([
|
||
baseTask({ sessionId: 'sess-42', runs: [{ at: 1_700_000_100_000 }] }),
|
||
]);
|
||
expect(findButton('View conversation (1)')).toBeUndefined();
|
||
expect(document.querySelector('button[aria-expanded]')).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('ScheduledTasksDialog next-run countdown', () => {
|
||
it('renders a countdown pill for a task with a next run', async () => {
|
||
// ~3h12m in the future — the hours unit is stable across the test's few ms.
|
||
await mount([
|
||
baseTask({ nextRunAt: Date.now() + 3 * 3_600_000 + 12 * 60_000 }),
|
||
]);
|
||
const pill = document.querySelector(
|
||
'[data-testid="scheduled-task-next-run"]',
|
||
);
|
||
expect(pill).not.toBeNull();
|
||
expect(pill!.textContent).toMatch(/3h/);
|
||
});
|
||
|
||
it('omits the countdown when there is no next run (disabled task)', async () => {
|
||
await mount([baseTask({ enabled: false, nextRunAt: null })]);
|
||
expect(
|
||
document.querySelector('[data-testid="scheduled-task-next-run"]'),
|
||
).toBeNull();
|
||
});
|
||
});
|