opencode-dux 1.0.0

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 (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/agents/descriptions.d.ts +6 -0
  4. package/dist/agents/designer.d.ts +2 -0
  5. package/dist/agents/explorer.d.ts +2 -0
  6. package/dist/agents/fixer.d.ts +2 -0
  7. package/dist/agents/index.d.ts +22 -0
  8. package/dist/agents/interpreter.d.ts +2 -0
  9. package/dist/agents/librarian.d.ts +2 -0
  10. package/dist/agents/oracle.d.ts +2 -0
  11. package/dist/agents/orchestrator.d.ts +27 -0
  12. package/dist/agents/overrides.d.ts +18 -0
  13. package/dist/agents/prompt-blocks.d.ts +97 -0
  14. package/dist/agents/steward.d.ts +3 -0
  15. package/dist/cli/config-io.d.ts +24 -0
  16. package/dist/cli/config-manager.d.ts +4 -0
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +1006 -0
  19. package/dist/cli/install.d.ts +2 -0
  20. package/dist/cli/mcps.d.ts +13 -0
  21. package/dist/cli/model-key-normalization.d.ts +1 -0
  22. package/dist/cli/paths.d.ts +35 -0
  23. package/dist/cli/providers.d.ts +137 -0
  24. package/dist/cli/skills.d.ts +22 -0
  25. package/dist/cli/system.d.ts +5 -0
  26. package/dist/cli/types.d.ts +38 -0
  27. package/dist/config/constants.d.ts +12 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/loader.d.ts +40 -0
  30. package/dist/config/runtime-preset.d.ts +12 -0
  31. package/dist/config/schema.d.ts +281 -0
  32. package/dist/config/utils.d.ts +10 -0
  33. package/dist/discovery/local/types.d.ts +79 -0
  34. package/dist/discovery/local.d.ts +73 -0
  35. package/dist/discovery/mcp-servers.d.ts +88 -0
  36. package/dist/discovery/skills.d.ts +94 -0
  37. package/dist/hooks/apply-patch/codec.d.ts +7 -0
  38. package/dist/hooks/apply-patch/errors.d.ts +25 -0
  39. package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
  40. package/dist/hooks/apply-patch/index.d.ts +15 -0
  41. package/dist/hooks/apply-patch/matching.d.ts +26 -0
  42. package/dist/hooks/apply-patch/operations.d.ts +3 -0
  43. package/dist/hooks/apply-patch/patch.d.ts +2 -0
  44. package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
  45. package/dist/hooks/apply-patch/resolution.d.ts +19 -0
  46. package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
  47. package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
  48. package/dist/hooks/apply-patch/types.d.ts +80 -0
  49. package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
  50. package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
  51. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  52. package/dist/hooks/auto-update-checker/index.d.ts +18 -0
  53. package/dist/hooks/auto-update-checker/types.d.ts +22 -0
  54. package/dist/hooks/chat-headers.d.ts +16 -0
  55. package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
  56. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  57. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  58. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  59. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  60. package/dist/hooks/filter-available-skills/index.d.ts +32 -0
  61. package/dist/hooks/foreground-fallback/index.d.ts +72 -0
  62. package/dist/hooks/image-hook.d.ts +5 -0
  63. package/dist/hooks/index.d.ts +14 -0
  64. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  65. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  66. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  67. package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
  68. package/dist/hooks/task-session-manager/index.d.ts +52 -0
  69. package/dist/hooks/todo-continuation/index.d.ts +53 -0
  70. package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
  71. package/dist/index.d.ts +5 -0
  72. package/dist/index.js +31782 -0
  73. package/dist/mcp/context7.d.ts +6 -0
  74. package/dist/mcp/grep-app.d.ts +6 -0
  75. package/dist/mcp/index.d.ts +13 -0
  76. package/dist/mcp/types.d.ts +12 -0
  77. package/dist/mcp/websearch.d.ts +9 -0
  78. package/dist/skills/registry.d.ts +29 -0
  79. package/dist/subscriptions/accounts-store.d.ts +57 -0
  80. package/dist/subscriptions/index.d.ts +13 -0
  81. package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
  82. package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
  83. package/dist/subscriptions/types.d.ts +115 -0
  84. package/dist/subscriptions/usage-service.d.ts +74 -0
  85. package/dist/tools/ast-grep/cli.d.ts +15 -0
  86. package/dist/tools/ast-grep/constants.d.ts +25 -0
  87. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  88. package/dist/tools/ast-grep/index.d.ts +10 -0
  89. package/dist/tools/ast-grep/tools.d.ts +3 -0
  90. package/dist/tools/ast-grep/types.d.ts +30 -0
  91. package/dist/tools/ast-grep/utils.d.ts +4 -0
  92. package/dist/tools/delegate.d.ts +14 -0
  93. package/dist/tools/index.d.ts +5 -0
  94. package/dist/tools/preset-manager.d.ts +27 -0
  95. package/dist/tools/smartfetch/binary.d.ts +3 -0
  96. package/dist/tools/smartfetch/cache.d.ts +6 -0
  97. package/dist/tools/smartfetch/constants.d.ts +12 -0
  98. package/dist/tools/smartfetch/index.d.ts +3 -0
  99. package/dist/tools/smartfetch/network.d.ts +38 -0
  100. package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
  101. package/dist/tools/smartfetch/tool.d.ts +3 -0
  102. package/dist/tools/smartfetch/types.d.ts +122 -0
  103. package/dist/tools/smartfetch/utils.d.ts +18 -0
  104. package/dist/tui-state.d.ts +168 -0
  105. package/dist/tui.d.ts +37 -0
  106. package/dist/tui.js +1896 -0
  107. package/dist/utils/agent-variant.d.ts +63 -0
  108. package/dist/utils/compat.d.ts +30 -0
  109. package/dist/utils/env.d.ts +1 -0
  110. package/dist/utils/index.d.ts +9 -0
  111. package/dist/utils/internal-initiator.d.ts +6 -0
  112. package/dist/utils/logger.d.ts +8 -0
  113. package/dist/utils/polling.d.ts +21 -0
  114. package/dist/utils/session-manager.d.ts +55 -0
  115. package/dist/utils/session.d.ts +90 -0
  116. package/dist/utils/subagent-depth.d.ts +35 -0
  117. package/dist/utils/system-collapse.d.ts +6 -0
  118. package/dist/utils/task.d.ts +4 -0
  119. package/dist/utils/zip-extractor.d.ts +1 -0
  120. package/index.ts +1 -0
  121. package/opencode-dux.schema.json +634 -0
  122. package/package.json +103 -0
  123. package/src/agents/descriptions.ts +55 -0
  124. package/src/agents/designer.test.ts +86 -0
  125. package/src/agents/designer.ts +154 -0
  126. package/src/agents/display-name.test.ts +186 -0
  127. package/src/agents/explorer.test.ts +79 -0
  128. package/src/agents/explorer.ts +144 -0
  129. package/src/agents/fixer.test.ts +79 -0
  130. package/src/agents/fixer.ts +145 -0
  131. package/src/agents/index.test.ts +472 -0
  132. package/src/agents/index.ts +248 -0
  133. package/src/agents/interpreter.ts +136 -0
  134. package/src/agents/librarian.test.ts +80 -0
  135. package/src/agents/librarian.ts +145 -0
  136. package/src/agents/oracle.test.ts +89 -0
  137. package/src/agents/oracle.ts +184 -0
  138. package/src/agents/orchestrator.test.ts +116 -0
  139. package/src/agents/orchestrator.ts +574 -0
  140. package/src/agents/overrides.ts +95 -0
  141. package/src/agents/prompt-blocks.test.ts +114 -0
  142. package/src/agents/prompt-blocks.ts +640 -0
  143. package/src/agents/steward.ts +146 -0
  144. package/src/cli/config-io.test.ts +536 -0
  145. package/src/cli/config-io.ts +473 -0
  146. package/src/cli/config-manager.test.ts +141 -0
  147. package/src/cli/config-manager.ts +4 -0
  148. package/src/cli/index.ts +88 -0
  149. package/src/cli/install.ts +282 -0
  150. package/src/cli/mcps.test.ts +62 -0
  151. package/src/cli/mcps.ts +39 -0
  152. package/src/cli/model-key-normalization.test.ts +21 -0
  153. package/src/cli/model-key-normalization.ts +60 -0
  154. package/src/cli/paths.test.ts +167 -0
  155. package/src/cli/paths.ts +144 -0
  156. package/src/cli/providers.test.ts +118 -0
  157. package/src/cli/providers.ts +141 -0
  158. package/src/cli/skills.test.ts +111 -0
  159. package/src/cli/skills.ts +103 -0
  160. package/src/cli/system.test.ts +91 -0
  161. package/src/cli/system.ts +180 -0
  162. package/src/cli/types.ts +43 -0
  163. package/src/config/constants.ts +58 -0
  164. package/src/config/index.ts +4 -0
  165. package/src/config/loader.test.ts +1194 -0
  166. package/src/config/loader.ts +269 -0
  167. package/src/config/model-resolution.test.ts +176 -0
  168. package/src/config/runtime-preset.test.ts +61 -0
  169. package/src/config/runtime-preset.ts +37 -0
  170. package/src/config/schema.ts +248 -0
  171. package/src/config/utils.test.ts +41 -0
  172. package/src/config/utils.ts +23 -0
  173. package/src/discovery/local/types.ts +85 -0
  174. package/src/discovery/local.ts +322 -0
  175. package/src/discovery/mcp-servers.ts +804 -0
  176. package/src/discovery/skills.ts +959 -0
  177. package/src/hooks/apply-patch/codec.test.ts +184 -0
  178. package/src/hooks/apply-patch/codec.ts +352 -0
  179. package/src/hooks/apply-patch/errors.ts +117 -0
  180. package/src/hooks/apply-patch/execution-context.ts +432 -0
  181. package/src/hooks/apply-patch/hook.test.ts +768 -0
  182. package/src/hooks/apply-patch/index.ts +126 -0
  183. package/src/hooks/apply-patch/matching.test.ts +215 -0
  184. package/src/hooks/apply-patch/matching.ts +586 -0
  185. package/src/hooks/apply-patch/operations.test.ts +1535 -0
  186. package/src/hooks/apply-patch/operations.ts +3 -0
  187. package/src/hooks/apply-patch/patch.ts +9 -0
  188. package/src/hooks/apply-patch/prepared-changes.ts +400 -0
  189. package/src/hooks/apply-patch/resolution.test.ts +420 -0
  190. package/src/hooks/apply-patch/resolution.ts +437 -0
  191. package/src/hooks/apply-patch/rewrite.ts +496 -0
  192. package/src/hooks/apply-patch/test-helpers.ts +52 -0
  193. package/src/hooks/apply-patch/types.ts +111 -0
  194. package/src/hooks/auto-update-checker/cache.test.ts +179 -0
  195. package/src/hooks/auto-update-checker/cache.ts +188 -0
  196. package/src/hooks/auto-update-checker/checker.test.ts +159 -0
  197. package/src/hooks/auto-update-checker/checker.ts +308 -0
  198. package/src/hooks/auto-update-checker/constants.ts +33 -0
  199. package/src/hooks/auto-update-checker/index.test.ts +282 -0
  200. package/src/hooks/auto-update-checker/index.ts +225 -0
  201. package/src/hooks/auto-update-checker/types.ts +26 -0
  202. package/src/hooks/chat-headers.test.ts +236 -0
  203. package/src/hooks/chat-headers.ts +97 -0
  204. package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
  205. package/src/hooks/context-pressure-reminder/index.ts +137 -0
  206. package/src/hooks/delegate-task-retry/guidance.ts +41 -0
  207. package/src/hooks/delegate-task-retry/hook.ts +23 -0
  208. package/src/hooks/delegate-task-retry/index.test.ts +38 -0
  209. package/src/hooks/delegate-task-retry/index.ts +7 -0
  210. package/src/hooks/delegate-task-retry/patterns.ts +79 -0
  211. package/src/hooks/filter-available-skills/index.test.ts +297 -0
  212. package/src/hooks/filter-available-skills/index.ts +160 -0
  213. package/src/hooks/foreground-fallback/index.test.ts +624 -0
  214. package/src/hooks/foreground-fallback/index.ts +374 -0
  215. package/src/hooks/image-hook.ts +6 -0
  216. package/src/hooks/index.ts +17 -0
  217. package/src/hooks/json-error-recovery/hook.ts +73 -0
  218. package/src/hooks/json-error-recovery/index.test.ts +111 -0
  219. package/src/hooks/json-error-recovery/index.ts +6 -0
  220. package/src/hooks/phase-reminder/index.test.ts +74 -0
  221. package/src/hooks/phase-reminder/index.ts +85 -0
  222. package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
  223. package/src/hooks/post-file-tool-nudge/index.ts +63 -0
  224. package/src/hooks/task-session-manager/index.test.ts +833 -0
  225. package/src/hooks/task-session-manager/index.ts +434 -0
  226. package/src/hooks/todo-continuation/index.test.ts +3026 -0
  227. package/src/hooks/todo-continuation/index.ts +878 -0
  228. package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
  229. package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
  230. package/src/index.ts +1672 -0
  231. package/src/mcp/context7.ts +14 -0
  232. package/src/mcp/grep-app.ts +11 -0
  233. package/src/mcp/index.test.ts +96 -0
  234. package/src/mcp/index.ts +66 -0
  235. package/src/mcp/types.ts +16 -0
  236. package/src/mcp/websearch.ts +47 -0
  237. package/src/skills/codemap/README.md +60 -0
  238. package/src/skills/codemap/SKILL.md +174 -0
  239. package/src/skills/codemap/scripts/codemap.mjs +483 -0
  240. package/src/skills/codemap/scripts/codemap.test.ts +129 -0
  241. package/src/skills/registry.ts +218 -0
  242. package/src/skills/simplify/README.md +19 -0
  243. package/src/skills/simplify/SKILL.md +138 -0
  244. package/src/subscriptions/accounts-store.test.ts +236 -0
  245. package/src/subscriptions/accounts-store.ts +184 -0
  246. package/src/subscriptions/index.ts +30 -0
  247. package/src/subscriptions/neuralwatt-scraper.ts +108 -0
  248. package/src/subscriptions/opencode-go-scraper.ts +301 -0
  249. package/src/subscriptions/types.ts +145 -0
  250. package/src/subscriptions/usage-service.test.ts +202 -0
  251. package/src/subscriptions/usage-service.ts +651 -0
  252. package/src/tools/ast-grep/cli.ts +257 -0
  253. package/src/tools/ast-grep/constants.ts +214 -0
  254. package/src/tools/ast-grep/downloader.ts +131 -0
  255. package/src/tools/ast-grep/index.ts +24 -0
  256. package/src/tools/ast-grep/tools.ts +117 -0
  257. package/src/tools/ast-grep/types.ts +51 -0
  258. package/src/tools/ast-grep/utils.ts +126 -0
  259. package/src/tools/delegate-handoff.test.ts +18 -0
  260. package/src/tools/delegate.ts +508 -0
  261. package/src/tools/index.ts +8 -0
  262. package/src/tools/preset-manager.test.ts +795 -0
  263. package/src/tools/preset-manager.ts +332 -0
  264. package/src/tools/smartfetch/binary.ts +58 -0
  265. package/src/tools/smartfetch/cache.test.ts +34 -0
  266. package/src/tools/smartfetch/cache.ts +112 -0
  267. package/src/tools/smartfetch/constants.ts +29 -0
  268. package/src/tools/smartfetch/index.ts +8 -0
  269. package/src/tools/smartfetch/network.test.ts +178 -0
  270. package/src/tools/smartfetch/network.ts +614 -0
  271. package/src/tools/smartfetch/secondary-model.test.ts +85 -0
  272. package/src/tools/smartfetch/secondary-model.ts +276 -0
  273. package/src/tools/smartfetch/tool.test.ts +60 -0
  274. package/src/tools/smartfetch/tool.ts +832 -0
  275. package/src/tools/smartfetch/types.ts +135 -0
  276. package/src/tools/smartfetch/utils.test.ts +24 -0
  277. package/src/tools/smartfetch/utils.ts +456 -0
  278. package/src/tui-state.test.ts +867 -0
  279. package/src/tui-state.ts +1255 -0
  280. package/src/tui.test.ts +336 -0
  281. package/src/tui.ts +1539 -0
  282. package/src/utils/agent-variant.test.ts +244 -0
  283. package/src/utils/agent-variant.ts +187 -0
  284. package/src/utils/compat.ts +91 -0
  285. package/src/utils/env.ts +12 -0
  286. package/src/utils/index.ts +9 -0
  287. package/src/utils/internal-initiator.ts +28 -0
  288. package/src/utils/logger.test.ts +220 -0
  289. package/src/utils/logger.ts +136 -0
  290. package/src/utils/polling.test.ts +191 -0
  291. package/src/utils/polling.ts +67 -0
  292. package/src/utils/session-manager.test.ts +173 -0
  293. package/src/utils/session-manager.ts +356 -0
  294. package/src/utils/session.test.ts +110 -0
  295. package/src/utils/session.ts +389 -0
  296. package/src/utils/subagent-depth.test.ts +170 -0
  297. package/src/utils/subagent-depth.ts +75 -0
  298. package/src/utils/system-collapse.test.ts +86 -0
  299. package/src/utils/system-collapse.ts +24 -0
  300. package/src/utils/task.test.ts +24 -0
  301. package/src/utils/task.ts +20 -0
  302. package/src/utils/zip-extractor.ts +102 -0
@@ -0,0 +1,3026 @@
1
+ import { describe, expect, mock, test } from 'bun:test';
2
+ import { SLIM_INTERNAL_INITIATOR_MARKER } from '../../utils';
3
+ import { createTodoContinuationHook } from './index';
4
+ import {
5
+ TODO_FINAL_ACTIVE_REMINDER,
6
+ TODO_HYGIENE_REMINDER,
7
+ } from './todo-hygiene';
8
+
9
+ describe('createTodoContinuationHook', () => {
10
+ function createMockContext(overrides?: {
11
+ todoResult?: {
12
+ data?: Array<{
13
+ id: string;
14
+ content: string;
15
+ status: string;
16
+ priority: string;
17
+ }>;
18
+ };
19
+ messagesResult?: {
20
+ data?: Array<{
21
+ info?: { role?: string };
22
+ parts?: Array<{ type?: string; text?: string }>;
23
+ }>;
24
+ };
25
+ }) {
26
+ return {
27
+ client: {
28
+ session: {
29
+ todo: mock(async () => overrides?.todoResult ?? { data: [] }),
30
+ messages: mock(async () => overrides?.messagesResult ?? { data: [] }),
31
+ prompt: mock(async () => ({})),
32
+ },
33
+ },
34
+ } as any;
35
+ }
36
+
37
+ async function delay(ms: number): Promise<void> {
38
+ await new Promise((resolve) => setTimeout(resolve, ms));
39
+ }
40
+
41
+ // Notification prompts (noReply:true, no marker) fire immediately when
42
+ // scheduling a continuation. These helpers check only for actual
43
+ // continuation prompts (with SLIM_INTERNAL_INITIATOR_MARKER).
44
+ function hasContinuation(m: ReturnType<typeof mock>): boolean {
45
+ return m.mock.calls.some((c: any[]) =>
46
+ (c[0]?.body?.parts as any[])?.some((p: any) =>
47
+ p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
48
+ ),
49
+ );
50
+ }
51
+ function contCount(m: ReturnType<typeof mock>): number {
52
+ return m.mock.calls.filter((c: any[]) =>
53
+ (c[0]?.body?.parts as any[])?.some((p: any) =>
54
+ p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
55
+ ),
56
+ ).length;
57
+ }
58
+ function contCall(m: ReturnType<typeof mock>): any[] {
59
+ const call = m.mock.calls.find((c: any[]) =>
60
+ (c[0]?.body?.parts as any[])?.some((p: any) =>
61
+ p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
62
+ ),
63
+ );
64
+ if (!call) {
65
+ throw new Error('No continuation call found');
66
+ }
67
+ return call;
68
+ }
69
+
70
+ function userMessages(
71
+ text: string,
72
+ sessionID = 'main1',
73
+ agent?: string,
74
+ parts?: Array<{ type: string; text?: string }>,
75
+ id?: string,
76
+ ) {
77
+ return {
78
+ messages: [
79
+ {
80
+ info: { id, role: 'user', agent, sessionID },
81
+ parts: parts ?? [{ type: 'text', text }],
82
+ },
83
+ ],
84
+ };
85
+ }
86
+
87
+ function allMessageText(output: {
88
+ messages: Array<{ parts: Array<{ type?: string; text?: string }> }>;
89
+ }) {
90
+ return output.messages
91
+ .flatMap((message) => message.parts)
92
+ .filter((part) => part.type === 'text' && typeof part.text === 'string')
93
+ .map((part) => part.text)
94
+ .join('\n');
95
+ }
96
+
97
+ describe('tool toggle', () => {
98
+ test('calling auto_continue execute with { enabled: true } sets state', async () => {
99
+ const ctx = createMockContext();
100
+ const hook = createTodoContinuationHook(ctx);
101
+
102
+ const result = await hook.tool.auto_continue.execute({ enabled: true });
103
+
104
+ expect(result).toContain('Auto-continue enabled');
105
+ expect(result).toContain('up to 5');
106
+ });
107
+
108
+ test('calling auto_continue execute with { enabled: false } disables', async () => {
109
+ const ctx = createMockContext();
110
+ const hook = createTodoContinuationHook(ctx);
111
+
112
+ const result = await hook.tool.auto_continue.execute({ enabled: false });
113
+
114
+ expect(result).toBe('Auto-continue disabled.');
115
+ });
116
+ });
117
+
118
+ describe('todo hygiene routing', () => {
119
+ test('does not inject hygiene reminder for unknown non-orchestrator session', async () => {
120
+ const ctx = createMockContext({
121
+ todoResult: {
122
+ data: [
123
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
124
+ ],
125
+ },
126
+ });
127
+ const hook = createTodoContinuationHook(ctx);
128
+ const toolOutput = { output: 'task result' };
129
+
130
+ await hook.handleMessagesTransform(
131
+ userMessages('continue previous work', 'sub1', 'explorer'),
132
+ );
133
+ await hook.handleToolExecuteAfter(
134
+ { tool: 'task', sessionID: 'sub1' },
135
+ toolOutput,
136
+ );
137
+
138
+ expect(toolOutput.output).toBe('task result');
139
+ expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
140
+ });
141
+
142
+ test('does not expose a system transform handler', async () => {
143
+ const ctx = createMockContext({
144
+ todoResult: {
145
+ data: [
146
+ {
147
+ id: '1',
148
+ content: 'todo1',
149
+ status: 'in_progress',
150
+ priority: 'high',
151
+ },
152
+ ],
153
+ },
154
+ });
155
+ const hook = createTodoContinuationHook(ctx);
156
+
157
+ expect('handleChatSystemTransform' in hook).toBe(false);
158
+ });
159
+
160
+ test('injects hygiene reminder into latest user message after todowrite activity', async () => {
161
+ const ctx = createMockContext({
162
+ todoResult: {
163
+ data: [
164
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
165
+ ],
166
+ },
167
+ });
168
+ const hook = createTodoContinuationHook(ctx);
169
+ const output = userMessages('primera request', 'main1', 'orchestrator');
170
+ const toolOutput = { output: 'read result' };
171
+
172
+ await hook.handleMessagesTransform(output);
173
+ await hook.handleToolExecuteAfter({
174
+ tool: 'todowrite',
175
+ sessionID: 'main1',
176
+ });
177
+ await hook.handleToolExecuteAfter(
178
+ { tool: 'read', sessionID: 'main1' },
179
+ toolOutput,
180
+ );
181
+ await hook.handleMessagesTransform(output);
182
+
183
+ expect(toolOutput.output).toBe('read result');
184
+ expect(allMessageText(output)).toContain(TODO_HYGIENE_REMINDER);
185
+ expect(allMessageText(output)).toContain(
186
+ '<instruction name="todo_hygiene">',
187
+ );
188
+ });
189
+
190
+ test('compaction-like transform does not consume pending reminder', async () => {
191
+ const ctx = createMockContext({
192
+ todoResult: {
193
+ data: [
194
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
195
+ ],
196
+ },
197
+ });
198
+ const hook = createTodoContinuationHook(ctx);
199
+ const live = userMessages('primera request', 'main1', 'orchestrator');
200
+ const compactionClone = structuredClone(live);
201
+
202
+ await hook.handleMessagesTransform(live);
203
+ await hook.handleToolExecuteAfter({
204
+ tool: 'todowrite',
205
+ sessionID: 'main1',
206
+ });
207
+ await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
208
+
209
+ await hook.handleMessagesTransform(compactionClone);
210
+ expect(allMessageText(compactionClone)).toContain(TODO_HYGIENE_REMINDER);
211
+
212
+ await hook.handleMessagesTransform(live);
213
+ expect(allMessageText(live)).toContain(TODO_HYGIENE_REMINDER);
214
+ });
215
+
216
+ test('new request clears stale pending reminder state', async () => {
217
+ const ctx = createMockContext({
218
+ todoResult: {
219
+ data: [
220
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
221
+ ],
222
+ },
223
+ });
224
+ const hook = createTodoContinuationHook(ctx);
225
+ const first = userMessages('primera request', 'main1', 'orchestrator');
226
+ const blocked = userMessages(
227
+ 'segunda request distinta',
228
+ 'main1',
229
+ 'orchestrator',
230
+ );
231
+ const allowed = userMessages(
232
+ 'segunda request distinta',
233
+ 'main1',
234
+ 'orchestrator',
235
+ );
236
+
237
+ await hook.handleMessagesTransform(first);
238
+ await hook.handleToolExecuteAfter({
239
+ tool: 'todowrite',
240
+ sessionID: 'main1',
241
+ });
242
+ await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
243
+
244
+ await hook.handleMessagesTransform(blocked);
245
+ expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
246
+
247
+ await hook.handleToolExecuteAfter({
248
+ tool: 'todowrite',
249
+ sessionID: 'main1',
250
+ });
251
+ await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
252
+ await hook.handleMessagesTransform(allowed);
253
+
254
+ expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
255
+ });
256
+
257
+ test('attachment-only requests reset stale state without synthetic text parts', async () => {
258
+ const ctx = createMockContext({
259
+ todoResult: {
260
+ data: [
261
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
262
+ ],
263
+ },
264
+ });
265
+ const hook = createTodoContinuationHook(ctx);
266
+ const first = userMessages('primera request', 'main1', 'orchestrator');
267
+ const attachmentOnly = userMessages('', 'main1', 'orchestrator', [
268
+ { type: 'image' },
269
+ ]);
270
+
271
+ await hook.handleMessagesTransform(first);
272
+ await hook.handleToolExecuteAfter({
273
+ tool: 'todowrite',
274
+ sessionID: 'main1',
275
+ });
276
+ await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
277
+
278
+ await hook.handleMessagesTransform(attachmentOnly);
279
+
280
+ expect(attachmentOnly.messages[0].parts).toHaveLength(1);
281
+ expect(allMessageText(attachmentOnly)).not.toContain(
282
+ TODO_HYGIENE_REMINDER,
283
+ );
284
+ });
285
+
286
+ test('falls back to known orchestrator session when transform message lacks sessionID', async () => {
287
+ const ctx = createMockContext({
288
+ todoResult: {
289
+ data: [
290
+ {
291
+ id: '1',
292
+ content: 'todo1',
293
+ status: 'in_progress',
294
+ priority: 'high',
295
+ },
296
+ ],
297
+ },
298
+ });
299
+ const hook = createTodoContinuationHook(ctx);
300
+ const output = {
301
+ messages: [
302
+ {
303
+ info: { role: 'user', agent: 'orchestrator' },
304
+ parts: [{ type: 'text', text: 'new request boundary' }],
305
+ },
306
+ ],
307
+ };
308
+
309
+ hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
310
+ await hook.handleMessagesTransform(output);
311
+ await hook.handleToolExecuteAfter({
312
+ tool: 'todowrite',
313
+ sessionID: 'main1',
314
+ });
315
+ await hook.handleMessagesTransform(output);
316
+
317
+ expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
318
+ });
319
+
320
+ test('does not promote sessions with missing agent metadata to orchestrator', async () => {
321
+ const ctx = createMockContext({
322
+ todoResult: {
323
+ data: [
324
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
325
+ ],
326
+ },
327
+ });
328
+ const hook = createTodoContinuationHook(ctx);
329
+ const toolOutput = { output: 'task result' };
330
+
331
+ await hook.handleMessagesTransform(
332
+ userMessages('continue previous work', 'sub1'),
333
+ );
334
+ await hook.handleToolExecuteAfter(
335
+ { tool: 'task', sessionID: 'sub1' },
336
+ toolOutput,
337
+ );
338
+
339
+ expect(toolOutput.output).toBe('task result');
340
+ expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
341
+ expect(toolOutput.output).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
342
+ });
343
+
344
+ test('known orchestrator sessions still process request boundaries when agent metadata is missing', async () => {
345
+ const ctx = createMockContext({
346
+ todoResult: {
347
+ data: [
348
+ {
349
+ id: '1',
350
+ content: 'todo1',
351
+ status: 'in_progress',
352
+ priority: 'high',
353
+ },
354
+ ],
355
+ },
356
+ });
357
+ const hook = createTodoContinuationHook(ctx);
358
+ const output = userMessages('new request boundary', 'main1');
359
+
360
+ hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
361
+ await hook.handleMessagesTransform(output);
362
+ await hook.handleToolExecuteAfter({
363
+ tool: 'todowrite',
364
+ sessionID: 'main1',
365
+ });
366
+ await hook.handleMessagesTransform(output);
367
+
368
+ expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
369
+ });
370
+
371
+ test('the same user message id consumes pending reminder even if array index shifts', async () => {
372
+ const ctx = createMockContext({
373
+ todoResult: {
374
+ data: [
375
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
376
+ ],
377
+ },
378
+ });
379
+ const hook = createTodoContinuationHook(ctx);
380
+ const shifted = {
381
+ messages: [
382
+ {
383
+ info: { role: 'assistant', sessionID: 'main1' },
384
+ parts: [{ type: 'text', text: 'intermediate output' }],
385
+ },
386
+ {
387
+ info: {
388
+ id: 'u1',
389
+ role: 'user',
390
+ agent: 'orchestrator',
391
+ sessionID: 'main1',
392
+ },
393
+ parts: [{ type: 'text', text: 'request boundary' }],
394
+ },
395
+ ],
396
+ };
397
+
398
+ await hook.handleMessagesTransform(
399
+ userMessages(
400
+ 'request boundary',
401
+ 'main1',
402
+ 'orchestrator',
403
+ undefined,
404
+ 'u1',
405
+ ),
406
+ );
407
+ await hook.handleToolExecuteAfter({
408
+ tool: 'todowrite',
409
+ sessionID: 'main1',
410
+ });
411
+ await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
412
+ await hook.handleMessagesTransform(shifted);
413
+
414
+ expect(allMessageText(shifted)).toContain(TODO_HYGIENE_REMINDER);
415
+ });
416
+
417
+ test('a new user message id resets the request even if text is unchanged', async () => {
418
+ const ctx = createMockContext({
419
+ todoResult: {
420
+ data: [
421
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
422
+ ],
423
+ },
424
+ });
425
+ const hook = createTodoContinuationHook(ctx);
426
+ const blocked = userMessages(
427
+ 'same text',
428
+ 'main1',
429
+ 'orchestrator',
430
+ undefined,
431
+ 'u2',
432
+ );
433
+ const allowed = userMessages(
434
+ 'same text',
435
+ 'main1',
436
+ 'orchestrator',
437
+ undefined,
438
+ 'u2',
439
+ );
440
+
441
+ await hook.handleMessagesTransform(
442
+ userMessages('same text', 'main1', 'orchestrator', undefined, 'u1'),
443
+ );
444
+ await hook.handleToolExecuteAfter({
445
+ tool: 'todowrite',
446
+ sessionID: 'main1',
447
+ });
448
+ await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
449
+
450
+ await hook.handleMessagesTransform(blocked);
451
+ expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
452
+
453
+ await hook.handleToolExecuteAfter({
454
+ tool: 'todowrite',
455
+ sessionID: 'main1',
456
+ });
457
+ await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
458
+ await hook.handleMessagesTransform(allowed);
459
+
460
+ expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
461
+ });
462
+
463
+ test('a repeated text without message ids resets when a later user turn appears', async () => {
464
+ const ctx = createMockContext({
465
+ todoResult: {
466
+ data: [
467
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
468
+ ],
469
+ },
470
+ });
471
+ const hook = createTodoContinuationHook(ctx);
472
+ const blocked = {
473
+ messages: [
474
+ {
475
+ info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
476
+ parts: [{ type: 'text', text: 'same text' }],
477
+ },
478
+ {
479
+ info: { role: 'assistant', sessionID: 'main1' },
480
+ parts: [{ type: 'text', text: 'intermediate output' }],
481
+ },
482
+ {
483
+ info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
484
+ parts: [{ type: 'text', text: 'same text' }],
485
+ },
486
+ ],
487
+ };
488
+ const allowed = structuredClone(blocked);
489
+
490
+ await hook.handleMessagesTransform(
491
+ userMessages('same text', 'main1', 'orchestrator'),
492
+ );
493
+ await hook.handleToolExecuteAfter({
494
+ tool: 'todowrite',
495
+ sessionID: 'main1',
496
+ });
497
+ await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
498
+
499
+ await hook.handleMessagesTransform(blocked);
500
+ expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
501
+
502
+ await hook.handleToolExecuteAfter({
503
+ tool: 'todowrite',
504
+ sessionID: 'main1',
505
+ });
506
+ await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
507
+ await hook.handleMessagesTransform(allowed);
508
+
509
+ expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
510
+ });
511
+
512
+ test('messages without inferable sessionID clear stale state for known orchestrators', async () => {
513
+ const ctx = createMockContext({
514
+ todoResult: {
515
+ data: [
516
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
517
+ ],
518
+ },
519
+ });
520
+ const hook = createTodoContinuationHook(ctx);
521
+ const unknown = {
522
+ messages: [
523
+ {
524
+ info: { role: 'user', agent: 'orchestrator' },
525
+ parts: [{ type: 'text', text: 'boundary without session id' }],
526
+ },
527
+ ],
528
+ };
529
+
530
+ hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
531
+ hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
532
+ await hook.handleMessagesTransform(
533
+ userMessages('first request', 'main1', 'orchestrator', undefined, 'u1'),
534
+ );
535
+ await hook.handleToolExecuteAfter({
536
+ tool: 'todowrite',
537
+ sessionID: 'main1',
538
+ });
539
+ await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
540
+
541
+ await hook.handleMessagesTransform(unknown);
542
+
543
+ expect(allMessageText(unknown)).not.toContain(TODO_HYGIENE_REMINDER);
544
+ });
545
+
546
+ test('does not inject from continuation-like wording alone', async () => {
547
+ const ctx = createMockContext({
548
+ todoResult: {
549
+ data: [
550
+ {
551
+ id: '1',
552
+ content: 'todo1',
553
+ status: 'in_progress',
554
+ priority: 'high',
555
+ },
556
+ ],
557
+ },
558
+ });
559
+ const hook = createTodoContinuationHook(ctx);
560
+ const toolOutput = { output: 'read result' };
561
+
562
+ await hook.handleMessagesTransform(
563
+ userMessages(
564
+ 'sigue este formato pero empieza de cero',
565
+ 'main1',
566
+ 'orchestrator',
567
+ ),
568
+ );
569
+ await hook.handleToolExecuteAfter(
570
+ { tool: 'read', sessionID: 'main1' },
571
+ toolOutput,
572
+ );
573
+
574
+ expect(toolOutput.output).toBe('read result');
575
+ expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
576
+ expect(toolOutput.output).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
577
+ });
578
+
579
+ test('rearms on activity after todowrite even if request wording is continuation-like', async () => {
580
+ const ctx = createMockContext({
581
+ todoResult: {
582
+ data: [
583
+ {
584
+ id: '1',
585
+ content: 'todo1',
586
+ status: 'in_progress',
587
+ priority: 'high',
588
+ },
589
+ ],
590
+ },
591
+ });
592
+ const hook = createTodoContinuationHook(ctx);
593
+ const output = userMessages(
594
+ 'finish the previous work',
595
+ 'main1',
596
+ 'orchestrator',
597
+ );
598
+
599
+ await hook.handleMessagesTransform(output);
600
+ await hook.handleToolExecuteAfter({
601
+ tool: 'todowrite',
602
+ sessionID: 'main1',
603
+ });
604
+ await hook.handleMessagesTransform(output);
605
+
606
+ expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
607
+ });
608
+
609
+ test('final active todo after todowrite uses the stronger finishing reminder', async () => {
610
+ const ctx = createMockContext({
611
+ todoResult: {
612
+ data: [
613
+ {
614
+ id: '1',
615
+ content: 'todo1',
616
+ status: 'in_progress',
617
+ priority: 'high',
618
+ },
619
+ ],
620
+ },
621
+ });
622
+ const hook = createTodoContinuationHook(ctx);
623
+ const output = userMessages('haz esto', 'main1', 'orchestrator');
624
+
625
+ await hook.handleMessagesTransform(output);
626
+ await hook.handleToolExecuteAfter({
627
+ tool: 'todowrite',
628
+ sessionID: 'main1',
629
+ });
630
+ await hook.handleMessagesTransform(output);
631
+
632
+ expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
633
+ expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
634
+ });
635
+ });
636
+
637
+ describe('continuation scheduling', () => {
638
+ test('session idle + enabled + incomplete todos → schedules continuation', async () => {
639
+ const ctx = createMockContext({
640
+ todoResult: {
641
+ data: [
642
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
643
+ { id: '2', content: 'todo2', status: 'completed', priority: 'low' },
644
+ ],
645
+ },
646
+ messagesResult: {
647
+ data: [
648
+ {
649
+ info: { role: 'assistant' },
650
+ parts: [{ type: 'text', text: 'Here is the result' }],
651
+ },
652
+ ],
653
+ },
654
+ });
655
+ const hook = createTodoContinuationHook(ctx, {
656
+ maxContinuations: 5,
657
+ cooldownMs: 50,
658
+ });
659
+
660
+ // Enable auto-continue
661
+ await hook.tool.auto_continue.execute({ enabled: true });
662
+
663
+ // Fire session.idle event
664
+ await hook.handleEvent({
665
+ event: {
666
+ type: 'session.idle',
667
+ properties: { sessionID: 'session-123' },
668
+ },
669
+ });
670
+
671
+ // Wait for cooldown
672
+ await delay(60);
673
+
674
+ // Verify session.prompt was called with continuation prompt
675
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
676
+ const promptCall = contCall(ctx.client.session.prompt);
677
+ expect(promptCall[0].path.id).toBe('session-123');
678
+ expect(promptCall[0].body.parts[0].text).toContain(
679
+ '[Auto-continue: enabled - there are incomplete todos remaining.',
680
+ );
681
+ expect(promptCall[0].body.parts[0].text).toContain(
682
+ SLIM_INTERNAL_INITIATOR_MARKER,
683
+ );
684
+ });
685
+
686
+ test('disabled → no continuation', async () => {
687
+ const ctx = createMockContext({
688
+ todoResult: {
689
+ data: [
690
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
691
+ ],
692
+ },
693
+ messagesResult: {
694
+ data: [
695
+ {
696
+ info: { role: 'assistant' },
697
+ parts: [{ type: 'text', text: 'Done' }],
698
+ },
699
+ ],
700
+ },
701
+ });
702
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
703
+
704
+ // Do NOT enable auto-continue
705
+
706
+ // Fire session.idle event
707
+ await hook.handleEvent({
708
+ event: {
709
+ type: 'session.idle',
710
+ properties: { sessionID: 'session-123' },
711
+ },
712
+ });
713
+
714
+ // Wait for cooldown
715
+ await delay(60);
716
+
717
+ // Verify session.prompt was NOT called
718
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
719
+ });
720
+
721
+ test('last message is a question → skip', async () => {
722
+ const ctx = createMockContext({
723
+ todoResult: {
724
+ data: [
725
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
726
+ ],
727
+ },
728
+ messagesResult: {
729
+ data: [
730
+ {
731
+ info: { role: 'assistant' },
732
+ parts: [
733
+ { type: 'text', text: 'Should I proceed with the next step?' },
734
+ ],
735
+ },
736
+ ],
737
+ },
738
+ });
739
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
740
+
741
+ // Enable auto-continue
742
+ await hook.tool.auto_continue.execute({ enabled: true });
743
+
744
+ // Fire session.idle event
745
+ await hook.handleEvent({
746
+ event: {
747
+ type: 'session.idle',
748
+ properties: { sessionID: 'session-123' },
749
+ },
750
+ });
751
+
752
+ // Wait for cooldown
753
+ await delay(60);
754
+
755
+ // Verify continuation NOT scheduled
756
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
757
+ });
758
+
759
+ test('question detection with question mark → skip', async () => {
760
+ const ctx = createMockContext({
761
+ todoResult: {
762
+ data: [
763
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
764
+ ],
765
+ },
766
+ messagesResult: {
767
+ data: [
768
+ {
769
+ info: { role: 'assistant' },
770
+ parts: [{ type: 'text', text: 'Ready to continue?' }],
771
+ },
772
+ ],
773
+ },
774
+ });
775
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
776
+
777
+ await hook.tool.auto_continue.execute({ enabled: true });
778
+
779
+ await hook.handleEvent({
780
+ event: {
781
+ type: 'session.idle',
782
+ properties: { sessionID: 'session-123' },
783
+ },
784
+ });
785
+
786
+ await delay(60);
787
+
788
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
789
+ });
790
+
791
+ test('question detection with "would you like" phrase → skip', async () => {
792
+ const ctx = createMockContext({
793
+ todoResult: {
794
+ data: [
795
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
796
+ ],
797
+ },
798
+ messagesResult: {
799
+ data: [
800
+ {
801
+ info: { role: 'assistant' },
802
+ parts: [
803
+ {
804
+ type: 'text',
805
+ text: 'Would you like me to proceed?',
806
+ },
807
+ ],
808
+ },
809
+ ],
810
+ },
811
+ });
812
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
813
+
814
+ await hook.tool.auto_continue.execute({ enabled: true });
815
+
816
+ await hook.handleEvent({
817
+ event: {
818
+ type: 'session.idle',
819
+ properties: { sessionID: 'session-123' },
820
+ },
821
+ });
822
+
823
+ await delay(60);
824
+
825
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
826
+ });
827
+
828
+ test('max continuations reached → skip', async () => {
829
+ const ctx = createMockContext({
830
+ todoResult: {
831
+ data: [
832
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
833
+ ],
834
+ },
835
+ messagesResult: {
836
+ data: [
837
+ {
838
+ info: { role: 'assistant' },
839
+ parts: [{ type: 'text', text: 'Working...' }],
840
+ },
841
+ ],
842
+ },
843
+ });
844
+ const hook = createTodoContinuationHook(ctx, {
845
+ maxContinuations: 2,
846
+ cooldownMs: 50,
847
+ });
848
+
849
+ await hook.tool.auto_continue.execute({ enabled: true });
850
+
851
+ // Fire idle events up to maxContinuations
852
+ for (let i = 0; i < 2; i++) {
853
+ await hook.handleEvent({
854
+ event: {
855
+ type: 'session.idle',
856
+ properties: { sessionID: 'session-123' },
857
+ },
858
+ });
859
+ await delay(60);
860
+ }
861
+
862
+ // Reset mock for the 3rd attempt
863
+ ctx.client.session.prompt.mockClear();
864
+
865
+ // On the N+1th idle, verify no continuation scheduled
866
+ await hook.handleEvent({
867
+ event: {
868
+ type: 'session.idle',
869
+ properties: { sessionID: 'session-123' },
870
+ },
871
+ });
872
+
873
+ await delay(60);
874
+
875
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
876
+ });
877
+
878
+ test('abort suppress window → skip', async () => {
879
+ const ctx = createMockContext({
880
+ todoResult: {
881
+ data: [
882
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
883
+ ],
884
+ },
885
+ messagesResult: {
886
+ data: [
887
+ {
888
+ info: { role: 'assistant' },
889
+ parts: [{ type: 'text', text: 'Working...' }],
890
+ },
891
+ ],
892
+ },
893
+ });
894
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
895
+
896
+ // Seed orchestrator session
897
+ await hook.handleEvent({
898
+ event: {
899
+ type: 'session.idle',
900
+ properties: { sessionID: 'session-123' },
901
+ },
902
+ });
903
+
904
+ await hook.tool.auto_continue.execute({ enabled: true });
905
+
906
+ // Fire session.error with MessageAbortedError
907
+ await hook.handleEvent({
908
+ event: {
909
+ type: 'session.error',
910
+ properties: {
911
+ sessionID: 'session-123',
912
+ error: { name: 'MessageAbortedError' },
913
+ },
914
+ },
915
+ });
916
+
917
+ // Immediately fire session.idle
918
+ await hook.handleEvent({
919
+ event: {
920
+ type: 'session.idle',
921
+ properties: { sessionID: 'session-123' },
922
+ },
923
+ });
924
+
925
+ // Wait less than suppress window (5s) - just enough to verify it's working
926
+ await delay(100);
927
+
928
+ // Verify no continuation within suppress window
929
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
930
+ });
931
+
932
+ test('session busy → cancel pending timer', async () => {
933
+ const ctx = createMockContext({
934
+ todoResult: {
935
+ data: [
936
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
937
+ ],
938
+ },
939
+ messagesResult: {
940
+ data: [
941
+ {
942
+ info: { role: 'assistant' },
943
+ parts: [{ type: 'text', text: 'Working...' }],
944
+ },
945
+ ],
946
+ },
947
+ });
948
+ const hook = createTodoContinuationHook(ctx, {
949
+ maxContinuations: 5,
950
+ cooldownMs: 500,
951
+ });
952
+
953
+ await hook.tool.auto_continue.execute({ enabled: true });
954
+
955
+ // Schedule a continuation
956
+ await hook.handleEvent({
957
+ event: {
958
+ type: 'session.idle',
959
+ properties: { sessionID: 'session-123' },
960
+ },
961
+ });
962
+
963
+ // After the notification grace but before cooldown expires, fire busy.
964
+ await delay(300);
965
+ await hook.handleEvent({
966
+ event: {
967
+ type: 'session.status',
968
+ properties: {
969
+ sessionID: 'session-123',
970
+ status: { type: 'busy' },
971
+ },
972
+ },
973
+ });
974
+
975
+ // Advance past original cooldown
976
+ await delay(250);
977
+
978
+ // Verify timer was cancelled and prompt NOT called
979
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
980
+ });
981
+
982
+ test('sub-agent session.busy does NOT cancel orchestrator timer', async () => {
983
+ const ctx = createMockContext({
984
+ todoResult: {
985
+ data: [
986
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
987
+ ],
988
+ },
989
+ messagesResult: {
990
+ data: [
991
+ {
992
+ info: { role: 'assistant' },
993
+ parts: [{ type: 'text', text: 'Working...' }],
994
+ },
995
+ ],
996
+ },
997
+ });
998
+ const hook = createTodoContinuationHook(ctx, {
999
+ maxContinuations: 5,
1000
+ cooldownMs: 100,
1001
+ });
1002
+
1003
+ await hook.tool.auto_continue.execute({ enabled: true });
1004
+
1005
+ // Schedule a continuation for orchestrator session
1006
+ await hook.handleEvent({
1007
+ event: {
1008
+ type: 'session.idle',
1009
+ properties: { sessionID: 'session-123' },
1010
+ },
1011
+ });
1012
+
1013
+ // A sub-agent (different session) goes busy
1014
+ await delay(50);
1015
+ await hook.handleEvent({
1016
+ event: {
1017
+ type: 'session.status',
1018
+ properties: {
1019
+ sessionID: 'sub-agent-456',
1020
+ status: { type: 'busy' },
1021
+ },
1022
+ },
1023
+ });
1024
+
1025
+ // Advance past original cooldown
1026
+ await delay(250);
1027
+
1028
+ // Orchestrator timer should still fire - prompt was called
1029
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
1030
+ });
1031
+
1032
+ test('all todos complete → skip', async () => {
1033
+ const ctx = createMockContext({
1034
+ todoResult: {
1035
+ data: [
1036
+ {
1037
+ id: '1',
1038
+ content: 'todo1',
1039
+ status: 'completed',
1040
+ priority: 'high',
1041
+ },
1042
+ { id: '2', content: 'todo2', status: 'cancelled', priority: 'low' },
1043
+ ],
1044
+ },
1045
+ messagesResult: {
1046
+ data: [
1047
+ {
1048
+ info: { role: 'assistant' },
1049
+ parts: [{ type: 'text', text: 'All done' }],
1050
+ },
1051
+ ],
1052
+ },
1053
+ });
1054
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
1055
+
1056
+ await hook.tool.auto_continue.execute({ enabled: true });
1057
+
1058
+ await hook.handleEvent({
1059
+ event: {
1060
+ type: 'session.idle',
1061
+ properties: { sessionID: 'session-123' },
1062
+ },
1063
+ });
1064
+
1065
+ await delay(60);
1066
+
1067
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
1068
+ });
1069
+
1070
+ test('non-orchestrator session → skip', async () => {
1071
+ const ctx = createMockContext({
1072
+ todoResult: {
1073
+ data: [
1074
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
1075
+ ],
1076
+ },
1077
+ messagesResult: {
1078
+ data: [
1079
+ {
1080
+ info: { role: 'assistant' },
1081
+ parts: [{ type: 'text', text: 'Working...' }],
1082
+ },
1083
+ ],
1084
+ },
1085
+ });
1086
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
1087
+
1088
+ await hook.tool.auto_continue.execute({ enabled: true });
1089
+
1090
+ // First idle from session A (becomes orchestrator)
1091
+ await hook.handleEvent({
1092
+ event: {
1093
+ type: 'session.idle',
1094
+ properties: { sessionID: 'session-A' },
1095
+ },
1096
+ });
1097
+
1098
+ await delay(60);
1099
+
1100
+ // Verify prompt was called for session A
1101
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
1102
+
1103
+ // Reset mock
1104
+ ctx.client.session.prompt.mockClear();
1105
+
1106
+ // Second idle from session B (different sessionID)
1107
+ await hook.handleEvent({
1108
+ event: {
1109
+ type: 'session.idle',
1110
+ properties: { sessionID: 'session-B' },
1111
+ },
1112
+ });
1113
+
1114
+ await delay(60);
1115
+
1116
+ // Verify no continuation for session B
1117
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
1118
+ });
1119
+
1120
+ test('cooldownMs from config', async () => {
1121
+ const customCooldownMs = 150;
1122
+ const ctx = createMockContext({
1123
+ todoResult: {
1124
+ data: [
1125
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
1126
+ ],
1127
+ },
1128
+ messagesResult: {
1129
+ data: [
1130
+ {
1131
+ info: { role: 'assistant' },
1132
+ parts: [{ type: 'text', text: 'Working...' }],
1133
+ },
1134
+ ],
1135
+ },
1136
+ });
1137
+ const hook = createTodoContinuationHook(ctx, {
1138
+ maxContinuations: 5,
1139
+ cooldownMs: customCooldownMs,
1140
+ });
1141
+
1142
+ await hook.tool.auto_continue.execute({ enabled: true });
1143
+
1144
+ await hook.handleEvent({
1145
+ event: {
1146
+ type: 'session.idle',
1147
+ properties: { sessionID: 'session-123' },
1148
+ },
1149
+ });
1150
+
1151
+ // Advance timer by well under the custom cooldown to avoid timer jitter
1152
+ await delay(60);
1153
+
1154
+ // Verify prompt not called yet
1155
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
1156
+
1157
+ // Advance timer past the configured cooldown
1158
+ await delay(100);
1159
+
1160
+ // Now prompt should be called
1161
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
1162
+ });
1163
+ });
1164
+
1165
+ describe('event handling - session.error', () => {
1166
+ test('MessageAbortedError sets suppress window', async () => {
1167
+ const ctx = createMockContext({
1168
+ todoResult: {
1169
+ data: [
1170
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
1171
+ ],
1172
+ },
1173
+ messagesResult: {
1174
+ data: [
1175
+ {
1176
+ info: { role: 'assistant' },
1177
+ parts: [{ type: 'text', text: 'Working...' }],
1178
+ },
1179
+ ],
1180
+ },
1181
+ });
1182
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
1183
+
1184
+ // Seed orchestrator session
1185
+ await hook.handleEvent({
1186
+ event: {
1187
+ type: 'session.idle',
1188
+ properties: { sessionID: 'session-123' },
1189
+ },
1190
+ });
1191
+
1192
+ await hook.tool.auto_continue.execute({ enabled: true });
1193
+
1194
+ // Fire session.error with MessageAbortedError
1195
+ await hook.handleEvent({
1196
+ event: {
1197
+ type: 'session.error',
1198
+ properties: {
1199
+ sessionID: 'session-123',
1200
+ error: { name: 'MessageAbortedError' },
1201
+ },
1202
+ },
1203
+ });
1204
+
1205
+ await hook.handleEvent({
1206
+ event: {
1207
+ type: 'session.idle',
1208
+ properties: { sessionID: 'session-123' },
1209
+ },
1210
+ });
1211
+
1212
+ // Wait less than suppress window
1213
+ await delay(100);
1214
+
1215
+ // Verify no continuation within suppress window
1216
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
1217
+ });
1218
+
1219
+ test('AbortError sets suppress window', async () => {
1220
+ const ctx = createMockContext({
1221
+ todoResult: {
1222
+ data: [
1223
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
1224
+ ],
1225
+ },
1226
+ messagesResult: {
1227
+ data: [
1228
+ {
1229
+ info: { role: 'assistant' },
1230
+ parts: [{ type: 'text', text: 'Working...' }],
1231
+ },
1232
+ ],
1233
+ },
1234
+ });
1235
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
1236
+
1237
+ // Seed orchestrator session (disabled, so no continuation fires)
1238
+ await hook.handleEvent({
1239
+ event: {
1240
+ type: 'session.idle',
1241
+ properties: { sessionID: 'session-123' },
1242
+ },
1243
+ });
1244
+
1245
+ await hook.tool.auto_continue.execute({ enabled: true });
1246
+
1247
+ await hook.handleEvent({
1248
+ event: {
1249
+ type: 'session.error',
1250
+ properties: {
1251
+ sessionID: 'session-123',
1252
+ error: { name: 'AbortError' },
1253
+ },
1254
+ },
1255
+ });
1256
+
1257
+ await hook.handleEvent({
1258
+ event: {
1259
+ type: 'session.idle',
1260
+ properties: { sessionID: 'session-123' },
1261
+ },
1262
+ });
1263
+
1264
+ // Wait less than suppress window
1265
+ await delay(100);
1266
+
1267
+ // Verify no continuation within suppress window
1268
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
1269
+ });
1270
+
1271
+ test('other errors do not set suppress window', async () => {
1272
+ const ctx = createMockContext({
1273
+ todoResult: {
1274
+ data: [
1275
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
1276
+ ],
1277
+ },
1278
+ messagesResult: {
1279
+ data: [
1280
+ {
1281
+ info: { role: 'assistant' },
1282
+ parts: [{ type: 'text', text: 'Working...' }],
1283
+ },
1284
+ ],
1285
+ },
1286
+ });
1287
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
1288
+
1289
+ await hook.tool.auto_continue.execute({ enabled: true });
1290
+
1291
+ await hook.handleEvent({
1292
+ event: {
1293
+ type: 'session.error',
1294
+ properties: {
1295
+ sessionID: 'session-123',
1296
+ error: { name: 'NetworkError' },
1297
+ },
1298
+ },
1299
+ });
1300
+
1301
+ await hook.handleEvent({
1302
+ event: {
1303
+ type: 'session.idle',
1304
+ properties: { sessionID: 'session-123' },
1305
+ },
1306
+ });
1307
+
1308
+ await delay(60);
1309
+
1310
+ // Prompt should be called immediately (no suppress window)
1311
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
1312
+ });
1313
+ });
1314
+
1315
+ describe('event handling - session.deleted', () => {
1316
+ test('clears pending timer on session delete', async () => {
1317
+ const ctx = createMockContext({
1318
+ todoResult: {
1319
+ data: [
1320
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
1321
+ ],
1322
+ },
1323
+ messagesResult: {
1324
+ data: [
1325
+ {
1326
+ info: { role: 'assistant' },
1327
+ parts: [{ type: 'text', text: 'Working...' }],
1328
+ },
1329
+ ],
1330
+ },
1331
+ });
1332
+ const hook = createTodoContinuationHook(ctx, {
1333
+ maxContinuations: 5,
1334
+ cooldownMs: 100,
1335
+ });
1336
+
1337
+ await hook.tool.auto_continue.execute({ enabled: true });
1338
+
1339
+ // Schedule continuation
1340
+ await hook.handleEvent({
1341
+ event: {
1342
+ type: 'session.idle',
1343
+ properties: { sessionID: 'session-123' },
1344
+ },
1345
+ });
1346
+
1347
+ // Delete session before timer fires
1348
+ await delay(50);
1349
+ await hook.handleEvent({
1350
+ event: {
1351
+ type: 'session.deleted',
1352
+ properties: {
1353
+ sessionID: 'session-123',
1354
+ },
1355
+ },
1356
+ });
1357
+
1358
+ // Advance past original cooldown
1359
+ await delay(250);
1360
+
1361
+ // Verify timer was cancelled and prompt NOT called
1362
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
1363
+ });
1364
+
1365
+ test('sub-agent session.deleted does NOT cancel orchestrator timer', async () => {
1366
+ const ctx = createMockContext({
1367
+ todoResult: {
1368
+ data: [
1369
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
1370
+ ],
1371
+ },
1372
+ messagesResult: {
1373
+ data: [
1374
+ {
1375
+ info: { role: 'assistant' },
1376
+ parts: [{ type: 'text', text: 'Working...' }],
1377
+ },
1378
+ ],
1379
+ },
1380
+ });
1381
+ const hook = createTodoContinuationHook(ctx, {
1382
+ maxContinuations: 5,
1383
+ cooldownMs: 100,
1384
+ });
1385
+
1386
+ await hook.tool.auto_continue.execute({ enabled: true });
1387
+
1388
+ // Schedule continuation for orchestrator session
1389
+ await hook.handleEvent({
1390
+ event: {
1391
+ type: 'session.idle',
1392
+ properties: { sessionID: 'session-123' },
1393
+ },
1394
+ });
1395
+
1396
+ // A sub-agent (different session) gets deleted
1397
+ await delay(50);
1398
+ await hook.handleEvent({
1399
+ event: {
1400
+ type: 'session.deleted',
1401
+ properties: {
1402
+ sessionID: 'sub-agent-456',
1403
+ },
1404
+ },
1405
+ });
1406
+
1407
+ // Advance past original cooldown
1408
+ await delay(250);
1409
+
1410
+ // Orchestrator timer should still fire - prompt was called
1411
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
1412
+ });
1413
+
1414
+ test('resets orchestrator session when deleted session matches', async () => {
1415
+ const ctx = createMockContext({
1416
+ todoResult: {
1417
+ data: [
1418
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
1419
+ ],
1420
+ },
1421
+ messagesResult: {
1422
+ data: [
1423
+ {
1424
+ info: { role: 'assistant' },
1425
+ parts: [{ type: 'text', text: 'Working...' }],
1426
+ },
1427
+ ],
1428
+ },
1429
+ });
1430
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
1431
+
1432
+ await hook.tool.auto_continue.execute({ enabled: true });
1433
+
1434
+ // First idle sets orchestrator
1435
+ await hook.handleEvent({
1436
+ event: {
1437
+ type: 'session.idle',
1438
+ properties: { sessionID: 'session-A' },
1439
+ },
1440
+ });
1441
+
1442
+ await delay(60);
1443
+
1444
+ // Delete orchestrator session
1445
+ await hook.handleEvent({
1446
+ event: {
1447
+ type: 'session.deleted',
1448
+ properties: {
1449
+ sessionID: 'session-A',
1450
+ },
1451
+ },
1452
+ });
1453
+
1454
+ // Second idle from new session should become orchestrator
1455
+ ctx.client.session.prompt.mockClear();
1456
+ await hook.handleEvent({
1457
+ event: {
1458
+ type: 'session.idle',
1459
+ properties: { sessionID: 'session-B' },
1460
+ },
1461
+ });
1462
+
1463
+ await delay(60);
1464
+
1465
+ // Prompt should be called for session-B (new orchestrator)
1466
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
1467
+ const promptCall = contCall(ctx.client.session.prompt);
1468
+ expect(promptCall[0].path.id).toBe('session-B');
1469
+ });
1470
+ });
1471
+
1472
+ describe('error handling', () => {
1473
+ test('fetch todos failure → skips continuation', async () => {
1474
+ const ctx = createMockContext({
1475
+ todoResult: undefined as any,
1476
+ });
1477
+ ctx.client.session.todo = mock(async () => {
1478
+ throw new Error('Failed to fetch todos');
1479
+ });
1480
+
1481
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
1482
+
1483
+ await hook.tool.auto_continue.execute({ enabled: true });
1484
+
1485
+ await hook.handleEvent({
1486
+ event: {
1487
+ type: 'session.idle',
1488
+ properties: { sessionID: 'session-123' },
1489
+ },
1490
+ });
1491
+
1492
+ await delay(60);
1493
+
1494
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
1495
+ });
1496
+
1497
+ test('fetch messages failure → skips continuation', async () => {
1498
+ const ctx = createMockContext({
1499
+ todoResult: {
1500
+ data: [
1501
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
1502
+ ],
1503
+ },
1504
+ });
1505
+ ctx.client.session.messages = mock(async () => {
1506
+ throw new Error('Failed to fetch messages');
1507
+ });
1508
+
1509
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
1510
+
1511
+ await hook.tool.auto_continue.execute({ enabled: true });
1512
+
1513
+ await hook.handleEvent({
1514
+ event: {
1515
+ type: 'session.idle',
1516
+ properties: { sessionID: 'session-123' },
1517
+ },
1518
+ });
1519
+
1520
+ await delay(60);
1521
+
1522
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
1523
+ });
1524
+ });
1525
+
1526
+ describe('command.execute.before interception', () => {
1527
+ test('unrelated command → no interception', async () => {
1528
+ const ctx = createMockContext();
1529
+ const hook = createTodoContinuationHook(ctx);
1530
+ const output = { parts: [] as Array<{ type: string; text?: string }> };
1531
+
1532
+ await hook.handleCommandExecuteBefore(
1533
+ { command: 'help', sessionID: 'session-123', arguments: '' },
1534
+ output,
1535
+ );
1536
+
1537
+ expect(output.parts).toHaveLength(0);
1538
+ });
1539
+
1540
+ test('/auto-continue enables and injects continuation when incomplete todos', async () => {
1541
+ const ctx = createMockContext({
1542
+ todoResult: {
1543
+ data: [
1544
+ {
1545
+ id: '1',
1546
+ content: 'todo1',
1547
+ status: 'pending',
1548
+ priority: 'high',
1549
+ },
1550
+ ],
1551
+ },
1552
+ });
1553
+ const hook = createTodoContinuationHook(ctx);
1554
+ const output = { parts: [] as Array<{ type: string; text?: string }> };
1555
+
1556
+ await hook.handleCommandExecuteBefore(
1557
+ { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
1558
+ output,
1559
+ );
1560
+
1561
+ expect(output.parts).toHaveLength(1);
1562
+ expect(output.parts[0].text).toContain(
1563
+ '[Auto-continue: enabled - there are incomplete todos remaining.',
1564
+ );
1565
+ expect(output.parts[0].text).toContain(SLIM_INTERNAL_INITIATOR_MARKER);
1566
+ });
1567
+
1568
+ test('/auto-continue enables but no continuation when all todos complete', async () => {
1569
+ const ctx = createMockContext({
1570
+ todoResult: {
1571
+ data: [
1572
+ {
1573
+ id: '1',
1574
+ content: 'todo1',
1575
+ status: 'completed',
1576
+ priority: 'high',
1577
+ },
1578
+ ],
1579
+ },
1580
+ });
1581
+ const hook = createTodoContinuationHook(ctx);
1582
+ const output = { parts: [] as Array<{ type: string; text?: string }> };
1583
+
1584
+ await hook.handleCommandExecuteBefore(
1585
+ { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
1586
+ output,
1587
+ );
1588
+
1589
+ expect(output.parts).toHaveLength(1);
1590
+ expect(output.parts[0].text).toContain('No incomplete todos right now');
1591
+ });
1592
+
1593
+ test('/auto-continue toggles off when already enabled', async () => {
1594
+ const ctx = createMockContext();
1595
+ const hook = createTodoContinuationHook(ctx);
1596
+ const output = { parts: [] as Array<{ type: string; text?: string }> };
1597
+
1598
+ // Enable via tool
1599
+ await hook.tool.auto_continue.execute({ enabled: true });
1600
+
1601
+ // Toggle off via command
1602
+ await hook.handleCommandExecuteBefore(
1603
+ { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
1604
+ output,
1605
+ );
1606
+
1607
+ expect(output.parts).toHaveLength(1);
1608
+ expect(output.parts[0].text).toContain('disabled by user command');
1609
+ });
1610
+
1611
+ test('/auto-continue resets consecutive continuations on toggle', async () => {
1612
+ const ctx = createMockContext({
1613
+ todoResult: {
1614
+ data: [
1615
+ {
1616
+ id: '1',
1617
+ content: 'todo1',
1618
+ status: 'pending',
1619
+ priority: 'high',
1620
+ },
1621
+ ],
1622
+ },
1623
+ messagesResult: {
1624
+ data: [
1625
+ {
1626
+ info: { role: 'assistant' },
1627
+ parts: [{ type: 'text', text: 'Working...' }],
1628
+ },
1629
+ ],
1630
+ },
1631
+ });
1632
+ const hook = createTodoContinuationHook(ctx, {
1633
+ maxContinuations: 2,
1634
+ cooldownMs: 50,
1635
+ });
1636
+
1637
+ // Enable and run up to max
1638
+ await hook.tool.auto_continue.execute({ enabled: true });
1639
+ for (let i = 0; i < 2; i++) {
1640
+ await hook.handleEvent({
1641
+ event: {
1642
+ type: 'session.idle',
1643
+ properties: { sessionID: 'session-123' },
1644
+ },
1645
+ });
1646
+ await delay(60);
1647
+ }
1648
+
1649
+ // Toggle off then on via command (resets count)
1650
+ const outputOff = {
1651
+ parts: [] as Array<{ type: string; text?: string }>,
1652
+ };
1653
+ await hook.handleCommandExecuteBefore(
1654
+ { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
1655
+ outputOff,
1656
+ );
1657
+ expect(outputOff.parts[0].text).toContain('disabled');
1658
+
1659
+ const outputOn = {
1660
+ parts: [] as Array<{ type: string; text?: string }>,
1661
+ };
1662
+ await hook.handleCommandExecuteBefore(
1663
+ { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
1664
+ outputOn,
1665
+ );
1666
+ // Should have continuation prompt again (count was reset)
1667
+ expect(outputOn.parts[0].text).toContain(
1668
+ '[Auto-continue: enabled - there are incomplete todos remaining.',
1669
+ );
1670
+ });
1671
+
1672
+ test('/auto-continue with todo fetch failure → enables without continuation', async () => {
1673
+ const ctx = createMockContext();
1674
+ ctx.client.session.todo = mock(async () => {
1675
+ throw new Error('Network error');
1676
+ });
1677
+ const hook = createTodoContinuationHook(ctx);
1678
+ const output = { parts: [] as Array<{ type: string; text?: string }> };
1679
+
1680
+ await hook.handleCommandExecuteBefore(
1681
+ { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
1682
+ output,
1683
+ );
1684
+
1685
+ // Should still enable but skip continuation (no todos fetched)
1686
+ expect(output.parts).toHaveLength(1);
1687
+ expect(output.parts[0].text).toContain('No incomplete todos right now');
1688
+ });
1689
+ });
1690
+
1691
+ describe('config defaults', () => {
1692
+ test('default config: maxContinuations = 5, cooldownMs = 3000', async () => {
1693
+ const ctx = createMockContext({
1694
+ todoResult: {
1695
+ data: [
1696
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
1697
+ ],
1698
+ },
1699
+ messagesResult: {
1700
+ data: [
1701
+ {
1702
+ info: { role: 'assistant' },
1703
+ parts: [{ type: 'text', text: 'Working...' }],
1704
+ },
1705
+ ],
1706
+ },
1707
+ });
1708
+ const hook = createTodoContinuationHook(ctx); // No config passed
1709
+
1710
+ const result = await hook.tool.auto_continue.execute({ enabled: true });
1711
+
1712
+ expect(result).toContain('up to 5');
1713
+
1714
+ // Test default cooldown - we'll just verify it waits before calling
1715
+ await hook.handleEvent({
1716
+ event: {
1717
+ type: 'session.idle',
1718
+ properties: { sessionID: 'session-123' },
1719
+ },
1720
+ });
1721
+
1722
+ // Wait less than default cooldown
1723
+ await delay(100);
1724
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
1725
+
1726
+ // Wait past default cooldown
1727
+ await delay(2900);
1728
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
1729
+ });
1730
+ });
1731
+
1732
+ describe('review findings', () => {
1733
+ describe('CRITICAL-1: counter bypass via session.status→busy', () => {
1734
+ test('counter persists when busy fires during auto-injection', async () => {
1735
+ let promptResolve!: () => void;
1736
+ const ctx = createMockContext({
1737
+ todoResult: {
1738
+ data: [
1739
+ {
1740
+ id: '1',
1741
+ content: 't1',
1742
+ status: 'pending',
1743
+ priority: 'high',
1744
+ },
1745
+ ],
1746
+ },
1747
+ messagesResult: {
1748
+ data: [
1749
+ {
1750
+ info: { role: 'assistant' },
1751
+ parts: [{ type: 'text', text: 'Work' }],
1752
+ },
1753
+ ],
1754
+ },
1755
+ });
1756
+
1757
+ // Make prompt hang so isAutoInjecting stays true
1758
+ ctx.client.session.prompt = mock(async () => {
1759
+ await new Promise<void>((r) => {
1760
+ promptResolve = r;
1761
+ });
1762
+ });
1763
+
1764
+ const hook = createTodoContinuationHook(ctx, {
1765
+ maxContinuations: 2,
1766
+ cooldownMs: 50,
1767
+ });
1768
+ await hook.tool.auto_continue.execute({ enabled: true });
1769
+
1770
+ // Cycle 1: idle → timer → prompt hangs
1771
+ await hook.handleEvent({
1772
+ event: {
1773
+ type: 'session.idle',
1774
+ properties: { sessionID: 's1' },
1775
+ },
1776
+ });
1777
+ await delay(60);
1778
+
1779
+ // Session goes busy from prompt - isAutoInjecting is true,
1780
+ // so counter should NOT be reset
1781
+ await hook.handleEvent({
1782
+ event: {
1783
+ type: 'session.status',
1784
+ properties: {
1785
+ sessionID: 's1',
1786
+ status: { type: 'busy' },
1787
+ },
1788
+ },
1789
+ });
1790
+
1791
+ // Resolve prompt → counter = 1
1792
+ promptResolve();
1793
+ await delay(10);
1794
+
1795
+ // Cycle 2: idle → timer → prompt hangs
1796
+ await hook.handleEvent({
1797
+ event: {
1798
+ type: 'session.idle',
1799
+ properties: { sessionID: 's1' },
1800
+ },
1801
+ });
1802
+ await delay(60);
1803
+
1804
+ // Session goes busy again - counter still not reset
1805
+ await hook.handleEvent({
1806
+ event: {
1807
+ type: 'session.status',
1808
+ properties: {
1809
+ sessionID: 's1',
1810
+ status: { type: 'busy' },
1811
+ },
1812
+ },
1813
+ });
1814
+
1815
+ // Resolve prompt → counter = 2
1816
+ promptResolve();
1817
+ await delay(10);
1818
+
1819
+ // Cycle 3: counter = 2 >= maxContinuations = 2 → BLOCKED
1820
+ ctx.client.session.prompt = mock(async () => ({}));
1821
+ await hook.handleEvent({
1822
+ event: {
1823
+ type: 'session.idle',
1824
+ properties: { sessionID: 's1' },
1825
+ },
1826
+ });
1827
+ await delay(60);
1828
+
1829
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
1830
+ });
1831
+ });
1832
+
1833
+ describe('CRITICAL-2: disable cancels pending timer', () => {
1834
+ test('tool disable during cooldown prevents injection', async () => {
1835
+ const ctx = createMockContext({
1836
+ todoResult: {
1837
+ data: [
1838
+ {
1839
+ id: '1',
1840
+ content: 't1',
1841
+ status: 'pending',
1842
+ priority: 'high',
1843
+ },
1844
+ ],
1845
+ },
1846
+ messagesResult: {
1847
+ data: [
1848
+ {
1849
+ info: { role: 'assistant' },
1850
+ parts: [{ type: 'text', text: 'Work' }],
1851
+ },
1852
+ ],
1853
+ },
1854
+ });
1855
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 });
1856
+ await hook.tool.auto_continue.execute({ enabled: true });
1857
+
1858
+ // Fire idle → timer scheduled (100ms cooldown)
1859
+ await hook.handleEvent({
1860
+ event: {
1861
+ type: 'session.idle',
1862
+ properties: { sessionID: 's1' },
1863
+ },
1864
+ });
1865
+
1866
+ // Disable before timer fires
1867
+ await delay(50);
1868
+ await hook.tool.auto_continue.execute({ enabled: false });
1869
+
1870
+ // Wait past original cooldown
1871
+ await delay(60);
1872
+
1873
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
1874
+ });
1875
+
1876
+ test('command disable during cooldown prevents injection', async () => {
1877
+ const ctx = createMockContext({
1878
+ todoResult: {
1879
+ data: [
1880
+ {
1881
+ id: '1',
1882
+ content: 't1',
1883
+ status: 'pending',
1884
+ priority: 'high',
1885
+ },
1886
+ ],
1887
+ },
1888
+ messagesResult: {
1889
+ data: [
1890
+ {
1891
+ info: { role: 'assistant' },
1892
+ parts: [{ type: 'text', text: 'Work' }],
1893
+ },
1894
+ ],
1895
+ },
1896
+ });
1897
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 });
1898
+
1899
+ // Enable via command
1900
+ const outputOn = {
1901
+ parts: [] as Array<{ type: string; text?: string }>,
1902
+ };
1903
+ await hook.handleCommandExecuteBefore(
1904
+ {
1905
+ command: 'auto-continue',
1906
+ sessionID: 's1',
1907
+ arguments: 'on',
1908
+ },
1909
+ outputOn,
1910
+ );
1911
+
1912
+ // Fire idle → timer scheduled
1913
+ await hook.handleEvent({
1914
+ event: {
1915
+ type: 'session.idle',
1916
+ properties: { sessionID: 's1' },
1917
+ },
1918
+ });
1919
+
1920
+ // Disable via command before timer fires
1921
+ await delay(50);
1922
+ const outputOff = {
1923
+ parts: [] as Array<{ type: string; text?: string }>,
1924
+ };
1925
+ await hook.handleCommandExecuteBefore(
1926
+ {
1927
+ command: 'auto-continue',
1928
+ sessionID: 's1',
1929
+ arguments: 'off',
1930
+ },
1931
+ outputOff,
1932
+ );
1933
+
1934
+ // Wait past original cooldown
1935
+ await delay(60);
1936
+
1937
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
1938
+ });
1939
+ });
1940
+
1941
+ describe('MAJOR-1: session.deleted resets counter', () => {
1942
+ test('deleted orchestrator session resets counter for next session', async () => {
1943
+ const ctx = createMockContext({
1944
+ todoResult: {
1945
+ data: [
1946
+ {
1947
+ id: '1',
1948
+ content: 't1',
1949
+ status: 'pending',
1950
+ priority: 'high',
1951
+ },
1952
+ ],
1953
+ },
1954
+ messagesResult: {
1955
+ data: [
1956
+ {
1957
+ info: { role: 'assistant' },
1958
+ parts: [{ type: 'text', text: 'Work' }],
1959
+ },
1960
+ ],
1961
+ },
1962
+ });
1963
+ const hook = createTodoContinuationHook(ctx, {
1964
+ maxContinuations: 2,
1965
+ cooldownMs: 50,
1966
+ });
1967
+ await hook.tool.auto_continue.execute({ enabled: true });
1968
+
1969
+ // Cycle 1: idle → inject → counter = 1
1970
+ await hook.handleEvent({
1971
+ event: {
1972
+ type: 'session.idle',
1973
+ properties: { sessionID: 's1' },
1974
+ },
1975
+ });
1976
+ await delay(60);
1977
+
1978
+ // Delete orchestrator session → counter should reset
1979
+ await hook.handleEvent({
1980
+ event: {
1981
+ type: 'session.deleted',
1982
+ properties: { sessionID: 's1' },
1983
+ },
1984
+ });
1985
+
1986
+ // New session becomes orchestrator - counter starts from 0
1987
+ ctx.client.session.prompt.mockClear();
1988
+ await hook.handleEvent({
1989
+ event: {
1990
+ type: 'session.idle',
1991
+ properties: { sessionID: 's2' },
1992
+ },
1993
+ });
1994
+ await delay(60); // counter = 1
1995
+
1996
+ // One more cycle → counter = 2 (reaches max)
1997
+ ctx.client.session.prompt.mockClear();
1998
+ await hook.handleEvent({
1999
+ event: {
2000
+ type: 'session.idle',
2001
+ properties: { sessionID: 's2' },
2002
+ },
2003
+ });
2004
+ await delay(60);
2005
+
2006
+ // Third cycle blocked (counter = 2 >= max = 2)
2007
+ ctx.client.session.prompt.mockClear();
2008
+ await hook.handleEvent({
2009
+ event: {
2010
+ type: 'session.idle',
2011
+ properties: { sessionID: 's2' },
2012
+ },
2013
+ });
2014
+ await delay(60);
2015
+
2016
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
2017
+ });
2018
+ });
2019
+
2020
+ describe('MAJOR-2: suppressUntil cleared on re-enable', () => {
2021
+ test('tool re-enable clears suppress window', async () => {
2022
+ const ctx = createMockContext({
2023
+ todoResult: {
2024
+ data: [
2025
+ {
2026
+ id: '1',
2027
+ content: 't1',
2028
+ status: 'pending',
2029
+ priority: 'high',
2030
+ },
2031
+ ],
2032
+ },
2033
+ messagesResult: {
2034
+ data: [
2035
+ {
2036
+ info: { role: 'assistant' },
2037
+ parts: [{ type: 'text', text: 'Work' }],
2038
+ },
2039
+ ],
2040
+ },
2041
+ });
2042
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
2043
+ await hook.tool.auto_continue.execute({ enabled: true });
2044
+
2045
+ // Fire abort → sets suppress window
2046
+ await hook.handleEvent({
2047
+ event: {
2048
+ type: 'session.error',
2049
+ properties: {
2050
+ sessionID: 's1',
2051
+ error: { name: 'AbortError' },
2052
+ },
2053
+ },
2054
+ });
2055
+
2056
+ // Re-enable within suppress window → clears suppressUntil
2057
+ await hook.tool.auto_continue.execute({ enabled: true });
2058
+
2059
+ // Fire idle → should NOT be suppressed
2060
+ await hook.handleEvent({
2061
+ event: {
2062
+ type: 'session.idle',
2063
+ properties: { sessionID: 's1' },
2064
+ },
2065
+ });
2066
+ await delay(60);
2067
+
2068
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2069
+ });
2070
+
2071
+ test('command re-enable clears suppress window', async () => {
2072
+ const ctx = createMockContext({
2073
+ todoResult: {
2074
+ data: [
2075
+ {
2076
+ id: '1',
2077
+ content: 't1',
2078
+ status: 'pending',
2079
+ priority: 'high',
2080
+ },
2081
+ ],
2082
+ },
2083
+ messagesResult: {
2084
+ data: [
2085
+ {
2086
+ info: { role: 'assistant' },
2087
+ parts: [{ type: 'text', text: 'Work' }],
2088
+ },
2089
+ ],
2090
+ },
2091
+ });
2092
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
2093
+ await hook.tool.auto_continue.execute({ enabled: true });
2094
+
2095
+ // Fire abort → sets suppress window
2096
+ await hook.handleEvent({
2097
+ event: {
2098
+ type: 'session.error',
2099
+ properties: {
2100
+ sessionID: 's1',
2101
+ error: { name: 'AbortError' },
2102
+ },
2103
+ },
2104
+ });
2105
+
2106
+ // Re-enable via command → clears suppressUntil
2107
+ const output = {
2108
+ parts: [] as Array<{ type: string; text?: string }>,
2109
+ };
2110
+ await hook.handleCommandExecuteBefore(
2111
+ {
2112
+ command: 'auto-continue',
2113
+ sessionID: 's1',
2114
+ arguments: 'on',
2115
+ },
2116
+ output,
2117
+ );
2118
+
2119
+ // Fire idle → should NOT be suppressed
2120
+ await hook.handleEvent({
2121
+ event: {
2122
+ type: 'session.idle',
2123
+ properties: { sessionID: 's1' },
2124
+ },
2125
+ });
2126
+ await delay(60);
2127
+
2128
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2129
+ });
2130
+ });
2131
+
2132
+ describe('error paths', () => {
2133
+ test('prompt failure in timer callback is handled gracefully', async () => {
2134
+ const ctx = createMockContext({
2135
+ todoResult: {
2136
+ data: [
2137
+ {
2138
+ id: '1',
2139
+ content: 't1',
2140
+ status: 'pending',
2141
+ priority: 'high',
2142
+ },
2143
+ ],
2144
+ },
2145
+ messagesResult: {
2146
+ data: [
2147
+ {
2148
+ info: { role: 'assistant' },
2149
+ parts: [{ type: 'text', text: 'Work' }],
2150
+ },
2151
+ ],
2152
+ },
2153
+ });
2154
+ ctx.client.session.prompt = mock(async () => {
2155
+ throw new Error('API error');
2156
+ });
2157
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
2158
+
2159
+ // Seed orchestrator session
2160
+ await hook.handleEvent({
2161
+ event: {
2162
+ type: 'session.idle',
2163
+ properties: { sessionID: 's1' },
2164
+ },
2165
+ });
2166
+
2167
+ await hook.tool.auto_continue.execute({ enabled: true });
2168
+
2169
+ await hook.handleEvent({
2170
+ event: {
2171
+ type: 'session.idle',
2172
+ properties: { sessionID: 's1' },
2173
+ },
2174
+ });
2175
+ await delay(60);
2176
+
2177
+ // Error caught; isAutoInjecting should be cleared via finally.
2178
+ // Verify by checking a second idle still works.
2179
+ ctx.client.session.prompt = mock(async () => ({}));
2180
+ await hook.handleEvent({
2181
+ event: {
2182
+ type: 'session.idle',
2183
+ properties: { sessionID: 's1' },
2184
+ },
2185
+ });
2186
+ await delay(60);
2187
+
2188
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2189
+ });
2190
+ });
2191
+
2192
+ describe('edge cases', () => {
2193
+ test('session.idle with missing sessionID returns early', async () => {
2194
+ const ctx = createMockContext();
2195
+ const hook = createTodoContinuationHook(ctx);
2196
+ await hook.tool.auto_continue.execute({ enabled: true });
2197
+
2198
+ // Fire idle without sessionID - should not throw
2199
+ await hook.handleEvent({
2200
+ event: { type: 'session.idle', properties: {} },
2201
+ });
2202
+
2203
+ expect(ctx.client.session.todo).not.toHaveBeenCalled();
2204
+ });
2205
+
2206
+ test('session.deleted with properties.info.id path', async () => {
2207
+ const ctx = createMockContext({
2208
+ todoResult: {
2209
+ data: [
2210
+ {
2211
+ id: '1',
2212
+ content: 't1',
2213
+ status: 'pending',
2214
+ priority: 'high',
2215
+ },
2216
+ ],
2217
+ },
2218
+ messagesResult: {
2219
+ data: [
2220
+ {
2221
+ info: { role: 'assistant' },
2222
+ parts: [{ type: 'text', text: 'Work' }],
2223
+ },
2224
+ ],
2225
+ },
2226
+ });
2227
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
2228
+ await hook.tool.auto_continue.execute({ enabled: true });
2229
+
2230
+ // Set orchestrator via idle
2231
+ await hook.handleEvent({
2232
+ event: {
2233
+ type: 'session.idle',
2234
+ properties: { sessionID: 's1' },
2235
+ },
2236
+ });
2237
+ await delay(60);
2238
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2239
+
2240
+ // Delete via info.id path (alternative shape from session store)
2241
+ await hook.handleEvent({
2242
+ event: {
2243
+ type: 'session.deleted',
2244
+ properties: { info: { id: 's1' } },
2245
+ },
2246
+ });
2247
+
2248
+ // New session should become orchestrator
2249
+ ctx.client.session.prompt.mockClear();
2250
+ await hook.handleEvent({
2251
+ event: {
2252
+ type: 'session.idle',
2253
+ properties: { sessionID: 's2' },
2254
+ },
2255
+ });
2256
+ await delay(60);
2257
+
2258
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2259
+ expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('s2');
2260
+ });
2261
+
2262
+ test('cooldownMs = 0 fires on next tick', async () => {
2263
+ const ctx = createMockContext({
2264
+ todoResult: {
2265
+ data: [
2266
+ {
2267
+ id: '1',
2268
+ content: 't1',
2269
+ status: 'pending',
2270
+ priority: 'high',
2271
+ },
2272
+ ],
2273
+ },
2274
+ messagesResult: {
2275
+ data: [
2276
+ {
2277
+ info: { role: 'assistant' },
2278
+ parts: [{ type: 'text', text: 'Work' }],
2279
+ },
2280
+ ],
2281
+ },
2282
+ });
2283
+ const hook = createTodoContinuationHook(ctx, {
2284
+ cooldownMs: 0,
2285
+ maxContinuations: 5,
2286
+ });
2287
+ await hook.tool.auto_continue.execute({ enabled: true });
2288
+
2289
+ await hook.handleEvent({
2290
+ event: {
2291
+ type: 'session.idle',
2292
+ properties: { sessionID: 's1' },
2293
+ },
2294
+ });
2295
+ await delay(10);
2296
+
2297
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2298
+ });
2299
+ });
2300
+
2301
+ describe('MAJOR-3: double-fire prevention', () => {
2302
+ test('rapid idle events during prompt delivery - single continuation', async () => {
2303
+ let promptResolve!: () => void;
2304
+ const ctx = createMockContext({
2305
+ todoResult: {
2306
+ data: [
2307
+ {
2308
+ id: '1',
2309
+ content: 't1',
2310
+ status: 'pending',
2311
+ priority: 'high',
2312
+ },
2313
+ ],
2314
+ },
2315
+ messagesResult: {
2316
+ data: [
2317
+ {
2318
+ info: { role: 'assistant' },
2319
+ parts: [{ type: 'text', text: 'Work' }],
2320
+ },
2321
+ ],
2322
+ },
2323
+ });
2324
+ ctx.client.session.prompt = mock(async () => {
2325
+ await new Promise<void>((r) => {
2326
+ promptResolve = r;
2327
+ });
2328
+ });
2329
+
2330
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
2331
+ await hook.tool.auto_continue.execute({ enabled: true });
2332
+
2333
+ // Fire idle → timer → prompt hangs (isAutoInjecting = true)
2334
+ await hook.handleEvent({
2335
+ event: {
2336
+ type: 'session.idle',
2337
+ properties: { sessionID: 's1' },
2338
+ },
2339
+ });
2340
+ await delay(60);
2341
+
2342
+ // Fire another idle while prompt is in flight
2343
+ await hook.handleEvent({
2344
+ event: {
2345
+ type: 'session.idle',
2346
+ properties: { sessionID: 's1' },
2347
+ },
2348
+ });
2349
+
2350
+ // Only one prompt call (blocked by isAutoInjecting gate)
2351
+ expect(contCount(ctx.client.session.prompt)).toBe(1);
2352
+
2353
+ // Resolve prompt
2354
+ promptResolve();
2355
+ await delay(10);
2356
+
2357
+ // Now idle should schedule a new timer
2358
+ ctx.client.session.prompt = mock(async () => ({}));
2359
+ await hook.handleEvent({
2360
+ event: {
2361
+ type: 'session.idle',
2362
+ properties: { sessionID: 's1' },
2363
+ },
2364
+ });
2365
+ await delay(60);
2366
+
2367
+ expect(contCount(ctx.client.session.prompt)).toBe(1);
2368
+ });
2369
+ });
2370
+
2371
+ describe('MAJOR-4: command explicit on|off arguments', () => {
2372
+ test('command "on" keeps enabled state when already enabled', async () => {
2373
+ const ctx = createMockContext();
2374
+ const hook = createTodoContinuationHook(ctx);
2375
+
2376
+ // Enable via tool
2377
+ await hook.tool.auto_continue.execute({ enabled: true });
2378
+
2379
+ // /auto-continue on → should KEEP enabled (not toggle to off)
2380
+ const output = {
2381
+ parts: [] as Array<{ type: string; text?: string }>,
2382
+ };
2383
+ await hook.handleCommandExecuteBefore(
2384
+ {
2385
+ command: 'auto-continue',
2386
+ sessionID: 's1',
2387
+ arguments: 'on',
2388
+ },
2389
+ output,
2390
+ );
2391
+
2392
+ expect(output.parts[0].text).not.toContain('disabled');
2393
+ });
2394
+
2395
+ test('command "off" keeps disabled state when already disabled', async () => {
2396
+ const ctx = createMockContext();
2397
+ const hook = createTodoContinuationHook(ctx);
2398
+
2399
+ // Start disabled (default)
2400
+ const output = {
2401
+ parts: [] as Array<{ type: string; text?: string }>,
2402
+ };
2403
+ await hook.handleCommandExecuteBefore(
2404
+ {
2405
+ command: 'auto-continue',
2406
+ sessionID: 's1',
2407
+ arguments: 'off',
2408
+ },
2409
+ output,
2410
+ );
2411
+
2412
+ expect(output.parts[0].text).toContain('disabled');
2413
+ });
2414
+
2415
+ test('command with no argument toggles state', async () => {
2416
+ const ctx = createMockContext();
2417
+ const hook = createTodoContinuationHook(ctx);
2418
+
2419
+ // First toggle: disabled → enabled
2420
+ const output1 = {
2421
+ parts: [] as Array<{ type: string; text?: string }>,
2422
+ };
2423
+ await hook.handleCommandExecuteBefore(
2424
+ {
2425
+ command: 'auto-continue',
2426
+ sessionID: 's1',
2427
+ arguments: '',
2428
+ },
2429
+ output1,
2430
+ );
2431
+ expect(output1.parts[0].text).not.toContain('disabled');
2432
+
2433
+ // Second toggle: enabled → disabled
2434
+ const output2 = {
2435
+ parts: [] as Array<{ type: string; text?: string }>,
2436
+ };
2437
+ await hook.handleCommandExecuteBefore(
2438
+ {
2439
+ command: 'auto-continue',
2440
+ sessionID: 's1',
2441
+ arguments: '',
2442
+ },
2443
+ output2,
2444
+ );
2445
+ expect(output2.parts[0].text).toContain('disabled');
2446
+ });
2447
+ });
2448
+ });
2449
+
2450
+ describe('session routing and notification cancellation', () => {
2451
+ function createPendingCtx() {
2452
+ return createMockContext({
2453
+ todoResult: {
2454
+ data: [
2455
+ { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
2456
+ ],
2457
+ },
2458
+ messagesResult: {
2459
+ data: [
2460
+ {
2461
+ info: { role: 'assistant' },
2462
+ parts: [{ type: 'text', text: 'Work in progress' }],
2463
+ },
2464
+ ],
2465
+ },
2466
+ });
2467
+ }
2468
+
2469
+ test('chat.message registers orchestrator sessions without first-idle lockout', async () => {
2470
+ const ctx = createPendingCtx();
2471
+ const hook = createTodoContinuationHook(ctx, {
2472
+ cooldownMs: 50,
2473
+ });
2474
+ await hook.tool.auto_continue.execute({ enabled: true });
2475
+
2476
+ hook.handleChatMessage({ sessionID: 'sub1', agent: 'fixer' });
2477
+ hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
2478
+ hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
2479
+
2480
+ await hook.handleEvent({
2481
+ event: {
2482
+ type: 'session.idle',
2483
+ properties: { sessionID: 'sub1' },
2484
+ },
2485
+ });
2486
+ await delay(60);
2487
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
2488
+
2489
+ await hook.handleEvent({
2490
+ event: {
2491
+ type: 'session.idle',
2492
+ properties: { sessionID: 'main2' },
2493
+ },
2494
+ });
2495
+ await delay(60);
2496
+
2497
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2498
+ expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('main2');
2499
+ });
2500
+
2501
+ test('chat.message without agent does not block legacy first-idle fallback', async () => {
2502
+ const ctx = createPendingCtx();
2503
+ const hook = createTodoContinuationHook(ctx, {
2504
+ cooldownMs: 50,
2505
+ autoEnable: true,
2506
+ autoEnableThreshold: 1,
2507
+ });
2508
+
2509
+ hook.handleChatMessage({ sessionID: 'main1' });
2510
+ await hook.handleEvent({
2511
+ event: {
2512
+ type: 'session.idle',
2513
+ properties: { sessionID: 'main1' },
2514
+ },
2515
+ });
2516
+ await delay(60);
2517
+
2518
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2519
+ });
2520
+
2521
+ test('subagent chat.message prevents first-idle fallback registration', async () => {
2522
+ const ctx = createPendingCtx();
2523
+ const hook = createTodoContinuationHook(ctx, {
2524
+ cooldownMs: 50,
2525
+ autoEnable: true,
2526
+ autoEnableThreshold: 1,
2527
+ });
2528
+
2529
+ hook.handleChatMessage({ sessionID: 'sub1', agent: 'fixer' });
2530
+ await hook.handleEvent({
2531
+ event: {
2532
+ type: 'session.idle',
2533
+ properties: { sessionID: 'sub1' },
2534
+ },
2535
+ });
2536
+ await delay(60);
2537
+
2538
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
2539
+ });
2540
+
2541
+ test('session.status idle triggers continuation like session.idle', async () => {
2542
+ const ctx = createPendingCtx();
2543
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
2544
+ await hook.tool.auto_continue.execute({ enabled: true });
2545
+ hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
2546
+
2547
+ await hook.handleEvent({
2548
+ event: {
2549
+ type: 'session.status',
2550
+ properties: { sessionID: 'main1', status: { type: 'idle' } },
2551
+ },
2552
+ });
2553
+ await delay(60);
2554
+
2555
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2556
+ });
2557
+
2558
+ test('deleting another orchestrator does not cancel the active session timer', async () => {
2559
+ const ctx = createPendingCtx();
2560
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
2561
+ await hook.tool.auto_continue.execute({ enabled: true });
2562
+ hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
2563
+ hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
2564
+
2565
+ await hook.handleEvent({
2566
+ event: {
2567
+ type: 'session.idle',
2568
+ properties: { sessionID: 'main1' },
2569
+ },
2570
+ });
2571
+ await hook.handleEvent({
2572
+ event: {
2573
+ type: 'session.deleted',
2574
+ properties: { sessionID: 'main2' },
2575
+ },
2576
+ });
2577
+ await delay(60);
2578
+
2579
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2580
+ expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('main1');
2581
+ });
2582
+
2583
+ test('deleting all orchestrators restores legacy first-idle fallback', async () => {
2584
+ const ctx = createPendingCtx();
2585
+ const hook = createTodoContinuationHook(ctx, {
2586
+ cooldownMs: 50,
2587
+ autoEnable: true,
2588
+ autoEnableThreshold: 1,
2589
+ });
2590
+ hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
2591
+ hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
2592
+
2593
+ await hook.handleEvent({
2594
+ event: {
2595
+ type: 'session.deleted',
2596
+ properties: { sessionID: 'main1' },
2597
+ },
2598
+ });
2599
+ await hook.handleEvent({
2600
+ event: {
2601
+ type: 'session.deleted',
2602
+ properties: { sessionID: 'main2' },
2603
+ },
2604
+ });
2605
+
2606
+ await hook.handleEvent({
2607
+ event: {
2608
+ type: 'session.idle',
2609
+ properties: { sessionID: 'legacy-main' },
2610
+ },
2611
+ });
2612
+ await delay(60);
2613
+
2614
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2615
+ expect(contCall(ctx.client.session.prompt)[0].path.id).toBe(
2616
+ 'legacy-main',
2617
+ );
2618
+ });
2619
+
2620
+ test('countdown notification busy status does not reset max-continuation counter', async () => {
2621
+ const ctx = createPendingCtx();
2622
+ const releaseNotifications: Array<() => void> = [];
2623
+ ctx.client.session.prompt = mock(async (args: any) => {
2624
+ if (args?.body?.noReply === true) {
2625
+ await new Promise<void>((resolve) => {
2626
+ releaseNotifications.push(resolve);
2627
+ });
2628
+ }
2629
+ return {};
2630
+ });
2631
+ const hook = createTodoContinuationHook(ctx, {
2632
+ cooldownMs: 50,
2633
+ maxContinuations: 2,
2634
+ });
2635
+ await hook.tool.auto_continue.execute({ enabled: true });
2636
+ hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
2637
+
2638
+ for (let i = 0; i < 2; i++) {
2639
+ await hook.handleEvent({
2640
+ event: {
2641
+ type: 'session.idle',
2642
+ properties: { sessionID: 'main1' },
2643
+ },
2644
+ });
2645
+ await hook.handleEvent({
2646
+ event: {
2647
+ type: 'session.status',
2648
+ properties: { sessionID: 'main1', status: { type: 'busy' } },
2649
+ },
2650
+ });
2651
+ await delay(60);
2652
+ releaseNotifications.shift()?.();
2653
+ await delay(10);
2654
+ }
2655
+
2656
+ ctx.client.session.prompt.mockClear();
2657
+ await hook.handleEvent({
2658
+ event: {
2659
+ type: 'session.idle',
2660
+ properties: { sessionID: 'main1' },
2661
+ },
2662
+ });
2663
+ await delay(60);
2664
+
2665
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
2666
+ });
2667
+
2668
+ test('late countdown notification busy status does not cancel continuation timer', async () => {
2669
+ const ctx = createPendingCtx();
2670
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
2671
+ await hook.tool.auto_continue.execute({ enabled: true });
2672
+ hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
2673
+
2674
+ await hook.handleEvent({
2675
+ event: {
2676
+ type: 'session.idle',
2677
+ properties: { sessionID: 'main1' },
2678
+ },
2679
+ });
2680
+ await delay(10);
2681
+ await hook.handleEvent({
2682
+ event: {
2683
+ type: 'session.status',
2684
+ properties: { sessionID: 'main1', status: { type: 'busy' } },
2685
+ },
2686
+ });
2687
+ await delay(60);
2688
+
2689
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2690
+ });
2691
+
2692
+ test('countdown notification busy status does not cancel continuation timer', async () => {
2693
+ const ctx = createPendingCtx();
2694
+ let callCount = 0;
2695
+ ctx.client.session.prompt = mock(async () => {
2696
+ callCount++;
2697
+ return {};
2698
+ });
2699
+ const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
2700
+ await hook.tool.auto_continue.execute({ enabled: true });
2701
+ hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
2702
+
2703
+ await hook.handleEvent({
2704
+ event: {
2705
+ type: 'session.idle',
2706
+ properties: { sessionID: 'main1' },
2707
+ },
2708
+ });
2709
+ await hook.handleEvent({
2710
+ event: {
2711
+ type: 'session.status',
2712
+ properties: { sessionID: 'main1', status: { type: 'busy' } },
2713
+ },
2714
+ });
2715
+ await delay(60);
2716
+
2717
+ expect(callCount).toBeGreaterThanOrEqual(2);
2718
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2719
+ });
2720
+ });
2721
+
2722
+ describe('auto-enable on todo count', () => {
2723
+ function createAutoEnableCtx(
2724
+ todos: Array<{
2725
+ id: string;
2726
+ content: string;
2727
+ status: string;
2728
+ priority: string;
2729
+ }>,
2730
+ ) {
2731
+ return createMockContext({
2732
+ todoResult: { data: todos },
2733
+ messagesResult: {
2734
+ data: [
2735
+ {
2736
+ info: { role: 'assistant' },
2737
+ parts: [{ type: 'text', text: 'Working...' }],
2738
+ },
2739
+ ],
2740
+ },
2741
+ });
2742
+ }
2743
+
2744
+ test('autoEnable=true, todos >= threshold → auto-enables and continues', async () => {
2745
+ const ctx = createAutoEnableCtx([
2746
+ { id: '1', content: 't1', status: 'pending', priority: 'high' },
2747
+ { id: '2', content: 't2', status: 'pending', priority: 'high' },
2748
+ { id: '3', content: 't3', status: 'pending', priority: 'high' },
2749
+ { id: '4', content: 't4', status: 'pending', priority: 'high' },
2750
+ ]);
2751
+ const hook = createTodoContinuationHook(ctx, {
2752
+ maxContinuations: 5,
2753
+ cooldownMs: 50,
2754
+ autoEnable: true,
2755
+ autoEnableThreshold: 4,
2756
+ });
2757
+
2758
+ // Do NOT manually enable - auto-enable should trigger
2759
+ await hook.handleEvent({
2760
+ event: {
2761
+ type: 'session.idle',
2762
+ properties: { sessionID: 's1' },
2763
+ },
2764
+ });
2765
+
2766
+ await delay(60);
2767
+
2768
+ // Should have scheduled continuation (auto-enabled)
2769
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2770
+ });
2771
+
2772
+ test('autoEnable=true, todos < threshold → does NOT auto-enable', async () => {
2773
+ const ctx = createAutoEnableCtx([
2774
+ { id: '1', content: 't1', status: 'pending', priority: 'high' },
2775
+ { id: '2', content: 't2', status: 'pending', priority: 'high' },
2776
+ { id: '3', content: 't3', status: 'pending', priority: 'high' },
2777
+ ]);
2778
+ const hook = createTodoContinuationHook(ctx, {
2779
+ maxContinuations: 5,
2780
+ cooldownMs: 50,
2781
+ autoEnable: true,
2782
+ autoEnableThreshold: 4,
2783
+ });
2784
+
2785
+ await hook.handleEvent({
2786
+ event: {
2787
+ type: 'session.idle',
2788
+ properties: { sessionID: 's1' },
2789
+ },
2790
+ });
2791
+
2792
+ await delay(60);
2793
+
2794
+ // Should NOT auto-enable or continue
2795
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
2796
+ });
2797
+
2798
+ test('autoEnable=false (default) → never auto-enables regardless of todo count', async () => {
2799
+ const ctx = createAutoEnableCtx(
2800
+ Array.from({ length: 10 }, (_, i) => ({
2801
+ id: String(i),
2802
+ content: `t${i}`,
2803
+ status: 'pending',
2804
+ priority: 'high',
2805
+ })),
2806
+ );
2807
+ const hook = createTodoContinuationHook(ctx, {
2808
+ maxContinuations: 5,
2809
+ cooldownMs: 50,
2810
+ // autoEnable defaults to false
2811
+ });
2812
+
2813
+ await hook.handleEvent({
2814
+ event: {
2815
+ type: 'session.idle',
2816
+ properties: { sessionID: 's1' },
2817
+ },
2818
+ });
2819
+
2820
+ await delay(60);
2821
+
2822
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
2823
+ });
2824
+
2825
+ test('auto-enable does not re-enable if already manually enabled', async () => {
2826
+ const ctx = createAutoEnableCtx([
2827
+ { id: '1', content: 't1', status: 'pending', priority: 'high' },
2828
+ { id: '2', content: 't2', status: 'pending', priority: 'high' },
2829
+ ]);
2830
+ const hook = createTodoContinuationHook(ctx, {
2831
+ maxContinuations: 5,
2832
+ cooldownMs: 50,
2833
+ autoEnable: true,
2834
+ autoEnableThreshold: 4,
2835
+ });
2836
+
2837
+ // Manually enable first
2838
+ await hook.tool.auto_continue.execute({ enabled: true });
2839
+
2840
+ // Only 2 todos (< threshold) - but already enabled, so should continue
2841
+ await hook.handleEvent({
2842
+ event: {
2843
+ type: 'session.idle',
2844
+ properties: { sessionID: 's1' },
2845
+ },
2846
+ });
2847
+
2848
+ await delay(60);
2849
+
2850
+ // Continues because already manually enabled (auto-enable check skipped)
2851
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2852
+ });
2853
+
2854
+ test('auto-enable respects custom threshold', async () => {
2855
+ const ctx = createAutoEnableCtx([
2856
+ { id: '1', content: 't1', status: 'pending', priority: 'high' },
2857
+ { id: '2', content: 't2', status: 'pending', priority: 'high' },
2858
+ ]);
2859
+ const hook = createTodoContinuationHook(ctx, {
2860
+ maxContinuations: 5,
2861
+ cooldownMs: 50,
2862
+ autoEnable: true,
2863
+ autoEnableThreshold: 2,
2864
+ });
2865
+
2866
+ await hook.handleEvent({
2867
+ event: {
2868
+ type: 'session.idle',
2869
+ properties: { sessionID: 's1' },
2870
+ },
2871
+ });
2872
+
2873
+ await delay(60);
2874
+
2875
+ // 2 todos >= threshold 2 → auto-enables
2876
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2877
+ });
2878
+
2879
+ test('auto-enable skipped for non-orchestrator session', async () => {
2880
+ const ctx = createAutoEnableCtx([
2881
+ { id: '1', content: 't1', status: 'pending', priority: 'high' },
2882
+ { id: '2', content: 't2', status: 'pending', priority: 'high' },
2883
+ { id: '3', content: 't3', status: 'pending', priority: 'high' },
2884
+ { id: '4', content: 't4', status: 'pending', priority: 'high' },
2885
+ ]);
2886
+ const hook = createTodoContinuationHook(ctx, {
2887
+ maxContinuations: 5,
2888
+ cooldownMs: 50,
2889
+ autoEnable: true,
2890
+ autoEnableThreshold: 4,
2891
+ });
2892
+
2893
+ // First idle sets orchestrator to session-A
2894
+ await hook.handleEvent({
2895
+ event: {
2896
+ type: 'session.idle',
2897
+ properties: { sessionID: 'session-A' },
2898
+ },
2899
+ });
2900
+ await delay(60);
2901
+
2902
+ // Reset mock
2903
+ ctx.client.session.prompt.mockClear();
2904
+
2905
+ // Second idle from session-B - not orchestrator, should skip
2906
+ await hook.handleEvent({
2907
+ event: {
2908
+ type: 'session.idle',
2909
+ properties: { sessionID: 'session-B' },
2910
+ },
2911
+ });
2912
+ await delay(60);
2913
+
2914
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
2915
+ });
2916
+
2917
+ test('auto-enable with todo fetch failure → no auto-enable, no crash', async () => {
2918
+ const ctx = createMockContext();
2919
+ ctx.client.session.todo = mock(async () => {
2920
+ throw new Error('Network error');
2921
+ });
2922
+ const hook = createTodoContinuationHook(ctx, {
2923
+ maxContinuations: 5,
2924
+ cooldownMs: 50,
2925
+ autoEnable: true,
2926
+ autoEnableThreshold: 4,
2927
+ });
2928
+
2929
+ // Should not throw
2930
+ await hook.handleEvent({
2931
+ event: {
2932
+ type: 'session.idle',
2933
+ properties: { sessionID: 's1' },
2934
+ },
2935
+ });
2936
+
2937
+ await delay(60);
2938
+
2939
+ // No auto-enable, no continuation
2940
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
2941
+ });
2942
+
2943
+ test('auto-enable resets consecutive counter and suppress window', async () => {
2944
+ const ctx = createAutoEnableCtx([
2945
+ { id: '1', content: 't1', status: 'pending', priority: 'high' },
2946
+ { id: '2', content: 't2', status: 'pending', priority: 'high' },
2947
+ { id: '3', content: 't3', status: 'pending', priority: 'high' },
2948
+ { id: '4', content: 't4', status: 'pending', priority: 'high' },
2949
+ ]);
2950
+ const hook = createTodoContinuationHook(ctx, {
2951
+ maxContinuations: 5,
2952
+ cooldownMs: 50,
2953
+ autoEnable: true,
2954
+ autoEnableThreshold: 4,
2955
+ });
2956
+
2957
+ // Manually enable, run a continuation, disable
2958
+ await hook.tool.auto_continue.execute({ enabled: true });
2959
+ await hook.handleEvent({
2960
+ event: {
2961
+ type: 'session.idle',
2962
+ properties: { sessionID: 's1' },
2963
+ },
2964
+ });
2965
+ await delay(60);
2966
+
2967
+ // Fire abort to set suppress window
2968
+ await hook.handleEvent({
2969
+ event: {
2970
+ type: 'session.error',
2971
+ properties: {
2972
+ sessionID: 's1',
2973
+ error: { name: 'AbortError' },
2974
+ },
2975
+ },
2976
+ });
2977
+
2978
+ // Disable
2979
+ await hook.tool.auto_continue.execute({ enabled: false });
2980
+
2981
+ // Reset mock
2982
+ ctx.client.session.prompt.mockClear();
2983
+
2984
+ // Fire idle again - auto-enable should trigger (4 todos >= 4),
2985
+ // resetting counter and suppress window
2986
+ await hook.handleEvent({
2987
+ event: {
2988
+ type: 'session.idle',
2989
+ properties: { sessionID: 's1' },
2990
+ },
2991
+ });
2992
+
2993
+ await delay(60);
2994
+
2995
+ // Should continue (suppressed window was cleared by auto-enable)
2996
+ expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
2997
+ });
2998
+
2999
+ test('auto-enable counts incomplete todos only, not completed', async () => {
3000
+ const ctx = createAutoEnableCtx([
3001
+ { id: '1', content: 't1', status: 'completed', priority: 'high' },
3002
+ { id: '2', content: 't2', status: 'completed', priority: 'high' },
3003
+ { id: '3', content: 't3', status: 'pending', priority: 'high' },
3004
+ { id: '4', content: 't4', status: 'pending', priority: 'high' },
3005
+ ]);
3006
+ const hook = createTodoContinuationHook(ctx, {
3007
+ maxContinuations: 5,
3008
+ cooldownMs: 50,
3009
+ autoEnable: true,
3010
+ autoEnableThreshold: 4,
3011
+ });
3012
+
3013
+ await hook.handleEvent({
3014
+ event: {
3015
+ type: 'session.idle',
3016
+ properties: { sessionID: 's1' },
3017
+ },
3018
+ });
3019
+
3020
+ await delay(60);
3021
+
3022
+ // Only 2 incomplete todos < threshold 4 → does NOT auto-enable
3023
+ expect(ctx.client.session.prompt).not.toHaveBeenCalled();
3024
+ });
3025
+ });
3026
+ });