Skip to content

Commit da6e637

Browse files
fix(mcp): recover box stdio sessions after disconnect
1 parent 6688500 commit da6e637

17 files changed

Lines changed: 1147 additions & 51 deletions

skills/scripts/e2e/agent-run-ledger-audit.py

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
import argparse
77
import asyncio
8+
import datetime
89
import json
9-
import os
1010
import pathlib
1111
import re
1212
import sys
@@ -47,7 +47,48 @@ def load_json(value: str | None, *, field: str, failures: list[dict]) -> object:
4747
return {}
4848

4949

50-
async def audit(repo: pathlib.Path, run_id: str | None) -> dict:
50+
def parse_created_after(value: str | None) -> datetime.datetime | None:
51+
if not value:
52+
return None
53+
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
54+
if parsed.tzinfo is not None:
55+
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
56+
return parsed
57+
58+
59+
def event_matches_tool_call(data_json: str | None, tool_name: str, parameters: dict | None) -> bool:
60+
try:
61+
data = json.loads(data_json or "{}")
62+
except (TypeError, ValueError):
63+
return False
64+
if not isinstance(data, dict) or data.get("tool_name") != tool_name:
65+
return False
66+
return parameters is None or data.get("parameters") == parameters
67+
68+
69+
def collect_result_texts(value: object) -> list[str]:
70+
texts: list[str] = []
71+
if isinstance(value, dict):
72+
for key, item in value.items():
73+
if key == "text" and isinstance(item, str):
74+
texts.append(item)
75+
else:
76+
texts.extend(collect_result_texts(item))
77+
elif isinstance(value, list):
78+
for item in value:
79+
texts.extend(collect_result_texts(item))
80+
return texts
81+
82+
83+
async def audit(
84+
repo: pathlib.Path,
85+
run_id: str | None,
86+
*,
87+
created_after: datetime.datetime | None = None,
88+
expected_tool_name: str | None = None,
89+
expected_parameters: dict | None = None,
90+
expected_result_text: str | None = None,
91+
) -> dict:
5192
engine = create_async_engine(database_url(repo))
5293
failures: list[dict] = []
5394
warnings: list[dict] = []
@@ -58,12 +99,41 @@ async def audit(repo: pathlib.Path, run_id: str | None) -> dict:
5899
sqlalchemy.text("SELECT * FROM agent_run WHERE run_id = :run_id"),
59100
{"run_id": run_id},
60101
)).mappings().first()
102+
elif expected_tool_name:
103+
query = "SELECT * FROM agent_run"
104+
params = {}
105+
if created_after is not None:
106+
query += " WHERE created_at >= :created_after"
107+
params["created_after"] = created_after
108+
query += " ORDER BY id DESC LIMIT 100"
109+
candidates = (await connection.execute(sqlalchemy.text(query), params)).mappings().all()
110+
run_row = None
111+
for candidate in candidates:
112+
started_rows = (await connection.execute(
113+
sqlalchemy.text(
114+
"SELECT data_json FROM agent_run_event "
115+
"WHERE run_id = :run_id AND type = 'tool.call.started' ORDER BY sequence"
116+
),
117+
{"run_id": str(candidate["run_id"])},
118+
)).mappings().all()
119+
if any(
120+
event_matches_tool_call(row.get("data_json"), expected_tool_name, expected_parameters)
121+
for row in started_rows
122+
):
123+
run_row = candidate
124+
break
61125
else:
62126
run_row = (await connection.execute(
63127
sqlalchemy.text("SELECT * FROM agent_run ORDER BY id DESC LIMIT 1")
64128
)).mappings().first()
65129
if run_row is None:
66-
return {"status": "env_issue", "reason": "No matching AgentRunner run exists.", "failures": [], "warnings": []}
130+
status = "fail" if expected_tool_name else "env_issue"
131+
return {
132+
"status": status,
133+
"reason": "No AgentRunner run contains the expected tool call." if expected_tool_name else "No matching AgentRunner run exists.",
134+
"failures": [{"kind": "expected_tool_call_missing"}] if expected_tool_name else [],
135+
"warnings": [],
136+
}
67137

