From 5aa7fbfae54132140ebe14960111b7c9b50064fc Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 19 Apr 2026 09:49:01 +0800 Subject: [PATCH] fix: align Lingma tool event lifecycle handling Handle tool/invokeResult and richer tool/call/sync payloads in the client, and document/retest the verified VSCode monitoring workflow for tool events. Co-Authored-By: Claude Opus 4.7 --- DESIGN.md | 39 ++++++++++++++++ app/lingma_client.py | 82 ++++++++++++++++++++++++---------- tests/test_tool_call_bridge.py | 51 ++++++++++++++++++++- 3 files changed, 147 insertions(+), 25 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 30696db..9f0198b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -708,6 +708,45 @@ uvicorn app.main:app --reload --port 8317 | → | `chat/ask` (notify!) | 见 `_build_payload` | 不回 result;通过 server push 下推 | | ← | `chat/answer` | `{requestId, text, content}` | 流式 token | | ← | `chat/finish` | `{requestId, sessionId, ...其它元数据}` | 结束信号,含上游真实 sessionId | +| ← | `tool/call/sync` | `{requestId?, toolCallId, toolCallStatus, parameters, results?}` | 工具状态与结果回流 | +| ← | `tool/invoke` | `{requestId?, toolCallId, ...}` | 工具调用中间事件(兼容旧链路) | +| ← | `tool/call/approve` | `{requestId?, toolCallId, approval, ...}` | 工具审批事件 | +| ← | `tool/invokeResult` | `{requestId?, toolCallId, name, success, errorMessage, result}` | 工具执行结果事件 | + +### 9.1 Tool call 监控 SOP(VSCode 真实环境) + +目标:拿到 Lingma 扩展真实 method/字段,避免猜测协议。 + +1. 确认入口文件 + - `~/.vscode/extensions/alibaba-cloud.tongyi-lingma-*/package.json` + - 查 `main`(当前是 `dist/extension.js`) + +2. 在发送侧打点 + - 在 `sendRequest` / `sendNotification` 处记录 method 与参数 keys + - 优先写文件,不依赖 console + +3. 在入站 `tool/call/sync` handler 打点 + - 记录 `toolCallId`、`toolCallStatus`、是否包含 `results` + +4. 用真实交互触发 + - VSCode 内发起会话并触发工具 + - 点击 Accept/Reject,观察事件闭环 + +5. 验证闭环 + - `tool/call/sync(pending|processing)` + - `tool/call/approve` + - `tool/invokeResult` + - `tool/call/sync(results)` + +6. 回滚 + - 用备份文件恢复 `dist/extension.js` + - 避免长期携带探针到日常环境 + +**建议日志位置**: +- `~/.lingma/vscode/sharedClientCache/logs/lingma-probe.log` +- `~/.lingma/vscode/sharedClientCache/logs/lingma-extension.log` + +**注意**:优先使用 VSCode,不混用 Cursor 扩展环境;`pipe` 连接模式下,扩展层探针最稳定。 **`chat/ask` payload 关键字段**: diff --git a/app/lingma_client.py b/app/lingma_client.py index f0447b2..45dd8fd 100644 --- a/app/lingma_client.py +++ b/app/lingma_client.py @@ -106,23 +106,36 @@ class LspWsRpcClient: @staticmethod def _extract_tool_event(params: dict[str, Any]) -> dict[str, Any] | None: candidates: list[dict[str, Any]] = [] - if isinstance(params.get("toolCall"), dict): - candidates.append(params["toolCall"]) - if isinstance(params.get("tool_call"), dict): - candidates.append(params["tool_call"]) - if isinstance(params.get("tool"), dict): - candidates.append(params["tool"]) + + def add_candidate(obj: Any) -> None: + if isinstance(obj, dict): + candidates.append(obj) + + add_candidate(params.get("toolCall")) + add_candidate(params.get("tool_call")) + add_candidate(params.get("tool")) + data = params.get("data") if isinstance(data, dict): - if isinstance(data.get("toolCall"), dict): - candidates.append(data["toolCall"]) - if isinstance(data.get("tool_call"), dict): - candidates.append(data["tool_call"]) - if isinstance(data.get("tool"), dict): - candidates.append(data["tool"]) + add_candidate(data.get("toolCall")) + add_candidate(data.get("tool_call")) + add_candidate(data.get("tool")) + + results = params.get("results") + if isinstance(results, list): + for item in results: + add_candidate(item) if not candidates: - return None + fallback_id = params.get("toolCallId") or params.get("tool_call_id") + if not fallback_id: + return None + return { + "id": str(fallback_id), + "name": str(params.get("name") or "tool"), + "input": params.get("parameters") or {}, + "result": params.get("result"), + } raw = candidates[0] tool_id = ( @@ -132,28 +145,42 @@ class LspWsRpcClient: or params.get("toolCallId") or params.get("tool_call_id") ) - name = raw.get("name") or raw.get("toolName") or raw.get("tool_name") + name = ( + raw.get("name") + or raw.get("toolName") + or raw.get("tool_name") + or params.get("name") + ) + call_input = raw.get("input") if call_input is None: call_input = raw.get("arguments") if call_input is None: call_input = raw.get("args") + if call_input is None: + call_input = raw.get("parameters") + if call_input is None: + call_input = params.get("parameters") result_payload = raw.get("result") if result_payload is None: result_payload = params.get("result") if result_payload is None and isinstance(data, dict): result_payload = data.get("result") + if result_payload is None and isinstance(raw.get("results"), list): + result_payload = raw.get("results") if not tool_id: return None - return { + event: dict[str, Any] = { "id": str(tool_id), "name": str(name or "tool"), "input": call_input if call_input is not None else {}, - "result": result_payload, } + if result_payload is not None: + event["result"] = result_payload + return event async def start(self): self._reader_task = asyncio.create_task(self._reader_loop()) @@ -239,14 +266,23 @@ class LspWsRpcClient: stream["first_chunk_at"] = time.monotonic() stream["chunks"].put_nowait({"type": "text", "text": text}) - if method in {"tool/call/sync", "tool/invoke", "tool/call/approve"}: + if method in {"tool/call/sync", "tool/invoke", "tool/call/approve", "tool/invokeResult"}: + tool_event = self._extract_tool_event(params) req_id = params.get("requestId") - stream = self._chat_streams.get(req_id) - if stream is not None: - tool_event = self._extract_tool_event(params) - if tool_event is not None: - stream["tool_events"].append(tool_event) - stream["chunks"].put_nowait({"type": "tool", "tool": tool_event}) + stream = self._chat_streams.get(req_id) if req_id else None + + if stream is None and tool_event is not None: + for item in self._chat_streams.values(): + if any(evt.get("id") == tool_event["id"] for evt in item["tool_events"]): + stream = item + break + + if stream is None and len(self._chat_streams) == 1: + stream = next(iter(self._chat_streams.values())) + + if stream is not None and tool_event is not None: + stream["tool_events"].append(tool_event) + stream["chunks"].put_nowait({"type": "tool", "tool": tool_event}) if method == "chat/finish": req_id = params.get("requestId") diff --git a/tests/test_tool_call_bridge.py b/tests/test_tool_call_bridge.py index e7126eb..05ea2b3 100644 --- a/tests/test_tool_call_bridge.py +++ b/tests/test_tool_call_bridge.py @@ -288,5 +288,52 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase): self.assertIn("event: message_stop", body) -if __name__ == "__main__": - unittest.main() +class LingmaClientToolEventExtractionTests(unittest.TestCase): + def test_extracts_tool_event_from_results_and_parameters(self) -> None: + from app.lingma_client import LspWsRpcClient + + event = LspWsRpcClient._extract_tool_event( + { + "toolCallId": "call_sync_1", + "parameters": {"path": "README.md"}, + "results": [ + { + "toolCallId": "call_sync_1", + "name": "read_file", + "result": {"ok": True}, + } + ], + } + ) + + self.assertEqual( + event, + { + "id": "call_sync_1", + "name": "read_file", + "input": {"path": "README.md"}, + "result": {"ok": True}, + }, + ) + + def test_extracts_tool_event_from_invoke_result_payload(self) -> None: + from app.lingma_client import LspWsRpcClient + + event = LspWsRpcClient._extract_tool_event( + { + "toolCallId": "call_inv_1", + "name": "search_docs", + "parameters": {"query": "gateway"}, + "result": {"hits": 3}, + } + ) + + self.assertEqual( + event, + { + "id": "call_inv_1", + "name": "search_docs", + "input": {"query": "gateway"}, + "result": {"hits": 3}, + }, + )