@openchambery/web 1.19.17-beta.2 → 1.19.17-beta.3

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 (128) hide show
  1. package/dist/assets/AgentAvatar-CUDJ3_nZ.js +1 -0
  2. package/dist/assets/AssistantView-CDXE0eTS.js +3 -0
  3. package/dist/assets/DiagramView-BrRV4o5T.js +1 -0
  4. package/dist/assets/FadeInOnReveal-BLSnyG2Z.js +1 -0
  5. package/dist/assets/Icon-C9ZHbwqp.js +1 -0
  6. package/dist/assets/MarkdownRenderer-CAdfUgV1.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl-BJd1Vjsc.js → MarkdownRendererImpl-D2dIlX0h.js} +2 -2
  8. package/dist/assets/MarkstreamRendererImpl-Drb2Tpv-.js +3 -0
  9. package/dist/assets/MobileDetailNavigation-BgCoDqtp.js +1 -0
  10. package/dist/assets/MobileShareBridge-ZlVURYWR.js +4 -0
  11. package/dist/assets/MobileSurface-DhLOTW0-.js +2 -0
  12. package/dist/assets/MultiRunWindow-BCvcwItK.js +1 -0
  13. package/dist/assets/OnboardingScreen-DSyZtiFS.js +1 -0
  14. package/dist/assets/OverlayScrollbar-C7fAOAKT.js +2 -0
  15. package/dist/assets/SettingsView-D57amZJb.js +25 -0
  16. package/dist/assets/SettingsWindow-B5DqLCkD.js +1 -0
  17. package/dist/assets/TerminalView-2_RFPaVs.js +8 -0
  18. package/dist/assets/{ToolOutputDialog-BQ_oXyS_.js → ToolOutputDialog-CQs_7KYT.js} +4 -4
  19. package/dist/assets/{appThemeRegistry-BDrIDWKH.js → appThemeRegistry-DpLe_jnW.js} +1 -1
  20. package/dist/assets/button-bhVnVYB_.js +1 -0
  21. package/dist/assets/collapsible-CoWJB_Zl.js +1 -0
  22. package/dist/assets/context-menu-gSO0b3sQ.js +1 -0
  23. package/dist/assets/diagram-Dx4UOZUn.js +1 -0
  24. package/dist/assets/dialog-C2Dw0yqA.js +1 -0
  25. package/dist/assets/{es-BQb6I8gl.js → es-Bxd4Lyu9.js} +2 -2
  26. package/dist/assets/{fileMentionTouchSelection-DzdgAvGS.js → fileMentionTouchSelection-CmQthb6Q.js} +2 -2
  27. package/dist/assets/{fr-BjGouC_l.js → fr-DiLDwVZs.js} +2 -2
  28. package/dist/assets/{gitApi-pLkl_IyQ.js → gitApi-8HxdR1zx.js} +3 -3
  29. package/dist/assets/input-8Te_vPwB.js +1 -0
  30. package/dist/assets/insertionBoundaries-Cdv9-1Bg.js +1 -0
  31. package/dist/assets/{ja-Cw9zliYY.js → ja-CWRlmEJM.js} +2 -2
  32. package/dist/assets/{ko-Dyz0Dk9b.js → ko-DmIugkgj.js} +2 -2
  33. package/dist/assets/main-CQY7JUX4.js +169 -0
  34. package/dist/assets/main-Cfru2-qr.js +2 -0
  35. package/dist/assets/{markdownHeightCache-BL8CUQ0M.js → markdownHeightCache-CT2d4yTV.js} +1 -1
  36. package/dist/assets/miniChat-BphFgAb_.js +2 -0
  37. package/dist/assets/mobile-BVRPTuLT.js +2 -0
  38. package/dist/assets/multirun-CYdieVzW.js +4 -0
  39. package/dist/assets/{pl-C7EY-dTH.js → pl-BU9AmdUM.js} +2 -2
  40. package/dist/assets/projectMeta-D8hiy3Dk.js +3 -0
  41. package/dist/assets/{pt-BR-B1uHpjFW.js → pt-BR-DQmEF9QB.js} +2 -2
  42. package/dist/assets/renderElectronMiniChatApp-CVqd3Nmc.js +2 -0
  43. package/dist/assets/renderMobileApp-DgtCGRKP.js +28 -0
  44. package/dist/assets/{runtimeConfig-BD1Fo5N5.js → runtimeConfig-DXXOBrK_.js} +1 -1
  45. package/dist/assets/runtimeEndpointReset-bSVOt7pi.js +147 -0
  46. package/dist/assets/sessionChangeSummary-CfnRZ0gS.js +2 -0
  47. package/dist/assets/sessionLookup-BZuujM6l.js +1 -0
  48. package/dist/assets/sortable-tabs-strip-BmvLnn6O.js +1 -0
  49. package/dist/assets/src-C_Yn9Ylz.css +2 -0
  50. package/dist/assets/{store-ClnJdJSj.js → store-BoixGrYS.js} +3 -3
  51. package/dist/assets/textarea-DRL916s6.js +1 -0
  52. package/dist/assets/tooltip-BLznq5KR.js +1 -0
  53. package/dist/assets/{uk-DgG8v2md.js → uk-Dax66sAC.js} +2 -2
  54. package/dist/assets/{useAppFontEffects-CN_3Jty7.js → useAppFontEffects-DhEfjuPp.js} +43 -43
  55. package/dist/assets/useDesktopWindowControlsLayout-BhNFU2Wd.js +1 -0
  56. package/dist/assets/{useEffectiveDirectory-C5Gg7EvP.js → useEffectiveDirectory-BhhzFJgX.js} +1 -1
  57. package/dist/assets/useMobileNavigationStore-1jdHeA49.js +413 -0
  58. package/dist/assets/useSessionAutoCleanup-C8u2-jrO.js +2 -0
  59. package/dist/assets/{useWorkerHighlightedLines-BSQQkZaO.js → useWorkerHighlightedLines-DFXK66lA.js} +2 -2
  60. package/dist/assets/vendor-zod-Dzk5kdUU.js +39 -0
  61. package/dist/assets/{zh-CN-C6OFKjeT.js → zh-CN-Du4VC8Z0.js} +2 -2
  62. package/dist/assets/{zh-TW-BVP99aMr.js → zh-TW-C0bY-Dso.js} +2 -2
  63. package/dist/index.html +3 -3
  64. package/dist/mini-chat.html +3 -3
  65. package/dist/mobile.html +3 -3
  66. package/package.json +1 -1
  67. package/server/index.js +51 -0
  68. package/server/lib/opencode/DOCUMENTATION.md +1 -0
  69. package/server/lib/opencode/core-routes.js +2 -0
  70. package/server/lib/opencode/feature-routes-runtime.js +20 -0
  71. package/server/lib/opencode/feature-routes-runtime.test.js +10 -0
  72. package/server/lib/opencode/routes.js +14 -1
  73. package/server/lib/opencode/settings-helpers.js +3 -0
  74. package/server/lib/opencode/settings-helpers.test.js +12 -0
  75. package/server/lib/opencode/shutdown-runtime.js +2 -0
  76. package/server/lib/question-auto-delegate/DOCUMENTATION.md +124 -0
  77. package/server/lib/question-auto-delegate/core.d.ts +255 -0
  78. package/server/lib/question-auto-delegate/core.js +1372 -0
  79. package/server/lib/question-auto-delegate/core.test.js +672 -0
  80. package/server/lib/question-auto-delegate/routes.js +223 -0
  81. package/server/lib/question-auto-delegate/routes.test.js +180 -0
  82. package/server/lib/question-auto-delegate/runtime.js +243 -0
  83. package/server/lib/session-goal/DOCUMENTATION.md +12 -2
  84. package/server/lib/session-goal/runtime.js +138 -35
  85. package/server/lib/session-goal/runtime.test.js +192 -0
  86. package/dist/assets/AgentAvatar-m1jNq4bX.js +0 -1
  87. package/dist/assets/AssistantView-DZMTfY1i.js +0 -3
  88. package/dist/assets/DiagramView-Byf2evGV.js +0 -1
  89. package/dist/assets/FadeInOnReveal-UGwClbL_.js +0 -1
  90. package/dist/assets/Icon-D1BcR8Tz.js +0 -1
  91. package/dist/assets/MarkdownRenderer-YMePGw0Z.js +0 -2
  92. package/dist/assets/MarkstreamRendererImpl-2oZKPfgd.js +0 -3
  93. package/dist/assets/MobileDetailNavigation-BeNUCxzK.js +0 -1
  94. package/dist/assets/MobileShareBridge-3tBtXXJJ.js +0 -4
  95. package/dist/assets/MobileSurface-ppn0zIpb.js +0 -2
  96. package/dist/assets/MultiRunWindow-CItY5bZ2.js +0 -1
  97. package/dist/assets/OnboardingScreen-bIWeMLVx.js +0 -1
  98. package/dist/assets/OverlayScrollbar-Bahg_P3r.js +0 -2
  99. package/dist/assets/SettingsView-0-Yqdzh-.js +0 -25
  100. package/dist/assets/SettingsWindow-1jOWzU2A.js +0 -1
  101. package/dist/assets/TerminalView-BLOCPFz9.js +0 -8
  102. package/dist/assets/button-9R-WgVMl.js +0 -1
  103. package/dist/assets/clipboard-GUUNlWIv.js +0 -1
  104. package/dist/assets/collapsible-Bbz9KiJf.js +0 -1
  105. package/dist/assets/context-menu-CSyOhRz8.js +0 -1
  106. package/dist/assets/diagram-NdsZih8P.js +0 -1
  107. package/dist/assets/dialog-DhMGbF_B.js +0 -1
  108. package/dist/assets/input-Cd_1HyQv.js +0 -1
  109. package/dist/assets/insertionBoundaries-DJvMDCJJ.js +0 -1
  110. package/dist/assets/main-B8fZfA3K.js +0 -169
  111. package/dist/assets/main-BGpPyUV2.js +0 -2
  112. package/dist/assets/miniChat-DHjphqgR.js +0 -2
  113. package/dist/assets/mobile-DNMX4u7x.js +0 -2
  114. package/dist/assets/multirun-BINJ2LCE.js +0 -4
  115. package/dist/assets/projectMeta-BMkGWqtz.js +0 -3
  116. package/dist/assets/renderElectronMiniChatApp-BonR3p7C.js +0 -2
  117. package/dist/assets/renderMobileApp-CZqNs2zK.js +0 -28
  118. package/dist/assets/runtimeEndpointReset-Dpx6GB_U.js +0 -147
  119. package/dist/assets/sessionChangeSummary-BG7l2heP.js +0 -2
  120. package/dist/assets/sessionLookup-BZuvc3Aa.js +0 -1
  121. package/dist/assets/sortable-tabs-strip-dYW6SH62.js +0 -1
  122. package/dist/assets/src-CO9q_82J.css +0 -2
  123. package/dist/assets/textarea-B9CmN13Z.js +0 -1
  124. package/dist/assets/tooltip-DSjr26A_.js +0 -1
  125. package/dist/assets/useDesktopWindowControlsLayout-C6kAkizT.js +0 -1
  126. package/dist/assets/useMobileNavigationStore-DDgESrzu.js +0 -413
  127. package/dist/assets/useSessionAutoCleanup-q2lauc7x.js +0 -2
  128. package/dist/assets/utils-2ZK8yboQ.js +0 -1