68138
selected_run_id = str(run_row["run_id"])
69139
event_rows = (await connection.execute(
@@ -173,6 +243,36 @@ def error_surface(value: object) -> list[str]:
173243
if not tools:
174244
warnings.append({"kind": "no_authorized_tools", "reason": "The run authorization snapshot exposes no tools."})
175245

246+
expected_call_summary = None
247+
if expected_tool_name:
248+
matching_starts = [
249+
item
250+
for items in starts.values()
251+
for item in items
252+
if item["tool_name"] == expected_tool_name
253+
and (expected_parameters is None or item["data"].get("parameters") == expected_parameters)
254+
]
255+
if len(matching_starts) != 1:
256+
failures.append({"kind": "expected_tool_call_count", "actual": len(matching_starts), "expected": 1})
257+
matching_completions = []
258+
for started in matching_starts:
259+
call_id = str(started["data"].get("tool_call_id", ""))
260+
matching_completions.extend(completions.get(call_id, []))
261+
result_text_match = expected_result_text is None or any(
262+
expected_result_text in collect_result_texts(completed["data"].get("result"))
263+
for completed in matching_completions
264+
)
265+
if expected_result_text is not None and not result_text_match:
266+
failures.append({"kind": "expected_tool_result_text_missing"})
267+
expected_call_summary = {
268+
"tool_name": expected_tool_name,
269+
"parameters_match_required": expected_parameters is not None,
270+
"matched_started_count": len(matching_starts),
271+
"matched_completed_count": len(matching_completions),
272+
"result_text_match_required": expected_result_text is not None,
273+
"result_text_match": result_text_match,
274+
}
275+
176276
metrics = {
177277
"event_count": len(event_rows),
178278
"tool_call_started": sum(len(items) for items in starts.values()),
@@ -193,6 +293,7 @@ def error_surface(value: object) -> list[str]:
193293
"finished_at": str(run_row["finished_at"]),
194294
},
195295
"metrics": metrics,
296+
"expected_tool_call": expected_call_summary,
196297
"failures": failures,
197298
"warnings": warnings,
198299
}
@@ -202,10 +303,28 @@ def main() -> int:
202303
parser = argparse.ArgumentParser()
203304
parser.add_argument("--repo", required=True)
204305
parser.add_argument("--run-id")
306+
parser.add_argument("--created-after")
307+
parser.add_argument("--expected-tool-name")
308+
parser.add_argument("--expected-parameters-json")
309+
parser.add_argument("--expected-result-text")
205310
parser.add_argument("--output", required=True)
206311
args = parser.parse_args()
207312
try:
208-
report = asyncio.run(audit(pathlib.Path(args.repo).resolve(), args.run_id))
313+
expected_parameters = None
314+
if args.expected_parameters_json:
315+
expected_parameters = json.loads(args.expected_parameters_json)
316+
if not isinstance(expected_parameters, dict):
317+
raise ValueError("--expected-parameters-json must decode to an object")
318+
if (expected_parameters is not None or args.expected_result_text) and not args.expected_tool_name:
319+
raise ValueError("--expected-tool-name is required with expected parameters or result text")
320+
report = asyncio.run(audit(
321+
pathlib.Path(args.repo).resolve(),
322+
args.run_id,
323+
created_after=parse_created_after(args.created_after),
324+
expected_tool_name=args.expected_tool_name,
325+
expected_parameters=expected_parameters,
326+
expected_result_text=args.expected_result_text,
327+
))
209328
except Exception as exc: # noqa: BLE001 - probe must classify environment failures
210329
report = {"status": "env_issue", "reason": str(exc), "failures": [], "warnings": []}
211330
pathlib.Path(args.output).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")

skills/scripts/e2e/fake-openai-provider.mjs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -585,10 +585,18 @@ function buildResponse(payload) {
585585
return buildSummaryResponse(text);
586586
}
587587

588+
if (/qa-effective-prompt/i.test(current) && /PROMPT_PREPROCESS_OK/.test(text)) {
589+
return { role: "assistant", content: "PROMPT_PREPROCESS_OK" };
590+
}
591+
588592
const pluginTool = firstToolName(tools, ["qa_plugin_echo"]);
589593
const pluginFailTool = firstToolName(tools, ["qa_plugin_fail"]);
590594
const pluginSleepTool = firstToolName(tools, ["qa_plugin_sleep"]);
591595
const mcpTool = firstToolName(tools, ["qa_mcp_echo"]);
596+
const requestedMcpEchoText = current.match(
597+
/qa_mcp_echo[\s\S]*?exactly this text:\s*([A-Za-z0-9_:-]+)/i,
598+
)?.[1] || "mcp-ok-local-agent";
599+
const expectedMcpEchoResult = `qa_mcp_echo:${requestedMcpEchoText}`;
592600

593601
if (/STEERING_NO_FOLLOWUP|qa_plugin_sleep|steering-e2e-anchor|qa_steering_sentinel_6194/i.test(current || text)) {
594602
if (text.includes(STEERING_FOLLOWUP_SENTINEL) && text.includes(STEERING_SLEEP_RESULT)) {
@@ -620,9 +628,9 @@ function buildResponse(payload) {
620628
if (/COMBO_CONTEXT_PRESSURE_READY/.test(current)) return { role: "assistant", content: "COMBO_CONTEXT_PRESSURE_READY" };
621629
if (/CONTEXT_PRESSURE_READY/.test(current)) return { role: "assistant", content: "CONTEXT_PRESSURE_READY" };
622630

623-
if (/qa_mcp_echo:mcp-ok-local-agent/.test(text)) return { role: "assistant", content: "qa_mcp_echo:mcp-ok-local-agent" };
624-
if (/qa_mcp_echo|mcp-ok-local-agent/i.test(current || text) && mcpTool && !/qa_mcp_echo:mcp-ok-local-agent/.test(text)) {
625-
return toolCall("call_qa_mcp_echo", mcpTool, { text: "mcp-ok-local-agent" });
631+
if (text.includes(expectedMcpEchoResult)) return { role: "assistant", content: expectedMcpEchoResult };
632+
if (/qa_mcp_echo|mcp-ok-local-agent/i.test(current || text) && mcpTool && !text.includes(expectedMcpEchoResult)) {
633+
return toolCall("call_qa_mcp_echo", mcpTool, { text: requestedMcpEchoText });
626634
}
627635

628636
if (/LOOP_LIMIT|loop-limit-repeat-local-agent|iteration limit/i.test(current || text) && pluginTool) {

skills/scripts/e2e/lib/langbot-e2e.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,38 @@ export async function ensureEvidence(paths) {
5050
await appendFile(paths.networkLog, "", "utf8");
5151
}
5252

53+
export async function beginBackendLogCapture(evidenceDir, sourcePath = env.LANGBOT_BACKEND_LOG || "") {
54+
if (!sourcePath) return null;
55+
const source = resolve(sourcePath);
56+
try {
57+
const info = await stat(source);
58+
return {
59+
source,
60+
start_offset: info.size,
61+
target: resolve(evidenceDir, "backend.log"),
62+
};
63+
} catch {
64+
return null;
65+
}
66+
}
67+
68+
export async function finishBackendLogCapture(capture) {
69+
if (!capture) return null;
70+
try {
71+
const content = await readFile(capture.source);
72+
const start = content.length >= capture.start_offset ? capture.start_offset : 0;
73+
const window = content.subarray(start);
74+
if (window.length === 0) return null;
75+
await writeFile(capture.target, window);
76+
return {
77+
path: capture.target,
78+
bytes: window.length,
79+
};
80+
} catch {
81+
return null;
82+
}
83+
}
84+
5385
export async function pathExists(path) {
5486
try {
5587
await stat(path);

skills/scripts/e2e/local-agent-steering-debug-chat.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import {
1010
waitForDebugChatTextStable,
1111
} from "./lib/debug-chat.mjs";
1212
import {
13+
beginBackendLogCapture,
1314
createBrowser,
1415
ensureAuthenticatedBrowser,
1516
ensureEvidence,
1617
evidencePaths,
1718
exitCode,
19+
finishBackendLogCapture,
1820
localIsoWithOffset,
1921
loadEnvFiles,
2022
pathExists,
@@ -27,6 +29,7 @@ await loadEnvFiles();
2729
const caseId = env.LBS_CASE_ID || "local-agent-steering-debug-chat";
2830
const paths = evidencePaths(caseId);
2931
await ensureEvidence(paths);
32+
const backendLogCapture = await beginBackendLogCapture(paths.evidenceDir);
3033

3134
const backendUrl = (env.LANGBOT_BACKEND_URL || "").replace(/\/$/, "");
3235
const pipelineUrl = env.LANGBOT_E2E_PIPELINE_URL || env.LANGBOT_LOCAL_AGENT_PIPELINE_URL || env.LANGBOT_PIPELINE_URL || "";
@@ -190,6 +193,12 @@ try {
190193
} finally {
191194
if (browser?.page) await safeScreenshot(browser.page, paths.screenshot);
192195
if (browser) await browser.close().catch(() => {});
196+
const backendLog = await finishBackendLogCapture(backendLogCapture);
197+
if (backendLog) {
198+
result.evidence.backend_log = backendLog.path;
199+
result.backend_log = backendLog;
200+
if (!result.evidence_collected.includes("backend_log")) result.evidence_collected.push("backend_log");
201+
}
193202
const finishedAt = new Date();
194203
result.finished_at = finishedAt.toISOString();
195204
result.finished_at_local = localIsoWithOffset(finishedAt);

skills/scripts/e2e/pipeline-debug-chat.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import {
1010
setDebugChatStreamOutput,
1111
} from "./lib/debug-chat.mjs";
1212
import {
13+
beginBackendLogCapture,
1314
createBrowser,
1415
ensureAuthenticatedBrowser,
1516
ensureEvidence,
1617
evidencePaths,
1718
exitCode,
19+
finishBackendLogCapture,
1820
localIsoWithOffset,
1921
pathExists,
2022
safeScreenshot,
@@ -25,6 +27,7 @@ import {
2527
const caseId = env.LBS_CASE_ID || "pipeline-debug-chat";
2628
const paths = evidencePaths(caseId);
2729
await ensureEvidence(paths);
30+
const backendLogCapture = await beginBackendLogCapture(paths.evidenceDir);
2831

2932
const expectedText = env.LANGBOT_E2E_EXPECTED_TEXT || "OK";
3033
const prompt = env.LANGBOT_E2E_PROMPT || `请只回复 ${expectedText},用于前端调试测试。`;
@@ -1062,6 +1065,12 @@ try {
10621065
result.pipeline_config_restore = restoreDiagnostic;
10631066
}
10641067
if (browser) await browser.close().catch(() => {});
1068+
const backendLog = await finishBackendLogCapture(backendLogCapture);
1069+
if (backendLog) {
1070+
result.evidence.backend_log = backendLog.path;
1071+
result.backend_log = backendLog;
1072+
if (!result.evidence_collected.includes("backend_log")) result.evidence_collected.push("backend_log");
1073+
}
10651074
const finishedAt = new Date();
10661075
result.finished_at = finishedAt.toISOString();
10671076
result.finished_at_local = localIsoWithOffset(finishedAt);

skills/skills.index.json

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@
155155
"agent-runner-release-preflight",
156156
"agent-runner-runtime-chaos",
157157
"bot-event-routing-product-flow",
158+
"box-mcp-heartbeat-recovery",
158159
"dify-agent-debug-chat",
159160
"langbot-fake-provider-debug-chat-cross-pipeline-isolation",
160161
"langbot-fake-provider-debug-chat-fault-recovery",
@@ -567,6 +568,37 @@
567568
"api_diagnostic"
568569
]
569570
},
571+
{
572+
"id": "box-mcp-heartbeat-recovery",
573+
"title": "Box heartbeat restores an existing stdio MCP tool session",
574+
"mode": "probe",
575+
"area": "reliability",
576+
"type": "chaos",
577+
"priority": "p1",
578+
"risk": "high",
579+
"ci_eligible": false,
580+
"tags": [
581+
"box",
582+
"mcp",
583+
"stdio",
584+
"reliability",
585+
"chaos",
586+
"fault-injection"
587+
],
588+
"automation": "skills/langbot-testing/probes/box-mcp-heartbeat-recovery.mjs",
589+
"setup_automation": [],
590+
"setup_provides_env": [],
591+
"evidence_required": [
592+
"ui",
593+
"screenshot",
594+
"console",
595+
"network",
596+
"metrics",
597+
"api_diagnostic",
598+
"resource_log",
599+
"filesystem"
600+
]
601+
},
570602
{
571603
"id": "dify-agent-debug-chat",
572604
"title": "Dify AgentRunner returns a response through Pipeline Debug Chat",
@@ -2231,7 +2263,8 @@
22312263
"kind": "python",
22322264
"path": "fixtures/mcp/qa_mcp_echo_server.py",
22332265
"related_cases": [
2234-
"mcp-stdio-tool-call"
2266+
"mcp-stdio-tool-call",
2267+
"box-mcp-heartbeat-recovery"
22352268
]
22362269
},
22372270
{

0 commit comments

Comments
 (0)