From a5d441e98e6d0282166ae4e98c82181c35d4892a Mon Sep 17 00:00:00 2001 From: coolrockin Date: Tue, 23 Jun 2026 07:22:13 +0800 Subject: [PATCH] fix(claudecode): emit EventToolResult so tool output reaches progress card (#1407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- agent/claudecode/session.go | 32 +++++++- agent/claudecode/session_test.go | 123 +++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/agent/claudecode/session.go b/agent/claudecode/session.go index 0cbadd546..25e7d4575 100644 --- a/agent/claudecode/session.go +++ b/agent/claudecode/session.go @@ -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 + } } } } diff --git a/agent/claudecode/session_test.go b/agent/claudecode/session_test.go index f65e02400..5e2410ebb 100644 --- a/agent/claudecode/session_test.go +++ b/agent/claudecode/session_test.go @@ -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")