muonroi-cli 1.8.0 → 1.8.1

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 (101) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +122 -122
  3. package/dist/src/agent-harness/mock-model.js +112 -4
  4. package/dist/src/cli/cost-forensics.js +17 -14
  5. package/dist/src/council/prompts.js +20 -20
  6. package/dist/src/flow/compaction/index.d.ts +1 -0
  7. package/dist/src/flow/compaction/index.js +4 -0
  8. package/dist/src/generated/version.d.ts +1 -1
  9. package/dist/src/generated/version.js +1 -1
  10. package/dist/src/index.js +4 -2
  11. package/dist/src/lsp/manager.js +11 -3
  12. package/dist/src/lsp/manager.test.js +39 -0
  13. package/dist/src/mcp/auto-setup.js +0 -8
  14. package/dist/src/mcp/oauth-callback.js +2 -2
  15. package/dist/src/mcp/parse-headers.test.js +14 -14
  16. package/dist/src/mcp/self-verify-jobs.d.ts +1 -0
  17. package/dist/src/mcp/self-verify-runner.js +1 -1
  18. package/dist/src/mcp/setup-guide-text.js +74 -74
  19. package/dist/src/mcp/smoke.test.js +43 -43
  20. package/dist/src/ops/doctor.js +7 -7
  21. package/dist/src/orchestrator/__tests__/batch-turn-runner.test.js +1 -0
  22. package/dist/src/orchestrator/__tests__/message-processor.test.js +1 -0
  23. package/dist/src/orchestrator/__tests__/prompts.test.d.ts +1 -0
  24. package/dist/src/orchestrator/__tests__/prompts.test.js +88 -0
  25. package/dist/src/orchestrator/__tests__/sub-session-delegation.test.d.ts +1 -0
  26. package/dist/src/orchestrator/__tests__/sub-session-delegation.test.js +507 -0
  27. package/dist/src/orchestrator/auto-commit.js +9 -7
  28. package/dist/src/orchestrator/message-processor.d.ts +2 -0
  29. package/dist/src/orchestrator/message-processor.js +7 -0
  30. package/dist/src/orchestrator/orchestrator.d.ts +3 -0
  31. package/dist/src/orchestrator/orchestrator.js +362 -28
  32. package/dist/src/orchestrator/pending-calls.js +2 -1
  33. package/dist/src/orchestrator/preprocessor.js +3 -2
  34. package/dist/src/orchestrator/prompts.d.ts +8 -0
  35. package/dist/src/orchestrator/prompts.js +65 -18
  36. package/dist/src/orchestrator/sub-agent-cap.d.ts +2 -0
  37. package/dist/src/orchestrator/sub-agent-cap.js +12 -4
  38. package/dist/src/orchestrator/subagent-compactor.d.ts +7 -0
  39. package/dist/src/orchestrator/subagent-compactor.js +65 -14
  40. package/dist/src/orchestrator/subagent-compactor.spec.js +125 -9
  41. package/dist/src/orchestrator/tool-engine.d.ts +2 -0
  42. package/dist/src/orchestrator/tool-engine.js +80 -21
  43. package/dist/src/orchestrator/turn-runner-deps.d.ts +2 -0
  44. package/dist/src/pil/__tests__/llm-classify.test.js +79 -1
  45. package/dist/src/pil/layer1-intent.js +37 -37
  46. package/dist/src/pil/layer2_5-ponytail.js +8 -8
  47. package/dist/src/pil/llm-classify.d.ts +11 -0
  48. package/dist/src/pil/llm-classify.js +193 -0
  49. package/dist/src/product-loop/done-gate.js +3 -3
  50. package/dist/src/product-loop/loop-driver.js +18 -18
  51. package/dist/src/product-loop/progress-snapshot.js +4 -4
  52. package/dist/src/providers/mcp-vision-bridge.js +48 -48
  53. package/dist/src/reporter/index.js +1 -1
  54. package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
  55. package/dist/src/scaffold/bb-quality-gate.js +5 -5
  56. package/dist/src/scaffold/continuation-prompt.js +60 -60
  57. package/dist/src/scaffold/init-new.js +453 -453
  58. package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
  59. package/dist/src/self-qa/agentic-loop.js +11 -2
  60. package/dist/src/storage/__tests__/hierarchical-session.test.d.ts +1 -0
  61. package/dist/src/storage/__tests__/hierarchical-session.test.js +161 -0
  62. package/dist/src/storage/__tests__/migrations.test.js +2 -2
  63. package/dist/src/storage/__tests__/sweep-stale-pending.test.js +2 -2
  64. package/dist/src/storage/index.d.ts +1 -1
  65. package/dist/src/storage/index.js +1 -1
  66. package/dist/src/storage/interaction-log.js +5 -5
  67. package/dist/src/storage/migrations.js +135 -123
  68. package/dist/src/storage/session-experience-store.js +4 -4
  69. package/dist/src/storage/sessions.js +43 -43
  70. package/dist/src/storage/transcript-response-entry.test.js +18 -5
  71. package/dist/src/storage/transcript.d.ts +2 -0
  72. package/dist/src/storage/transcript.js +223 -97
  73. package/dist/src/storage/usage.js +34 -17
  74. package/dist/src/storage/workspaces.js +12 -12
  75. package/dist/src/tools/native-tools.js +10 -1
  76. package/dist/src/tools/registry.d.ts +1 -0
  77. package/dist/src/tools/registry.js +30 -2
  78. package/dist/src/types/index.d.ts +1 -1
  79. package/dist/src/ui/app.js +0 -0
  80. package/dist/src/ui/slash/council-inspect.js +4 -4
  81. package/dist/src/ui/slash/expand.js +14 -1
  82. package/dist/src/ui/use-app-logic.js +0 -0
  83. package/dist/src/utils/__tests__/compaction-caps.test.js +9 -9
  84. package/dist/src/utils/__tests__/llm-deadline.test.js +16 -4
  85. package/dist/src/utils/__tests__/logger.test.d.ts +1 -0
  86. package/dist/src/utils/__tests__/logger.test.js +115 -0
  87. package/dist/src/utils/__tests__/settings-agent-first.test.d.ts +1 -0
  88. package/dist/src/utils/__tests__/settings-agent-first.test.js +48 -0
  89. package/dist/src/utils/clipboard-image.js +23 -23
  90. package/dist/src/utils/install-manager.d.ts +1 -0
  91. package/dist/src/utils/install-manager.js +76 -7
  92. package/dist/src/utils/install-manager.test.js +43 -1
  93. package/dist/src/utils/logger.d.ts +28 -0
  94. package/dist/src/utils/logger.js +171 -0
  95. package/dist/src/utils/settings.d.ts +9 -1
  96. package/dist/src/utils/settings.js +59 -22
  97. package/dist/src/utils/side-question.js +2 -2
  98. package/dist/src/utils/skills.js +3 -3
  99. package/dist/src/utils/update-checker.test.js +5 -1
  100. package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
  101. package/package.json +1 -1
