diff --git a/apps/node/internal/adapters/cli/cli_internal_test.go b/apps/node/internal/adapters/cli/cli_internal_test.go index 2163842..753d4a9 100644 --- a/apps/node/internal/adapters/cli/cli_internal_test.go +++ b/apps/node/internal/adapters/cli/cli_internal_test.go @@ -211,6 +211,42 @@ func TestStreamJSONEmitter_ErrorEvent(t *testing.T) { } } +func TestStreamJSONEmitter_ToolUseBecomesVisibleDelta(t *testing.T) { + e := streamJSONEmitter{} + events, _ := e.Emit(`{"type":"tool_use","tool_name":"run_shell_command","tool_id":"tool-1","parameters":{"command":"go test ./..."}}`) + if len(events) != 1 || events[0].Type != runtime.EventTypeDelta { + t.Fatalf("unexpected events: %+v", events) + } + if events[0].Delta != "[tool] run_shell_command: go test ./...\n" { + t.Fatalf("unexpected delta: %q", events[0].Delta) + } + if events[0].Metadata["kind"] != "tool_use" || events[0].Metadata["tool_name"] != "run_shell_command" { + t.Fatalf("unexpected metadata: %+v", events[0].Metadata) + } +} + +func TestStreamJSONEmitter_ToolResultBecomesVisibleDelta(t *testing.T) { + e := streamJSONEmitter{} + events, _ := e.Emit(`{"type":"tool_result","tool_id":"tool-1","status":"success","output":"ok\n"}`) + if len(events) != 1 || events[0].Type != runtime.EventTypeDelta { + t.Fatalf("unexpected events: %+v", events) + } + if events[0].Delta != "[tool-result] success: ok\n" { + t.Fatalf("unexpected delta: %q", events[0].Delta) + } + if events[0].Metadata["kind"] != "tool_result" { + t.Fatalf("unexpected metadata: %+v", events[0].Metadata) + } +} + +func TestStreamJSONEmitter_ResultError(t *testing.T) { + e := streamJSONEmitter{} + events, _ := e.Emit(`{"type":"result","status":"error","error":{"message":"quota exceeded"}}`) + if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "quota exceeded" { + t.Fatalf("unexpected events: %+v", events) + } +} + func TestStreamJSONEmitter_EmptyContentSkipped(t *testing.T) { e := streamJSONEmitter{} events, _ := e.Emit(`{"type":"message","role":"assistant","content":""}`) diff --git a/apps/node/internal/adapters/cli/emitters.go b/apps/node/internal/adapters/cli/emitters.go index cdebd42..5d6bdce 100644 --- a/apps/node/internal/adapters/cli/emitters.go +++ b/apps/node/internal/adapters/cli/emitters.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "fmt" "io" "strings" "time" @@ -91,10 +92,16 @@ func (streamJSONEmitter) Name() string { return "stream-json" } func (e streamJSONEmitter) Emit(line string) ([]runtime.RuntimeEvent, error) { var ev struct { - Type string `json:"type"` - Role string `json:"role"` - Content string `json:"content"` - Error string `json:"error"` + Type string `json:"type"` + Role string `json:"role"` + Content string `json:"content"` + Error any `json:"error"` + Message string `json:"message"` + Status string `json:"status"` + ToolName string `json:"tool_name"` + ToolID string `json:"tool_id"` + Parameters map[string]any `json:"parameters"` + Output string `json:"output"` } if err := json.Unmarshal([]byte(line), &ev); err != nil { return nil, nil @@ -108,15 +115,124 @@ func (e streamJSONEmitter) Emit(line string) ([]runtime.RuntimeEvent, error) { Type: runtime.EventTypeDelta, Delta: ev.Content, }}, nil + case "tool_use": + if delta := geminiToolUseDelta(ev.ToolName, ev.Parameters); delta != "" { + return []runtime.RuntimeEvent{{ + Type: runtime.EventTypeDelta, + Delta: delta, + Metadata: map[string]string{ + "kind": "tool_use", + "tool_name": ev.ToolName, + "tool_id": ev.ToolID, + }, + }}, nil + } + return nil, nil + case "tool_result": + if delta := geminiToolResultDelta(ev.Status, ev.Output); delta != "" { + return []runtime.RuntimeEvent{{ + Type: runtime.EventTypeDelta, + Delta: delta, + Metadata: map[string]string{ + "kind": "tool_result", + "tool_id": ev.ToolID, + }, + }}, nil + } + return nil, nil + case "result": + if ev.Status != "" && ev.Status != "success" { + msg := ev.Message + if msg == "" { + msg = geminiErrorString(ev.Error) + } + if msg == "" { + msg = ev.Status + } + return []runtime.RuntimeEvent{{ + Type: runtime.EventTypeError, + Error: msg, + }}, nil + } + return nil, nil case "error": return []runtime.RuntimeEvent{{ Type: runtime.EventTypeError, - Error: ev.Error, + Error: geminiErrorString(ev.Error), }}, nil } return nil, nil } +func geminiToolUseDelta(toolName string, params map[string]any) string { + if toolName == "" { + return "" + } + summary := geminiToolParamSummary(params) + if summary == "" { + return fmt.Sprintf("[tool] %s\n", toolName) + } + return fmt.Sprintf("[tool] %s: %s\n", toolName, summary) +} + +func geminiToolParamSummary(params map[string]any) string { + if params == nil { + return "" + } + for _, key := range []string{"command", "description", "title", "summary"} { + if s, ok := params[key].(string); ok && strings.TrimSpace(s) != "" { + return truncateGeminiToolText(strings.TrimSpace(s), 180) + } + } + return "" +} + +func geminiToolResultDelta(status, output string) string { + status = strings.TrimSpace(status) + output = strings.TrimSpace(output) + if status == "" && output == "" { + return "" + } + if status == "" { + status = "done" + } + if output == "" { + return fmt.Sprintf("[tool-result] %s\n", status) + } + return fmt.Sprintf("[tool-result] %s: %s\n", status, truncateGeminiToolText(output, 180)) +} + +func geminiErrorString(v any) string { + switch err := v.(type) { + case string: + return err + case map[string]any: + if msg, ok := err["message"].(string); ok { + return msg + } + if data, ok := err["data"].(map[string]any); ok { + if msg, ok := data["message"].(string); ok { + return msg + } + } + if name, ok := err["name"].(string); ok { + return name + } + } + return "" +} + +func truncateGeminiToolText(s string, max int) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} + // --- claude-json emitter --- type claudeJSONEmitter struct{} diff --git a/configs/edge.yaml b/configs/edge.yaml index 835acb2..6edfa88 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -17,7 +17,7 @@ metrics: console: adapter: "cli" - target: "claude" + target: "gemini" session_id: "default" background: false timeout_sec: 300