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.
- package/LICENSE +21 -21
- package/README.md +122 -122
- package/dist/src/agent-harness/mock-model.js +112 -4
- package/dist/src/cli/cost-forensics.js +17 -14
- package/dist/src/council/prompts.js +20 -20
- package/dist/src/flow/compaction/index.d.ts +1 -0
- package/dist/src/flow/compaction/index.js +4 -0
- package/dist/src/generated/version.d.ts +1 -1
- package/dist/src/generated/version.js +1 -1
- package/dist/src/index.js +4 -2
- package/dist/src/lsp/manager.js +11 -3
- package/dist/src/lsp/manager.test.js +39 -0
- package/dist/src/mcp/auto-setup.js +0 -8
- package/dist/src/mcp/oauth-callback.js +2 -2
- package/dist/src/mcp/parse-headers.test.js +14 -14
- package/dist/src/mcp/self-verify-jobs.d.ts +1 -0
- package/dist/src/mcp/self-verify-runner.js +1 -1
- package/dist/src/mcp/setup-guide-text.js +74 -74
- package/dist/src/mcp/smoke.test.js +43 -43
- package/dist/src/ops/doctor.js +7 -7
- package/dist/src/orchestrator/__tests__/batch-turn-runner.test.js +1 -0
- package/dist/src/orchestrator/__tests__/message-processor.test.js +1 -0
- package/dist/src/orchestrator/__tests__/prompts.test.d.ts +1 -0
- package/dist/src/orchestrator/__tests__/prompts.test.js +88 -0
- package/dist/src/orchestrator/__tests__/sub-session-delegation.test.d.ts +1 -0
- package/dist/src/orchestrator/__tests__/sub-session-delegation.test.js +507 -0
- package/dist/src/orchestrator/auto-commit.js +9 -7
- package/dist/src/orchestrator/message-processor.d.ts +2 -0
- package/dist/src/orchestrator/message-processor.js +7 -0
- package/dist/src/orchestrator/orchestrator.d.ts +3 -0
- package/dist/src/orchestrator/orchestrator.js +362 -28
- package/dist/src/orchestrator/pending-calls.js +2 -1
- package/dist/src/orchestrator/preprocessor.js +3 -2
- package/dist/src/orchestrator/prompts.d.ts +8 -0
- package/dist/src/orchestrator/prompts.js +65 -18
- package/dist/src/orchestrator/sub-agent-cap.d.ts +2 -0
- package/dist/src/orchestrator/sub-agent-cap.js +12 -4
- package/dist/src/orchestrator/subagent-compactor.d.ts +7 -0
- package/dist/src/orchestrator/subagent-compactor.js +65 -14
- package/dist/src/orchestrator/subagent-compactor.spec.js +125 -9
- package/dist/src/orchestrator/tool-engine.d.ts +2 -0
- package/dist/src/orchestrator/tool-engine.js +80 -21
- package/dist/src/orchestrator/turn-runner-deps.d.ts +2 -0
- package/dist/src/pil/__tests__/llm-classify.test.js +79 -1
- package/dist/src/pil/layer1-intent.js +37 -37
- package/dist/src/pil/layer2_5-ponytail.js +8 -8
- package/dist/src/pil/llm-classify.d.ts +11 -0
- package/dist/src/pil/llm-classify.js +193 -0
- package/dist/src/product-loop/done-gate.js +3 -3
- package/dist/src/product-loop/loop-driver.js +18 -18
- package/dist/src/product-loop/progress-snapshot.js +4 -4
- package/dist/src/providers/mcp-vision-bridge.js +48 -48
- package/dist/src/reporter/index.js +1 -1
- package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
- package/dist/src/scaffold/bb-quality-gate.js +5 -5
- package/dist/src/scaffold/continuation-prompt.js +60 -60
- package/dist/src/scaffold/init-new.js +453 -453
- package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
- package/dist/src/self-qa/agentic-loop.js +11 -2
- package/dist/src/storage/__tests__/hierarchical-session.test.d.ts +1 -0
- package/dist/src/storage/__tests__/hierarchical-session.test.js +161 -0
- package/dist/src/storage/__tests__/migrations.test.js +2 -2
- package/dist/src/storage/__tests__/sweep-stale-pending.test.js +2 -2
- package/dist/src/storage/index.d.ts +1 -1
- package/dist/src/storage/index.js +1 -1
- package/dist/src/storage/interaction-log.js +5 -5
- package/dist/src/storage/migrations.js +135 -123
- package/dist/src/storage/session-experience-store.js +4 -4
- package/dist/src/storage/sessions.js +43 -43
- package/dist/src/storage/transcript-response-entry.test.js +18 -5
- package/dist/src/storage/transcript.d.ts +2 -0
- package/dist/src/storage/transcript.js +223 -97
- package/dist/src/storage/usage.js +34 -17
- package/dist/src/storage/workspaces.js +12 -12
- package/dist/src/tools/native-tools.js +10 -1
- package/dist/src/tools/registry.d.ts +1 -0
- package/dist/src/tools/registry.js +30 -2
- package/dist/src/types/index.d.ts +1 -1
- package/dist/src/ui/app.js +0 -0
- package/dist/src/ui/slash/council-inspect.js +4 -4
- package/dist/src/ui/slash/expand.js +14 -1
- package/dist/src/ui/use-app-logic.js +0 -0
- package/dist/src/utils/__tests__/compaction-caps.test.js +9 -9
- package/dist/src/utils/__tests__/llm-deadline.test.js +16 -4
- package/dist/src/utils/__tests__/logger.test.d.ts +1 -0
- package/dist/src/utils/__tests__/logger.test.js +115 -0
- package/dist/src/utils/__tests__/settings-agent-first.test.d.ts +1 -0
- package/dist/src/utils/__tests__/settings-agent-first.test.js +48 -0
- package/dist/src/utils/clipboard-image.js +23 -23
- package/dist/src/utils/install-manager.d.ts +1 -0
- package/dist/src/utils/install-manager.js +76 -7
- package/dist/src/utils/install-manager.test.js +43 -1
- package/dist/src/utils/logger.d.ts +28 -0
- package/dist/src/utils/logger.js +171 -0
- package/dist/src/utils/settings.d.ts +9 -1
- package/dist/src/utils/settings.js +59 -22
- package/dist/src/utils/side-question.js +2 -2
- package/dist/src/utils/skills.js +3 -3
- package/dist/src/utils/update-checker.test.js +5 -1
- package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|