mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user