@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.81
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/README.md +11 -2
- package/dist/node/index.cjs +76 -38
- package/dist/node/index.d.cts +946 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +564 -51
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +76 -38
- package/dist/node/index.js.map +1 -1
- package/package.json +34 -16
- 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 +39 -8
- package/src/agent/agent-command-module.ts +14 -7
- package/src/agent/agent-command-parser.ts +1 -1
- package/src/agent/agent-command.ts +3 -3
- package/src/background/__tests__/background-command-module.test.ts +59 -52
- package/src/background/background-command-module.ts +2 -1
- package/src/background/background-command.ts +3 -3
- package/src/command-module-utils.ts +33 -0
- package/src/compact/__tests__/compact-command-module.test.ts +31 -23
- package/src/compact/compact-command-module.ts +10 -1
- package/src/compact/compact-command.ts +6 -3
- package/src/context/__tests__/context-command-module.test.ts +214 -63
- package/src/context/auto-compact-format.ts +25 -0
- package/src/context/context-breakdown.ts +295 -0
- package/src/context/context-command-module.ts +45 -14
- package/src/context/context-command.ts +34 -257
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
- package/src/default/__tests__/default-command-modules.test.ts +59 -7
- package/src/default/__tests__/model-exposure.test.ts +241 -0
- package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
- package/src/default/default-command-modules.ts +130 -18
- package/src/default/index.ts +4 -1
- package/src/devices/__tests__/devices-command-module.test.ts +212 -0
- package/src/devices/devices-command-module.ts +224 -0
- package/src/devices/devices-command-port.ts +82 -0
- package/src/devices/index.ts +11 -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 +145 -0
- package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
- package/src/doctor/doctor-command-module.ts +132 -0
- package/src/doctor/doctor-extensions-probe.ts +299 -0
- package/src/doctor/doctor-node-deps.ts +89 -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 +49 -16
- package/src/editor/editor-command-module.ts +6 -5
- package/src/editor/editor-command.ts +10 -5
- package/src/effort/effort-command-module.ts +51 -0
- package/src/effort/effort-command.test.ts +140 -0
- package/src/effort/effort-command.ts +122 -0
- package/src/effort/index.ts +6 -0
- package/src/exit/__tests__/exit-command-module.test.ts +16 -9
- package/src/exit/exit-command-module.ts +2 -1
- package/src/exit/exit-command.ts +6 -6
- 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 +28 -0
- package/src/git/__tests__/git-command-module.test.ts +103 -0
- package/src/git/__tests__/git-commit.test.ts +185 -0
- package/src/git/__tests__/git-diff.test.ts +125 -0
- package/src/git/__tests__/git-process.test.ts +221 -0
- package/src/git/__tests__/git-status.test.ts +117 -0
- package/src/git/git-command-module.ts +139 -0
- package/src/git/git-commit.ts +251 -0
- package/src/git/git-diff.ts +129 -0
- package/src/git/git-process.ts +169 -0
- package/src/git/git-status.ts +134 -0
- package/src/git/index.ts +17 -0
- package/src/goal/__tests__/goal-command.test.ts +9 -4
- package/src/goal/goal-command-module.ts +2 -1
- package/src/goal/goal-command.ts +6 -9
- package/src/handoff/__tests__/handoff-command.test.ts +218 -0
- package/src/handoff/handoff-command-module.ts +48 -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 +63 -40
- package/src/help/__tests__/help-command.test.ts +46 -37
- package/src/help/help-command-module.ts +2 -1
- package/src/help/help-command.ts +3 -3
- package/src/index.ts +124 -2
- 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 +74 -0
- package/src/language/__tests__/language-command-module.test.ts +33 -28
- package/src/language/language-command-module.ts +2 -1
- package/src/language/language-command.ts +8 -6
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
- package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
- package/src/mcp-activation/index.ts +12 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
- package/src/mcp-activation/mcp-activation-command.ts +448 -0
- package/src/mcp-activation/mcp-model-notice.ts +85 -0
- package/src/mcp-activation/mcp-model-status.ts +99 -0
- package/src/memory/__tests__/memory-command-module.test.ts +167 -74
- package/src/memory/memory-command-module.ts +18 -1
- package/src/memory/memory-command.ts +38 -38
- package/src/mode/__tests__/mode-command-module.test.ts +51 -38
- package/src/mode/mode-command-module.ts +2 -1
- package/src/mode/mode-command.ts +15 -7
- 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.test.ts +241 -0
- package/src/peers/index.ts +9 -0
- package/src/peers/peers-command-module.ts +47 -0
- package/src/peers/peers-command.ts +142 -0
- package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
- package/src/permissions/permissions-command-module.ts +2 -1
- package/src/permissions/permissions-command.ts +46 -4
- 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-command-module.test.ts +46 -40
- package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
- package/src/plugin/plugin-command-module.ts +3 -1
- package/src/plugin/plugin-command.ts +17 -16
- package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
- package/src/preset/__tests__/preset-command-module.test.ts +102 -56
- package/src/preset/preset-command-module.ts +26 -8
- package/src/preset/preset-command.ts +85 -27
- package/src/provider/__tests__/org-policy.test.ts +4 -5
- package/src/provider/__tests__/provider-command-module.test.ts +11 -14
- package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
- package/src/provider/__tests__/scripted-interaction.ts +7 -5
- package/src/provider/provider-command-execution.ts +4 -4
- package/src/provider/provider-command-module.ts +2 -1
- package/src/provider/provider-command-profile-lifecycle.ts +4 -4
- package/src/provider/provider-command-profile-operations.ts +3 -3
- package/src/provider/provider-command-profile.ts +1 -1
- package/src/provider/provider-command-setup.ts +4 -4
- package/src/provider/provider-startup.ts +62 -35
- 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 +5 -5
- package/src/reset/reset-command-module.ts +2 -1
- package/src/reset/reset-command.ts +3 -4
- package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
- package/src/rewind/rewind-command-module.ts +2 -1
- package/src/rewind/rewind-command.ts +73 -6
- 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 +386 -0
- package/src/schedule/__tests__/loop-command.test.ts +360 -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 +143 -3
- package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
- package/src/schedule/index.ts +3 -0
- package/src/schedule/loop-command.ts +333 -0
- package/src/schedule/loop-jitter.ts +41 -0
- package/src/schedule/schedule-command-module.ts +92 -14
- package/src/schedule/schedule-command.ts +98 -9
- package/src/schedule/schedule-spec-parser.ts +5 -1
- package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
- package/src/session/__tests__/session-command-module.test.ts +210 -45
- package/src/session/session-command-module.ts +68 -37
- package/src/session/session-command.ts +147 -80
- package/src/settings/settings-command-module.ts +3 -2
- package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
- package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
- package/src/shell/resolve-shell.ts +2 -2
- package/src/shell/shell-command-module.ts +6 -5
- package/src/shell/shell-command.ts +9 -5
- package/src/skills/__tests__/skills-command-module.test.ts +69 -28
- package/src/skills/index.ts +1 -0
- package/src/skills/skills-command-module.ts +22 -5
- package/src/skills/skills-command.ts +14 -13
- package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
- package/src/statusline/statusline-command-module.ts +2 -1
- package/src/statusline/statusline-command.ts +4 -4
- package/src/theme/__tests__/theme-command.test.ts +204 -0
- package/src/theme/index.ts +12 -0
- package/src/theme/theme-command-module.ts +211 -0
- package/src/user-local/__tests__/user-local-command.test.ts +145 -110
- package/src/user-local/user-local-command-constants.ts +1 -1
- package/src/user-local/user-local-command-module.ts +6 -5
- package/src/user-local/user-local-command.ts +12 -6
- package/src/user-local/user-local-memory-command.ts +17 -7
- package/src/plugins/default-plugin-command-adapter.ts +0 -164
- package/src/plugins/default-plugin-command-source-loader.ts +0 -31
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { executeLoopCommand } from '../loop-command.js';
|
|
4
|
+
|
|
5
|
+
import { createTestAgentJobHost } from '@robota-sdk/agent-framework/testing';
|
|
6
|
+
|
|
7
|
+
describe('fixed in-session loop', () => {
|
|
8
|
+
it('uses the host maintenance prompt for interval-only forms', async () => {
|
|
9
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_default' });
|
|
10
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
11
|
+
const options = { defaultPrompt: 'Tend only the current task and its PR.' };
|
|
12
|
+
|
|
13
|
+
expect((await executeLoopCommand(host, vi.fn(), '5m', options)).success).toBe(true);
|
|
14
|
+
expect(spawnScheduledWake).toHaveBeenLastCalledWith(
|
|
15
|
+
expect.objectContaining({
|
|
16
|
+
cronExpression: expect.any(String),
|
|
17
|
+
agentInstruction: options.defaultPrompt,
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('routes bare and prompt-only forms to the durable self-paced controller', async () => {
|
|
23
|
+
const createSelfPacedLoop = vi.fn().mockImplementation(async (instruction: string) => ({
|
|
24
|
+
loopId: 'loop_self', instruction, phase: 'pending', expiresAt: '2030-01-01T00:00:00.000Z',
|
|
25
|
+
}));
|
|
26
|
+
const spawnScheduledWake = vi.fn();
|
|
27
|
+
const host = createTestAgentJobHost({ spawnScheduledWake, createSelfPacedLoop });
|
|
28
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
29
|
+
|
|
30
|
+
expect((await executeLoopCommand(host, vi.fn(), '', options)).success).toBe(true);
|
|
31
|
+
expect((await executeLoopCommand(host, vi.fn(), 'check the build')).success).toBe(true);
|
|
32
|
+
expect(createSelfPacedLoop.mock.calls.map(([prompt]) => prompt)).toEqual([
|
|
33
|
+
options.defaultPrompt,
|
|
34
|
+
'check the build',
|
|
35
|
+
]);
|
|
36
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('marks omitted prompts for live override resolution without changing explicit prompts', async () => {
|
|
40
|
+
const createSelfPacedLoop = vi.fn().mockResolvedValue({
|
|
41
|
+
loopId: 'loop_default', expiresAt: '2030-01-01T00:00:00.000Z',
|
|
42
|
+
});
|
|
43
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'timer_default' });
|
|
44
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop, spawnScheduledWake });
|
|
45
|
+
const resolveDefaultPrompt = vi.fn().mockReturnValue('current file prompt');
|
|
46
|
+
const options = { defaultPrompt: 'built-in prompt', resolveDefaultPrompt };
|
|
47
|
+
|
|
48
|
+
expect((await executeLoopCommand(host, vi.fn(), '', options)).success).toBe(true);
|
|
49
|
+
expect(createSelfPacedLoop).toHaveBeenCalledWith('current file prompt', { useDefaultPrompt: true });
|
|
50
|
+
expect((await executeLoopCommand(host, vi.fn(), '5m', options)).success).toBe(true);
|
|
51
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(expect.objectContaining({
|
|
52
|
+
agentInstruction: 'current file prompt', sessionLoopDefaultPrompt: true,
|
|
53
|
+
}));
|
|
54
|
+
expect((await executeLoopCommand(host, vi.fn(), 'explicit check', options)).success).toBe(true);
|
|
55
|
+
expect(createSelfPacedLoop).toHaveBeenLastCalledWith('explicit check');
|
|
56
|
+
expect(resolveDefaultPrompt).toHaveBeenCalledTimes(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('lists and stops a self-paced loop by stable identity without touching schedules', async () => {
|
|
60
|
+
const stopSelfPacedLoop = vi.fn().mockResolvedValue(undefined);
|
|
61
|
+
const host = createTestAgentJobHost({
|
|
62
|
+
listSelfPacedLoops: () => [{
|
|
63
|
+
loopId: 'loop_self', instruction: 'check', phase: 'waiting',
|
|
64
|
+
createdAt: '2026-09-24T00:00:00.000Z', expiresAt: '2026-10-01T00:00:00.000Z',
|
|
65
|
+
revision: 1, generation: 1, fallbackUsed: false,
|
|
66
|
+
nextAllowedAt: '2026-09-24T00:20:00.000Z',
|
|
67
|
+
}],
|
|
68
|
+
stopSelfPacedLoop,
|
|
69
|
+
});
|
|
70
|
+
expect((await executeLoopCommand(host, vi.fn(), 'list')).message).toContain('loop_self');
|
|
71
|
+
expect((await executeLoopCommand(host, vi.fn(), 'stop loop_self')).success).toBe(true);
|
|
72
|
+
expect(stopSelfPacedLoop).toHaveBeenCalledExactlyOnceWith('loop_self', 'Loop stopped by user');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not describe a previous delay as the next check while a loop is running', async () => {
|
|
76
|
+
const host = createTestAgentJobHost({
|
|
77
|
+
listSelfPacedLoops: () => [{
|
|
78
|
+
loopId: 'loop_running', instruction: 'check', phase: 'running',
|
|
79
|
+
createdAt: '2026-09-24T00:00:00.000Z', expiresAt: '2026-10-01T00:00:00.000Z',
|
|
80
|
+
revision: 2, generation: 1, fallbackUsed: false,
|
|
81
|
+
delaySeconds: 60, reason: 'previous check',
|
|
82
|
+
}],
|
|
83
|
+
});
|
|
84
|
+
const listed = await executeLoopCommand(host, vi.fn(), 'list');
|
|
85
|
+
expect(listed.message).toContain('loop_running [running]');
|
|
86
|
+
expect(listed.message).not.toContain('next 60s');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('assigns every new loop a seven-day expiry', async () => {
|
|
90
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_expiring' });
|
|
91
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
92
|
+
const before = Date.now();
|
|
93
|
+
const result = await executeLoopCommand(host, vi.fn(), '5m check');
|
|
94
|
+
const after = Date.now();
|
|
95
|
+
const expiresAt = spawnScheduledWake.mock.calls[0]?.[0].sessionLoopExpiresAt as string;
|
|
96
|
+
|
|
97
|
+
expect(result.success).toBe(true);
|
|
98
|
+
expect(Date.parse(expiresAt)).toBeGreaterThanOrEqual(before + 7 * 24 * 60 * 60_000);
|
|
99
|
+
expect(Date.parse(expiresAt)).toBeLessThanOrEqual(after + 7 * 24 * 60 * 60_000);
|
|
100
|
+
expect(result.message).toContain(expiresAt);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not promise a first fire before the requested interval elapses', async () => {
|
|
104
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({
|
|
105
|
+
id: 'loop_first',
|
|
106
|
+
nextFireAt: '2026-09-24T00:10:00.000Z',
|
|
107
|
+
});
|
|
108
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
109
|
+
const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-24T00:09:00.000Z'));
|
|
110
|
+
try {
|
|
111
|
+
const result = await executeLoopCommand(host, vi.fn(), '7m check');
|
|
112
|
+
const input = spawnScheduledWake.mock.calls[0]?.[0] as {
|
|
113
|
+
sessionLoopFirstAllowedAt?: string;
|
|
114
|
+
};
|
|
115
|
+
expect(input.sessionLoopFirstAllowedAt).toBe('2026-09-24T00:16:00.000Z');
|
|
116
|
+
expect(result.message).toContain('First eligible at or after 2026-09-24T00:16:00.000Z');
|
|
117
|
+
expect(result.message).not.toContain('Next fire: 2026-09-24T00:10:00.000Z');
|
|
118
|
+
} finally {
|
|
119
|
+
clock.mockRestore();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('refuses loop creation when the host kill switch is on, but still permits list', async () => {
|
|
124
|
+
const spawnScheduledWake = vi.fn();
|
|
125
|
+
const host = createTestAgentJobHost({
|
|
126
|
+
spawnScheduledWake,
|
|
127
|
+
listSchedules: vi.fn().mockReturnValue([
|
|
128
|
+
{
|
|
129
|
+
id: 'loop_existing',
|
|
130
|
+
kind: 'scheduled',
|
|
131
|
+
status: 'sleeping',
|
|
132
|
+
label: 'Loop: existing',
|
|
133
|
+
metadata: { sessionLoop: true },
|
|
134
|
+
},
|
|
135
|
+
]),
|
|
136
|
+
});
|
|
137
|
+
const options = { disabled: true, defaultPrompt: 'Tend current task.' };
|
|
138
|
+
|
|
139
|
+
expect((await executeLoopCommand(host, vi.fn(), '5m check', options)).success).toBe(false);
|
|
140
|
+
const listed = await executeLoopCommand(host, vi.fn(), 'list', options);
|
|
141
|
+
expect(listed.success).toBe(true);
|
|
142
|
+
expect(listed.message).toContain('loop_existing');
|
|
143
|
+
expect(listed.message).toContain('disabled');
|
|
144
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('uses a stable loop id to find a re-armed task after its runtime id changes', async () => {
|
|
148
|
+
const spawnScheduledWake = vi
|
|
149
|
+
.fn()
|
|
150
|
+
.mockImplementation(async (input: { sessionLoopId: string }) => ({
|
|
151
|
+
id: 'runtime_before_resume',
|
|
152
|
+
metadata: { sessionLoop: true, sessionLoopId: input.sessionLoopId },
|
|
153
|
+
}));
|
|
154
|
+
const listSchedules = vi.fn().mockImplementation(() => [
|
|
155
|
+
{
|
|
156
|
+
id: 'runtime_after_resume',
|
|
157
|
+
kind: 'scheduled',
|
|
158
|
+
status: 'sleeping',
|
|
159
|
+
label: 'Loop: check',
|
|
160
|
+
metadata: {
|
|
161
|
+
sessionLoop: true,
|
|
162
|
+
sessionLoopId: spawnScheduledWake.mock.calls[0]?.[0].sessionLoopId,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
const host = createTestAgentJobHost({ spawnScheduledWake, listSchedules });
|
|
167
|
+
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
168
|
+
|
|
169
|
+
const created = await executeLoopCommand(host, cancel, '5m check');
|
|
170
|
+
const loopId = (created.data as { loopId?: string }).loopId;
|
|
171
|
+
expect(loopId).toMatch(/^loop_[0-9a-f-]{36}$/);
|
|
172
|
+
expect(loopId).not.toBe('runtime_before_resume');
|
|
173
|
+
expect((await executeLoopCommand(host, cancel, 'list')).message).toContain(loopId);
|
|
174
|
+
|
|
175
|
+
const stopped = await executeLoopCommand(host, cancel, `stop ${loopId}`);
|
|
176
|
+
expect(stopped.success).toBe(true);
|
|
177
|
+
expect(cancel).toHaveBeenCalledWith('runtime_after_resume', 'Loop stopped by user');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('refuses a fourth active loop without spawning a timer', async () => {
|
|
181
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'unexpected' });
|
|
182
|
+
const listSchedules = vi.fn().mockReturnValue(
|
|
183
|
+
Array.from({ length: 3 }, (_, index) => ({
|
|
184
|
+
id: `runtime_${index}`,
|
|
185
|
+
kind: 'scheduled',
|
|
186
|
+
status: 'sleeping',
|
|
187
|
+
label: 'Loop: check',
|
|
188
|
+
metadata: { sessionLoop: true },
|
|
189
|
+
})),
|
|
190
|
+
);
|
|
191
|
+
const host = createTestAgentJobHost({ spawnScheduledWake, listSchedules });
|
|
192
|
+
const result = await executeLoopCommand(host, vi.fn(), '5m check');
|
|
193
|
+
expect(result.success).toBe(false);
|
|
194
|
+
expect(result.message).toContain('3 active loops');
|
|
195
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('reserves the final slot while a loop creation is still pending', async () => {
|
|
199
|
+
const finishSpawns: Array<(task: { id: string }) => void> = [];
|
|
200
|
+
const spawnScheduledWake = vi.fn().mockImplementation(
|
|
201
|
+
() =>
|
|
202
|
+
new Promise<{ id: string }>((resolve) => {
|
|
203
|
+
finishSpawns.push(resolve);
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
const listSchedules = vi.fn().mockReturnValue(
|
|
207
|
+
Array.from({ length: 2 }, (_, index) => ({
|
|
208
|
+
id: `runtime_${index}`,
|
|
209
|
+
kind: 'scheduled',
|
|
210
|
+
status: 'sleeping',
|
|
211
|
+
metadata: { sessionLoop: true },
|
|
212
|
+
})),
|
|
213
|
+
);
|
|
214
|
+
const host = createTestAgentJobHost({ spawnScheduledWake, listSchedules });
|
|
215
|
+
|
|
216
|
+
const first = executeLoopCommand(host, vi.fn(), '5m first');
|
|
217
|
+
expect(spawnScheduledWake).toHaveBeenCalledTimes(1);
|
|
218
|
+
const secondPending = executeLoopCommand(host, vi.fn(), '5m second');
|
|
219
|
+
const spawnCount = spawnScheduledWake.mock.calls.length;
|
|
220
|
+
finishSpawns.forEach((resolve, index) => resolve({ id: `runtime_${index + 2}` }));
|
|
221
|
+
const second = await secondPending;
|
|
222
|
+
expect(second.success).toBe(false);
|
|
223
|
+
expect(second.message).toContain('3 active loops');
|
|
224
|
+
expect(spawnCount).toBe(1);
|
|
225
|
+
|
|
226
|
+
expect((await first).success).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('creates a recurring scheduled wake with the requested prompt', async () => {
|
|
230
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_1' });
|
|
231
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
232
|
+
|
|
233
|
+
const result = await executeLoopCommand(host, vi.fn(), '5m check the build');
|
|
234
|
+
|
|
235
|
+
expect(result.success).toBe(true);
|
|
236
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
237
|
+
expect.objectContaining({
|
|
238
|
+
label: 'Loop: check the build',
|
|
239
|
+
cronExpression: expect.any(String),
|
|
240
|
+
agentInstruction: 'check the build',
|
|
241
|
+
sessionLoop: true,
|
|
242
|
+
sessionLoopId: expect.stringMatching(/^loop_[0-9a-f-]{36}$/),
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
const loopId = (result.data as { loopId: string }).loopId;
|
|
246
|
+
expect(result.message).toContain(`/loop stop ${loopId}`);
|
|
247
|
+
expect(result.data).toMatchObject({ taskId: 'loop_task_1', cadenceLabel: '5m' });
|
|
248
|
+
expect(result.data).toEqual(expect.objectContaining({ jitterSeconds: expect.any(Number) }));
|
|
249
|
+
expect(result.message).toMatch(/stable offset \+\d+s/);
|
|
250
|
+
expect((result.data as { jitterSeconds: number }).jitterSeconds).toBeLessThanOrEqual(150);
|
|
251
|
+
expect(spawnScheduledWake.mock.calls[0]![0].cronExpression)
|
|
252
|
+
.toBe((result.data as { cronExpression: string }).cronExpression);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('accepts a trailing interval and reports the actual rounded cadence', async () => {
|
|
256
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_2' });
|
|
257
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
258
|
+
|
|
259
|
+
const result = await executeLoopCommand(host, vi.fn(), 'check the deploy every 7 minutes');
|
|
260
|
+
|
|
261
|
+
expect(result.success).toBe(true);
|
|
262
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
263
|
+
expect.objectContaining({
|
|
264
|
+
cronExpression: expect.any(String),
|
|
265
|
+
agentInstruction: 'check the deploy',
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
expect(result.message).toContain('10m (rounded up) local-clock step');
|
|
269
|
+
expect(result.message).toContain('daylight saving');
|
|
270
|
+
expect(result.data).toMatchObject({ requestedMs: 420_000, cadenceLabel: '10m' });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('parses a malformed trailing interval in bounded time even after a long whitespace run', async () => {
|
|
274
|
+
const host = createTestAgentJobHost();
|
|
275
|
+
const started = performance.now();
|
|
276
|
+
const result = await executeLoopCommand(host, vi.fn(), `check${' '.repeat(50_000)}every nope`);
|
|
277
|
+
|
|
278
|
+
expect(result.success).toBe(false);
|
|
279
|
+
expect(performance.now() - started).toBeLessThan(250);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('does not treat a prompt beginning with stop as a management command', async () => {
|
|
283
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_3' });
|
|
284
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
285
|
+
const result = await executeLoopCommand(host, vi.fn(), 'stopwatch the build every 2 minutes');
|
|
286
|
+
expect(result.success).toBe(true);
|
|
287
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
288
|
+
expect.objectContaining({ agentInstruction: 'stopwatch the build' }),
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('accepts a leading interval when the prompt ends in non-time every-quantity text', async () => {
|
|
293
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_4' });
|
|
294
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
295
|
+
const result = await executeLoopCommand(host, vi.fn(), '5m inspect every 2 files');
|
|
296
|
+
expect(result.success).toBe(true);
|
|
297
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
298
|
+
expect.objectContaining({ agentInstruction: 'inspect every 2 files' }),
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('lists only active loops and stops only the selected loop', async () => {
|
|
303
|
+
const listSchedules = vi.fn().mockReturnValue([
|
|
304
|
+
{
|
|
305
|
+
id: 'loop_a',
|
|
306
|
+
kind: 'scheduled',
|
|
307
|
+
status: 'sleeping',
|
|
308
|
+
label: 'Loop: check A',
|
|
309
|
+
metadata: { sessionLoop: true },
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
id: 'loop_b',
|
|
313
|
+
kind: 'scheduled',
|
|
314
|
+
status: 'sleeping',
|
|
315
|
+
label: 'Scheduled: edited B',
|
|
316
|
+
metadata: { sessionLoop: true },
|
|
317
|
+
},
|
|
318
|
+
{ id: 'schedule_c', kind: 'scheduled', status: 'sleeping', label: 'Scheduled: check C' },
|
|
319
|
+
{
|
|
320
|
+
id: 'loop_old',
|
|
321
|
+
kind: 'scheduled',
|
|
322
|
+
status: 'cancelled',
|
|
323
|
+
label: 'Loop: old',
|
|
324
|
+
metadata: { sessionLoop: true },
|
|
325
|
+
},
|
|
326
|
+
]);
|
|
327
|
+
const createSelfPacedLoop = vi.fn();
|
|
328
|
+
const spawnScheduledWake = vi.fn();
|
|
329
|
+
const host = createTestAgentJobHost({ listSchedules, createSelfPacedLoop, spawnScheduledWake });
|
|
330
|
+
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
331
|
+
|
|
332
|
+
const listed = await executeLoopCommand(host, cancel, 'list');
|
|
333
|
+
expect(listed.message).toContain('loop_a');
|
|
334
|
+
expect(listed.message).toContain('loop_b');
|
|
335
|
+
expect(listed.message).not.toContain('schedule_c');
|
|
336
|
+
expect(listed.message).not.toContain('loop_old');
|
|
337
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
338
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
339
|
+
|
|
340
|
+
const stopped = await executeLoopCommand(host, cancel, 'stop loop_a');
|
|
341
|
+
expect(stopped.success).toBe(true);
|
|
342
|
+
expect(cancel).toHaveBeenCalledExactlyOnceWith('loop_a', 'Loop stopped by user');
|
|
343
|
+
expect(stopped.message).toContain('already-running turn may finish');
|
|
344
|
+
|
|
345
|
+
const unrelated = await executeLoopCommand(host, cancel, 'stop schedule_c');
|
|
346
|
+
expect(unrelated.success).toBe(false);
|
|
347
|
+
expect(cancel).toHaveBeenCalledTimes(1);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it.each(['', '0m check', '2d check', '5x check', '5m check every 2m'])(
|
|
351
|
+
'rejects an unsupported or ambiguous loop form: %s',
|
|
352
|
+
async (args) => {
|
|
353
|
+
const spawnScheduledWake = vi.fn();
|
|
354
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
355
|
+
const result = await executeLoopCommand(host, vi.fn(), args);
|
|
356
|
+
expect(result.success).toBe(false);
|
|
357
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A model-requested `/monitor` starts its command as a process, so the command is decided by the
|
|
3
|
+
* shell tool's own gate — the user's Bash/Shell rules — never by consent to `/monitor` itself.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { scriptedSession, type ScriptedSessionHarness } from '@robota-sdk/agent-framework/testing';
|
|
9
|
+
|
|
10
|
+
import { createScheduleCommandModule } from '../schedule-command-module.js';
|
|
11
|
+
|
|
12
|
+
import type { THooksConfig, TPermissionMode } from '@robota-sdk/agent-core';
|
|
13
|
+
type ISandboxClient = NonNullable<Parameters<typeof scriptedSession>[0]['sandboxClient']>;
|
|
14
|
+
|
|
15
|
+
/** A sandbox that would let every confined shell command run without a prompt. */
|
|
16
|
+
const autoApprovingSandbox: ISandboxClient = {
|
|
17
|
+
filesystem: 'shared',
|
|
18
|
+
wrapCommand: (invocation) => invocation,
|
|
19
|
+
autoApproves: () => true,
|
|
20
|
+
run: () => Promise.reject(new Error('not used')),
|
|
21
|
+
readFile: () => Promise.reject(new Error('not used')),
|
|
22
|
+
writeFile: () => Promise.reject(new Error('not used')),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let harness: ScriptedSessionHarness | undefined;
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await harness?.dispose();
|
|
28
|
+
harness = undefined;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function session(options: {
|
|
32
|
+
permissionMode: TPermissionMode;
|
|
33
|
+
permissions?: { allow?: string[]; deny?: string[] };
|
|
34
|
+
hooks?: THooksConfig;
|
|
35
|
+
sandboxClient?: ISandboxClient;
|
|
36
|
+
}): ScriptedSessionHarness {
|
|
37
|
+
harness = scriptedSession({
|
|
38
|
+
turns: [],
|
|
39
|
+
commandModules: [createScheduleCommandModule()],
|
|
40
|
+
backgroundTasks: true,
|
|
41
|
+
...options,
|
|
42
|
+
});
|
|
43
|
+
return harness;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MONITOR_ARGS = '"sleep 30" "ready" check the server';
|
|
47
|
+
|
|
48
|
+
function monitorTasks(h: ScriptedSessionHarness) {
|
|
49
|
+
return h.session.listBackgroundTasks().filter((task) => task.kind === 'process');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('/monitor from the model is decided by the shell gate', () => {
|
|
53
|
+
it('a Bash deny rule refuses the monitored command, even under bypassPermissions', async () => {
|
|
54
|
+
const h = session({
|
|
55
|
+
permissionMode: 'bypassPermissions',
|
|
56
|
+
permissions: { deny: ['Bash(sleep *)'] },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const result = await h.session.executeModelCommand('monitor', MONITOR_ARGS);
|
|
60
|
+
|
|
61
|
+
expect(result?.success).toBe(false);
|
|
62
|
+
expect(result?.message).toContain('shell permission rules refused this command');
|
|
63
|
+
expect(monitorTasks(h)).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('an allow rule for a different command does not approve it', async () => {
|
|
67
|
+
// Plan mode refuses an execute call no allow rule matches, so a prompt cannot stand in for it.
|
|
68
|
+
const h = session({ permissionMode: 'plan', permissions: { allow: ['Bash(echo *)'] } });
|
|
69
|
+
|
|
70
|
+
const result = await h.session.executeModelCommand('monitor', MONITOR_ARGS);
|
|
71
|
+
|
|
72
|
+
expect(result?.success).toBe(false);
|
|
73
|
+
expect(monitorTasks(h)).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('starts the monitor when the shell rules allow that command', async () => {
|
|
77
|
+
const h = session({ permissionMode: 'plan', permissions: { allow: ['Bash(sleep *)'] } });
|
|
78
|
+
|
|
79
|
+
const result = await h.session.executeModelCommand('monitor', MONITOR_ARGS);
|
|
80
|
+
|
|
81
|
+
expect(result?.success).toBe(true);
|
|
82
|
+
expect(monitorTasks(h)).toHaveLength(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('leaves the user-typed command to the user', async () => {
|
|
86
|
+
const h = session({
|
|
87
|
+
permissionMode: 'bypassPermissions',
|
|
88
|
+
permissions: { deny: ['Bash(sleep *)'] },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const result = await h.command('monitor', MONITOR_ARGS);
|
|
92
|
+
|
|
93
|
+
expect(result?.success).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('never takes the sandbox auto-approval: the monitor does not run inside the sandbox', async () => {
|
|
97
|
+
// Default mode would auto-run a sandboxed shell command. The monitor's process is started by the
|
|
98
|
+
// background-task manager, outside the sandbox, so it falls to the ask path — and with no one
|
|
99
|
+
// to ask here, it is refused rather than started unconfined.
|
|
100
|
+
const h = session({ permissionMode: 'default', sandboxClient: autoApprovingSandbox });
|
|
101
|
+
|
|
102
|
+
const result = await h.session.executeModelCommand('monitor', MONITOR_ARGS);
|
|
103
|
+
|
|
104
|
+
expect(result?.success).toBe(false);
|
|
105
|
+
expect(monitorTasks(h)).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('runs the PreToolUse hooks, so a guardrail blocks the monitored command', async () => {
|
|
109
|
+
const h = session({
|
|
110
|
+
permissionMode: 'bypassPermissions',
|
|
111
|
+
hooks: {
|
|
112
|
+
PreToolUse: [
|
|
113
|
+
{
|
|
114
|
+
matcher: 'Shell',
|
|
115
|
+
hooks: [{ type: 'command', command: 'echo "no sleeping" >&2; exit 2' }],
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const result = await h.session.executeModelCommand('monitor', MONITOR_ARGS);
|
|
122
|
+
|
|
123
|
+
expect(result?.success).toBe(false);
|
|
124
|
+
expect(monitorTasks(h)).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -7,22 +7,62 @@
|
|
|
7
7
|
import { describe, expect, it, vi } from 'vitest';
|
|
8
8
|
|
|
9
9
|
import { executeMonitorCommand, executeScheduleCommand } from '../schedule-command.js';
|
|
10
|
+
import { createScheduleCommandModule } from '../schedule-command-module.js';
|
|
10
11
|
import { parseScheduleSpec } from '../schedule-spec-parser.js';
|
|
11
12
|
|
|
12
13
|
import type { IAgentJobHostContext } from '@robota-sdk/agent-framework';
|
|
14
|
+
import { createTestAgentJobHost } from '@robota-sdk/agent-framework/testing';
|
|
15
|
+
|
|
16
|
+
describe('in-session repeat command', () => {
|
|
17
|
+
it('exposes a user-visible loop command through the default schedule module', () => {
|
|
18
|
+
const module = createScheduleCommandModule();
|
|
19
|
+
const executable = module.systemCommands?.find((command) => command.name === 'loop');
|
|
20
|
+
const palette = module.commandSources?.flatMap((source) => source.getCommands())
|
|
21
|
+
.find((command) => command.name === 'loop');
|
|
22
|
+
|
|
23
|
+
expect(executable).toBeDefined();
|
|
24
|
+
expect(palette).toBeDefined();
|
|
25
|
+
expect(executable?.requiresPermission).toBe(true);
|
|
26
|
+
expect(executable?.modelInvocable).toBe(true);
|
|
27
|
+
expect(executable?.userInvocable).toBe(true);
|
|
28
|
+
expect(palette?.subcommands?.map(({ name }) => name)).toEqual(['list', 'stop']);
|
|
29
|
+
expect(palette?.subcommands).toEqual(executable?.subcommands);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
13
32
|
|
|
14
33
|
function mockHost(): {
|
|
15
34
|
host: IAgentJobHostContext;
|
|
16
35
|
scheduled: ReturnType<typeof vi.fn>;
|
|
17
36
|
monitor: ReturnType<typeof vi.fn>;
|
|
37
|
+
listSchedules: ReturnType<typeof vi.fn>;
|
|
38
|
+
pauseSchedule: ReturnType<typeof vi.fn>;
|
|
39
|
+
resumeSchedule: ReturnType<typeof vi.fn>;
|
|
40
|
+
editSchedule: ReturnType<typeof vi.fn>;
|
|
18
41
|
} {
|
|
19
42
|
const scheduled = vi.fn().mockResolvedValue({ id: 'task_sched' });
|
|
20
43
|
const monitor = vi.fn().mockResolvedValue({ id: 'task_mon' });
|
|
21
|
-
const
|
|
44
|
+
const listSchedules = vi.fn().mockReturnValue([
|
|
45
|
+
{
|
|
46
|
+
id: 'sched_1',
|
|
47
|
+
status: 'sleeping',
|
|
48
|
+
label: 'daily',
|
|
49
|
+
kind: 'scheduled',
|
|
50
|
+
nextFireAt: '2030-01-01T00:00:00.000Z',
|
|
51
|
+
schedule: { cronExpression: '0 0 * * *' },
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
const pauseSchedule = vi.fn().mockResolvedValue(undefined);
|
|
55
|
+
const resumeSchedule = vi.fn().mockResolvedValue(undefined);
|
|
56
|
+
const editSchedule = vi.fn().mockResolvedValue(undefined);
|
|
57
|
+
const host = createTestAgentJobHost({
|
|
22
58
|
spawnScheduledWake: scheduled,
|
|
23
59
|
spawnMonitorWake: monitor,
|
|
24
|
-
|
|
25
|
-
|
|
60
|
+
listSchedules,
|
|
61
|
+
pauseSchedule,
|
|
62
|
+
resumeSchedule,
|
|
63
|
+
editSchedule,
|
|
64
|
+
});
|
|
65
|
+
return { host, scheduled, monitor, listSchedules, pauseSchedule, resumeSchedule, editSchedule };
|
|
26
66
|
}
|
|
27
67
|
|
|
28
68
|
describe('parseScheduleSpec (FLOW-005)', () => {
|
|
@@ -105,3 +145,103 @@ describe('executeScheduleCommand (FLOW-005)', () => {
|
|
|
105
145
|
);
|
|
106
146
|
});
|
|
107
147
|
});
|
|
148
|
+
|
|
149
|
+
// SELFHOST-012 TC-05: /schedule list|pause|resume|edit dispatch (mirror /background); create stays default.
|
|
150
|
+
describe('executeScheduleCommand — management subcommands (SELFHOST-012)', () => {
|
|
151
|
+
it('list returns the caller schedules with cadence + status, no create', async () => {
|
|
152
|
+
const { host, listSchedules, scheduled } = mockHost();
|
|
153
|
+
const result = await executeScheduleCommand(host, 'list', Date.now());
|
|
154
|
+
expect(result.success).toBe(true);
|
|
155
|
+
expect(listSchedules).toHaveBeenCalledOnce();
|
|
156
|
+
expect(result.message).toContain('sched_1');
|
|
157
|
+
expect(result.message).toContain('[sleeping]');
|
|
158
|
+
expect(result.message).toContain('0 0 * * *');
|
|
159
|
+
expect(scheduled).not.toHaveBeenCalled(); // list is not a create
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('pause <id> / resume <id> dispatch to the host lifecycle calls', async () => {
|
|
163
|
+
const { host, pauseSchedule, resumeSchedule } = mockHost();
|
|
164
|
+
const paused = await executeScheduleCommand(host, 'pause sched_1', Date.now());
|
|
165
|
+
expect(paused.success).toBe(true);
|
|
166
|
+
expect(pauseSchedule).toHaveBeenCalledWith('sched_1');
|
|
167
|
+
|
|
168
|
+
const resumed = await executeScheduleCommand(host, 'resume sched_1', Date.now());
|
|
169
|
+
expect(resumed.success).toBe(true);
|
|
170
|
+
expect(resumeSchedule).toHaveBeenCalledWith('sched_1');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('edit <id> <spec> parses the spec and patches the schedule', async () => {
|
|
174
|
+
const { host, editSchedule } = mockHost();
|
|
175
|
+
const result = await executeScheduleCommand(
|
|
176
|
+
host,
|
|
177
|
+
'edit sched_1 cron "*/5 * * * *" ping',
|
|
178
|
+
Date.now(),
|
|
179
|
+
);
|
|
180
|
+
expect(result.success).toBe(true);
|
|
181
|
+
expect(editSchedule).toHaveBeenCalledWith('sched_1', {
|
|
182
|
+
cronExpression: '*/5 * * * *',
|
|
183
|
+
agentInstruction: 'ping',
|
|
184
|
+
label: 'Scheduled: ping',
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// CMD-009: create → edit → list must render the EDITED instruction. The host double applies the
|
|
189
|
+
// patch the way `BackgroundTaskManager.editScheduledTask` does (pinned in agent-executor), so a
|
|
190
|
+
// command that omits `label` from the patch leaves the list showing the creation-time text.
|
|
191
|
+
it('list renders the edited instruction, not the one captured at creation (CMD-009)', async () => {
|
|
192
|
+
const schedules: Array<Record<string, unknown>> = [];
|
|
193
|
+
const host = createTestAgentJobHost({
|
|
194
|
+
spawnScheduledWake: vi.fn().mockImplementation((input: Record<string, unknown>) => {
|
|
195
|
+
const task = {
|
|
196
|
+
id: 'sched_x',
|
|
197
|
+
status: 'sleeping',
|
|
198
|
+
kind: 'scheduled',
|
|
199
|
+
label: input['label'],
|
|
200
|
+
schedule: {
|
|
201
|
+
cronExpression: input['cronExpression'],
|
|
202
|
+
agentInstruction: input['agentInstruction'],
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
schedules.push(task);
|
|
206
|
+
return Promise.resolve(task);
|
|
207
|
+
}),
|
|
208
|
+
listSchedules: vi.fn().mockImplementation(() => schedules),
|
|
209
|
+
editSchedule: vi.fn().mockImplementation((id: string, patch: Record<string, unknown>) => {
|
|
210
|
+
const { label, ...schedulePatch } = patch;
|
|
211
|
+
const task = schedules.find((s) => s['id'] === id);
|
|
212
|
+
if (task === undefined) throw new Error(`no schedule ${id}`);
|
|
213
|
+
task['schedule'] = { ...(task['schedule'] as Record<string, unknown>), ...schedulePatch };
|
|
214
|
+
if (label !== undefined) task['label'] = label;
|
|
215
|
+
return Promise.resolve();
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await executeScheduleCommand(host, 'cron "0 9 * * *" write the standup', 0);
|
|
220
|
+
await executeScheduleCommand(host, 'edit sched_x cron "0 10 * * *" file the report', 0);
|
|
221
|
+
const listed = await executeScheduleCommand(host, 'list', 0);
|
|
222
|
+
|
|
223
|
+
expect(listed.message).toContain('file the report');
|
|
224
|
+
expect(listed.message).not.toContain('write the standup');
|
|
225
|
+
expect(listed.message).toContain('0 10 * * *');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('pause/resume/edit without an id is a usage error (no host call)', async () => {
|
|
229
|
+
const { host, pauseSchedule, editSchedule } = mockHost();
|
|
230
|
+
const p = await executeScheduleCommand(host, 'pause', Date.now());
|
|
231
|
+
expect(p.success).toBe(false);
|
|
232
|
+
expect(p.message).toContain('Usage:');
|
|
233
|
+
expect(pauseSchedule).not.toHaveBeenCalled();
|
|
234
|
+
|
|
235
|
+
const e = await executeScheduleCommand(host, 'edit sched_1', Date.now());
|
|
236
|
+
expect(e.success).toBe(false);
|
|
237
|
+
expect(editSchedule).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('a create spec (in/cron) still creates — subcommand keywords do not collide', async () => {
|
|
241
|
+
const { host, scheduled, pauseSchedule } = mockHost();
|
|
242
|
+
const result = await executeScheduleCommand(host, 'in 5m summarize', 0);
|
|
243
|
+
expect(result.success).toBe(true);
|
|
244
|
+
expect(scheduled).toHaveBeenCalledOnce();
|
|
245
|
+
expect(pauseSchedule).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
});
|