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 <noreply@anthropic.com>
This commit is contained in:
GitHub Actions
2026-04-19 09:49:01 +08:00
parent 1c7b86e2c0
commit 5aa7fbfae5
3 changed files with 147 additions and 25 deletions

View File

@@ -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 监控 SOPVSCode 真实环境)
目标:拿到 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 关键字段**

View File

@@ -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:
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,12 +266,21 @@ 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"}:
req_id = params.get("requestId")
stream = self._chat_streams.get(req_id)
if stream is not None:
if method in {"tool/call/sync", "tool/invoke", "tool/call/approve", "tool/invokeResult"}:
tool_event = self._extract_tool_event(params)
if tool_event is not None:
req_id = params.get("requestId")
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})

View File

@@ -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},
},
)