@robota-sdk/agent-command 3.0.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1351 -0
- package/LICENSE +661 -0
- package/README.md +100 -0
- package/dist/node/index.cjs +121 -0
- package/dist/node/index.d.cts +1053 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1053 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +122 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +83 -0
- package/src/advisor/advisor-command-module.ts +47 -0
- package/src/advisor/advisor-command.test.ts +102 -0
- package/src/advisor/advisor-command.ts +55 -0
- package/src/advisor/advisor-cost-functional.test.ts +92 -0
- package/src/advisor/index.ts +6 -0
- package/src/agent/__tests__/agent-command.test.ts +597 -0
- package/src/agent/agent-command-module.ts +89 -0
- package/src/agent/agent-command-parser.ts +180 -0
- package/src/agent/agent-command.ts +255 -0
- package/src/agent/index.ts +7 -0
- package/src/background/__tests__/background-command-module.test.ts +261 -0
- package/src/background/background-command-module.ts +52 -0
- package/src/background/background-command.ts +65 -0
- package/src/background/index.ts +6 -0
- package/src/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +35 -0
- package/src/compact/__tests__/compact-command-module.test.ts +181 -0
- package/src/compact/compact-command-module.ts +59 -0
- package/src/compact/compact-command.ts +33 -0
- package/src/compact/index.ts +6 -0
- package/src/context/__tests__/context-command-module.test.ts +502 -0
- package/src/context/auto-compact-format.ts +25 -0
- package/src/context/context-breakdown.ts +295 -0
- package/src/context/context-command-module.ts +82 -0
- package/src/context/context-command.ts +291 -0
- package/src/context/index.ts +6 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +121 -0
- package/src/default/__tests__/default-command-modules.test.ts +192 -0
- package/src/default/__tests__/model-exposure.test.ts +269 -0
- package/src/default/__tests__/org-policy-forwarding.test.ts +106 -0
- package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
- package/src/default/default-command-modules.ts +247 -0
- package/src/default/index.ts +5 -0
- package/src/devices/__tests__/devices-command-module.test.ts +299 -0
- package/src/devices/devices-command-module.ts +308 -0
- package/src/devices/devices-command-port.ts +143 -0
- package/src/devices/index.ts +14 -0
- package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
- package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
- package/src/doctor/__tests__/doctor-fixture.ts +146 -0
- package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +484 -0
- package/src/doctor/doctor-command-module.ts +132 -0
- package/src/doctor/doctor-extensions-probe.ts +323 -0
- package/src/doctor/doctor-node-deps.ts +88 -0
- package/src/doctor/doctor-redaction.ts +99 -0
- package/src/doctor/doctor-render.ts +70 -0
- package/src/doctor/doctor-repair.ts +136 -0
- package/src/doctor/doctor-runner.ts +122 -0
- package/src/doctor/doctor-settings-probe.ts +239 -0
- package/src/doctor/doctor-storage-probe.ts +187 -0
- package/src/doctor/doctor-types.ts +109 -0
- package/src/doctor/index.ts +32 -0
- package/src/editor/__tests__/editor-command-functional.test.ts +124 -0
- package/src/editor/editor-command-module.ts +53 -0
- package/src/editor/editor-command.ts +58 -0
- package/src/editor/index.ts +7 -0
- package/src/editor/resolve-editor.ts +21 -0
- package/src/effort/effort-command-module.ts +51 -0
- package/src/effort/effort-command.test.ts +203 -0
- package/src/effort/effort-command.ts +136 -0
- package/src/effort/index.ts +6 -0
- package/src/events/__tests__/events-command.test.ts +73 -0
- package/src/events/events-command-module.ts +49 -0
- package/src/events/events-command.ts +69 -0
- package/src/events/index.ts +2 -0
- package/src/exit/__tests__/exit-command-module.test.ts +61 -0
- package/src/exit/exit-command-module.ts +47 -0
- package/src/exit/exit-command.ts +26 -0
- package/src/exit/index.ts +6 -0
- package/src/fork/__tests__/fork-command.test.ts +184 -0
- package/src/fork/fork-command-module.ts +61 -0
- package/src/fork/fork-command.ts +162 -0
- package/src/fork/index.ts +8 -0
- package/src/git/__tests__/fake-git-port.ts +35 -0
- package/src/git/__tests__/git-command-module.test.ts +111 -0
- package/src/git/__tests__/git-commit.test.ts +185 -0
- package/src/git/git-command-module.ts +138 -0
- package/src/git/git-commit.ts +254 -0
- package/src/git/index.ts +20 -0
- package/src/goal/__tests__/goal-command.test.ts +80 -0
- package/src/goal/goal-command-module.ts +49 -0
- package/src/goal/goal-command.ts +69 -0
- package/src/goal/index.ts +6 -0
- package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
- package/src/handoff/__tests__/handoff-command.test.ts +218 -0
- package/src/handoff/handoff-command-module.ts +49 -0
- package/src/handoff/handoff-command.ts +187 -0
- package/src/handoff/index.ts +6 -0
- package/src/help/__tests__/help-command-module.test.ts +134 -0
- package/src/help/__tests__/help-command.test.ts +130 -0
- package/src/help/help-command-module.ts +47 -0
- package/src/help/help-command.ts +11 -0
- package/src/help/index.ts +6 -0
- package/src/index.ts +163 -0
- package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
- package/src/keybindings/index.ts +6 -0
- package/src/keybindings/keybindings-command-module.ts +88 -0
- package/src/language/__tests__/language-command-module.test.ts +126 -0
- package/src/language/index.ts +6 -0
- package/src/language/language-command-module.ts +55 -0
- package/src/language/language-command.ts +52 -0
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +895 -0
- package/src/mcp-activation/__tests__/mcp-model-view.test.ts +242 -0
- package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
- package/src/mcp-activation/index.ts +12 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +123 -0
- package/src/mcp-activation/mcp-activation-command.ts +662 -0
- package/src/mcp-activation/mcp-model-notice.ts +99 -0
- package/src/mcp-activation/mcp-model-status.ts +104 -0
- package/src/memory/__tests__/memory-command-module.test.ts +476 -0
- package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
- package/src/memory/index.ts +6 -0
- package/src/memory/memory-command-module.ts +77 -0
- package/src/memory/memory-command.ts +326 -0
- package/src/mode/__tests__/mode-command-module.test.ts +230 -0
- package/src/mode/index.ts +6 -0
- package/src/mode/mode-command-module.ts +55 -0
- package/src/mode/mode-command.ts +67 -0
- package/src/model/__tests__/model-command-module.test.ts +80 -0
- package/src/model/__tests__/model-command.test.ts +265 -0
- package/src/model/index.ts +6 -0
- package/src/model/model-command-module.ts +56 -0
- package/src/model/model-command.ts +249 -0
- package/src/output-style/__tests__/output-style-command.test.ts +96 -0
- package/src/output-style/index.ts +6 -0
- package/src/output-style/output-style-command-module.ts +143 -0
- package/src/peers/__tests__/peers-command-module.test.ts +24 -0
- package/src/peers/__tests__/peers-command.test.ts +399 -0
- package/src/peers/index.ts +9 -0
- package/src/peers/peers-command-module.ts +49 -0
- package/src/peers/peers-command.ts +220 -0
- package/src/permissions/__tests__/permissions-command-module.test.ts +276 -0
- package/src/permissions/index.ts +6 -0
- package/src/permissions/permissions-command-module.ts +55 -0
- package/src/permissions/permissions-command.ts +89 -0
- package/src/plan/__tests__/plan-command.test.ts +129 -0
- package/src/plan/index.ts +9 -0
- package/src/plan/plan-command-module.ts +49 -0
- package/src/plan/plan-command.ts +92 -0
- package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +300 -0
- package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/plugin-command-module.ts +85 -0
- package/src/plugin/plugin-command.ts +271 -0
- package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
- package/src/preset/__tests__/preset-command-module.test.ts +220 -0
- package/src/preset/index.ts +6 -0
- package/src/preset/preset-command-module.ts +80 -0
- package/src/preset/preset-command.ts +148 -0
- package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
- package/src/provider/__tests__/org-policy.test.ts +174 -0
- package/src/provider/__tests__/provider-command-module.test.ts +884 -0
- package/src/provider/__tests__/provider-connection-setup.test.ts +567 -0
- package/src/provider/__tests__/provider-reference-switch.test.ts +113 -0
- package/src/provider/__tests__/provider-setup-flow.test.ts +244 -0
- package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
- package/src/provider/__tests__/scripted-interaction.ts +40 -0
- package/src/provider/index.ts +32 -0
- package/src/provider/provider-command-execution.ts +235 -0
- package/src/provider/provider-command-module.ts +82 -0
- package/src/provider/provider-command-profile-lifecycle.ts +239 -0
- package/src/provider/provider-command-profile-operations.ts +218 -0
- package/src/provider/provider-command-profile.ts +116 -0
- package/src/provider/provider-command-setup.ts +79 -0
- package/src/provider/provider-connection-setup.ts +287 -0
- package/src/provider/provider-connection-validation.ts +38 -0
- package/src/provider/provider-onboarding.ts +73 -0
- package/src/provider/provider-prompt-interaction.ts +28 -0
- package/src/provider/provider-setup-ask.ts +67 -0
- package/src/provider/provider-setup-flow.ts +326 -0
- package/src/provider/provider-startup.ts +225 -0
- package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
- package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
- package/src/remote-control/index.ts +8 -0
- package/src/remote-control/remote-control-command-module.ts +61 -0
- package/src/remote-control/remote-control-command.ts +138 -0
- package/src/reset/__tests__/reset-command-module.test.ts +63 -0
- package/src/reset/index.ts +2 -0
- package/src/reset/reset-command-module.ts +47 -0
- package/src/reset/reset-command.ts +10 -0
- package/src/rewind/__tests__/rewind-command-module.test.ts +287 -0
- package/src/rewind/index.ts +2 -0
- package/src/rewind/rewind-command-module.ts +56 -0
- package/src/rewind/rewind-command.ts +279 -0
- package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/sandbox-command-module.ts +56 -0
- package/src/sandbox/sandbox-command.ts +94 -0
- package/src/schedule/__tests__/loop-command-functional.test.ts +399 -0
- package/src/schedule/__tests__/loop-command.test.ts +594 -0
- package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
- package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
- package/src/schedule/__tests__/schedule-command.test.ts +247 -0
- package/src/schedule/__tests__/schedule-redos.test.ts +114 -0
- package/src/schedule/index.ts +12 -0
- package/src/schedule/loop-command.ts +427 -0
- package/src/schedule/loop-jitter.ts +41 -0
- package/src/schedule/schedule-command-module.ts +184 -0
- package/src/schedule/schedule-command.ts +161 -0
- package/src/schedule/schedule-spec-parser.ts +70 -0
- package/src/session/__tests__/model-pricing.test.ts +66 -0
- package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
- package/src/session/__tests__/session-command-module.test.ts +589 -0
- package/src/session/index.ts +17 -0
- package/src/session/model-pricing.ts +26 -0
- package/src/session/session-command-module.ts +197 -0
- package/src/session/session-command.ts +272 -0
- package/src/settings/index.ts +7 -0
- package/src/settings/settings-command-module.ts +47 -0
- package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
- package/src/shell/__tests__/shell-command-functional.test.ts +118 -0
- package/src/shell/index.ts +8 -0
- package/src/shell/resolve-shell.ts +25 -0
- package/src/shell/shell-command-module.ts +53 -0
- package/src/shell/shell-command.ts +48 -0
- package/src/shell/spawn-inherited.ts +32 -0
- package/src/skills/__tests__/skill-read-command.test.ts +64 -0
- package/src/skills/__tests__/skills-command-module.test.ts +200 -0
- package/src/skills/index.ts +7 -0
- package/src/skills/skill-read-command.ts +60 -0
- package/src/skills/skills-command-module.ts +76 -0
- package/src/skills/skills-command.ts +108 -0
- package/src/statusline/__tests__/statusline-command-module.test.ts +96 -0
- package/src/statusline/index.ts +6 -0
- package/src/statusline/statusline-command-module.ts +55 -0
- package/src/statusline/statusline-command.ts +80 -0
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
- package/src/terminal-client/index.ts +5 -0
- package/src/terminal-client/terminal-client-commands.ts +109 -0
- package/src/theme/__tests__/theme-command.test.ts +204 -0
- package/src/theme/index.ts +12 -0
- package/src/theme/theme-command-module.ts +221 -0
- package/src/user-local/__tests__/user-local-command.test.ts +180 -0
- package/src/user-local/index.ts +13 -0
- package/src/user-local/user-local-command-constants.ts +5 -0
- package/src/user-local/user-local-command-module.ts +65 -0
- package/src/user-local/user-local-command.ts +214 -0
- package/src/user-local/user-local-memory-command.ts +160 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { executeLoopCommand } from '../loop-command.js';
|
|
4
|
+
|
|
5
|
+
import type { ILoopInvocationContext } from '../loop-command.js';
|
|
6
|
+
import { createTestAgentJobHost } from '@robota-sdk/agent-framework/testing';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* #3288 §1: every default-prompt form (bare or interval-only) is gated on confirmation — an
|
|
10
|
+
* interactive invocation with an ask port that answers Start, for a test whose OWN point is
|
|
11
|
+
* something else (default-prompt resolution, cadence, etc.), not the confirmation gate itself.
|
|
12
|
+
*/
|
|
13
|
+
function interactiveStart(): ILoopInvocationContext {
|
|
14
|
+
return { ui: { ask: vi.fn().mockResolvedValue({ type: 'answer', values: ['start'] }) }, source: 'user' };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('fixed in-session loop', () => {
|
|
18
|
+
it('uses the host maintenance prompt for interval-only forms', async () => {
|
|
19
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_default' });
|
|
20
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
21
|
+
const options = { defaultPrompt: 'Tend only the current task and its PR.' };
|
|
22
|
+
|
|
23
|
+
expect(
|
|
24
|
+
(await executeLoopCommand(host, vi.fn(), '5m', options, interactiveStart())).success,
|
|
25
|
+
).toBe(true);
|
|
26
|
+
expect(spawnScheduledWake).toHaveBeenLastCalledWith(
|
|
27
|
+
expect.objectContaining({
|
|
28
|
+
cronExpression: expect.any(String),
|
|
29
|
+
agentInstruction: options.defaultPrompt,
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('routes bare and prompt-only forms to the durable self-paced controller', async () => {
|
|
35
|
+
const createSelfPacedLoop = vi.fn().mockImplementation(async (instruction: string) => ({
|
|
36
|
+
loopId: 'loop_self', instruction, phase: 'pending', expiresAt: '2030-01-01T00:00:00.000Z',
|
|
37
|
+
}));
|
|
38
|
+
const spawnScheduledWake = vi.fn();
|
|
39
|
+
const host = createTestAgentJobHost({ spawnScheduledWake, createSelfPacedLoop });
|
|
40
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
41
|
+
|
|
42
|
+
expect(
|
|
43
|
+
(await executeLoopCommand(host, vi.fn(), '', options, interactiveStart())).success,
|
|
44
|
+
).toBe(true);
|
|
45
|
+
// An explicit prompt is never gated — no invocation (headless) needed to succeed.
|
|
46
|
+
expect((await executeLoopCommand(host, vi.fn(), 'check the build')).success).toBe(true);
|
|
47
|
+
expect(createSelfPacedLoop.mock.calls.map(([prompt]) => prompt)).toEqual([
|
|
48
|
+
options.defaultPrompt,
|
|
49
|
+
'check the build',
|
|
50
|
+
]);
|
|
51
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('marks omitted prompts for live override resolution without changing explicit prompts', async () => {
|
|
55
|
+
const createSelfPacedLoop = vi.fn().mockResolvedValue({
|
|
56
|
+
loopId: 'loop_default', expiresAt: '2030-01-01T00:00:00.000Z',
|
|
57
|
+
});
|
|
58
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'timer_default' });
|
|
59
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop, spawnScheduledWake });
|
|
60
|
+
const resolveDefaultPrompt = vi.fn().mockReturnValue('current file prompt');
|
|
61
|
+
const options = { defaultPrompt: 'built-in prompt', resolveDefaultPrompt };
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
(await executeLoopCommand(host, vi.fn(), '', options, interactiveStart())).success,
|
|
65
|
+
).toBe(true);
|
|
66
|
+
expect(createSelfPacedLoop).toHaveBeenCalledWith('current file prompt', { useDefaultPrompt: true });
|
|
67
|
+
expect(
|
|
68
|
+
(await executeLoopCommand(host, vi.fn(), '5m', options, interactiveStart())).success,
|
|
69
|
+
).toBe(true);
|
|
70
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(expect.objectContaining({
|
|
71
|
+
agentInstruction: 'current file prompt', sessionLoopDefaultPrompt: true,
|
|
72
|
+
}));
|
|
73
|
+
expect((await executeLoopCommand(host, vi.fn(), 'explicit check', options)).success).toBe(true);
|
|
74
|
+
expect(createSelfPacedLoop).toHaveBeenLastCalledWith('explicit check');
|
|
75
|
+
expect(resolveDefaultPrompt).toHaveBeenCalledTimes(2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('lists and stops a self-paced loop by stable identity without touching schedules', async () => {
|
|
79
|
+
const stopSelfPacedLoop = vi.fn().mockResolvedValue(undefined);
|
|
80
|
+
const host = createTestAgentJobHost({
|
|
81
|
+
listSelfPacedLoops: () => [{
|
|
82
|
+
loopId: 'loop_self', instruction: 'check', phase: 'waiting',
|
|
83
|
+
createdAt: '2026-09-24T00:00:00.000Z', expiresAt: '2026-10-01T00:00:00.000Z',
|
|
84
|
+
revision: 1, generation: 1, fallbackUsed: false,
|
|
85
|
+
nextAllowedAt: '2026-09-24T00:20:00.000Z',
|
|
86
|
+
}],
|
|
87
|
+
stopSelfPacedLoop,
|
|
88
|
+
});
|
|
89
|
+
expect((await executeLoopCommand(host, vi.fn(), 'list')).message).toContain('loop_self');
|
|
90
|
+
expect((await executeLoopCommand(host, vi.fn(), 'stop loop_self')).success).toBe(true);
|
|
91
|
+
expect(stopSelfPacedLoop).toHaveBeenCalledExactlyOnceWith('loop_self', 'Loop stopped by user');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does not describe a previous delay as the next check while a loop is running', async () => {
|
|
95
|
+
const host = createTestAgentJobHost({
|
|
96
|
+
listSelfPacedLoops: () => [{
|
|
97
|
+
loopId: 'loop_running', instruction: 'check', phase: 'running',
|
|
98
|
+
createdAt: '2026-09-24T00:00:00.000Z', expiresAt: '2026-10-01T00:00:00.000Z',
|
|
99
|
+
revision: 2, generation: 1, fallbackUsed: false,
|
|
100
|
+
delaySeconds: 60, reason: 'previous check',
|
|
101
|
+
}],
|
|
102
|
+
});
|
|
103
|
+
const listed = await executeLoopCommand(host, vi.fn(), 'list');
|
|
104
|
+
expect(listed.message).toContain('loop_running [running]');
|
|
105
|
+
expect(listed.message).not.toContain('next 60s');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('assigns every new loop a seven-day expiry', async () => {
|
|
109
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_expiring' });
|
|
110
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
111
|
+
const before = Date.now();
|
|
112
|
+
const result = await executeLoopCommand(host, vi.fn(), '5m check');
|
|
113
|
+
const after = Date.now();
|
|
114
|
+
const expiresAt = spawnScheduledWake.mock.calls[0]?.[0].sessionLoopExpiresAt as string;
|
|
115
|
+
|
|
116
|
+
expect(result.success).toBe(true);
|
|
117
|
+
expect(Date.parse(expiresAt)).toBeGreaterThanOrEqual(before + 7 * 24 * 60 * 60_000);
|
|
118
|
+
expect(Date.parse(expiresAt)).toBeLessThanOrEqual(after + 7 * 24 * 60 * 60_000);
|
|
119
|
+
expect(result.message).toContain(expiresAt);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('does not promise a first fire before the requested interval elapses', async () => {
|
|
123
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({
|
|
124
|
+
id: 'loop_first',
|
|
125
|
+
nextFireAt: '2026-09-24T00:10:00.000Z',
|
|
126
|
+
});
|
|
127
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
128
|
+
const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-24T00:09:00.000Z'));
|
|
129
|
+
try {
|
|
130
|
+
const result = await executeLoopCommand(host, vi.fn(), '7m check');
|
|
131
|
+
const input = spawnScheduledWake.mock.calls[0]?.[0] as {
|
|
132
|
+
sessionLoopFirstAllowedAt?: string;
|
|
133
|
+
};
|
|
134
|
+
expect(input.sessionLoopFirstAllowedAt).toBe('2026-09-24T00:16:00.000Z');
|
|
135
|
+
expect(result.message).toContain('First eligible at or after 2026-09-24T00:16:00.000Z');
|
|
136
|
+
expect(result.message).not.toContain('Next fire: 2026-09-24T00:10:00.000Z');
|
|
137
|
+
} finally {
|
|
138
|
+
clock.mockRestore();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('refuses loop creation when the host kill switch is on, but still permits list', async () => {
|
|
143
|
+
const spawnScheduledWake = vi.fn();
|
|
144
|
+
const host = createTestAgentJobHost({
|
|
145
|
+
spawnScheduledWake,
|
|
146
|
+
listSchedules: vi.fn().mockReturnValue([
|
|
147
|
+
{
|
|
148
|
+
id: 'loop_existing',
|
|
149
|
+
kind: 'scheduled',
|
|
150
|
+
status: 'sleeping',
|
|
151
|
+
label: 'Loop: existing',
|
|
152
|
+
metadata: { sessionLoop: true },
|
|
153
|
+
},
|
|
154
|
+
]),
|
|
155
|
+
});
|
|
156
|
+
const options = { disabled: true, defaultPrompt: 'Tend current task.' };
|
|
157
|
+
|
|
158
|
+
expect((await executeLoopCommand(host, vi.fn(), '5m check', options)).success).toBe(false);
|
|
159
|
+
const listed = await executeLoopCommand(host, vi.fn(), 'list', options);
|
|
160
|
+
expect(listed.success).toBe(true);
|
|
161
|
+
expect(listed.message).toContain('loop_existing');
|
|
162
|
+
expect(listed.message).toContain('disabled');
|
|
163
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('uses a stable loop id to find a re-armed task after its runtime id changes', async () => {
|
|
167
|
+
const spawnScheduledWake = vi
|
|
168
|
+
.fn()
|
|
169
|
+
.mockImplementation(async (input: { sessionLoopId: string }) => ({
|
|
170
|
+
id: 'runtime_before_resume',
|
|
171
|
+
metadata: { sessionLoop: true, sessionLoopId: input.sessionLoopId },
|
|
172
|
+
}));
|
|
173
|
+
const listSchedules = vi.fn().mockImplementation(() => [
|
|
174
|
+
{
|
|
175
|
+
id: 'runtime_after_resume',
|
|
176
|
+
kind: 'scheduled',
|
|
177
|
+
status: 'sleeping',
|
|
178
|
+
label: 'Loop: check',
|
|
179
|
+
metadata: {
|
|
180
|
+
sessionLoop: true,
|
|
181
|
+
sessionLoopId: spawnScheduledWake.mock.calls[0]?.[0].sessionLoopId,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
]);
|
|
185
|
+
const host = createTestAgentJobHost({ spawnScheduledWake, listSchedules });
|
|
186
|
+
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
187
|
+
|
|
188
|
+
const created = await executeLoopCommand(host, cancel, '5m check');
|
|
189
|
+
const loopId = (created.data as { loopId?: string }).loopId;
|
|
190
|
+
expect(loopId).toMatch(/^loop_[0-9a-f-]{36}$/);
|
|
191
|
+
expect(loopId).not.toBe('runtime_before_resume');
|
|
192
|
+
expect((await executeLoopCommand(host, cancel, 'list')).message).toContain(loopId);
|
|
193
|
+
|
|
194
|
+
const stopped = await executeLoopCommand(host, cancel, `stop ${loopId}`);
|
|
195
|
+
expect(stopped.success).toBe(true);
|
|
196
|
+
expect(cancel).toHaveBeenCalledWith('runtime_after_resume', 'Loop stopped by user');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('refuses a fourth active loop without spawning a timer', async () => {
|
|
200
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'unexpected' });
|
|
201
|
+
const listSchedules = vi.fn().mockReturnValue(
|
|
202
|
+
Array.from({ length: 3 }, (_, index) => ({
|
|
203
|
+
id: `runtime_${index}`,
|
|
204
|
+
kind: 'scheduled',
|
|
205
|
+
status: 'sleeping',
|
|
206
|
+
label: 'Loop: check',
|
|
207
|
+
metadata: { sessionLoop: true },
|
|
208
|
+
})),
|
|
209
|
+
);
|
|
210
|
+
const host = createTestAgentJobHost({ spawnScheduledWake, listSchedules });
|
|
211
|
+
const result = await executeLoopCommand(host, vi.fn(), '5m check');
|
|
212
|
+
expect(result.success).toBe(false);
|
|
213
|
+
expect(result.message).toContain('3 active loops');
|
|
214
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('reserves the final slot while a loop creation is still pending', async () => {
|
|
218
|
+
const finishSpawns: Array<(task: { id: string }) => void> = [];
|
|
219
|
+
const spawnScheduledWake = vi.fn().mockImplementation(
|
|
220
|
+
() =>
|
|
221
|
+
new Promise<{ id: string }>((resolve) => {
|
|
222
|
+
finishSpawns.push(resolve);
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
const listSchedules = vi.fn().mockReturnValue(
|
|
226
|
+
Array.from({ length: 2 }, (_, index) => ({
|
|
227
|
+
id: `runtime_${index}`,
|
|
228
|
+
kind: 'scheduled',
|
|
229
|
+
status: 'sleeping',
|
|
230
|
+
metadata: { sessionLoop: true },
|
|
231
|
+
})),
|
|
232
|
+
);
|
|
233
|
+
const host = createTestAgentJobHost({ spawnScheduledWake, listSchedules });
|
|
234
|
+
|
|
235
|
+
const first = executeLoopCommand(host, vi.fn(), '5m first');
|
|
236
|
+
expect(spawnScheduledWake).toHaveBeenCalledTimes(1);
|
|
237
|
+
const secondPending = executeLoopCommand(host, vi.fn(), '5m second');
|
|
238
|
+
const spawnCount = spawnScheduledWake.mock.calls.length;
|
|
239
|
+
finishSpawns.forEach((resolve, index) => resolve({ id: `runtime_${index + 2}` }));
|
|
240
|
+
const second = await secondPending;
|
|
241
|
+
expect(second.success).toBe(false);
|
|
242
|
+
expect(second.message).toContain('3 active loops');
|
|
243
|
+
expect(spawnCount).toBe(1);
|
|
244
|
+
|
|
245
|
+
expect((await first).success).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('creates a recurring scheduled wake with the requested prompt', async () => {
|
|
249
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_1' });
|
|
250
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
251
|
+
|
|
252
|
+
const result = await executeLoopCommand(host, vi.fn(), '5m check the build');
|
|
253
|
+
|
|
254
|
+
expect(result.success).toBe(true);
|
|
255
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
256
|
+
expect.objectContaining({
|
|
257
|
+
label: 'Loop: check the build',
|
|
258
|
+
cronExpression: expect.any(String),
|
|
259
|
+
agentInstruction: 'check the build',
|
|
260
|
+
sessionLoop: true,
|
|
261
|
+
sessionLoopId: expect.stringMatching(/^loop_[0-9a-f-]{36}$/),
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
const loopId = (result.data as { loopId: string }).loopId;
|
|
265
|
+
expect(result.message).toContain(`/loop stop ${loopId}`);
|
|
266
|
+
expect(result.data).toMatchObject({ taskId: 'loop_task_1', cadenceLabel: '5m' });
|
|
267
|
+
expect(result.data).toEqual(expect.objectContaining({ jitterSeconds: expect.any(Number) }));
|
|
268
|
+
expect(result.message).toMatch(/stable offset \+\d+s/);
|
|
269
|
+
expect((result.data as { jitterSeconds: number }).jitterSeconds).toBeLessThanOrEqual(150);
|
|
270
|
+
expect(spawnScheduledWake.mock.calls[0]![0].cronExpression)
|
|
271
|
+
.toBe((result.data as { cronExpression: string }).cronExpression);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('accepts a trailing interval and reports the actual rounded cadence', async () => {
|
|
275
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_2' });
|
|
276
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
277
|
+
|
|
278
|
+
const result = await executeLoopCommand(host, vi.fn(), 'check the deploy every 7 minutes');
|
|
279
|
+
|
|
280
|
+
expect(result.success).toBe(true);
|
|
281
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
282
|
+
expect.objectContaining({
|
|
283
|
+
cronExpression: expect.any(String),
|
|
284
|
+
agentInstruction: 'check the deploy',
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
expect(result.message).toContain('10m (rounded up) local-clock step');
|
|
288
|
+
expect(result.message).toContain('daylight saving');
|
|
289
|
+
expect(result.data).toMatchObject({ requestedMs: 420_000, cadenceLabel: '10m' });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('parses a malformed trailing interval in bounded time even after a long whitespace run', async () => {
|
|
293
|
+
const host = createTestAgentJobHost();
|
|
294
|
+
const started = performance.now();
|
|
295
|
+
const result = await executeLoopCommand(host, vi.fn(), `check${' '.repeat(50_000)}every nope`);
|
|
296
|
+
|
|
297
|
+
expect(result.success).toBe(false);
|
|
298
|
+
expect(performance.now() - started).toBeLessThan(250);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('does not treat a prompt beginning with stop as a management command', async () => {
|
|
302
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_3' });
|
|
303
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
304
|
+
const result = await executeLoopCommand(host, vi.fn(), 'stopwatch the build every 2 minutes');
|
|
305
|
+
expect(result.success).toBe(true);
|
|
306
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
307
|
+
expect.objectContaining({ agentInstruction: 'stopwatch the build' }),
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('accepts a leading interval when the prompt ends in non-time every-quantity text', async () => {
|
|
312
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_4' });
|
|
313
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
314
|
+
const result = await executeLoopCommand(host, vi.fn(), '5m inspect every 2 files');
|
|
315
|
+
expect(result.success).toBe(true);
|
|
316
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
317
|
+
expect.objectContaining({ agentInstruction: 'inspect every 2 files' }),
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('lists only active loops and stops only the selected loop', async () => {
|
|
322
|
+
const listSchedules = vi.fn().mockReturnValue([
|
|
323
|
+
{
|
|
324
|
+
id: 'loop_a',
|
|
325
|
+
kind: 'scheduled',
|
|
326
|
+
status: 'sleeping',
|
|
327
|
+
label: 'Loop: check A',
|
|
328
|
+
metadata: { sessionLoop: true },
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: 'loop_b',
|
|
332
|
+
kind: 'scheduled',
|
|
333
|
+
status: 'sleeping',
|
|
334
|
+
label: 'Scheduled: edited B',
|
|
335
|
+
metadata: { sessionLoop: true },
|
|
336
|
+
},
|
|
337
|
+
{ id: 'schedule_c', kind: 'scheduled', status: 'sleeping', label: 'Scheduled: check C' },
|
|
338
|
+
{
|
|
339
|
+
id: 'loop_old',
|
|
340
|
+
kind: 'scheduled',
|
|
341
|
+
status: 'cancelled',
|
|
342
|
+
label: 'Loop: old',
|
|
343
|
+
metadata: { sessionLoop: true },
|
|
344
|
+
},
|
|
345
|
+
]);
|
|
346
|
+
const createSelfPacedLoop = vi.fn();
|
|
347
|
+
const spawnScheduledWake = vi.fn();
|
|
348
|
+
const host = createTestAgentJobHost({ listSchedules, createSelfPacedLoop, spawnScheduledWake });
|
|
349
|
+
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
350
|
+
|
|
351
|
+
const listed = await executeLoopCommand(host, cancel, 'list');
|
|
352
|
+
expect(listed.message).toContain('loop_a');
|
|
353
|
+
expect(listed.message).toContain('loop_b');
|
|
354
|
+
expect(listed.message).not.toContain('schedule_c');
|
|
355
|
+
expect(listed.message).not.toContain('loop_old');
|
|
356
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
357
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
358
|
+
|
|
359
|
+
const stopped = await executeLoopCommand(host, cancel, 'stop loop_a');
|
|
360
|
+
expect(stopped.success).toBe(true);
|
|
361
|
+
expect(cancel).toHaveBeenCalledExactlyOnceWith('loop_a', 'Loop stopped by user');
|
|
362
|
+
expect(stopped.message).toContain('already-running turn may finish');
|
|
363
|
+
|
|
364
|
+
const unrelated = await executeLoopCommand(host, cancel, 'stop schedule_c');
|
|
365
|
+
expect(unrelated.success).toBe(false);
|
|
366
|
+
expect(cancel).toHaveBeenCalledTimes(1);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it.each(['', '0m check', '2d check', '5x check', '5m check every 2m'])(
|
|
370
|
+
'rejects an unsupported or ambiguous loop form: %s',
|
|
371
|
+
async (args) => {
|
|
372
|
+
const spawnScheduledWake = vi.fn();
|
|
373
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
374
|
+
const result = await executeLoopCommand(host, vi.fn(), args);
|
|
375
|
+
expect(result.success).toBe(false);
|
|
376
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* #3288 §1: every form that falls back to the host's default maintenance prompt — bare `/loop` or
|
|
383
|
+
* an interval-only `/loop <N><s|m|h|d>` (self-paced or fixed cadence) — keeps working on its own for
|
|
384
|
+
* up to 7 days: a standing commitment the operator never typed, gated the same way regardless of
|
|
385
|
+
* which form reached it. An explicit prompt is the operator's own instruction and is never gated.
|
|
386
|
+
* A model-invoked call is always refused outright; an interactive one (an ask port attached) asks;
|
|
387
|
+
* a headless one (no ask port — print mode, a scheduled run) is refused too, the same reason as the
|
|
388
|
+
* model: nobody is there to say yes to a loop that can run for a week.
|
|
389
|
+
*/
|
|
390
|
+
describe('/loop asks before starting on its own (#3288 §1)', () => {
|
|
391
|
+
function askResolving(value: 'start' | 'cancel'): ReturnType<typeof vi.fn> {
|
|
392
|
+
return vi.fn().mockResolvedValue({ type: 'answer', values: [value] });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
it('asks before creating a bare self-paced loop, and creates it on Start', async () => {
|
|
396
|
+
const createSelfPacedLoop = vi.fn().mockResolvedValue({
|
|
397
|
+
loopId: 'loop_default',
|
|
398
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
399
|
+
});
|
|
400
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
401
|
+
const ask = askResolving('start');
|
|
402
|
+
const options = { defaultPrompt: 'Tend the current task.\nCheck CI when it finishes.' };
|
|
403
|
+
|
|
404
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'user' });
|
|
405
|
+
|
|
406
|
+
expect(ask).toHaveBeenCalledTimes(1);
|
|
407
|
+
const request = ask.mock.calls[0]![0] as { title: string; options: Array<{ value: string }> };
|
|
408
|
+
expect(request.title).toContain('Start a loop that keeps working on its own for up to 7 days');
|
|
409
|
+
expect(request.title).toContain('Tend the current task.');
|
|
410
|
+
expect(request.title).not.toContain('Check CI when it finishes.');
|
|
411
|
+
expect(request.options.map((option) => option.value)).toEqual(['start', 'cancel']);
|
|
412
|
+
expect(createSelfPacedLoop).toHaveBeenCalledWith('Tend the current task.\nCheck CI when it finishes.', {
|
|
413
|
+
useDefaultPrompt: true,
|
|
414
|
+
});
|
|
415
|
+
expect(result.success).toBe(true);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('does not create the loop when the user cancels', async () => {
|
|
419
|
+
const createSelfPacedLoop = vi.fn();
|
|
420
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
421
|
+
const ask = askResolving('cancel');
|
|
422
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
423
|
+
|
|
424
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'user' });
|
|
425
|
+
|
|
426
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
427
|
+
expect(result).toMatchObject({ success: true, message: 'Loop not started.' });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('treats a dismissed (cancelled) ask the same as Cancel', async () => {
|
|
431
|
+
const createSelfPacedLoop = vi.fn();
|
|
432
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
433
|
+
const ask = vi.fn().mockResolvedValue({ type: 'cancelled' });
|
|
434
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
435
|
+
|
|
436
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'user' });
|
|
437
|
+
|
|
438
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
439
|
+
expect(result.success).toBe(true);
|
|
440
|
+
expect(result.message).toBe('Loop not started.');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('lists other active loops in the confirmation', async () => {
|
|
444
|
+
const createSelfPacedLoop = vi.fn().mockResolvedValue({
|
|
445
|
+
loopId: 'loop_new',
|
|
446
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
447
|
+
});
|
|
448
|
+
const host = createTestAgentJobHost({
|
|
449
|
+
createSelfPacedLoop,
|
|
450
|
+
listSelfPacedLoops: () => [
|
|
451
|
+
{
|
|
452
|
+
loopId: 'loop_existing',
|
|
453
|
+
instruction: 'watch CI',
|
|
454
|
+
phase: 'waiting',
|
|
455
|
+
createdAt: '2026-09-24T00:00:00.000Z',
|
|
456
|
+
expiresAt: '2026-10-01T00:00:00.000Z',
|
|
457
|
+
revision: 1,
|
|
458
|
+
generation: 1,
|
|
459
|
+
fallbackUsed: false,
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
});
|
|
463
|
+
const ask = askResolving('start');
|
|
464
|
+
|
|
465
|
+
await executeLoopCommand(host, vi.fn(), '', { defaultPrompt: 'Tend.' }, { ui: { ask }, source: 'user' });
|
|
466
|
+
|
|
467
|
+
const request = ask.mock.calls[0]![0] as { description?: string };
|
|
468
|
+
expect(request.description).toContain('loop_existing');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('refuses a model-invoked bare loop outright, without asking', async () => {
|
|
472
|
+
const createSelfPacedLoop = vi.fn();
|
|
473
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
474
|
+
const ask = vi.fn();
|
|
475
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
476
|
+
|
|
477
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'model' });
|
|
478
|
+
|
|
479
|
+
expect(ask).not.toHaveBeenCalled();
|
|
480
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
481
|
+
expect(result.success).toBe(false);
|
|
482
|
+
expect(result.message).toContain('/loop');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('refuses a model-invoked bare loop even with no interactive renderer attached', async () => {
|
|
486
|
+
const createSelfPacedLoop = vi.fn();
|
|
487
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
488
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
489
|
+
|
|
490
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: undefined, source: 'model' });
|
|
491
|
+
|
|
492
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
493
|
+
expect(result.success).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('refuses a bare loop headless (no ask port attached) — never proceeds unconfirmed', async () => {
|
|
497
|
+
const createSelfPacedLoop = vi.fn();
|
|
498
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
499
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
500
|
+
|
|
501
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: undefined, source: 'user' });
|
|
502
|
+
|
|
503
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
504
|
+
expect(result.success).toBe(false);
|
|
505
|
+
expect(result.message).toContain('needs someone to confirm it');
|
|
506
|
+
expect(result.message).toContain('/loop <prompt>');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('refuses an interval-only default-prompt loop headless too, the same as bare', async () => {
|
|
510
|
+
const spawnScheduledWake = vi.fn();
|
|
511
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
512
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
513
|
+
|
|
514
|
+
const result = await executeLoopCommand(host, vi.fn(), '1h', options, { ui: undefined, source: 'user' });
|
|
515
|
+
|
|
516
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
517
|
+
expect(result.success).toBe(false);
|
|
518
|
+
expect(result.message).toContain('needs someone to confirm it');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('refuses a model-invoked interval-only default-prompt loop outright, without asking', async () => {
|
|
522
|
+
const spawnScheduledWake = vi.fn();
|
|
523
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
524
|
+
const ask = vi.fn();
|
|
525
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
526
|
+
|
|
527
|
+
const result = await executeLoopCommand(host, vi.fn(), '1h', options, { ui: { ask }, source: 'model' });
|
|
528
|
+
|
|
529
|
+
expect(ask).not.toHaveBeenCalled();
|
|
530
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
531
|
+
expect(result.success).toBe(false);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('asks for an interactive interval-only default-prompt loop, and creates it on Start', async () => {
|
|
535
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_interval' });
|
|
536
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
537
|
+
const ask = askResolving('start');
|
|
538
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
539
|
+
|
|
540
|
+
const result = await executeLoopCommand(host, vi.fn(), '1h', options, { ui: { ask }, source: 'user' });
|
|
541
|
+
|
|
542
|
+
expect(ask).toHaveBeenCalledTimes(1);
|
|
543
|
+
const request = ask.mock.calls[0]![0] as { title: string };
|
|
544
|
+
expect(request.title).toContain('Start a loop that keeps working on its own for up to 7 days');
|
|
545
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
546
|
+
expect.objectContaining({ agentInstruction: options.defaultPrompt }),
|
|
547
|
+
);
|
|
548
|
+
expect(result.success).toBe(true);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('never asks for an interval WITH an explicit prompt, even headless', async () => {
|
|
552
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_explicit_interval' });
|
|
553
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
554
|
+
|
|
555
|
+
// No invocation at all (defaults to headless, source 'user') — an explicit prompt is unaffected.
|
|
556
|
+
const result = await executeLoopCommand(host, vi.fn(), '1h check the build', {
|
|
557
|
+
defaultPrompt: 'Tend the current task.',
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
561
|
+
expect.objectContaining({ agentInstruction: 'check the build' }),
|
|
562
|
+
);
|
|
563
|
+
expect(result.success).toBe(true);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('never asks for an explicit prompt, even with a person attached', async () => {
|
|
567
|
+
const createSelfPacedLoop = vi.fn().mockResolvedValue({
|
|
568
|
+
loopId: 'loop_explicit',
|
|
569
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
570
|
+
});
|
|
571
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
572
|
+
const ask = vi.fn();
|
|
573
|
+
|
|
574
|
+
const result = await executeLoopCommand(host, vi.fn(), 'check the build', {}, { ui: { ask }, source: 'user' });
|
|
575
|
+
|
|
576
|
+
expect(ask).not.toHaveBeenCalled();
|
|
577
|
+
expect(createSelfPacedLoop).toHaveBeenCalledWith('check the build');
|
|
578
|
+
expect(result.success).toBe(true);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('never asks for a fixed-cadence loop with an explicit prompt', async () => {
|
|
582
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_fixed' });
|
|
583
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
584
|
+
const ask = vi.fn();
|
|
585
|
+
|
|
586
|
+
const result = await executeLoopCommand(host, vi.fn(), '5m check the build', {}, {
|
|
587
|
+
ui: { ask },
|
|
588
|
+
source: 'user',
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
expect(ask).not.toHaveBeenCalled();
|
|
592
|
+
expect(result.success).toBe(true);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { jitterFixedLoop } from '../loop-jitter.js';
|
|
4
|
+
|
|
5
|
+
describe('fixed-loop jitter', () => {
|
|
6
|
+
it('is stable for a loop identity and stays within half of a short interval', () => {
|
|
7
|
+
const cadence = { description: '5m', milliseconds: 300_000, cronExpression: '0 */5 * * * *' };
|
|
8
|
+
const first = jitterFixedLoop('loop_stable', cadence);
|
|
9
|
+
expect(jitterFixedLoop('loop_stable', cadence)).toEqual(first);
|
|
10
|
+
expect(first.jitterSeconds).toBeGreaterThanOrEqual(0);
|
|
11
|
+
expect(first.jitterSeconds).toBeLessThanOrEqual(150);
|
|
12
|
+
expect(first.cronExpression).toMatch(/^\d+ (?:\*\/5|\d+-59\/5) \* \* \* \*$/);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('never adds more than 30 minutes to an hourly or daily slot', () => {
|
|
16
|
+
for (const cadence of [
|
|
17
|
+
{ description: '2h', milliseconds: 7_200_000, cronExpression: '0 0 */2 * * *' },
|
|
18
|
+
{ description: '24h', milliseconds: 86_400_000, cronExpression: '0 0 0 * * *' },
|
|
19
|
+
]) {
|
|
20
|
+
const result = jitterFixedLoop('loop_stable', cadence);
|
|
21
|
+
expect(result.jitterSeconds).toBeLessThanOrEqual(1_800);
|
|
22
|
+
expect(result.cronExpression).toMatch(/^\d+ \d+ (?:\*\/2|0) \* \* \*$/);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|