fix(claudecode): emit EventToolResult so tool output reaches progress card (#1407)

* fix(claudecode): emit EventToolResult so tool output reaches progress card

## 背景

cc-connect 的飞书卡片(以及其他平台的 progress card)渲染工具结果
依赖 engine 收到 EventToolResult 事件。engine.go:4745 的 case
EventToolResult 会调用 formatProgressToolResult / cp.AppendEvent
把工具输出渲染到卡片。

## 问题

agent/claudecode/session.go 的 handleUser 函数处理 stream-json
里的 tool_result content block 时,原本只做了一件事:

  if isError { slog.Debug("claudeSession: tool error", ...) }

也就是 **error 情况只记日志,正常情况完全静默丢弃**,始终没有
emit EventToolResult 事件给 engine。

对照 agent/codex/session.go:522 case "command_execution":codex
agent 在工具完成时正确构造了 EventToolResult 事件并发送到
cs.events channel,engine 收到后才能渲染工具结果卡片。

## 用户感知的现象

- claude-multi 飞书群里调工具后,**只看到最终文字回复,看不到
  工具调用面板和工具结果**
- codex-multi 飞书群里调工具后,能看到完整的 "工具调用 + 工具结果"
  卡片渲染
- 两个 project 的 cc-connect 配置完全一致,差异只在 agent type

之前误以为是 stream preview / 飞书 universal card payload
(ErrCode 200800) 等问题,实际那些是次要现象 —— 真正的根因是
claudecode agent 根本没把工具结果事件转发给 engine。

## 方案

最小化修复:在 if contentType == "tool_result" 分支里,无论
isError 与否都构造 EventToolResult 事件 emit 出去。

实现细节:
- content 字段兼容两种格式:string 直接用;array of {type:"text",
  text:"..."} 拼接所有 text 块(Anthropic SDK 两种格式都可能出现)
- 工具名留空:Anthropic 的 tool_result block 只带 tool_use_id 不
  带工具名,反查 id→name 映射需要额外缓存且容易出错。视觉上工具
  调用面板和结果面板按顺序排列,用户能自行配对,因此留空可接受
  (飞书 buildToolDisplay 对空名 fallback 显示为 "Tool")
- exit code: isError → 1, 否则 0;success: !isError
- 复用包内已有的 truncateStr 截断到 500 字符(与 codex agent 一致)

## 影响范围

- 只改 agent/claudecode/session.go 一处(handleUser 函数)
- 不影响 codex / gemini / opencode 等其他 agent
- 不影响 engine 及下游渲染逻辑(只是补上之前缺失的事件源)

* test(claudecode): regression test for EventToolResult emit
This commit is contained in:
coolrockin
2026-06-23 07:22:13 +08:00
committed by GitHub
parent 79e3132fd8
commit a5d441e98e
2 changed files with 154 additions and 1 deletions

View File

@@ -740,10 +740,40 @@ func (cs *claudeSession) handleUser(raw map[string]any) {
contentType, _ := item["type"].(string)
if contentType == "tool_result" {
isError, _ := item["is_error"].(bool)
var result string
switch c := item["content"].(type) {
case string:
result = c
case []any:
var parts []string
for _, elem := range c {
if m, ok := elem.(map[string]any); ok {
if t, _ := m["text"].(string); t != "" {
parts = append(parts, t)
}
}
}
result = strings.Join(parts, "\n")
}
if isError {
result, _ := item["content"].(string)
slog.Debug("claudeSession: tool error", "content", result)
}
success := !isError
code := 0
if isError {
code = 1
}
evt := core.Event{
Type: core.EventToolResult,
ToolResult: truncateStr(strings.TrimSpace(result), 500),
ToolExitCode: &code,
ToolSuccess: &success,
}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
}

View File

@@ -582,6 +582,129 @@ func makeFiller(n int) string {
return string(b)
}
// TestHandleUserEmitsToolResult is a regression test for the bug where
// claudeSession.handleUser silently dropped tool_result content blocks
// (only logging when is_error=true) instead of emitting EventToolResult.
// Without this event, engine never sees tool output and the Feishu/Slack/
// Discord progress card never renders tool results — only the final
// assistant text reaches the user.
//
// Cases covered:
// - string content (plain text result)
// - array content (Anthropic SDK multi-block: [{type:"text", text:"..."}])
// - is_error=true (exit code 1, success=false)
func TestHandleUserEmitsToolResult(t *testing.T) {
cases := []struct {
name string
raw map[string]any
wantResult string
wantCode int
wantSuccess bool
}{
{
name: "string content",
raw: map[string]any{
"type": "user",
"message": map[string]any{
"content": []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "toolu_abc",
"is_error": false,
"content": "command output here",
},
},
},
},
wantResult: "command output here",
wantCode: 0,
wantSuccess: true,
},
{
name: "array content",
raw: map[string]any{
"type": "user",
"message": map[string]any{
"content": []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "toolu_def",
"is_error": false,
"content": []any{
map[string]any{"type": "text", "text": "line one"},
map[string]any{"type": "text", "text": "line two"},
},
},
},
},
},
wantResult: "line one\nline two",
wantCode: 0,
wantSuccess: true,
},
{
name: "error result",
raw: map[string]any{
"type": "user",
"message": map[string]any{
"content": []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "toolu_err",
"is_error": true,
"content": "boom",
},
},
},
},
wantResult: "boom",
wantCode: 1,
wantSuccess: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &claudeSession{
events: make(chan core.Event, 4),
ctx: ctx,
}
cs.alive.Store(true)
cs.handleUser(tc.raw)
select {
case evt := <-cs.events:
if evt.Type != core.EventToolResult {
t.Fatalf("event type = %q, want %q", evt.Type, core.EventToolResult)
}
if evt.ToolResult != tc.wantResult {
t.Errorf("ToolResult = %q, want %q", evt.ToolResult, tc.wantResult)
}
if evt.ToolExitCode == nil || *evt.ToolExitCode != tc.wantCode {
got := -1
if evt.ToolExitCode != nil {
got = *evt.ToolExitCode
}
t.Errorf("ToolExitCode = %d, want %d", got, tc.wantCode)
}
if evt.ToolSuccess == nil || *evt.ToolSuccess != tc.wantSuccess {
got := false
if evt.ToolSuccess != nil {
got = *evt.ToolSuccess
}
t.Errorf("ToolSuccess = %v, want %v", got, tc.wantSuccess)
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for EventToolResult — handleUser dropped the tool_result")
}
})
}
}
func helperCommand(ctx context.Context, mode string) *exec.Cmd {
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestHelperProcess", "--", mode)
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")