@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.82

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.
Files changed (201) hide show
  1. package/README.md +11 -2
  2. package/dist/node/index.cjs +77 -38
  3. package/dist/node/index.d.cts +946 -0
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +564 -51
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +77 -38
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +39 -16
  10. package/src/advisor/advisor-command-module.ts +47 -0
  11. package/src/advisor/advisor-command.test.ts +102 -0
  12. package/src/advisor/advisor-command.ts +55 -0
  13. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  14. package/src/advisor/index.ts +6 -0
  15. package/src/agent/__tests__/agent-command.test.ts +39 -8
  16. package/src/agent/agent-command-module.ts +14 -7
  17. package/src/agent/agent-command-parser.ts +1 -1
  18. package/src/agent/agent-command.ts +3 -3
  19. package/src/background/__tests__/background-command-module.test.ts +59 -52
  20. package/src/background/background-command-module.ts +2 -1
  21. package/src/background/background-command.ts +3 -3
  22. package/src/command-module-utils.ts +33 -0
  23. package/src/compact/__tests__/compact-command-module.test.ts +31 -23
  24. package/src/compact/compact-command-module.ts +10 -1
  25. package/src/compact/compact-command.ts +6 -3
  26. package/src/context/__tests__/context-command-module.test.ts +214 -63
  27. package/src/context/auto-compact-format.ts +25 -0
  28. package/src/context/context-breakdown.ts +295 -0
  29. package/src/context/context-command-module.ts +45 -14
  30. package/src/context/context-command.ts +34 -257
  31. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
  32. package/src/default/__tests__/default-command-modules.test.ts +59 -7
  33. package/src/default/__tests__/model-exposure.test.ts +241 -0
  34. package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
  35. package/src/default/default-command-modules.ts +130 -18
  36. package/src/default/index.ts +4 -1
  37. package/src/devices/__tests__/devices-command-module.test.ts +212 -0
  38. package/src/devices/devices-command-module.ts +224 -0
  39. package/src/devices/devices-command-port.ts +82 -0
  40. package/src/devices/index.ts +11 -0
  41. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  42. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  43. package/src/doctor/__tests__/doctor-fixture.ts +145 -0
  44. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  45. package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
  46. package/src/doctor/doctor-command-module.ts +132 -0
  47. package/src/doctor/doctor-extensions-probe.ts +299 -0
  48. package/src/doctor/doctor-node-deps.ts +89 -0
  49. package/src/doctor/doctor-redaction.ts +99 -0
  50. package/src/doctor/doctor-render.ts +70 -0
  51. package/src/doctor/doctor-repair.ts +136 -0
  52. package/src/doctor/doctor-runner.ts +122 -0
  53. package/src/doctor/doctor-settings-probe.ts +239 -0
  54. package/src/doctor/doctor-storage-probe.ts +187 -0
  55. package/src/doctor/doctor-types.ts +109 -0
  56. package/src/doctor/index.ts +32 -0
  57. package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
  58. package/src/editor/editor-command-module.ts +6 -5
  59. package/src/editor/editor-command.ts +10 -5
  60. package/src/effort/effort-command-module.ts +51 -0
  61. package/src/effort/effort-command.test.ts +140 -0
  62. package/src/effort/effort-command.ts +122 -0
  63. package/src/effort/index.ts +6 -0
  64. package/src/exit/__tests__/exit-command-module.test.ts +16 -9
  65. package/src/exit/exit-command-module.ts +2 -1
  66. package/src/exit/exit-command.ts +6 -6
  67. package/src/fork/__tests__/fork-command.test.ts +184 -0
  68. package/src/fork/fork-command-module.ts +61 -0
  69. package/src/fork/fork-command.ts +162 -0
  70. package/src/fork/index.ts +8 -0
  71. package/src/git/__tests__/fake-git-port.ts +28 -0
  72. package/src/git/__tests__/git-command-module.test.ts +103 -0
  73. package/src/git/__tests__/git-commit.test.ts +185 -0
  74. package/src/git/__tests__/git-diff.test.ts +125 -0
  75. package/src/git/__tests__/git-process.test.ts +221 -0
  76. package/src/git/__tests__/git-status.test.ts +117 -0
  77. package/src/git/git-command-module.ts +139 -0
  78. package/src/git/git-commit.ts +251 -0
  79. package/src/git/git-diff.ts +129 -0
  80. package/src/git/git-process.ts +169 -0
  81. package/src/git/git-status.ts +134 -0
  82. package/src/git/index.ts +17 -0
  83. package/src/goal/__tests__/goal-command.test.ts +9 -4
  84. package/src/goal/goal-command-module.ts +2 -1
  85. package/src/goal/goal-command.ts +6 -9
  86. package/src/handoff/__tests__/handoff-command-module.test.ts +24 -0
  87. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  88. package/src/handoff/handoff-command-module.ts +49 -0
  89. package/src/handoff/handoff-command.ts +187 -0
  90. package/src/handoff/index.ts +6 -0
  91. package/src/help/__tests__/help-command-module.test.ts +63 -40
  92. package/src/help/__tests__/help-command.test.ts +46 -37
  93. package/src/help/help-command-module.ts +2 -1
  94. package/src/help/help-command.ts +3 -3
  95. package/src/index.ts +124 -2
  96. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  97. package/src/keybindings/index.ts +6 -0
  98. package/src/keybindings/keybindings-command-module.ts +74 -0
  99. package/src/language/__tests__/language-command-module.test.ts +33 -28
  100. package/src/language/language-command-module.ts +2 -1
  101. package/src/language/language-command.ts +8 -6
  102. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
  103. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
  104. package/src/mcp-activation/index.ts +12 -0
  105. package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
  106. package/src/mcp-activation/mcp-activation-command.ts +448 -0
  107. package/src/mcp-activation/mcp-model-notice.ts +85 -0
  108. package/src/mcp-activation/mcp-model-status.ts +99 -0
  109. package/src/memory/__tests__/memory-command-module.test.ts +167 -74
  110. package/src/memory/memory-command-module.ts +18 -1
  111. package/src/memory/memory-command.ts +38 -38
  112. package/src/mode/__tests__/mode-command-module.test.ts +51 -38
  113. package/src/mode/mode-command-module.ts +2 -1
  114. package/src/mode/mode-command.ts +15 -7
  115. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  116. package/src/output-style/index.ts +6 -0
  117. package/src/output-style/output-style-command-module.ts +143 -0
  118. package/src/peers/__tests__/peers-command.test.ts +321 -0
  119. package/src/peers/index.ts +9 -0
  120. package/src/peers/peers-command-module.ts +49 -0
  121. package/src/peers/peers-command.ts +191 -0
  122. package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
  123. package/src/permissions/permissions-command-module.ts +2 -1
  124. package/src/permissions/permissions-command.ts +46 -4
  125. package/src/plan/__tests__/plan-command.test.ts +129 -0
  126. package/src/plan/index.ts +9 -0
  127. package/src/plan/plan-command-module.ts +49 -0
  128. package/src/plan/plan-command.ts +92 -0
  129. package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
  130. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  131. package/src/plugin/plugin-command-module.ts +3 -1
  132. package/src/plugin/plugin-command.ts +17 -16
  133. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  134. package/src/preset/__tests__/preset-command-module.test.ts +102 -56
  135. package/src/preset/preset-command-module.ts +26 -8
  136. package/src/preset/preset-command.ts +85 -27
  137. package/src/provider/__tests__/org-policy.test.ts +4 -5
  138. package/src/provider/__tests__/provider-command-module.test.ts +11 -14
  139. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  140. package/src/provider/__tests__/scripted-interaction.ts +7 -5
  141. package/src/provider/provider-command-execution.ts +4 -4
  142. package/src/provider/provider-command-module.ts +2 -1
  143. package/src/provider/provider-command-profile-lifecycle.ts +4 -4
  144. package/src/provider/provider-command-profile-operations.ts +3 -3
  145. package/src/provider/provider-command-profile.ts +1 -1
  146. package/src/provider/provider-command-setup.ts +4 -4
  147. package/src/provider/provider-startup.ts +62 -35
  148. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  149. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  150. package/src/remote-control/index.ts +8 -0
  151. package/src/remote-control/remote-control-command-module.ts +61 -0
  152. package/src/remote-control/remote-control-command.ts +138 -0
  153. package/src/reset/__tests__/reset-command-module.test.ts +5 -5
  154. package/src/reset/reset-command-module.ts +2 -1
  155. package/src/reset/reset-command.ts +3 -4
  156. package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
  157. package/src/rewind/rewind-command-module.ts +2 -1
  158. package/src/rewind/rewind-command.ts +73 -6
  159. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  160. package/src/sandbox/index.ts +2 -0
  161. package/src/sandbox/sandbox-command-module.ts +56 -0
  162. package/src/sandbox/sandbox-command.ts +94 -0
  163. package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
  164. package/src/schedule/__tests__/loop-command.test.ts +360 -0
  165. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  166. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  167. package/src/schedule/__tests__/schedule-command.test.ts +143 -3
  168. package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
  169. package/src/schedule/index.ts +3 -0
  170. package/src/schedule/loop-command.ts +333 -0
  171. package/src/schedule/loop-jitter.ts +41 -0
  172. package/src/schedule/schedule-command-module.ts +92 -14
  173. package/src/schedule/schedule-command.ts +98 -9
  174. package/src/schedule/schedule-spec-parser.ts +5 -1
  175. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  176. package/src/session/__tests__/session-command-module.test.ts +210 -45
  177. package/src/session/session-command-module.ts +68 -37
  178. package/src/session/session-command.ts +147 -80
  179. package/src/settings/settings-command-module.ts +3 -2
  180. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  181. package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
  182. package/src/shell/resolve-shell.ts +2 -2
  183. package/src/shell/shell-command-module.ts +6 -5
  184. package/src/shell/shell-command.ts +9 -5
  185. package/src/skills/__tests__/skills-command-module.test.ts +69 -28
  186. package/src/skills/index.ts +1 -0
  187. package/src/skills/skills-command-module.ts +22 -5
  188. package/src/skills/skills-command.ts +14 -13
  189. package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
  190. package/src/statusline/statusline-command-module.ts +2 -1
  191. package/src/statusline/statusline-command.ts +4 -4
  192. package/src/theme/__tests__/theme-command.test.ts +204 -0
  193. package/src/theme/index.ts +12 -0
  194. package/src/theme/theme-command-module.ts +211 -0
  195. package/src/user-local/__tests__/user-local-command.test.ts +145 -110
  196. package/src/user-local/user-local-command-constants.ts +1 -1
  197. package/src/user-local/user-local-command-module.ts +6 -5
  198. package/src/user-local/user-local-command.ts +12 -6
  199. package/src/user-local/user-local-memory-command.ts +17 -7
  200. package/src/plugins/default-plugin-command-adapter.ts +0 -164
  201. package/src/plugins/default-plugin-command-source-loader.ts +0 -31