@@ -0,0 +1,507 @@
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { installMockModel, textOnlyStream } from "../../agent-harness/mock-model.js";
3
+ import { loadCatalog } from "../../models/registry.js";
4
+ import { Agent } from "../orchestrator.js";
5
+ // Mock generateText from 'ai'
6
+ const mockGenerateText = vi.fn().mockResolvedValue({ text: "Mocked parent advice response" });
7
+ vi.mock("ai", () => {
8
+ return {
9
+ generateText: (...args) => mockGenerateText(...args),
10
+ };
11
+ });
12
+ // 1. Mock classifySubSessionAction
13
+ const mockClassifySubSessionAction = vi.fn();
14
+ vi.mock("../../pil/llm-classify.js", () => {
15
+ return {
16
+ classifySubSessionAction: (...args) => mockClassifySubSessionAction.apply(null, args),
17
+ };
18
+ });
19
+ // Mock deliberateCompact
20
+ const mockDeliberateCompact = vi.fn().mockResolvedValue({
21
+ summary: "Compact summary",
22
+ tokensBeforeCompress: 100,
23
+ });
24
+ vi.mock("../../flow/compaction/index.js", () => {
25
+ return {
26
+ deliberateCompact: (...args) => mockDeliberateCompact.apply(null, args),
27
+ };
28
+ });
29
+ // 2. Mock DB
30
+ const mockDb = {
31
+ prepare: vi.fn().mockReturnValue({
32
+ run: vi.fn().mockReturnValue({ changes: 1 }),
33
+ get: vi.fn(),
34
+ all: vi.fn().mockReturnValue([]),
35
+ }),
36
+ };
37
+ vi.mock("../../storage/db.js", () => {
38
+ return {
39
+ getDatabase: () => mockDb,
40
+ };
41
+ });
42
+ // 3. Mock Storage/Transcript module functions
43
+ const mockLoadLatestCompaction = vi.fn();
44
+ const mockGetNextMessageSequence = vi.fn(() => 1);
45
+ const mockAppendCompaction = vi.fn();
46
+ const mockLoadTranscriptState = vi.fn();
47
+ const mockAppendMessages = vi.fn((sessId, msgs) => msgs.map((_, idx) => idx + 100));
48
+ const mockMarkMessageCompleted = vi.fn();
49
+ vi.mock("../../storage/transcript.js", () => {
50
+ return {
51
+ loadLatestCompaction: (...args) => mockLoadLatestCompaction.apply(null, args),
52
+ getNextMessageSequence: (...args) => mockGetNextMessageSequence.apply(null, args),
53
+ appendCompaction: (...args) => mockAppendCompaction.apply(null, args),
54
+ loadTranscriptState: (...args) => mockLoadTranscriptState.apply(null, args),
55
+ appendMessages: (...args) => mockAppendMessages.apply(null, args),
56
+ markMessageCompleted: (...args) => mockMarkMessageCompleted.apply(null, args),
57
+ buildChatEntries: vi.fn(() => []),
58
+ getLastTodoWriteArgs: vi.fn(() => null),
59
+ loadTranscript: vi.fn(() => []),
60
+ };
61
+ });
62
+ // 4. Mock the main storage index barrel file
63
+ const parentSessionState = {
64
+ messages: [{ role: "user", content: "Hello parent" }],
65
+ seqs: [1],
66
+ };
67
+ vi.mock("../../storage/index.js", () => {
68
+ return {
69
+ appendCompaction: (...args) => mockAppendCompaction.apply(null, args),
70
+ appendMessages: (...args) => mockAppendMessages.apply(null, args),
71
+ getNextMessageSequence: (...args) => mockGetNextMessageSequence.apply(null, args),
72
+ loadTranscriptState: (...args) => mockLoadTranscriptState.apply(null, args),
73
+ loadTranscript: vi.fn(() => []),
74
+ buildChatEntries: vi.fn(() => []),
75
+ getLastTodoWriteArgs: vi.fn(() => null),
76
+ markMessageCompleted: (...args) => mockMarkMessageCompleted.apply(null, args),
77
+ appendSystemMessage: vi.fn(() => 0),
78
+ getSessionTotalTokens: vi.fn(() => ({ inputTokens: 0, outputTokens: 0 })),
79
+ logInteraction: vi.fn(),
80
+ recordUsageEvent: vi.fn(),
81
+ SessionStore: class {
82
+ getWorkspace() {
83
+ return { id: "workspace-1", rootPath: "/dummy" };
84
+ }
85
+ openSession(selector, model, mode, cwd) {
86
+ return {
87
+ id: "session-parent",
88
+ workspaceId: "workspace-1",
89
+ model,
90
+ mode,
91
+ cwdAtStart: cwd,
92
+ cwdLast: cwd,
93
+ status: "active",
94
+ createdAt: new Date(),
95
+ updatedAt: new Date(),
96
+ };
97
+ }
98
+ createSession(model, mode, cwd) {
99
+ return {
100
+ id: "session-child",
101
+ workspaceId: "workspace-1",
102
+ model,
103
+ mode,
104
+ cwdAtStart: cwd,
105
+ cwdLast: cwd,
106
+ status: "active",
107
+ createdAt: new Date(),
108
+ updatedAt: new Date(),
109
+ };
110
+ }
111
+ getRequiredSession(id) {
112
+ return {
113
+ id,
114
+ workspaceId: "workspace-1",
115
+ model: "dummy-model",
116
+ mode: "agent",
117
+ cwdAtStart: "/dummy",
118
+ cwdLast: "/dummy",
119
+ status: "active",
120
+ createdAt: new Date(),
121
+ updatedAt: new Date(),
122
+ };
123
+ }
124
+ touchSession() { }
125
+ setMode() { }
126
+ setStatus() { }
127
+ setModel() { }
128
+ setTitle() { }
129
+ getLatestSession() {
130
+ return null;
131
+ }
132
+ getSessionById() {
133
+ return null;
134
+ }
135
+ },
136
+ };
137
+ });
138
+ // 5. Mock MessageProcessor so we can simulate the messages it writes
139
+ vi.mock("../message-processor.js", () => {
140
+ return {
141
+ MessageProcessor: class {
142
+ deps;
143
+ constructor(deps) {
144
+ this.deps = deps;
145
+ }
146
+ async *run(userMessage) {
147
+ if (userMessage === "trigger error") {
148
+ throw new Error("Simulated MessageProcessor crash");
149
+ }
150
+ if (userMessage === "trigger transient error") {
151
+ globalThis.__transientAttempts = (globalThis.__transientAttempts || 0) + 1;
152
+ if (globalThis.__transientAttempts < 3) {
153
+ throw new Error("fetch failed");
154
+ }
155
+ this.deps.messages.push({ role: "assistant", content: "Sub-session final structured response after retry" }, { role: "tool", content: "Final tool outcome (should be copied)" });
156
+ yield { type: "content", content: "processing after retry..." };
157
+ return;
158
+ }
159
+ if (userMessage === "test consult") {
160
+ if (this.deps.consultParentSession) {
161
+ const advice = await this.deps.consultParentSession("How to handle this error?");
162
+ this.deps.messages.push({ role: "assistant", content: `Advice: ${advice}` }, { role: "tool", content: "Final tool outcome (should be copied)" });
163
+ }
164
+ yield { type: "content", content: "consulting..." };
165
+ return;
166
+ }
167
+ // Simulating writing messages during turn execution
168
+ // Under a sub-session, we want to write intermediate clutter,
169
+ // and check that only final assistant response gets absorbed.
170
+ this.deps.messages.push({ role: "assistant", content: "Intermediate assistant prompt analysis" }, { role: "tool", content: "Intermediate tool result that should be ignored" }, { role: "assistant", content: "Sub-session final structured response" }, { role: "tool", content: "Final tool outcome (should be copied)" });
171
+ yield { type: "content", content: "processing..." };
172
+ }
173
+ },
174
+ };
175
+ });
176
+ describe("Agent - Sub-Session Delegation & Absorption", () => {
177
+ let cleanup = null;
178
+ beforeAll(async () => {
179
+ await loadCatalog();
180
+ });
181
+ beforeEach(() => {
182
+ process.env.MUONROI_FORCE_ROUTING_CLASSIFY = "1";
183
+ vi.clearAllMocks();
184
+ globalThis.__transientAttempts = 0;
185
+ parentSessionState.messages = [{ role: "user", content: "Hello parent" }];
186
+ parentSessionState.seqs = [1];
187
+ mockLoadTranscriptState.mockReturnValue(parentSessionState);
188
+ const mockModelHandle = installMockModel({ fixture: { stream: textOnlyStream("ignored mock stream") } });
189
+ cleanup = () => {
190
+ delete process.env.MUONROI_FORCE_ROUTING_CLASSIFY;
191
+ mockModelHandle.uninstall();
192
+ };
193
+ });
194
+ afterEach(() => {
195
+ cleanup?.();
196
+ });
197
+ it("routes DIRECT_ANSWER directly without spawning a sub-session", async () => {
198
+ mockClassifySubSessionAction.mockResolvedValue({
199
+ action: "DIRECT_ANSWER",
200
+ confidence: 0.95,
201
+ reason: "simple question",
202
+ });
203
+ const agent = new Agent("sk-dummy", undefined, "deepseek-v4-flash", undefined, {
204
+ persistSession: true,
205
+ session: "session-parent",
206
+ });
207
+ // Clear initial messages
208
+ agent.messages = [];
209
+ const generator = agent.processMessage("What is 2+2?");
210
+ const chunks = [];
211
+ for await (const chunk of generator) {
212
+ chunks.push(chunk);
213
+ }
214
+ // Since it's DIRECT_ANSWER, it should NOT try to insert child sub-sessions
215
+ expect(mockDb.prepare).not.toHaveBeenCalledWith(expect.stringContaining("parent_session_id"));
216
+ expect(agent.getSessionId()).toBe("session-parent");
217
+ });
218
+ it("routes SPAWN_SUB_SESSION: creates child session, injects overlay prompt, runs in isolation, and absorbs final outcome", async () => {
219
+ mockClassifySubSessionAction.mockResolvedValue({
220
+ action: "SPAWN_SUB_SESSION",
221
+ confidence: 0.98,
222
+ reason: "requires multi-step tool execution",
223
+ });
224
+ const agent = new Agent("sk-dummy", undefined, "deepseek-v4-flash", undefined, {
225
+ persistSession: true,
226
+ session: "session-parent",
227
+ });
228
+ // Start with empty messages in parent
229
+ agent.messages = [];
230
+ const generator = agent.processMessage("Implement auth and write tests");
231
+ const chunks = [];
232
+ for await (const chunk of generator) {
233
+ chunks.push(chunk);
234
+ }
235
+ // 1. Verify DB parent session link update is triggered
236
+ expect(mockDb.prepare).toHaveBeenCalledWith("UPDATE sessions SET parent_session_id = ? WHERE id = ?");
237
+ // 2. Verify parent session was restored at the end
238
+ expect(agent.getSessionId()).toBe("session-parent");
239
+ // 3. Verify final messages in parent contain the absorbed outcome
240
+ // We expect the final messages list to have absorbed:
241
+ // - last assistant message: "Sub-session final structured response"
242
+ // - subsequent tool message(s): "Final tool outcome (should be copied)"
243
+ // It should NOT contain:
244
+ // - intermediate assistant: "Intermediate assistant prompt analysis"
245
+ // - intermediate tool: "Intermediate tool result that should be ignored"
246
+ expect(agent.messages).toHaveLength(3); // Hello parent + absorbed assistant + absorbed tool
247
+ expect(agent.messages[1]).toEqual({
248
+ role: "assistant",
249
+ content: "Sub-session final structured response",
250
+ });
251
+ expect(agent.messages[2]).toEqual({
252
+ role: "tool",
253
+ content: "Final tool outcome (should be copied)",
254
+ });
255
+ // 4. Verify appendMessages was called to persist the absorbed turn in the parent
256
+ expect(mockAppendMessages).toHaveBeenCalledWith("session-parent", expect.arrayContaining([
257
+ expect.objectContaining({ role: "user", content: "Implement auth and write tests" }),
258
+ expect.objectContaining({ role: "assistant", content: "Sub-session final structured response" }),
259
+ expect.objectContaining({ role: "tool", content: "Final tool outcome (should be copied)" }),
260
+ ]));
261
+ });
262
+ it("restores parent session if sub-session execution crashes", async () => {
263
+ mockClassifySubSessionAction.mockResolvedValue({
264
+ action: "SPAWN_SUB_SESSION",
265
+ confidence: 0.98,
266
+ reason: "requires sub-session",
267
+ });
268
+ const agent = new Agent("sk-dummy", undefined, "deepseek-v4-flash", undefined, {
269
+ persistSession: true,
270
+ session: "session-parent",
271
+ });
272
+ // Start with empty messages in parent
273
+ agent.messages = [];
274
+ // Run a turn that will crash
275
+ let threw = false;
276
+ try {
277
+ const generator = agent.processMessage("trigger error");
278
+ for await (const chunk of generator) {
279
+ // consume stream
280
+ }
281
+ }
282
+ catch (err) {
283
+ expect(err.message).toBe("Simulated MessageProcessor crash");
284
+ threw = true;
285
+ }
286
+ expect(threw).toBe(true);
287
+ // Verify parent session is restored even after crash
288
+ expect(agent.getSessionId()).toBe("session-parent");
289
+ });
290
+ it("resumes an existing active sub-session if found in database", async () => {
291
+ mockClassifySubSessionAction.mockResolvedValue({
292
+ action: "SPAWN_SUB_SESSION",
293
+ confidence: 0.98,
294
+ reason: "requires sub-session",
295
+ });
296
+ // Mock active sub-session row lookup
297
+ mockDb.prepare.mockImplementation((sql) => {
298
+ if (sql.includes("status = 'active'")) {
299
+ return {
300
+ get: () => ({ id: "session-active-child" }),
301
+ run: () => ({ changes: 1 }),
302
+ all: () => [],
303
+ };
304
+ }
305
+ return {
306
+ run: () => ({ changes: 1 }),
307
+ get: () => undefined,
308
+ all: () => [],
309
+ };
310
+ });
311
+ // Mock loadTranscriptState to return a state for the child session or parent session depending on input
312
+ mockLoadTranscriptState.mockImplementation((sessId) => {
313
+ if (sessId === "session-parent") {
314
+ return parentSessionState;
315
+ }
316
+ return {
317
+ messages: [{ role: "user", content: "Previous attempt" }],
318
+ seqs: [1],
319
+ };
320
+ });
321
+ const agent = new Agent("sk-dummy", undefined, "deepseek-v4-flash", undefined, {
322
+ persistSession: true,
323
+ session: "session-parent",
324
+ });
325
+ // Start with empty messages in parent
326
+ agent.messages = [];
327
+ const generator = agent.processMessage("Implement auth and write tests");
328
+ const chunks = [];
329
+ for await (const chunk of generator) {
330
+ chunks.push(chunk);
331
+ }
332
+ // 1. Verify that we yielded the resumption status message
333
+ const hasResumeMessage = chunks.some((c) => c.type === "content" && c.content?.includes("Phát hiện sub-session trước đó bị gián đoạn"));
334
+ expect(hasResumeMessage).toBe(true);
335
+ // 2. Verify that the agent restored parent session at the end
336
+ expect(agent.getSessionId()).toBe("session-parent");
337
+ // 3. Verify that loadTranscriptState was called with the resumed sub-session ID
338
+ expect(mockLoadTranscriptState).toHaveBeenCalledWith("session-active-child");
339
+ });
340
+ it("triggers session rotation if the model decides ROTATE_SESSION", async () => {
341
+ mockClassifySubSessionAction.mockResolvedValue({
342
+ action: "ROTATE_SESSION",
343
+ confidence: 0.9,
344
+ reason: "New topic",
345
+ });
346
+ const agent = new Agent("sk-dummy", undefined, "deepseek-v4-flash", undefined, {
347
+ persistSession: true,
348
+ session: "session-parent",
349
+ });
350
+ agent.messages = [{ role: "user", content: "Switch topics" }];
351
+ const generator = agent.processMessage("switch topic");
352
+ const chunks = [];
353
+ for await (const chunk of generator) {
354
+ chunks.push(chunk);
355
+ }
356
+ // Verify deliberateCompact was called
357
+ expect(mockDeliberateCompact).toHaveBeenCalled();
358
+ // Verify a new session was created (the active session ID changes)
359
+ expect(agent.getSessionId()).toBe("session-child");
360
+ });
361
+ it("triggers session rotation if currentChars exceeds the hard safety threshold (threshold * 2)", async () => {
362
+ process.env.MUONROI_SILENT_ROTATION_THRESHOLD = "100";
363
+ mockClassifySubSessionAction.mockResolvedValue({
364
+ action: "DIRECT_ANSWER",
365
+ confidence: 0.95,
366
+ reason: "simple answer",
367
+ });
368
+ const agent = new Agent("sk-dummy", undefined, "deepseek-v4-flash", undefined, {
369
+ persistSession: true,
370
+ session: "session-parent",
371
+ });
372
+ agent.messages = [{ role: "user", content: "A".repeat(250) }];
373
+ try {
374
+ const generator = agent.processMessage("short message");
375
+ const chunks = [];
376
+ for await (const chunk of generator) {
377
+ chunks.push(chunk);
378
+ }
379
+ // Verify deliberateCompact was called
380
+ expect(mockDeliberateCompact).toHaveBeenCalled();
381
+ // Verify it rotated to session-child
382
+ expect(agent.getSessionId()).toBe("session-child");
383
+ }
384
+ finally {
385
+ delete process.env.MUONROI_SILENT_ROTATION_THRESHOLD;
386
+ }
387
+ });
388
+ it("does not resume a stale active sub-session (timeout), marks it as abandoned, and forks a new child session", async () => {
389
+ mockClassifySubSessionAction.mockResolvedValue({
390
+ action: "SPAWN_SUB_SESSION",
391
+ confidence: 0.98,
392
+ reason: "requires sub-session",
393
+ });
394
+ const twentyMinsAgo = new Date(Date.now() - 20 * 60 * 1000).toISOString();
395
+ // Mock active sub-session row lookup returning a stale row
396
+ mockDb.prepare.mockImplementation((sql) => {
397
+ if (sql.includes("status = 'active'")) {
398
+ return {
399
+ get: () => ({ id: "session-stale-child", updated_at: twentyMinsAgo }),
400
+ run: () => ({ changes: 1 }),
401
+ all: () => [],
402
+ };
403
+ }
404
+ return {
405
+ run: () => ({ changes: 1 }),
406
+ get: () => undefined,
407
+ all: () => [],
408
+ };
409
+ });
410
+ const agent = new Agent("sk-dummy", undefined, "deepseek-v4-flash", undefined, {
411
+ persistSession: true,
412
+ session: "session-parent",
413
+ });
414
+ // Start with empty messages in parent
415
+ agent.messages = [];
416
+ const generator = agent.processMessage("Implement auth and write tests");
417
+ const chunks = [];
418
+ for await (const chunk of generator) {
419
+ chunks.push(chunk);
420
+ }
421
+ // 1. Verify that we did NOT yield the resumption message
422
+ const hasResumeMessage = chunks.some((c) => c.type === "content" && c.content?.includes("Phát hiện sub-session trước đó bị gián đoạn"));
423
+ expect(hasResumeMessage).toBe(false);
424
+ // 2. Verify that we marked the stale sub-session as abandoned
425
+ expect(mockDb.prepare).toHaveBeenCalledWith("UPDATE sessions SET status = 'abandoned', updated_at = ? WHERE id = ?");
426
+ // 3. Verify that we created a new session
427
+ expect(agent.getSessionId()).toBe("session-parent");
428
+ });
429
+ it("registers and successfully executes consultParentSession tool in sub-session", async () => {
430
+ mockClassifySubSessionAction.mockResolvedValue({
431
+ action: "SPAWN_SUB_SESSION",
432
+ confidence: 0.98,
433
+ reason: "requires sub-session for consultation test",
434
+ });
435
+ mockDb.prepare.mockImplementation((sql) => {
436
+ if (sql.includes("SELECT parent_session_id")) {
437
+ return {
438
+ get: () => ({ parent_session_id: "session-parent" }),
439
+ run: () => ({ changes: 1 }),
440
+ all: () => [],
441
+ };
442
+ }
443
+ if (sql.includes("SELECT model_id")) {
444
+ return {
445
+ get: () => ({ model_id: "deepseek-v4-flash" }),
446
+ run: () => ({ changes: 1 }),
447
+ all: () => [],
448
+ };
449
+ }
450
+ return {
451
+ run: () => ({ changes: 1 }),
452
+ get: () => undefined,
453
+ all: () => [],
454
+ };
455
+ });
456
+ mockLoadTranscriptState.mockReturnValue({
457
+ messages: [{ role: "user", content: "Hello parent" }],
458
+ seqs: [1],
459
+ });
460
+ const agent = new Agent("sk-dummy", undefined, "deepseek-v4-flash", undefined, {
461
+ persistSession: true,
462
+ session: "session-parent",
463
+ });
464
+ // Start with empty messages in parent
465
+ agent.messages = [];
466
+ const generator = agent.processMessage("test consult");
467
+ const chunks = [];
468
+ for await (const chunk of generator) {
469
+ chunks.push(chunk);
470
+ }
471
+ // Verify that generateText was called to advice the sub-session
472
+ expect(mockGenerateText).toHaveBeenCalled();
473
+ // Verify parent session is restored
474
+ expect(agent.getSessionId()).toBe("session-parent");
475
+ // The parent's final messages should have absorbed the assistant message which contains Advice
476
+ expect(agent.messages).toHaveLength(3); // Hello parent + absorbed assistant (advice) + absorbed tool
477
+ expect(agent.messages[1].content).toContain("Advice: Mocked parent advice response");
478
+ });
479
+ it("automatically retries transient errors in sub-session before succeeding", async () => {
480
+ mockClassifySubSessionAction.mockResolvedValue({
481
+ action: "SPAWN_SUB_SESSION",
482
+ confidence: 0.98,
483
+ reason: "requires sub-session for transient error test",
484
+ });
485
+ const agent = new Agent("sk-dummy", undefined, "deepseek-v4-flash", undefined, {
486
+ persistSession: true,
487
+ session: "session-parent",
488
+ });
489
+ // Start with empty messages in parent
490
+ agent.messages = [];
491
+ const generator = agent.processMessage("trigger transient error");
492
+ const chunks = [];
493
+ for await (const chunk of generator) {
494
+ chunks.push(chunk);
495
+ }
496
+ // Verify parent session is restored
497
+ expect(agent.getSessionId()).toBe("session-parent");
498
+ console.log("TEST MESSAGES: ", JSON.stringify(agent.messages, null, 2));
499
+ // The sub-session should have succeeded on the 3rd attempt and its output should be absorbed
500
+ expect(agent.messages).toHaveLength(3); // Hello parent + absorbed assistant (after retry) + absorbed tool
501
+ expect(agent.messages[1].content).toContain("Sub-session final structured response after retry");
502
+ // Verify warning chunks were emitted
503
+ const warningChunks = chunks.filter((c) => c.type === "content" && c.content?.includes("Có lỗi mạng hoặc rate limit xảy ra trong sub-session"));
504
+ expect(warningChunks.length).toBe(2);
505
+ });
506
+ });
507
+ //# sourceMappingURL=sub-session-delegation.test.js.map
@@ -21,6 +21,7 @@
21
21
  * cannot inject shell commands.
