muonroi-cli 1.6.1 → 1.6.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.
- package/dist/src/generated/version.d.ts +1 -1
- package/dist/src/generated/version.js +1 -1
- package/dist/src/mcp/__tests__/client-pool.spec.js +21 -0
- package/dist/src/mcp/client-pool.js +22 -0
- package/dist/src/mcp/runtime.d.ts +11 -0
- package/dist/src/orchestrator/message-processor.js +12 -0
- package/dist/src/orchestrator/subagent-compactor.d.ts +10 -0
- package/dist/src/orchestrator/subagent-compactor.js +14 -0
- package/dist/src/orchestrator/subagent-compactor.spec.js +54 -0
- package/dist/src/pil/__tests__/layer4-gsd.test.js +10 -0
- package/dist/src/pil/layer4-gsd.js +10 -5
- package/dist/src/storage/tool-results.js +23 -0
- package/dist/src/storage/tool-results.test.d.ts +1 -0
- package/dist/src/storage/tool-results.test.js +48 -0
- package/dist/src/ui/__tests__/markdown-render.test.js +17 -0
- package/dist/src/ui/app.js +3 -9
- package/dist/src/ui/markdown-render.js +12 -0
- package/package.json +1 -1
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const PACKAGE_VERSION = "1.6.
|
|
1
|
+
export declare const PACKAGE_VERSION = "1.6.3";
|
|
2
2
|
export declare const PACKAGE_DESCRIPTION = "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/sync-version.cjs. DO NOT EDIT BY HAND.
|
|
2
2
|
// Sourced from package.json at build time so it survives bun --compile bundling.
|
|
3
|
-
export const PACKAGE_VERSION = "1.6.
|
|
3
|
+
export const PACKAGE_VERSION = "1.6.3";
|
|
4
4
|
export const PACKAGE_DESCRIPTION = "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.";
|
|
5
5
|
//# sourceMappingURL=version.js.map
|
|
@@ -109,6 +109,27 @@ describe("acquireMcpTools — cross-turn client pool", () => {
|
|
|
109
109
|
expect(results.every((r) => r === "pong")).toBe(true);
|
|
110
110
|
expect(connectOneServer).toHaveBeenCalledTimes(2); // 14 failures → exactly ONE shared reconnect
|
|
111
111
|
});
|
|
112
|
+
it("waits for a criticalServerId past the normal deadline so it lands THIS turn (session 584ba476c07a)", async () => {
|
|
113
|
+
// Normal deadline is 500ms (mock). docs connects at ~700ms — past the normal
|
|
114
|
+
// deadline but within the critical window → must be included when critical.
|
|
115
|
+
connectOneServer.mockImplementation((s) => new Promise((res) => {
|
|
116
|
+
if (s.id === "docs")
|
|
117
|
+
setTimeout(() => res(connected(s.id)), 700);
|
|
118
|
+
else
|
|
119
|
+
res(connected(s.id));
|
|
120
|
+
}));
|
|
121
|
+
const b = await acquireMcpTools([srv("docs")], { criticalServerIds: ["docs"], criticalDeadlineMs: 3000 });
|
|
122
|
+
expect(Object.keys(b.tools)).toContain("mcp_docs__ping");
|
|
123
|
+
expect(b.errors).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
it("without criticalServerIds, a slow server is reported still-connecting (available next turn)", async () => {
|
|
126
|
+
connectOneServer.mockImplementation((s) => new Promise((res) => {
|
|
127
|
+
setTimeout(() => res(connected(s.id)), 700);
|
|
128
|
+
}));
|
|
129
|
+
const b = await acquireMcpTools([srv("docs")]);
|
|
130
|
+
expect(Object.keys(b.tools)).not.toContain("mcp_docs__ping");
|
|
131
|
+
expect(b.errors.some((e) => /still connecting/.test(e))).toBe(true);
|
|
132
|
+
});
|
|
112
133
|
it("keys by cwd/config — a different command reconnects rather than reusing", async () => {
|
|
113
134
|
connectOneServer.mockImplementation(async (s) => connected(s.id));
|
|
114
135
|
await acquireMcpTools([
|
|
@@ -179,6 +179,28 @@ export async function acquireMcpTools(servers, opts) {
|
|
|
179
179
|
await Promise.race([Promise.allSettled(attempts), deadline]);
|
|
180
180
|
if (deadlineTimer)
|
|
181
181
|
clearTimeout(deadlineTimer);
|
|
182
|
+
// Critical-server extended wait: a turn that MUST have a specific server (e.g.
|
|
183
|
+
// muonroi-docs on an ecosystem question) waits for just that server's connect
|
|
184
|
+
// beyond the normal deadline, so a cold first-connect is included THIS turn
|
|
185
|
+
// rather than reported "still connecting → next turn". Only the named servers
|
|
186
|
+
// are awaited; everything already settled is untouched (no added latency for
|
|
187
|
+
// normal turns, which pass no criticalServerIds).
|
|
188
|
+
const critical = new Set((opts?.criticalServerIds ?? []).filter(Boolean));
|
|
189
|
+
if (critical.size > 0) {
|
|
190
|
+
const pendingIdx = enabled.map((s, i) => ({ s, i })).filter(({ s, i }) => critical.has(s.id) && !slots[i].done);
|
|
191
|
+
if (pendingIdx.length > 0) {
|
|
192
|
+
const criticalDeadlineMs = Math.max(deadlineMs, opts?.criticalDeadlineMs ?? 8000);
|
|
193
|
+
const extraMs = Math.max(0, criticalDeadlineMs - deadlineMs);
|
|
194
|
+
let extraTimer;
|
|
195
|
+
const extraDeadline = new Promise((resolve) => {
|
|
196
|
+
extraTimer = setTimeout(resolve, extraMs);
|
|
197
|
+
extraTimer.unref?.();
|
|
198
|
+
});
|
|
199
|
+
await Promise.race([Promise.allSettled(pendingIdx.map(({ i }) => attempts[i])), extraDeadline]);
|
|
200
|
+
if (extraTimer)
|
|
201
|
+
clearTimeout(extraTimer);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
182
204
|
for (let i = 0; i < slots.length; i++) {
|
|
183
205
|
const slot = slots[i];
|
|
184
206
|
if (slot.done) {
|
|
@@ -8,6 +8,17 @@ export interface McpToolBundle {
|
|
|
8
8
|
}
|
|
9
9
|
export interface McpBuildOptions {
|
|
10
10
|
onOAuthRequired?: (serverId: string, url: URL) => void;
|
|
11
|
+
/**
|
|
12
|
+
* Server ids the CURRENT turn critically needs (e.g. muonroi-docs on an
|
|
13
|
+
* ecosystem question). acquireMcpTools waits for these specifically beyond the
|
|
14
|
+
* normal build deadline — up to `criticalDeadlineMs` — so a cold first-connect
|
|
15
|
+
* is included THIS turn instead of "ready next turn" (session 584ba476c07a:
|
|
16
|
+
* first ecosystem question missed muonroi-docs while it was still warming).
|
|
17
|
+
* Other servers are unaffected — only the named ones get the extended wait.
|
|
18
|
+
*/
|
|
19
|
+
criticalServerIds?: string[];
|
|
20
|
+
/** Extended ceiling (ms) for criticalServerIds. Default 8000. */
|
|
21
|
+
criticalDeadlineMs?: number;
|
|
11
22
|
}
|
|
12
23
|
/**
|
|
13
24
|
* Total wall-clock budget for building the MCP tool set. Servers connect in
|
|
@@ -60,6 +60,7 @@ import * as phaseTracker from "../ee/phase-tracker.js";
|
|
|
60
60
|
import { buildScope as buildScopeForVeto } from "../ee/scope.js";
|
|
61
61
|
import { fireTrajectoryEvent } from "../ee/session-trajectory.js";
|
|
62
62
|
import { getTenantId as getTenantIdForVeto } from "../ee/tenant.js";
|
|
63
|
+
import { mentionsEcosystemScope } from "../gsd/directives.js";
|
|
63
64
|
import { acquireMcpTools } from "../mcp/client-pool.js";
|
|
64
65
|
import { dropRedundantFsMcpTools, filterMcpServersByMessage } from "../mcp/smart-filter.js";
|
|
65
66
|
import { getModelInfo } from "../models/registry.js";
|
|
@@ -1022,6 +1023,16 @@ export class MessageProcessor {
|
|
|
1022
1023
|
const filteredServers = filterMcpServersByMessage(loadMcpServers(), userMessage, {
|
|
1023
1024
|
disabled: process.env.MUONROI_DISABLE_SMART_MCP === "1",
|
|
1024
1025
|
});
|
|
1026
|
+
// Ecosystem question → muonroi-docs is the authoritative source the
|
|
1027
|
+
// agent is nudged to consult FIRST. Wait for it specifically beyond the
|
|
1028
|
+
// normal deadline so a cold first-connect lands THIS turn instead of
|
|
1029
|
+
// "ready next turn" (session 584ba476c07a: first ecosystem question
|
|
1030
|
+
// missed docs while warming → agent guessed from local files).
|
|
1031
|
+
const criticalServerIds = mentionsEcosystemScope(userMessage)
|
|
1032
|
+
? filteredServers
|
|
1033
|
+
.filter((s) => /(^|[-_])docs([-_]|$)/.test(s.id) && /muonroi/i.test(s.id))
|
|
1034
|
+
.map((s) => s.id)
|
|
1035
|
+
: undefined;
|
|
1025
1036
|
// MCP non-blocking: acquireMcpTools self-bounds — it connects servers
|
|
1026
1037
|
// in parallel and returns PARTIAL results at its internal deadline
|
|
1027
1038
|
// (fast/cached servers included; slow first-connects reported in
|
|
@@ -1038,6 +1049,7 @@ export class MessageProcessor {
|
|
|
1038
1049
|
// command-injection vector the old exec() opener had.
|
|
1039
1050
|
openUrl(url);
|
|
1040
1051
|
},
|
|
1052
|
+
...(criticalServerIds && criticalServerIds.length > 0 ? { criticalServerIds } : {}),
|
|
1041
1053
|
});
|
|
1042
1054
|
}
|
|
1043
1055
|
catch (err) {
|
|
@@ -111,6 +111,16 @@ export declare const SUBAGENT_COMPACT_DEFAULT_KEEP_LAST = 3;
|
|
|
111
111
|
* the native contract + native-capabilities tell the agent to rely on for "task finished?" and
|
|
112
112
|
* rehydrate during long meta conversations about CLI/PIL/compaction/EE. */
|
|
113
113
|
export declare const IMPORTANT_TOOL_NAMES: readonly ["read_file", "grep", "lsp", "bash", "ee_query", "usage_forensics", "selfverify_start", "selfverify_result", "selfverify_status"];
|
|
114
|
+
/**
|
|
115
|
+
* MCP tool prefixes whose results are an AUTHORITATIVE source the agent is
|
|
116
|
+
* explicitly steered to fetch FIRST and ground on (the ECOSYSTEM_DOCS_NUDGE in
|
|
117
|
+
* src/gsd/directives.ts). Eliding them defeats the nudge — the agent calls the
|
|
118
|
+
* ecosystem docs, then compaction discards them and it goes blind on the very
|
|
119
|
+
* source it was told to trust (session 584ba476c07a: mcp_muonroi-docs__setup_guide
|
|
120
|
+
* + bb_recipe_list elided, ee_unavailable, 0 rehydrated → "partially blind").
|
|
121
|
+
* Keep their results verbatim so the agent stays grounded across the session.
|
|
122
|
+
*/
|
|
123
|
+
export declare const HIGH_VALUE_MCP_PREFIXES: readonly ["mcp_muonroi-docs__"];
|
|
114
124
|
/**
|
|
115
125
|
* Heuristic: keep full (no stub) for high-signal tool results.
|
|
116
126
|
* Signals: allowlist tool + (error/todo/plan/keyfile/large output or explicit keep list).
|
|
@@ -73,6 +73,16 @@ export const IMPORTANT_TOOL_NAMES = [
|
|
|
73
73
|
"selfverify_result",
|
|
74
74
|
"selfverify_status",
|
|
75
75
|
];
|
|
76
|
+
/**
|
|
77
|
+
* MCP tool prefixes whose results are an AUTHORITATIVE source the agent is
|
|
78
|
+
* explicitly steered to fetch FIRST and ground on (the ECOSYSTEM_DOCS_NUDGE in
|
|
79
|
+
* src/gsd/directives.ts). Eliding them defeats the nudge — the agent calls the
|
|
80
|
+
* ecosystem docs, then compaction discards them and it goes blind on the very
|
|
81
|
+
* source it was told to trust (session 584ba476c07a: mcp_muonroi-docs__setup_guide
|
|
82
|
+
* + bb_recipe_list elided, ee_unavailable, 0 rehydrated → "partially blind").
|
|
83
|
+
* Keep their results verbatim so the agent stays grounded across the session.
|
|
84
|
+
*/
|
|
85
|
+
export const HIGH_VALUE_MCP_PREFIXES = ["mcp_muonroi-docs__"];
|
|
76
86
|
/**
|
|
77
87
|
* Heuristic: keep full (no stub) for high-signal tool results.
|
|
78
88
|
* Signals: allowlist tool + (error/todo/plan/keyfile/large output or explicit keep list).
|
|
@@ -86,6 +96,10 @@ export function isHighValueToolResult(toolName, preview, explicitKeepIds, toolCa
|
|
|
86
96
|
// work/findings. Truncating it causes the agent to think it lost its answer.
|
|
87
97
|
if (name.startsWith("respond_"))
|
|
88
98
|
return true;
|
|
99
|
+
// Authoritative ecosystem-docs MCP results: the agent is nudged to fetch these
|
|
100
|
+
// FIRST, so eliding them strands it (session 584ba476c07a). Keep verbatim.
|
|
101
|
+
if (HIGH_VALUE_MCP_PREFIXES.some((p) => name.startsWith(p)))
|
|
102
|
+
return true;
|
|
89
103
|
if (IMPORTANT_TOOL_NAMES.includes(name)) {
|
|
90
104
|
const p = preview.toLowerCase();
|
|
91
105
|
if (/error|fail|todo|plan|done|✔|blocked|critical/.test(p))
|
|
@@ -110,6 +110,60 @@ describe("subagent-compactor: compactSubAgentMessages", () => {
|
|
|
110
110
|
expect(out[out.length - i]).toBe(msgs[msgs.length - i]);
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
|
+
it("keeps an OLDER authoritative muonroi-docs MCP result verbatim while eliding low-value peers (session 584ba476c07a)", () => {
|
|
114
|
+
// History: an early muonroi-docs setup_guide (older than keepLast=3) + many
|
|
115
|
+
// low-value tool turns. The ecosystem doc must survive compaction so the
|
|
116
|
+
// agent stays grounded on the source it was nudged to fetch first.
|
|
117
|
+
const docsValue = bigText("ECOSYSTEM_DOCS", 6); // ~6kb authoritative payload
|
|
118
|
+
const msgs = [
|
|
119
|
+
{ role: "system", content: "You are the agent." },
|
|
120
|
+
{ role: "user", content: "ecosystem question" },
|
|
121
|
+
{
|
|
122
|
+
role: "assistant",
|
|
123
|
+
content: [
|
|
124
|
+
{
|
|
125
|
+
type: "tool-call",
|
|
126
|
+
toolCallId: "call_docs",
|
|
127
|
+
toolName: "mcp_muonroi-docs__setup_guide",
|
|
128
|
+
input: JSON.stringify({ component: "ecosystem" }),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
role: "tool",
|
|
134
|
+
content: [
|
|
135
|
+
{
|
|
136
|
+
type: "tool-result",
|
|
137
|
+
toolCallId: "call_docs",
|
|
138
|
+
toolName: "mcp_muonroi-docs__setup_guide",
|
|
139
|
+
output: { type: "text", value: docsValue },
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
// Pile on low-value turns to push well past threshold and make the docs turn "old".
|
|
145
|
+
for (let i = 1; i <= 10; i++) {
|
|
146
|
+
const t = toolTurn(i, 10);
|
|
147
|
+
t[1].content[0].toolName = "mcp_filesystem__list_directory"; // low-value MCP
|
|
148
|
+
t[0].content[0].toolName = "mcp_filesystem__list_directory";
|
|
149
|
+
msgs.push(...t);
|
|
150
|
+
}
|
|
151
|
+
const out = compactSubAgentMessages(msgs);
|
|
152
|
+
expect(out).not.toBe(msgs); // compaction fired
|
|
153
|
+
// The muonroi-docs result is kept verbatim (full payload, no stub).
|
|
154
|
+
const docsMsg = out.find((m) => m.role === "tool" &&
|
|
155
|
+
Array.isArray(m.content) &&
|
|
156
|
+
m.content[0]?.toolName === "mcp_muonroi-docs__setup_guide");
|
|
157
|
+
const docsOut = (docsMsg?.content)[0].output.value;
|
|
158
|
+
expect(docsOut).toBe(docsValue);
|
|
159
|
+
expect(docsOut).not.toMatch(/elided by/);
|
|
160
|
+
// A low-value filesystem MCP peer from an OLD turn IS stubbed.
|
|
161
|
+
const stubbed = out.some((m) => m.role === "tool" &&
|
|
162
|
+
Array.isArray(m.content) &&
|
|
163
|
+
typeof m.content[0]?.output?.value === "string" &&
|
|
164
|
+
m.content[0].output.value.includes("elided by"));
|
|
165
|
+
expect(stubbed).toBe(true);
|
|
166
|
+
});
|
|
113
167
|
it("rewrites older tool-result parts with elision stub", () => {
|
|
114
168
|
const msgs = buildHistory(10, 10);
|
|
115
169
|
// Neutralize tool so the basic elision test is not affected by high-value auto-keep (idea 1).
|
|
@@ -104,6 +104,16 @@ describe("layer4Gsd (gsd-native)", () => {
|
|
|
104
104
|
const result = await layer4Gsd(makeCtx({ raw, enriched: raw, taskType: "analyze", intentKind: "task", deliverableKind: "answer" }));
|
|
105
105
|
expect(result.enriched).toContain("QUESTION / explanatory");
|
|
106
106
|
});
|
|
107
|
+
it("deliverableKind='report' is informational (no council/discuss scaffold) — session 666630479c1a", async () => {
|
|
108
|
+
// "Đọc và tóm tắt kiến trúc…" classifies as deliverableKind 'report'. A
|
|
109
|
+
// report is human-facing with NO code change, so it must route to the
|
|
110
|
+
// QUESTION directive, not the heavy implement/discuss/council scaffold that
|
|
111
|
+
// over-asked with askcards on a read/summarize task.
|
|
112
|
+
const raw = "đọc và tóm tắt kiến trúc src/orchestrator, src/pil, src/mcp kèm file:line";
|
|
113
|
+
const result = await layer4Gsd(makeCtx({ raw, enriched: raw, taskType: "analyze", intentKind: "task", deliverableKind: "report" }));
|
|
114
|
+
expect(result.enriched).toContain("QUESTION / explanatory");
|
|
115
|
+
expect(result.enriched).not.toContain("MANDATORY");
|
|
116
|
+
});
|
|
107
117
|
it("Phase 2b: deliverableKind='code' is NOT informational even for a question-shaped prompt", async () => {
|
|
108
118
|
// The raw text reads as a question — the legacy regex would mark it
|
|
109
119
|
// informational. The model's deliverableKind='code' must override that so
|
|
@@ -84,15 +84,20 @@ export async function layer4Gsd(ctx) {
|
|
|
84
84
|
// into the human-facing reply as a "2-3 line plan" + process narration
|
|
85
85
|
// (session 829a83888dd2). Route them to the human-facing question directive.
|
|
86
86
|
//
|
|
87
|
-
// Phase 2b: when the model classified the deliverable, CONSUME it
|
|
88
|
-
// "answer"
|
|
89
|
-
//
|
|
90
|
-
//
|
|
87
|
+
// Phase 2b: when the model classified the deliverable, CONSUME it. Both an
|
|
88
|
+
// "answer" AND a "report" deliverable are HUMAN-FACING with no code change, so
|
|
89
|
+
// both are informational — only "code" routes through the implement/verify (and
|
|
90
|
+
// heavy discuss/council) scaffold. Treating "report" as non-informational sent
|
|
91
|
+
// read/summarize/architecture tasks (deliverableKind "report") down the heavy
|
|
92
|
+
// council + AskUserQuestion path, over-asking on a task that just wanted a
|
|
93
|
+
// written summary (session 666630479c1a: "Đọc và tóm tắt kiến trúc…" raised 2
|
|
94
|
+
// askcards + a council loop). Only when the model emitted no deliverable
|
|
95
|
+
// (deliverableKind null → legacy cascade) do we fall back to regex predicates:
|
|
91
96
|
// 1. isMetaAnalysisPrompt — self/CLI evaluation, prior-turn reflection.
|
|
92
97
|
// 2. taskType "general" classified as a real task by L1.
|
|
93
98
|
// 3. question-shaped prompt that is NOT an implementation request.
|
|
94
99
|
const informational = ctx.deliverableKind
|
|
95
|
-
? ctx.deliverableKind
|
|
100
|
+
? ctx.deliverableKind !== "code"
|
|
96
101
|
: isMetaAnalysisPrompt(ctx.raw) ||
|
|
97
102
|
(ctx.taskType === "general" && ctx.intentKind === "task") ||
|
|
98
103
|
(isQuestionLike(ctx.raw) && !isImplementationIntent(ctx.raw));
|
|
@@ -31,6 +31,29 @@ export function extractToolResultFromOutput(output) {
|
|
|
31
31
|
output: String(output.value),
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
+
// MCP tool results: `{ type: "content", value: [{ type: "text", text }, ...] }`
|
|
35
|
+
// (see cap-tool-result.ts). Before this branch, extraction returned null, so
|
|
36
|
+
// persisted output_json was the raw envelope with NO `success` field — on
|
|
37
|
+
// reload the renderer read `toolResult.success` as undefined and displayed
|
|
38
|
+
// "Error" for a SUCCESSFUL call (session 63f2d542b772: 50 muonroi-docs calls,
|
|
39
|
+
// 0 DB failures, all shown as "Error"). Flatten the text parts so it round-
|
|
40
|
+
// trips as a real ToolResult. A genuinely failed MCP call throws → the SDK
|
|
41
|
+
// records an `error-text` part, handled above, so content == success here.
|
|
42
|
+
if ("type" in output && output.type === "content" && "value" in output && Array.isArray(output.value)) {
|
|
43
|
+
const parts = output.value;
|
|
44
|
+
const text = parts
|
|
45
|
+
.filter((p) => !!p &&
|
|
46
|
+
typeof p === "object" &&
|
|
47
|
+
p.type === "text" &&
|
|
48
|
+
typeof p.text === "string")
|
|
49
|
+
.map((p) => p.text)
|
|
50
|
+
.join("\n");
|
|
51
|
+
const nonText = parts.length - parts.filter((p) => p?.type === "text").length;
|
|
52
|
+
return {
|
|
53
|
+
success: true,
|
|
54
|
+
output: text || (nonText > 0 ? `[${nonText} non-text MCP part(s)]` : "(empty MCP result)"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
34
57
|
return null;
|
|
35
58
|
}
|
|
36
59
|
export function getOutputKind(output) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { extractToolResultFromOutput, isOutputSuccess } from "./tool-results.js";
|
|
3
|
+
describe("extractToolResultFromOutput", () => {
|
|
4
|
+
it("passes through a native ToolResult shape", () => {
|
|
5
|
+
const r = extractToolResultFromOutput({ success: true, output: "hi" });
|
|
6
|
+
expect(r).toMatchObject({ success: true, output: "hi" });
|
|
7
|
+
});
|
|
8
|
+
it("treats error-text as a failure", () => {
|
|
9
|
+
const r = extractToolResultFromOutput({ type: "error-text", value: "boom" });
|
|
10
|
+
expect(r).toMatchObject({ success: false, error: "boom" });
|
|
11
|
+
});
|
|
12
|
+
it("flattens an MCP content envelope into a successful ToolResult (session 63f2d542b772)", () => {
|
|
13
|
+
// MCP tools return { type: "content", value: [{ type: "text", text }] }.
|
|
14
|
+
// Before the fix this returned null, so the persisted output_json had no
|
|
15
|
+
// `success` field and the renderer showed "Error" for a successful call.
|
|
16
|
+
const out = {
|
|
17
|
+
type: "content",
|
|
18
|
+
value: [
|
|
19
|
+
{ type: "text", text: "package list" },
|
|
20
|
+
{ type: "text", text: "more" },
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
const r = extractToolResultFromOutput(out);
|
|
24
|
+
expect(r).toMatchObject({ success: true, output: "package list\nmore" });
|
|
25
|
+
});
|
|
26
|
+
it("round-trips through JSON so the renderer reads success=true (the actual bug)", () => {
|
|
27
|
+
// transcript.loadStoredToolResults does JSON.parse(output_json) and the
|
|
28
|
+
// renderer reads `.success`. Simulate persist→load and assert it is NOT
|
|
29
|
+
// misread as an error.
|
|
30
|
+
const mcpOutput = { type: "content", value: [{ type: "text", text: "## Muonroi.Core\nNuGet: Muonroi.Core" }] };
|
|
31
|
+
const persisted = JSON.stringify(extractToolResultFromOutput(mcpOutput));
|
|
32
|
+
const loaded = JSON.parse(persisted);
|
|
33
|
+
const rendered = loaded.success ? loaded.output || "Success" : loaded.error || "Error";
|
|
34
|
+
expect(rendered).toContain("Muonroi.Core");
|
|
35
|
+
expect(rendered).not.toBe("Error");
|
|
36
|
+
});
|
|
37
|
+
it("describes a non-text-only MCP content result instead of dropping to Error", () => {
|
|
38
|
+
const out = { type: "content", value: [{ type: "image", data: "..." }] };
|
|
39
|
+
const r = extractToolResultFromOutput(out);
|
|
40
|
+
expect(r?.success).toBe(true);
|
|
41
|
+
expect(r?.output).toMatch(/non-text MCP part/);
|
|
42
|
+
});
|
|
43
|
+
it("isOutputSuccess still treats content envelopes as success", () => {
|
|
44
|
+
expect(isOutputSuccess({ type: "content", value: [] })).toBe(true);
|
|
45
|
+
expect(isOutputSuccess({ type: "error-text", value: "x" })).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
//# sourceMappingURL=tool-results.test.js.map
|
|
@@ -39,6 +39,23 @@ describe("parseInline — marker concealment", () => {
|
|
|
39
39
|
expect(text(parseInline("a **partial answer", t))).toBe("a **partial answer");
|
|
40
40
|
expect(text(parseInline("trailing `code", t))).toBe("trailing `code");
|
|
41
41
|
});
|
|
42
|
+
it("does NOT treat intra-word underscores as emphasis (identifiers stay intact)", () => {
|
|
43
|
+
// Session 584ba476c07a rendered `mcp_filesystem__list_directory` as
|
|
44
|
+
// "mcpfilesystemlistdirectory" — underscores eaten as italic/bold.
|
|
45
|
+
expect(text(parseInline("mcp_filesystem__list_directory", t))).toBe("mcp_filesystem__list_directory");
|
|
46
|
+
expect(text(parseInline("a snake_case name", t))).toBe("a snake_case name");
|
|
47
|
+
expect(text(parseInline("call mcp_muonroi-docs__setup_guide first", t))).toBe("call mcp_muonroi-docs__setup_guide first");
|
|
48
|
+
// None of these should be emphasized.
|
|
49
|
+
expect(parseInline("mcp_filesystem__list_directory", t).some((s) => s.italic || s.bold)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
it("still emphasizes underscores at word boundaries", () => {
|
|
52
|
+
expect(text(parseInline("an _italic_ word", t))).toBe("an italic word");
|
|
53
|
+
expect(parseInline("an _italic_ word", t).find((s) => s.text === "italic")?.italic).toBe(true);
|
|
54
|
+
expect(text(parseInline("a __bold__ word", t))).toBe("a bold word");
|
|
55
|
+
expect(parseInline("a __bold__ word", t).find((s) => s.text === "bold")?.bold).toBe(true);
|
|
56
|
+
// Underscore emphasis adjacent to punctuation still works.
|
|
57
|
+
expect(parseInline("(_em_)", t).find((s) => s.text === "em")?.italic).toBe(true);
|
|
58
|
+
});
|
|
42
59
|
it("never leaves ** ` ### markers in styled segments", () => {
|
|
43
60
|
const sample = "**A** and `b` and ***c*** and [d](http://e) and ~~f~~";
|
|
44
61
|
const out = text(parseInline(sample, t));
|
package/dist/src/ui/app.js
CHANGED
|
@@ -2525,7 +2525,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }) {
|
|
|
2525
2525
|
applyLocalAssistantDelta(`\n⚠ [Experience] ${eeChunk.experienceWarning?.message ?? eeChunk.content ?? ""}\nWhy: ${eeChunk.experienceWarning?.why ?? ""}\n`);
|
|
2526
2526
|
}
|
|
2527
2527
|
else if (eeChunk.type === "experience_injected") {
|
|
2528
|
-
applyLocalAssistantDelta(
|
|
2528
|
+
applyLocalAssistantDelta(formatExperienceInjectedBlock(eeChunk.experienceInjected ?? {}));
|
|
2529
2529
|
}
|
|
2530
2530
|
});
|
|
2531
2531
|
for await (const chunk of agent.processMessage(text.trim(), undefined, images)) {
|
|
@@ -3472,10 +3472,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }) {
|
|
|
3472
3472
|
},
|
|
3473
3473
|
];
|
|
3474
3474
|
}
|
|
3475
|
-
return [
|
|
3476
|
-
...prev,
|
|
3477
|
-
buildAssistantEntry(`💡 [Experience Injected] ${chunk.experienceInjected.pointCount} point(s)`),
|
|
3478
|
-
];
|
|
3475
|
+
return [...prev, buildAssistantEntry(formatExperienceInjectedBlock(chunk.experienceInjected))];
|
|
3479
3476
|
});
|
|
3480
3477
|
}
|
|
3481
3478
|
if (chunk.type === "halt" && chunk.haltChunk) {
|
|
@@ -3694,10 +3691,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }) {
|
|
|
3694
3691
|
},
|
|
3695
3692
|
];
|
|
3696
3693
|
}
|
|
3697
|
-
return [
|
|
3698
|
-
...prev,
|
|
3699
|
-
buildAssistantEntry(`💡 [Experience Injected] ${chunk.experienceInjected.pointCount} point(s)`),
|
|
3700
|
-
];
|
|
3694
|
+
return [...prev, buildAssistantEntry(formatExperienceInjectedBlock(chunk.experienceInjected))];
|
|
3701
3695
|
});
|
|
3702
3696
|
}
|
|
3703
3697
|
if (chunk.type === "done")
|
|
@@ -77,6 +77,18 @@ export function parseInline(line, t, base = {}) {
|
|
|
77
77
|
const inner = line.slice(i + m.open.length, close);
|
|
78
78
|
if (inner.length === 0)
|
|
79
79
|
continue;
|
|
80
|
+
// CommonMark: underscore does NOT open/close emphasis intra-word, so
|
|
81
|
+
// identifiers like `mcp_filesystem__list_directory` or `snake_case` keep
|
|
82
|
+
// their underscores instead of being eaten as italic/bold (session
|
|
83
|
+
// 584ba476c07a rendered "mcpfilesystemlistdirectory"). Asterisk markers
|
|
84
|
+
// keep intraword behaviour. Reject when a word char hugs the marker on the
|
|
85
|
+
// word-internal side.
|
|
86
|
+
if (m.open[0] === "_") {
|
|
87
|
+
const before = i > 0 ? line[i - 1] : "";
|
|
88
|
+
const after = line[close + m.close.length] ?? "";
|
|
89
|
+
if (/[A-Za-z0-9]/.test(before) || /[A-Za-z0-9]/.test(after))
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
80
92
|
flushPlain(i);
|
|
81
93
|
const isBold = "bold" in m && m.bold;
|
|
82
94
|
const isItalic = "italic" in m && m.italic;
|