@@ -0,0 +1,386 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { scriptedSession, type ScriptedSessionHarness } from '@robota-sdk/agent-framework/testing';
4
+
5
+ import { createScheduleCommandModule } from '../schedule-command-module.js';
6
+
7
+ let harness: ScriptedSessionHarness | undefined;
8
+ afterEach(async () => {
9
+ await harness?.dispose();
10
+ harness = undefined;
11
+ });
12
+
13
+ describe('/loop command in a real interactive session', () => {
14
+ it('re-reads an omitted self-paced prompt before each iteration without changing explicit prompts', async () => {
15
+ let prompt = 'project first';
16
+ const resolveDefaultPrompt = () => prompt;
17
+ harness = scriptedSession({
18
+ turns: [{ text: 'first' }, { text: 'second' }],
19
+ commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
20
+ resolveDefaultLoopPrompt: resolveDefaultPrompt,
21
+ backgroundTasks: true,
22
+ persistence: true,
23
+ });
24
+ const created = await harness.command('loop', '');
25
+ expect(created?.success).toBe(true);
26
+ const loopId = (created?.data as { loopId: string }).loopId;
27
+ await vi.waitFor(() => expect(harness?.session.listSelfPacedLoops()[0]?.phase).toBe('waiting'));
28
+ expect(JSON.stringify(harness.requests[0])).toContain('project first');
29
+
30
+ prompt = 'project second';
31
+ const timer = harness.session.listSchedules().find((task) => task.metadata?.['sessionLoopId'] === loopId)!;
32
+ const nextAllowedAt = harness.session.listSelfPacedLoops()[0]!.nextAllowedAt!;
33
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(nextAllowedAt) + 1);
34
+ try {
35
+ expect(await harness.wake('project first', timer.id)).not.toBeNull();
36
+ } finally {
37
+ clock.mockRestore();
38
+ }
39
+ expect(harness.requests[1]?.filter((message) => message.role === 'user').at(-1)?.content)
40
+ .toBe('project second');
41
+ expect(harness.session.getFullHistory().filter((entry) => entry.type === 'user').at(-1))
42
+ .toMatchObject({ data: { content: 'project second' } });
43
+ expect(harness.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId)?.phase)
44
+ .toBe('stopped');
45
+ });
46
+
47
+ it('re-reads an interval-only fixed-loop prompt before each wake', async () => {
48
+ let prompt = 'user first';
49
+ const resolveDefaultPrompt = () => prompt;
50
+ harness = scriptedSession({
51
+ turns: [{ text: 'first' }, { text: 'second' }],
52
+ commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
53
+ resolveDefaultLoopPrompt: resolveDefaultPrompt,
54
+ backgroundTasks: true,
55
+ persistence: true,
56
+ });
57
+ const created = await harness.command('loop', '5m');
58
+ expect(created?.success).toBe(true);
59
+ const { loopId, taskId, firstAllowedAt } = created?.data as {
60
+ loopId: string; taskId: string; firstAllowedAt: string;
61
+ };
62
+ const eligibleClock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(firstAllowedAt) + 1);
63
+ try {
64
+ expect(await harness.wake('user first', taskId)).not.toBeNull();
65
+ prompt = 'user second';
66
+ expect(await harness.wake('user first', taskId)).not.toBeNull();
67
+ } finally {
68
+ eligibleClock.mockRestore();
69
+ }
70
+ expect(JSON.stringify(harness.requests[0])).toContain('user first');
71
+ expect(harness.requests[1]?.filter((message) => message.role === 'user').at(-1)?.content)
72
+ .toBe('user second');
73
+ expect(harness.session.getFullHistory().filter((entry) => entry.type === 'user').at(-1))
74
+ .toMatchObject({ data: { content: 'user second' } });
75
+ expect((await harness.command('loop', `stop ${loopId}`))?.success).toBe(true);
76
+ });
77
+
78
+ it('does not consult the default resolver for an explicit fixed-loop prompt', async () => {
79
+ harness = scriptedSession({
80
+ turns: [{ text: 'done' }],
81
+ commandModules: [createScheduleCommandModule({
82
+ defaultPrompt: 'fallback',
83
+ resolveDefaultPrompt: () => { throw new Error('must not read default'); },
84
+ })],
85
+ resolveDefaultLoopPrompt: () => { throw new Error('must not read default'); },
86
+ backgroundTasks: true,
87
+ persistence: true,
88
+ });
89
+ const created = await harness.command('loop', '5m explicit check');
90
+ expect(created?.success).toBe(true);
91
+ const { loopId, taskId, firstAllowedAt } = created?.data as {
92
+ loopId: string; taskId: string; firstAllowedAt: string;
93
+ };
94
+ const eligibleClock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(firstAllowedAt) + 1);
95
+ try {
96
+ expect(await harness.wake('explicit check', taskId)).not.toBeNull();
97
+ } finally {
98
+ eligibleClock.mockRestore();
99
+ }
100
+ expect(harness.requests[0]?.filter((message) => message.role === 'user').at(-1)?.content)
101
+ .toBe('explicit check');
102
+ expect((await harness.command('loop', `stop ${loopId}`))?.success).toBe(true);
103
+ });
104
+
105
+ it('honors an explicit schedule edit to a fixed loop that originally used the default', async () => {
106
+ let prompt = 'default first';
107
+ const resolveDefaultPrompt = () => prompt;
108
+ harness = scriptedSession({
109
+ turns: [{ text: 'done' }],
110
+ commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
111
+ resolveDefaultLoopPrompt: resolveDefaultPrompt,
112
+ backgroundTasks: true,
113
+ persistence: true,
114
+ });
115
+ const created = await harness.command('loop', '5m');
116
+ expect(created?.success).toBe(true);
117
+ const { loopId, taskId, firstAllowedAt } = created?.data as {
118
+ loopId: string; taskId: string; firstAllowedAt: string;
119
+ };
120
+ expect((await harness.command('schedule', `edit ${taskId} cron "*/5 * * * *" explicit edit`))?.success)
121
+ .toBe(true);
122
+ prompt = 'default changed';
123
+ const eligibleClock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(firstAllowedAt) + 1);
124
+ try {
125
+ expect(await harness.wake('explicit edit', taskId)).not.toBeNull();
126
+ } finally {
127
+ eligibleClock.mockRestore();
128
+ }
129
+ expect(harness.requests[0]?.filter((message) => message.role === 'user').at(-1)?.content)
130
+ .toBe('explicit edit');
131
+ expect((await harness.command('loop', `stop ${loopId}`))?.success).toBe(true);
132
+ });
133
+
134
+ it('runs a prompt-only iteration, records the model delay, and stops its one-shot timer', async () => {
135
+ harness = scriptedSession({
136
+ turns: [
137
+ { toolCalls: [{ name: 'report_loop_decision', args: {
138
+ action: 'continue', delaySeconds: 60, reason: 'Recheck after the build settles',
139
+ } }] },
140
+ { text: 'Build still running.' },
141
+ ],
142
+ commandModules: [createScheduleCommandModule()],
143
+ backgroundTasks: true,
144
+ persistence: true,
145
+ });
146
+ let phaseAtComplete: string | undefined;
147
+ let historyAtComplete = '';
148
+ harness.session.on('complete', () => {
149
+ phaseAtComplete = harness?.session.listSelfPacedLoops()[0]?.phase;
150
+ historyAtComplete = JSON.stringify(harness?.session.getFullHistory());
151
+ });
152
+ const created = await harness.command('loop', 'check the build');
153
+ expect(created?.success).toBe(true);
154
+ const loopId = (created?.data as { loopId: string }).loopId;
155
+ await vi.waitFor(() =>
156
+ expect(harness?.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId))
157
+ .toMatchObject({ phase: 'waiting', generation: 1, delaySeconds: 60, fallbackUsed: false }),
158
+ );
159
+ const timer = harness.session.listSchedules().find(
160
+ (task) => task.metadata?.['sessionLoopId'] === loopId && task.metadata?.['sessionLoopSelfPaced'],
161
+ );
162
+ expect(timer?.kind === 'scheduled' ? timer.schedule?.cronExpression : undefined).toBe(
163
+ harness.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId)?.nextAllowedAt,
164
+ );
165
+ const listed = await harness.command('loop', 'list');
166
+ expect(listed?.message).toContain(loopId);
167
+ expect(listed?.message).toContain('60s');
168
+ expect(listed?.message).toContain('Recheck after the build settles');
169
+ expect(JSON.stringify(harness.session.getFullHistory()))
170
+ .toContain('Recheck after the build settles');
171
+ expect(phaseAtComplete).toBe('waiting');
172
+ expect(historyAtComplete).toContain('Recheck after the build settles');
173
+ expect((await harness.command('loop', `stop ${loopId}`))?.success).toBe(true);
174
+ expect(harness.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId)?.phase).toBe('stopped');
175
+ expect((await harness.command('loop', 'list'))?.message).toBe('No active loops.');
176
+ });
177
+
178
+ it('restores one future self-paced wake with a new timer id, without replaying the turn', async () => {
179
+ harness = scriptedSession({
180
+ turns: [{ text: 'Still waiting.' }],
181
+ commandModules: [createScheduleCommandModule()],
182
+ backgroundTasks: true,
183
+ persistence: true,
184
+ });
185
+ const created = await harness.command('loop', 'check the build');
186
+ const loopId = (created?.data as { loopId: string }).loopId;
187
+ await vi.waitFor(() =>
188
+ expect(harness?.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId)?.phase)
189
+ .toBe('waiting'),
190
+ );
191
+ const firstTimer = harness.session.listSchedules().find(
192
+ (task) => task.metadata?.['sessionLoopId'] === loopId,
193
+ )!;
194
+ const sessionId = harness.session.getSession().getSessionId();
195
+ await harness.session.shutdown({ reason: 'other' });
196
+
197
+ const resumed = scriptedSession({
198
+ turns: [],
199
+ cwd: harness.cwd,
200
+ resumeSessionId: sessionId,
201
+ backgroundTasks: true,
202
+ persistence: true,
203
+ });
204
+ try {
205
+ await vi.waitFor(() =>
206
+ expect(resumed.session.listSchedules().some(
207
+ (task) => task.metadata?.['sessionLoopId'] === loopId && task.id !== firstTimer.id,
208
+ )).toBe(true),
209
+ );
210
+ expect(resumed.requests).toHaveLength(0);
211
+ expect(resumed.session.listSelfPacedLoops().find((loop) => loop.loopId === loopId)?.phase)
212
+ .toBe('waiting');
213
+ } finally {
214
+ await resumed.dispose();
215
+ }
216
+ });
217
+
218
+ it('does not honor a denied model reschedule call', async () => {
219
+ harness = scriptedSession({
220
+ turns: [
221
+ { toolCalls: [{ name: 'report_loop_decision', args: {
222
+ action: 'continue', delaySeconds: 60, reason: 'Model asked, host denied',
223
+ } }] },
224
+ { text: 'done' },
225
+ ],
226
+ commandModules: [createScheduleCommandModule()],
227
+ backgroundTasks: true,
228
+ persistence: true,
229
+ deniedTools: ['report_loop_decision'],
230
+ permissionMode: 'default',
231
+ });
232
+ const created = await harness.command('loop', 'check');
233
+ expect(created?.success).toBe(true);
234
+ await vi.waitFor(() =>
235
+ expect(harness?.session.listSelfPacedLoops()[0]?.phase).toBe('waiting'),
236
+ );
237
+ expect(harness.session.listSelfPacedLoops()[0]).toMatchObject({
238
+ delaySeconds: 1200,
239
+ fallbackUsed: true,
240
+ });
241
+ });
242
+
243
+ it('runs only one fallback wake after omitted decisions, then stops', async () => {
244
+ harness = scriptedSession({
245
+ turns: [{ text: 'First check.' }, { text: 'Second check.' }],
246
+ commandModules: [createScheduleCommandModule()],
247
+ backgroundTasks: true,
248
+ persistence: true,
249
+ });
250
+ const created = await harness.command('loop', 'check');
251
+ const loopId = (created?.data as { loopId: string }).loopId;
252
+ await vi.waitFor(() =>
253
+ expect(harness?.session.listSelfPacedLoops()[0]).toMatchObject({
254
+ phase: 'waiting', generation: 1, fallbackUsed: true,
255
+ }),
256
+ );
257
+ const timer = harness.session.listSchedules().find(
258
+ (task) => task.metadata?.['sessionLoopId'] === loopId,
259
+ )!;
260
+ const nextAllowedAt = harness.session.listSelfPacedLoops()[0]!.nextAllowedAt!;
261
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(nextAllowedAt) + 1);
262
+ try {
263
+ expect(await harness.wake('check', timer.id)).not.toBeNull();
264
+ } finally {
265
+ clock.mockRestore();
266
+ }
267
+ await vi.waitFor(() =>
268
+ expect(harness?.session.listSelfPacedLoops()[0]).toMatchObject({
269
+ phase: 'stopped', terminalReason: 'missing-reschedule-decision',
270
+ }),
271
+ );
272
+ expect(harness.requests).toHaveLength(2);
273
+ });
274
+
275
+ it('allows the model to stop its own loop without arming a timer', async () => {
276
+ harness = scriptedSession({
277
+ turns: [
278
+ { toolCalls: [{ name: 'report_loop_decision', args: { action: 'stop' } }] },
279
+ { text: 'Done checking.' },
280
+ ],
281
+ commandModules: [createScheduleCommandModule()],
282
+ backgroundTasks: true,
283
+ persistence: true,
284
+ });
285
+ await harness.command('loop', 'check');
286
+ await vi.waitFor(() =>
287
+ expect(harness?.session.listSelfPacedLoops()[0]).toMatchObject({
288
+ phase: 'stopped', terminalReason: 'model-stopped',
289
+ }),
290
+ );
291
+ expect(harness.session.listSchedules().filter((task) => task.metadata?.['sessionLoopSelfPaced']))
292
+ .toHaveLength(0);
293
+ });
294
+
295
+ it('uses the host maintenance prompt, persists expiry, and refuses a wake after expiry', async () => {
296
+ const maintenancePrompt = 'Check the current work only.';
297
+ harness = scriptedSession({
298
+ turns: [{ text: 'unused' }],
299
+ commandModules: [createScheduleCommandModule({ defaultPrompt: maintenancePrompt })],
300
+ backgroundTasks: true,
301
+ persistence: true,
302
+ });
303
+
304
+ const created = await harness.command('loop', '5m');
305
+ expect(created?.success).toBe(true);
306
+ const { taskId, expiresAt, firstAllowedAt } = created?.data as {
307
+ taskId: string;
308
+ expiresAt: string;
309
+ firstAllowedAt: string;
310
+ };
311
+ const scheduled = harness.session.listSchedules().find((task) => task.id === taskId);
312
+ expect(scheduled?.kind === 'scheduled' ? scheduled.schedule?.agentInstruction : undefined).toBe(
313
+ maintenancePrompt,
314
+ );
315
+ expect(scheduled?.metadata?.['sessionLoopExpiresAt']).toBe(expiresAt);
316
+ expect(scheduled?.metadata?.['sessionLoopFirstAllowedAt']).toBe(firstAllowedAt);
317
+
318
+ const earlyClock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(firstAllowedAt) - 1);
319
+ try {
320
+ expect(await harness.wake(maintenancePrompt, taskId)).toBeNull();
321
+ expect(harness.requests).toHaveLength(0);
322
+ } finally {
323
+ earlyClock.mockRestore();
324
+ }
325
+
326
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(expiresAt) + 1);
327
+ try {
328
+ expect(await harness.wake(maintenancePrompt, taskId)).toBeNull();
329
+ await vi.waitFor(() =>
330
+ expect(harness?.session.listSchedules().find((task) => task.id === taskId)?.status).toBe(
331
+ 'cancelled',
332
+ ),
333
+ );
334
+ expect(harness.requests).toHaveLength(0);
335
+ } finally {
336
+ clock.mockRestore();
337
+ }
338
+ });
339
+
340
+ it('creates, lists, and stops one scheduled wake without cancelling another schedule', async () => {
341
+ harness = scriptedSession({
342
+ turns: [{ text: 'unused' }],
343
+ commandModules: [createScheduleCommandModule()],
344
+ backgroundTasks: true,
345
+ persistence: true,
346
+ });
347
+
348
+ const other = await harness.command('schedule', 'cron "0 0 * * *" unrelated check');
349
+ expect(other?.success).toBe(true);
350
+
351
+ const created = await harness.command('loop', '1h check the build');
352
+ expect(created?.success).toBe(true);
353
+ const { loopId, taskId, firstAllowedAt } = created?.data as {
354
+ loopId: string;
355
+ taskId: string;
356
+ firstAllowedAt: string;
357
+ };
358
+ expect(loopId).not.toBe(taskId);
359
+ expect(created?.message).toContain(`Stop with /loop stop ${loopId}`);
360
+
361
+ const listed = await harness.command('loop', 'list');
362
+ expect(listed?.message).toContain(loopId);
363
+ expect(listed?.message).not.toContain('unrelated check');
364
+
365
+ const eligibleClock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(firstAllowedAt) + 1);
366
+ const fired = await harness
367
+ .wake('check the build', taskId)
368
+ .finally(() => eligibleClock.mockRestore());
369
+ expect(fired).not.toBeNull();
370
+ expect(harness.requests.length).toBe(1);
371
+ await new Promise<void>((resolve) => setImmediate(resolve));
372
+
373
+ const edited = await harness.command(
374
+ 'schedule',
375
+ `edit ${taskId} cron "0 0 * * *" renamed check`,
376
+ );
377
+ expect(edited?.success).toBe(true);
378
+ expect((await harness.command('loop', 'list'))?.message).toContain(loopId);
379
+
380
+ const stopped = await harness.command('loop', `stop ${loopId}`);
381
+ expect(stopped?.message).toContain('Loop stopped:');
382
+ expect(await harness.wake('check the build', taskId)).toBeNull();
383
+ expect((await harness.command('loop', 'list'))?.message).toBe('No active loops.');
384
+ expect((await harness.command('schedule', 'list'))?.message).toContain('unrelated check');
385
+ }, 20_000);
386
+ });