22
22
  */
23
23
  import { execFile } from "node:child_process";
24
+ import { logger } from "../utils/logger.js";
24
25
  import { resolve } from "node:path";
25
26
  import { promisify } from "node:util";
26
27
  const pexecFile = promisify(execFile);
@@ -114,7 +115,7 @@ export async function gateStagedPaths(cwd, paths, budgetMs = 9_000) {
114
115
  new Promise((r) => setTimeout(() => r(TIMED_OUT), budgetMs)),
115
116
  ]);
116
117
  if (outcome === TIMED_OUT) {
117
- console.error(`[commit-gate] LSP gate exceeded ${budgetMs}ms — allowing commit (fail-open)`);
118
+ logger.error("orchestrator", `[commit-gate] LSP gate exceeded ${budgetMs}ms — allowing commit (fail-open)`);
118
119
  return { ok: true };
119
120
  }
120
121
  if (errorFiles.length === 0)
@@ -123,7 +124,8 @@ export async function gateStagedPaths(cwd, paths, budgetMs = 9_000) {
123
124
  return { ok: false, summary };
124
125
  }
125
126
  catch (err) {
126
- console.error(`[commit-gate] gate failed open: ${err?.message}`, {
127
+ logger.error("orchestrator", "gate failed open", {
128
+ error: err,
127
129
  stack: err?.stack?.split("\n").slice(0, 3),
128
130
  });
129
131
  return { ok: true };
@@ -282,7 +284,7 @@ export async function maybeAutoCommitTurn(opts) {
282
284
  return { committed: false, reason: "no-agent-changes" };
283
285
  const add = await git(cwd, ["add", "--", ...newPaths]);
284
286
  if (!add.ok) {
285
- console.error(`[auto-commit] git add failed for ${newPaths.length} path(s) in ${cwd}`);
287
+ logger.error("orchestrator", `[auto-commit] git add failed for ${newPaths.length} path(s) in ${cwd}`);
286
288
  return { committed: false, reason: "add-failed" };
287
289
  }
288
290
  // G1 quality gate: do NOT auto-commit code that fails LSP error checks. The
@@ -290,7 +292,7 @@ export async function maybeAutoCommitTurn(opts) {
290
292
  // will see/fix it next turn. Staged paths stay staged (idempotent).
291
293
  const gate = await gateStagedPaths(cwd, newPaths);
292
294
  if (!gate.ok) {
293
- console.error(`[auto-commit] skipped — staged files have LSP errors:\n${gate.summary}`);
295
+ logger.error("orchestrator", `[auto-commit] skipped — staged files have LSP errors:\n${gate.summary}`);
294
296
  return { committed: false, reason: "lsp-errors", detail: gate.summary };
295
297
  }
296
298
  // Separate -m flags → git inserts the blank line between subject and
@@ -306,7 +308,7 @@ export async function maybeAutoCommitTurn(opts) {
306
308
  ...newPaths,
307
309
  ]);
308
310
  if (!commit.ok) {
309
- console.error(`[auto-commit] git commit failed in ${cwd} (a pre-commit/commit-msg hook may have rejected it)`);
311
+ logger.error("orchestrator", `[auto-commit] git commit failed in ${cwd} (a pre-commit/commit-msg hook may have rejected it)`);
310
312
  return { committed: false, reason: "commit-failed" };
311
313
  }
312
314
  const head = await git(cwd, ["rev-parse", "--short", "HEAD"]);
@@ -330,7 +332,7 @@ export async function commitSpecificPaths(cwd, paths, message) {
330
332
  return { committed: false, reason: "not-a-repo" };
331
333
  const add = await git(cwd, ["add", "--", ...safe]);
332
334
  if (!add.ok) {
333
- console.error(`[git_commit] git add failed for ${safe.length} path(s) in ${cwd}`);
335
+ logger.error("orchestrator", `[git_commit] git add failed for ${safe.length} path(s) in ${cwd}`);
334
336
  return { committed: false, reason: "add-failed" };
335
337
  }
336
338
  // Only commit if these paths actually have staged changes (idempotent across
@@ -351,7 +353,7 @@ export async function commitSpecificPaths(cwd, paths, message) {
351
353
  : ["-m", subject, "-m", AUTO_COMMIT_ATTRIBUTION];
352
354
  const commit = await git(cwd, ["commit", ...mArgs, "--", ...safe]);
353
355
  if (!commit.ok) {
354
- console.error(`[git_commit] git commit failed in ${cwd} (a pre-commit/commit-msg hook may have rejected it)`);
356
+ logger.error("orchestrator", `[git_commit] git commit failed in ${cwd} (a pre-commit/commit-msg hook may have rejected it)`);
355
357
  return { committed: false, reason: "commit-failed" };
356
358
  }
357
359
  const head = await git(cwd, ["rev-parse", "--short", "HEAD"]);
@@ -21,6 +21,7 @@ import type { TurnRunnerDepsBase } from "./turn-runner-deps.js";
21
21
  * subsequent turns). Method callbacks delegate to Agent private methods.
22
22
  */
23
23
  export interface MessageProcessorDeps extends TurnRunnerDepsBase {
24
+ readonly isSubSession?: boolean;
24
25
  /** Live messageSeqs array (mutated by push; parallel to messages). */
25
26
  readonly messageSeqs: Array<number | null>;
26
27
  /** Session bookkeeping. */
@@ -75,6 +76,7 @@ export interface MessageProcessorDeps extends TurnRunnerDepsBase {
75
76
  setMessages(messages: ModelMessage[]): void;
76
77
  requireProvider(): LegacyProvider;
77
78
  emitSubagentStatus(status: SubagentStatus | null): void;
79
+ consultParentSession?: (question: string) => Promise<string>;
78
80
  fireHook(input: unknown, signal?: AbortSignal): Promise<{
79
81
  blocked: boolean;
80
82
  blockingErrors: Array<{
@@ -692,6 +692,10 @@ export class MessageProcessor {
692
692
  const _pilResponseTools = getResponseToolSet(pilCtx, deps.providerId);
693
693
  const _hasResponseTools = Object.keys(_pilResponseTools).length > 0;
694
694
  const systemParts = buildSystemPromptParts(deps.bash.getCwd(), deps.mode, deps.bash.getSandboxMode(), deps.getPlanContext(), subagents, deps.bash.getSandboxSettings(), deps.providerId, deps.getResumeDigest(), { chitchat: isChitchat });
695
+ // F3c — tool-turn system prompt: same context, but skips native-capabilities
696
+ // and skills sections (~4K tokens) that the model already saw on the first
697
+ // call. The orchestrator switches to this on the 2nd+ streamText invocation.
698
+ const toolTurnParts = buildSystemPromptParts(deps.bash.getCwd(), deps.mode, deps.bash.getSandboxMode(), deps.getPlanContext(), subagents, deps.bash.getSandboxSettings(), deps.providerId, deps.getResumeDigest(), { chitchat: isChitchat, toolTurn: true });
695
699
  if (deps.getResumeDigest())
696
700
  deps.setResumeDigest(null);
697
701
  // Skip vision/playwright guidance unless the user's message has a URL
@@ -700,6 +704,8 @@ export class MessageProcessor {
700
704
  const _browserGuidanceNeeded = /https?:\/\/\S+|\b(screenshot|browser|playwright|chrome|figma|canva|render|webpage|website|url|hyperlink|navigate|click|scrape)\b/i.test(userMessage);
701
705
  const playwrightGuidance = isChitchat || !_browserGuidanceNeeded ? "" : getVisionGuidanceForTextOnly(turnModelId);
702
706
  const system = applyModelConstraints(applyPilSuffix(`${systemParts.staticPrefix}${playwrightGuidance}${systemParts.dynamicSuffix}`, pilCtx, _hasResponseTools), turnModelId);
707
+ // Tool-turn system: same template as system but with toolTurn-prefix
708
+ const toolTurnSystem = applyModelConstraints(applyPilSuffix(`${toolTurnParts.staticPrefix}${playwrightGuidance}${toolTurnParts.dynamicSuffix}`, pilCtx, _hasResponseTools), turnModelId);
703
709
  const runtime = resolveModelRuntime(provider, turnModelId);
704
710
  const modelInfo = runtime.modelInfo;
705
711
  // SAMR: Step-Aware Model Routing — downgrade to fast model for tool-execution
@@ -784,6 +790,7 @@ export class MessageProcessor {
784
790
  taskHash,
785
791
  provider,
786
792
  system,
793
+ toolTurnSystem,
787
794
  routerStore,
788
795
  attemptedOverflowRecovery,
789
796
  patternLoopForceHalt,
@@ -24,6 +24,7 @@ export declare class Agent {
24
24
  /** UI-registered live-queue steer drain; see Agent.setSteerDrain. */
25
25
  private steerDrain;
26
26
  private maxToolRounds;
27
+ private hardMaxToolRounds;
27
28
  private mode;
28
29
  private modelId;
29
30
  private maxTokens;
@@ -125,6 +126,7 @@ export declare class Agent {
125
126
  getCwd(): string;
126
127
  setCwd(dir: string): void;
127
128
  getMessages(): ModelMessage[];
129
+ setMessages(messages: ModelMessage[]): void;
128
130
  listSchedules(): Promise<StoredSchedule[]>;
129
131
  removeSchedule(id: string): Promise<string>;
130
132
  getScheduleDaemonStatus(): Promise<ScheduleDaemonStatus>;
@@ -233,6 +235,7 @@ export declare class Agent {
233
235
  private appendMidTurnMessages;
234
236
  private appendCompletedTurn;
235
237
  private fireHook;
238
+ private estimateConversationChars;
236
239
  processMessage(userMessage: string, observer?: ProcessMessageObserver, images?: Array<{
237
240
  path: string;
238
241
  mediaType: string;