@@ -0,0 +1,672 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ QUESTION_AUTO_DELEGATE_ANSWER,
4
+ QUESTION_AUTO_DELEGATE_DELAY_MS,
5
+ QUESTION_SUBMISSION_CLAIMED_CODE,
6
+ buildAutoDelegateAnswers,
7
+ createQuestionAutoDelegateCore,
8
+ } from './core.js';
9
+
10
+ const flush = async (times = 15) => {
11
+ for (let i = 0; i < times; i += 1) await Promise.resolve();
12
+ };
13
+
14
+ const createFakeTimers = () => {
15
+ /** @type {Array<{ id: number, fireAt: number, callback: () => void, cleared: boolean }>} */
16
+ const timers = [];
17
+ let nextId = 1;
18
+ let now = 1_000_000;
19
+ return {
20
+ now: () => now,
21
+ advance: async (ms) => {
22
+ now += ms;
23
+ const due = timers
24
+ .filter((timer) => !timer.cleared && timer.fireAt <= now)
25
+ .sort((a, b) => a.fireAt - b.fireAt);
26
+ for (const timer of due) {
27
+ if (timer.cleared) continue;
28
+ timer.cleared = true;
29
+ timer.callback();
30
+ await flush();
31
+ }
32
+ },
33
+ createTimer: (callback, delayMs) => {
34
+ const timer = {
35
+ id: nextId++,
36
+ fireAt: now + delayMs,
37
+ callback,
38
+ cleared: false,
39
+ };
40
+ timers.push(timer);
41
+ return {
42
+ clear: () => {
43
+ timer.cleared = true;
44
+ },
45
+ };
46
+ },
47
+ pendingCount: () => timers.filter((timer) => !timer.cleared).length,
48
+ };
49
+ };
50
+
51
+ const createIo = (overrides = {}) => {
52
+ const clock = createFakeTimers();
53
+ const posts = [];
54
+ const io = {
55
+ now: clock.now,
56
+ createTimer: clock.createTimer,
57
+ createEpoch: () => 'epoch-test',
58
+ onChanged: vi.fn(),
59
+ onUserTakeover: vi.fn(),
60
+ readEnabled: async () => true,
61
+ listDirectories: async () => ['/proj-a', '/proj-b'],
62
+ listQuestions: async () => [],
63
+ getSession: async (sessionID, directory) => ({
64
+ id: sessionID,
65
+ parentID: null,
66
+ directory: directory || '/proj-a',
67
+ }),
68
+ postReply: async (requestID, directory, answers) => {
69
+ posts.push({ kind: 'reply', requestID, directory, answers });
70
+ return { ok: true, uncertain: false, status: 200, body: true };
71
+ },
72
+ postReject: async (requestID, directory, body) => {
73
+ posts.push({ kind: 'reject', requestID, directory, body });
74
+ return { ok: true, uncertain: false, status: 200, body: true };
75
+ },
76
+ ...overrides,
77
+ __clock: clock,
78
+ __posts: posts,
79
+ };
80
+ if (!overrides.now) io.now = clock.now;
81
+ if (!overrides.createTimer) io.createTimer = clock.createTimer;
82
+ return io;
83
+ };
84
+
85
+ const asked = (id = 'q1', sessionID = 'ses-1', directory = '/proj-a', questionCount = 1) => ({
86
+ type: 'question.asked',
87
+ properties: {
88
+ id,
89
+ sessionID,
90
+ ...(directory ? { directory } : {}),
91
+ questions: Array.from({ length: questionCount }, (_, index) => ({
92
+ question: `Q${index}`,
93
+ header: `H${index}`,
94
+ options: [{ label: 'A', description: '' }],
95
+ })),
96
+ },
97
+ });
98
+
99
+ /** Ready settings without racing start() boot read. */
100
+ const ready = (core) => {
101
+ core.applyEnabled(true);
102
+ };
103
+
104
+ describe('buildAutoDelegateAnswers', () => {
105
+ it('builds one custom-text slot per question', () => {
106
+ expect(buildAutoDelegateAnswers(2)).toEqual([
107
+ [QUESTION_AUTO_DELEGATE_ANSWER],
108
+ [QUESTION_AUTO_DELEGATE_ANSWER],
109
+ ]);
110
+ });
111
+ });
112
+
113
+ describe('question auto-delegate core — claim / upstream outcomes', () => {
114
+ afterEach(() => vi.restoreAllMocks());
115
+
116
+ it('fires auto-reply at 30000ms but not at 29999ms when directory-bound', async () => {
117
+ const io = createIo();
118
+ const core = createQuestionAutoDelegateCore({ io, delayMs: QUESTION_AUTO_DELEGATE_DELAY_MS });
119
+ ready(core);
120
+ core.processEvent(asked());
121
+ expect(core.snapshot().requests[0]?.state).toBe('counting');
122
+ expect(io.__clock.pendingCount()).toBe(1);
123
+
124
+ await io.__clock.advance(29_999);
125
+ expect(io.__posts).toHaveLength(0);
126
+
127
+ await io.__clock.advance(1);
128
+ expect(io.__posts).toHaveLength(1);
129
+ expect(io.__posts[0].answers).toEqual([[QUESTION_AUTO_DELEGATE_ANSWER]]);
130
+ expect(core.snapshot().requests[0]?.state).toBe('settled');
131
+ core.dispose();
132
+ });
133
+
134
+ it('HTTP 400 definitive reject releases claim and pauses for manual retry (no auto loop)', async () => {
135
+ let calls = 0;
136
+ const io = createIo({
137
+ postReply: async () => {
138
+ calls += 1;
139
+ return { ok: false, uncertain: false, status: 400, body: { error: 'bad answers' } };
140
+ },
141
+ });
142
+ const core = createQuestionAutoDelegateCore({ io });
143
+ ready(core);
144
+ core.processEvent(asked());
145
+ await io.__clock.advance(30_000);
146
+ expect(calls).toBe(1);
147
+ const snap = core.snapshot().requests[0];
148
+ expect(snap.state).toBe('paused');
149
+ expect(snap.claimAuthority == null || snap.claimAuthority === null).toBe(true);
150
+
151
+ // No automatic retry.
152
+ await io.__clock.advance(60_000);
153
+ expect(calls).toBe(1);
154
+
155
+ // Manual can claim again.
156
+ io.postReply = async (requestID, directory, answers) => {
157
+ calls += 1;
158
+ return { ok: true, uncertain: false, status: 200, body: true, answers };
159
+ };
160
+ const manual = await core.submit({
161
+ requestID: 'q1',
162
+ sessionID: 'ses-1',
163
+ directory: '/proj-a',
164
+ kind: 'reply',
165
+ authority: 'manual',
166
+ answers: [['yes']],
167
+ });
168
+ expect(manual.outcome).toBe('submitted');
169
+ expect(calls).toBe(2);
170
+ core.dispose();
171
+ });
172
+
173
+ it('uncertain after dispatch stays locked; successful scoped empty converges to external', async () => {
174
+ const io = createIo({
175
+ postReply: async () => ({ ok: false, uncertain: true, status: 503, body: null }),
176
+ listQuestions: async (directory) => {
177
+ if (directory === '/proj-a') return [];
178
+ return null;
179
+ },
180
+ });
181
+ const core = createQuestionAutoDelegateCore({ io });
182
+ ready(core);
183
+ core.processEvent(asked());
184
+ await io.__clock.advance(30_000);
185
+ expect(core.snapshot().requests[0].state).toBe('uncertain');
186
+ expect(core.isBlockingSession('ses-1')).toBe(true);
187
+
188
+ const claimed = await core.submit({
189
+ requestID: 'q1',
190
+ sessionID: 'ses-1',
191
+ directory: '/proj-a',
192
+ kind: 'reply',
193
+ authority: 'manual',
194
+ answers: [['x']],
195
+ });
196
+ expect(claimed.outcome).toBe('claimed');
197
+ expect(claimed.code).toBe(QUESTION_SUBMISSION_CLAIMED_CODE);
198
+
199
+ await core.reconcile({ directories: ['/proj-a'] });
200
+ expect(core.snapshot().requests[0].state).toBe('settled');
201
+ expect(core.snapshot().requests[0].resolution).toBe('external');
202
+ core.dispose();
203
+ });
204
+
205
+ it('settled is not downgraded by a late POST failure', async () => {
206
+ let release;
207
+ const gate = new Promise((resolve) => { release = resolve; });
208
+ const io = createIo({
209
+ postReply: async () => {
210
+ await gate;
211
+ return { ok: false, uncertain: true, status: 503, body: null };
212
+ },
213
+ });
214
+ const core = createQuestionAutoDelegateCore({ io });
215
+ ready(core);
216
+ core.processEvent(asked());
217
+ const pending = core.submit({
218
+ requestID: 'q1',
219
+ sessionID: 'ses-1',
220
+ directory: '/proj-a',
221
+ kind: 'reply',
222
+ authority: 'auto',
223
+ answers: buildAutoDelegateAnswers(1),
224
+ });
225
+ // Authoritative event settles while POST in flight.
226
+ core.processEvent({ type: 'question.replied', properties: { id: 'q1', sessionID: 'ses-1' } });
227
+ expect(core.snapshot().requests[0].state).toBe('settled');
228
+ release();
229
+ const result = await pending;
230
+ expect(result.outcome).toBe('settled');
231
+ expect(core.snapshot().requests[0].state).toBe('settled');
232
+ expect(core.snapshot().requests[0].resolution).toBe('replied');
233
+ core.dispose();
234
+ });
235
+
236
+ it('manual answers:[] are forwarded as-is (never auto text)', async () => {
237
+ const io = createIo();
238
+ const core = createQuestionAutoDelegateCore({ io });
239
+ ready(core);
240
+ core.processEvent(asked());
241
+ const result = await core.submit({
242
+ requestID: 'q1',
243
+ sessionID: 'ses-1',
244
+ directory: '/proj-a',
245
+ kind: 'reply',
246
+ authority: 'manual',
247
+ answers: [],
248
+ });
249
+ expect(result.outcome).toBe('submitted');
250
+ expect(io.__posts[0].answers).toEqual([]);
251
+ core.dispose();
252
+ });
253
+
254
+ it('manual missing answers array is rejected without claim', async () => {
255
+ const io = createIo();
256
+ const core = createQuestionAutoDelegateCore({ io });
257
+ ready(core);
258
+ core.processEvent(asked());
259
+ const result = await core.submit({
260
+ requestID: 'q1',
261
+ sessionID: 'ses-1',
262
+ directory: '/proj-a',
263
+ kind: 'reply',
264
+ authority: 'manual',
265
+ answers: null,
266
+ });
267
+ expect(result.outcome).toBe('error');
268
+ expect(result.status).toBe(400);
269
+ expect(core.snapshot().requests[0].state).toBe('counting');
270
+ core.dispose();
271
+ });
272
+
273
+ it('allows only one upstream mutation across concurrent manual and auto', async () => {
274
+ let release;
275
+ const gate = new Promise((resolve) => { release = resolve; });
276
+ let replyCount = 0;
277
+ const io = createIo({
278
+ postReply: async () => {
279
+ replyCount += 1;
280
+ await gate;
281
+ return { ok: true, uncertain: false, status: 200, body: true };
282
+ },
283
+ });
284
+ const core = createQuestionAutoDelegateCore({ io });
285
+ ready(core);
286
+ core.processEvent(asked());
287
+ const auto = core.submit({
288
+ requestID: 'q1', sessionID: 'ses-1', directory: '/proj-a',
289
+ kind: 'reply', authority: 'auto', answers: buildAutoDelegateAnswers(1),
290
+ });
291
+ const manual = core.submit({
292
+ requestID: 'q1', sessionID: 'ses-1', directory: '/proj-a',
293
+ kind: 'reply', authority: 'manual', answers: [['user']],
294
+ });
295
+ const manualResult = await manual;
296
+ expect(manualResult.outcome).toBe('claimed');
297
+ expect(manualResult.code).toBe(QUESTION_SUBMISSION_CLAIMED_CODE);
298
+ expect(replyCount).toBe(1);
299
+ release();
300
+ await auto;
301
+ expect(replyCount).toBe(1);
302
+ expect(io.onUserTakeover).not.toHaveBeenCalled(); // auto won; manual never claimed
303
+ core.dispose();
304
+ });
305
+
306
+ it('manual claim fires takeover for goal linkage', async () => {
307
+ const io = createIo();
308
+ const core = createQuestionAutoDelegateCore({ io });
309
+ ready(core);
310
+ core.processEvent(asked());
311
+ await core.submit({
312
+ requestID: 'q1', sessionID: 'ses-1', directory: '/proj-a',
313
+ kind: 'reply', authority: 'manual', answers: [['ok']],
314
+ });
315
+ expect(io.onUserTakeover).toHaveBeenCalledWith(expect.objectContaining({
316
+ reason: 'interaction',
317
+ sessionID: 'ses-1',
318
+ }));
319
+ core.dispose();
320
+ });
321
+ });
322
+
323
+ describe('question auto-delegate core — directory binding', () => {
324
+ it('does not arm auto timer until authoritative directory is bound', async () => {
325
+ const io = createIo({
326
+ getSession: async (sessionID) => ({
327
+ id: sessionID,
328
+ parentID: null,
329
+ directory: '/worktree-child',
330
+ }),
331
+ });
332
+ const core = createQuestionAutoDelegateCore({ io });
333
+ ready(core);
334
+ core.processEvent(asked('q-wt', 'ses-wt', '')); // no directory
335
+ expect(core.snapshot().requests[0].directory).toBe('');
336
+ expect(core.snapshot().requests[0].deadlineAt).toBeNull();
337
+ expect(io.__clock.pendingCount()).toBe(0);
338
+
339
+ await flush(20);
340
+ expect(core.snapshot().requests[0].directory).toBe('/worktree-child');
341
+ expect(core.snapshot().requests[0].deadlineAt).not.toBeNull();
342
+ expect(io.__clock.pendingCount()).toBe(1);
343
+
344
+ await io.__clock.advance(30_000);
345
+ expect(io.__posts).toHaveLength(1);
346
+ expect(io.__posts[0].directory).toBe('/worktree-child');
347
+ core.dispose();
348
+ });
349
+
350
+ it('rejects claim/pause with conflicting directory or session hints (409)', async () => {
351
+ const io = createIo();
352
+ const core = createQuestionAutoDelegateCore({ io });
353
+ ready(core);
354
+ core.processEvent(asked('q1', 'ses-1', '/proj-a'));
355
+
356
+ const badClaim = await core.submit({
357
+ requestID: 'q1',
358
+ sessionID: 'ses-OTHER',
359
+ directory: '/proj-a',
360
+ kind: 'reply',
361
+ authority: 'manual',
362
+ answers: [['x']],
363
+ });
364
+ expect(badClaim.outcome).toBe('error');
365
+ expect(badClaim.status).toBe(409);
366
+
367
+ const badDir = await core.pause({
368
+ requestID: 'q1',
369
+ sessionID: 'ses-1',
370
+ directory: '/wrong',
371
+ reason: 'user',
372
+ });
373
+ expect(badDir.outcome).toBe('error');
374
+ expect(badDir.status).toBe(409);
375
+ expect(core.snapshot().requests[0].directory).toBe('/proj-a');
376
+ core.dispose();
377
+ });
378
+
379
+ it('unknown multi-question delegate recovers identity via scoped list (not invent count=1)', async () => {
380
+ const io = createIo({
381
+ listQuestions: async (directory) => {
382
+ if (directory !== '/proj-a') return [];
383
+ return [{
384
+ id: 'q-unknown',
385
+ sessionID: 'ses-1',
386
+ directory: '/proj-a',
387
+ questions: [
388
+ { question: 'a', header: 'a', options: [] },
389
+ { question: 'b', header: 'b', options: [] },
390
+ { question: 'c', header: 'c', options: [] },
391
+ ],
392
+ }];
393
+ },
394
+ });
395
+ const core = createQuestionAutoDelegateCore({ io });
396
+ ready(core);
397
+ // Feature off still allows explicit delegate.
398
+ core.applyEnabled(false);
399
+ const result = await core.delegate({
400
+ requestID: 'q-unknown',
401
+ sessionID: 'ses-1',
402
+ directory: '/proj-a',
403
+ });
404
+ expect(result.outcome).toBe('delegated');
405
+ expect(io.__posts[0].answers).toHaveLength(3);
406
+ core.dispose();
407
+ });
408
+ });
409
+
410
+ describe('question auto-delegate core — reconcile scoping', () => {
411
+ it('only settles directories that listed successfully (A empty does not clear B/C)', async () => {
412
+ const io = createIo({
413
+ listDirectories: async () => ['/A', '/B', '/C'],
414
+ listQuestions: async (directory) => {
415
+ if (directory === '/A') return [];
416
+ if (directory === '/B') return null; // not queried successfully
417
+ if (directory === '/C') return null; // failed
418
+ return [];
419
+ },
420
+ });
421
+ const core = createQuestionAutoDelegateCore({ io });
422
+ ready(core);
423
+ core.processEvent(asked('qa', 'ses-a', '/A'));
424
+ core.processEvent(asked('qb', 'ses-b', '/B'));
425
+ core.processEvent(asked('qc', 'ses-c', '/C'));
426
+
427
+ await core.reconcile({ directories: ['/A', '/B', '/C'] });
428
+ const byId = Object.fromEntries(core.snapshot().requests.map((r) => [r.requestID, r]));
429
+ expect(byId.qa.state).toBe('settled');
430
+ expect(byId.qa.resolution).toBe('external');
431
+ expect(byId.qb.state).toBe('counting');
432
+ expect(byId.qc.state).toBe('counting');
433
+ expect(core.snapshot().coverage.state).toBe('partial');
434
+ expect(core.snapshot().coverage.failedDirectories).toEqual(expect.arrayContaining(['/B', '/C']));
435
+ core.dispose();
436
+ });
437
+
438
+ it('single-flight reconcile merges trailing scopes', async () => {
439
+ const calls = [];
440
+ let releaseFirst;
441
+ const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
442
+ const io = createIo({
443
+ listQuestions: async (directory) => {
444
+ calls.push(directory);
445
+ if (directory === '/A') await firstGate;
446
+ return [];
447
+ },
448
+ });
449
+ const core = createQuestionAutoDelegateCore({ io });
450
+ ready(core);
451
+ const first = core.reconcile({ directories: ['/A'] });
452
+ const second = core.reconcile({ directories: ['/B'] });
453
+ releaseFirst();
454
+ await Promise.all([first, second]);
455
+ expect(calls).toContain('/A');
456
+ expect(calls).toContain('/B');
457
+ core.dispose();
458
+ });
459
+ });
460
+
461
+ describe('question auto-delegate core — settings boot race', () => {
462
+ it('applyEnabled during boot is not overwritten by a late settings read', async () => {
463
+ let releaseRead;
464
+ const readGate = new Promise((resolve) => { releaseRead = resolve; });
465
+ const io = createIo({
466
+ readEnabled: async () => {
467
+ await readGate;
468
+ return true; // stale "on"
469
+ },
470
+ // Keep the pending question present so reconcile does not external-settle it.
471
+ listQuestions: async (directory) => (directory === '/proj-a'
472
+ ? [{ id: 'q1', sessionID: 'ses-1', directory: '/proj-a', questions: [{ question: 'x', header: 'h', options: [] }] }]
473
+ : []),
474
+ });
475
+ const core = createQuestionAutoDelegateCore({ io });
476
+ core.start();
477
+ core.applyEnabled(false);
478
+ expect(core.snapshot().enabled).toBe(false);
479
+ core.processEvent(asked());
480
+ expect(core.snapshot().requests[0].state).toBe('disabled');
481
+ releaseRead();
482
+ await flush(30);
483
+ expect(core.snapshot().enabled).toBe(false);
484
+ expect(core.snapshot().requests[0].state).toBe('disabled');
485
+ expect(io.__clock.pendingCount()).toBe(0);
486
+ expect(io.__posts).toHaveLength(0);
487
+ core.dispose();
488
+ });
489
+
490
+ it('settings read failure enters unavailable — no auto timers', async () => {
491
+ const io = createIo({
492
+ readEnabled: async () => null,
493
+ });
494
+ const core = createQuestionAutoDelegateCore({ io });
495
+ core.start();
496
+ await flush(20);
497
+ expect(core.snapshot().enabled).toBe(false);
498
+ expect(core.snapshot().coverage.state).toBe('partial');
499
+ core.processEvent(asked());
500
+ expect(io.__clock.pendingCount()).toBe(0);
501
+ await io.__clock.advance(60_000);
502
+ expect(io.__posts).toHaveLength(0);
503
+ core.dispose();
504
+ });
505
+
506
+ it('applyEnabled ignores non-boolean input', () => {
507
+ const io = createIo();
508
+ const core = createQuestionAutoDelegateCore({ io });
509
+ core.applyEnabled(true);
510
+ const before = core.snapshot();
511
+ core.applyEnabled('false');
512
+ core.applyEnabled(null);
513
+ core.applyEnabled(undefined);
514
+ expect(core.snapshot().enabled).toBe(true);
515
+ expect(core.snapshot().revision).toBe(before.revision);
516
+ core.dispose();
517
+ });
518
+
519
+ it('settings not ready blocks auto-submit even if counting entry exists', async () => {
520
+ const io = createIo({
521
+ readEnabled: async () => {
522
+ await new Promise(() => {});
523
+ return true;
524
+ },
525
+ });
526
+ const core = createQuestionAutoDelegateCore({ io });
527
+ core.start();
528
+ core.processEvent(asked());
529
+ const result = await core.submit({
530
+ requestID: 'q1',
531
+ sessionID: 'ses-1',
532
+ directory: '/proj-a',
533
+ kind: 'reply',
534
+ authority: 'auto',
535
+ answers: buildAutoDelegateAnswers(1),
536
+ });
537
+ expect(result.outcome).toBe('disabled');
538
+ expect(io.__posts).toHaveLength(0);
539
+ core.dispose();
540
+ });
541
+ });
542
+
543
+ describe('question auto-delegate core — goal blocking helpers', () => {
544
+ it('uncertain blocks session; pauseForSessionTree pauses counting timers', async () => {
545
+ const io = createIo({
546
+ postReply: async () => ({ ok: false, uncertain: true, status: 0, body: null }),
547
+ });
548
+ const core = createQuestionAutoDelegateCore({ io });
549
+ ready(core);
550
+ core.processEvent({
551
+ type: 'session.created',
552
+ properties: { info: { id: 'child', parentID: 'root', directory: '/proj-a' } },
553
+ });
554
+ core.processEvent({
555
+ type: 'session.created',
556
+ properties: { info: { id: 'grand', parentID: 'child', directory: '/proj-a' } },
557
+ });
558
+ core.processEvent(asked('q-g', 'grand', '/proj-a'));
559
+ await io.__clock.advance(30_000);
560
+ expect(core.snapshot().requests[0].state).toBe('uncertain');
561
+ expect(core.isBlockingSession('root')).toBe(true);
562
+
563
+ // Fresh counting sibling paused by goal reverse path.
564
+ core.processEvent(asked('q2', 'child', '/proj-a'));
565
+ expect(core.snapshot().requests.find((r) => r.requestID === 'q2').state).toBe('counting');
566
+ await core.pauseForSessionTree('grand', '/proj-a');
567
+ expect(core.snapshot().requests.find((r) => r.requestID === 'q2').state).toBe('paused');
568
+ expect(core.snapshot().requests.find((r) => r.requestID === 'q2').pauseReason).toBe('goal');
569
+ core.dispose();
570
+ });
571
+ });
572
+
573
+ describe('question auto-delegate core — unscoped lineage + notSent', () => {
574
+ it('cold-start unscoped 2-question child binds /worktree via getSession then auto-POSTs once', async () => {
575
+ const io = createIo({
576
+ listDirectories: async () => [],
577
+ listQuestions: async (directory) => {
578
+ // Only unscoped discovers the pending question (no directory on the item).
579
+ if (directory === undefined) {
580
+ return [{
581
+ id: 'q-child',
582
+ sessionID: 'ses-child',
583
+ questions: [
584
+ { question: 'one', header: 'h1', options: [] },
585
+ { question: 'two', header: 'h2', options: [] },
586
+ ],
587
+ }];
588
+ }
589
+ return [];
590
+ },
591
+ getSession: async (sessionID) => ({
592
+ id: sessionID,
593
+ parentID: 'ses-root',
594
+ directory: '/worktree',
595
+ }),
596
+ });
597
+ const core = createQuestionAutoDelegateCore({ io });
598
+ ready(core);
599
+ await core.reconcile();
600
+ await flush(30);
601
+
602
+ const entry = core.snapshot().requests.find((r) => r.requestID === 'q-child');
603
+ expect(entry).toBeTruthy();
604
+ expect(entry.directory).toBe('/worktree');
605
+ expect(entry.questionCount).toBe(2);
606
+ expect(entry.deadlineAt).not.toBeNull();
607
+ expect(io.__clock.pendingCount()).toBe(1);
608
+
609
+ await io.__clock.advance(30_000);
610
+ expect(io.__posts).toHaveLength(1);
611
+ expect(io.__posts[0].directory).toBe('/worktree');
612
+ expect(io.__posts[0].answers).toEqual([
613
+ [QUESTION_AUTO_DELEGATE_ANSWER],
614
+ [QUESTION_AUTO_DELEGATE_ANSWER],
615
+ ]);
616
+ core.dispose();
617
+ });
618
+
619
+ it('notSent:true releases claim for retry; bare status:0 stays uncertain', async () => {
620
+ const io = createIo({
621
+ postReply: async () => ({
622
+ ok: false,
623
+ uncertain: false,
624
+ notSent: true,
625
+ status: 0,
626
+ body: { error: 'dns' },
627
+ }),
628
+ });
629
+ const core = createQuestionAutoDelegateCore({ io });
630
+ ready(core);
631
+ core.processEvent(asked());
632
+ await io.__clock.advance(30_000);
633
+ expect(core.snapshot().requests[0].state).toBe('paused');
634
+
635
+ // Human can retry after notSent.
636
+ let calls = 0;
637
+ io.postReply = async (requestID, directory, answers) => {
638
+ calls += 1;
639
+ return { ok: true, uncertain: false, status: 200, body: true, answers };
640
+ };
641
+ const retry = await core.submit({
642
+ requestID: 'q1',
643
+ sessionID: 'ses-1',
644
+ directory: '/proj-a',
645
+ kind: 'reply',
646
+ authority: 'manual',
647
+ answers: [['ok']],
648
+ });
649
+ expect(retry.outcome).toBe('submitted');
650
+ expect(calls).toBe(1);
651
+ core.dispose();
652
+
653
+ const io2 = createIo({
654
+ postReply: async () => ({ ok: false, uncertain: false, status: 0, body: null }),
655
+ });
656
+ const core2 = createQuestionAutoDelegateCore({ io: io2 });
657
+ ready(core2);
658
+ core2.processEvent(asked());
659
+ await io2.__clock.advance(30_000);
660
+ expect(core2.snapshot().requests[0].state).toBe('uncertain');
661
+ const locked = await core2.submit({
662
+ requestID: 'q1',
663
+ sessionID: 'ses-1',
664
+ directory: '/proj-a',
665
+ kind: 'reply',
666
+ authority: 'manual',
667
+ answers: [['x']],
668
+ });
669
+ expect(locked.outcome).toBe('claimed');
670
+ core2.dispose();
671
+ });
672
+ });