From 1c6892348d44664d1cee21ddc3aacee69168d726 Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:51:42 +0000 Subject: [PATCH 1/2] feat(#241): render thinking, result, and system events in progress stream Extend the progress parser to handle additional Claude Code stream-json event types beyond tool_use. The parser now emits progress for thinking content blocks (safe indicator, no content leaked), result events (completion status with subtype for errors), and system events (session initialization). Error results use ::warning:: in CI and StepWarn formatting. Sensitive data from thinking blocks and result messages is never surfaced. Closes #241 --- internal/runtime/claude_progress.go | 91 +++++++++++--- internal/runtime/claude_progress_test.go | 150 +++++++++++++++++++++++ 2 files changed, 226 insertions(+), 15 deletions(-) diff --git a/internal/runtime/claude_progress.go b/internal/runtime/claude_progress.go index cc9681976..998b987e6 100644 --- a/internal/runtime/claude_progress.go +++ b/internal/runtime/claude_progress.go @@ -20,7 +20,9 @@ const ( // streamEvent represents a single NDJSON event from Claude Code's stream-json output. type streamEvent struct { - Type string `json:"type"` + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + IsError bool `json:"is_error,omitempty"` } // assistantMessage contains tool_use blocks from complete assistant messages. @@ -79,13 +81,18 @@ func progressParser(r io.Reader, printer *ui.Printer, start time.Time, metrics * continue } - if evt.Type == "assistant" { - parseAssistantToolUse(line, printer, start, metrics, isCI) + switch evt.Type { + case "assistant": + parseAssistantEvent(line, printer, start, metrics, isCI) + case "result": + emitResultProgress(printer, evt, start, metrics, isCI) + case "system": + emitSystemProgress(printer, evt, start, isCI) } } } -func parseAssistantToolUse(line []byte, printer *ui.Printer, start time.Time, metrics *RunMetrics, isCI bool) { +func parseAssistantEvent(line []byte, printer *ui.Printer, start time.Time, metrics *RunMetrics, isCI bool) { var msg assistantMessage if err := json.Unmarshal(line, &msg); err != nil { return @@ -97,18 +104,20 @@ func parseAssistantToolUse(line []byte, printer *ui.Printer, start time.Time, me } for _, item := range items { - if item.Type != "tool_use" { - continue - } - toolName := item.Name - var ctx string - if !allowedTools[toolName] { - toolName = "tool" - } else { - ctx = extractSafeContext(item.Name, item.Input) + switch item.Type { + case "tool_use": + toolName := item.Name + var ctx string + if !allowedTools[toolName] { + toolName = "tool" + } else { + ctx = extractSafeContext(item.Name, item.Input) + } + count := metrics.ToolCalls.Add(1) + emitToolProgress(printer, toolName, ctx, start, count, isCI) + case "thinking": + emitThinkingProgress(printer, start, metrics, isCI) } - count := metrics.ToolCalls.Add(1) - emitToolProgress(printer, toolName, ctx, start, count, isCI) } } @@ -209,3 +218,55 @@ func emitToolProgress(printer *ui.Printer, toolName, context string, start time. } printer.Heartbeat(msg) } + +// emitThinkingProgress reports that the agent is reasoning without exposing +// the thinking content, which may contain sensitive data from the prompt or +// sandbox. +func emitThinkingProgress(printer *ui.Printer, start time.Time, metrics *RunMetrics, isCI bool) { + elapsed := time.Since(start).Truncate(time.Second) + toolCount := metrics.ToolCalls.Load() + msg := fmt.Sprintf("Thinking (%s, %d tools)", elapsed, toolCount) + if isCI { + fmt.Fprintf(os.Stderr, "::notice::%s\n", msg) + } + printer.Heartbeat(msg) +} + +// emitResultProgress reports completion or error from a result event. +func emitResultProgress(printer *ui.Printer, evt streamEvent, start time.Time, metrics *RunMetrics, isCI bool) { + elapsed := time.Since(start).Truncate(time.Second) + toolCount := metrics.ToolCalls.Load() + + var msg string + if evt.IsError { + subtype := sanitizeOutput(evt.Subtype) + if subtype == "" { + subtype = "error" + } + msg = fmt.Sprintf("Result: %s (%s, %d tools)", subtype, elapsed, toolCount) + if isCI { + fmt.Fprintf(os.Stderr, "::warning::%s\n", msg) + } + printer.StepWarn(msg) + } else { + msg = fmt.Sprintf("Result: completed (%s, %d tools)", elapsed, toolCount) + if isCI { + fmt.Fprintf(os.Stderr, "::notice::%s\n", msg) + } + printer.StepDone(msg) + } +} + +// emitSystemProgress reports system-level events (e.g., session initialization). +func emitSystemProgress(printer *ui.Printer, evt streamEvent, start time.Time, isCI bool) { + elapsed := time.Since(start).Truncate(time.Second) + subtype := sanitizeOutput(evt.Subtype) + if subtype == "" { + subtype = "system" + } + msg := fmt.Sprintf("System: %s (%s)", subtype, elapsed) + if isCI { + fmt.Fprintf(os.Stderr, "::notice::%s\n", msg) + } + printer.Heartbeat(msg) +} diff --git a/internal/runtime/claude_progress_test.go b/internal/runtime/claude_progress_test.go index 3c7fc78d3..892aebe98 100644 --- a/internal/runtime/claude_progress_test.go +++ b/internal/runtime/claude_progress_test.go @@ -450,3 +450,153 @@ func TestHeartbeatConcurrency(t *testing.T) { t.Errorf("expected main goroutine output, got: %s", output) } } + +func TestProgressParserThinkingEvent(t *testing.T) { + lines := []string{ + `{"type":"assistant","content":[{"type":"thinking","thinking":"I need to analyze the code..."}]}`, + `{"type":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/src/main.go"}}]}`, + } + + input := strings.NewReader(strings.Join(lines, "\n")) + var buf bytes.Buffer + printer := ui.New(&buf) + metrics := &RunMetrics{} + + if err := progressParser(input, printer, time.Now(), metrics); err != nil { + t.Fatalf("progressParser returned error: %v", err) + } + + if metrics.ToolCalls.Load() != 1 { + t.Errorf("expected 1 tool call (thinking is not a tool), got %d", metrics.ToolCalls.Load()) + } + + output := buf.String() + if !strings.Contains(output, "Thinking") { + t.Errorf("expected Thinking progress, got: %s", output) + } + if strings.Contains(output, "I need to analyze") { + t.Errorf("thinking content should not appear in output, got: %s", output) + } +} + +func TestProgressParserThinkingContentNotLeaked(t *testing.T) { + lines := []string{ + `{"type":"assistant","content":[{"type":"thinking","thinking":"SECRET_API_KEY=abc123 sensitive data here"}]}`, + } + + input := strings.NewReader(strings.Join(lines, "\n")) + var buf bytes.Buffer + printer := ui.New(&buf) + metrics := &RunMetrics{} + + _ = progressParser(input, printer, time.Now(), metrics) + + output := buf.String() + if strings.Contains(output, "SECRET_API_KEY") { + t.Errorf("thinking content should not leak sensitive data, got: %s", output) + } + if strings.Contains(output, "abc123") { + t.Errorf("thinking content should not leak sensitive data, got: %s", output) + } +} + +func TestProgressParserResultSuccess(t *testing.T) { + lines := []string{ + `{"type":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/src/main.go"}}]}`, + `{"type":"result","subtype":"success","is_error":false,"result":"Task completed"}`, + } + + input := strings.NewReader(strings.Join(lines, "\n")) + var buf bytes.Buffer + printer := ui.New(&buf) + metrics := &RunMetrics{} + + if err := progressParser(input, printer, time.Now(), metrics); err != nil { + t.Fatalf("progressParser returned error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Result: completed") { + t.Errorf("expected success result, got: %s", output) + } +} + +func TestProgressParserResultError(t *testing.T) { + lines := []string{ + `{"type":"result","subtype":"error_max_turns","is_error":true,"result":"Agent reached maximum turns"}`, + } + + input := strings.NewReader(strings.Join(lines, "\n")) + var buf bytes.Buffer + printer := ui.New(&buf) + metrics := &RunMetrics{} + + if err := progressParser(input, printer, time.Now(), metrics); err != nil { + t.Fatalf("progressParser returned error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Result: error_max_turns") { + t.Errorf("expected error result with subtype, got: %s", output) + } + if strings.Contains(output, "Agent reached maximum turns") { + t.Errorf("result error message content should not appear in progress, got: %s", output) + } +} + +func TestProgressParserResultErrorNoSubtype(t *testing.T) { + lines := []string{ + `{"type":"result","is_error":true}`, + } + + input := strings.NewReader(strings.Join(lines, "\n")) + var buf bytes.Buffer + printer := ui.New(&buf) + metrics := &RunMetrics{} + + _ = progressParser(input, printer, time.Now(), metrics) + + output := buf.String() + if !strings.Contains(output, "Result: error") { + t.Errorf("expected fallback 'error' subtype, got: %s", output) + } +} + +func TestProgressParserSystemEvent(t *testing.T) { + lines := []string{ + `{"type":"system","subtype":"init"}`, + `{"type":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/src/main.go"}}]}`, + } + + input := strings.NewReader(strings.Join(lines, "\n")) + var buf bytes.Buffer + printer := ui.New(&buf) + metrics := &RunMetrics{} + + if err := progressParser(input, printer, time.Now(), metrics); err != nil { + t.Fatalf("progressParser returned error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "System: init") { + t.Errorf("expected system init progress, got: %s", output) + } +} + +func TestProgressParserSystemEventNoSubtype(t *testing.T) { + lines := []string{ + `{"type":"system"}`, + } + + input := strings.NewReader(strings.Join(lines, "\n")) + var buf bytes.Buffer + printer := ui.New(&buf) + metrics := &RunMetrics{} + + _ = progressParser(input, printer, time.Now(), metrics) + + output := buf.String() + if !strings.Contains(output, "System: system") { + t.Errorf("expected fallback 'system' subtype, got: %s", output) + } +} From 3fe5d3c78405c4848070df0d46e9b959d7fde173 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:44:22 +0000 Subject: [PATCH 2/2] fix(#241): sanitize full message in emitResultProgress and emitSystemProgress Apply sanitizeOutput to the composed message instead of individual fields, matching the pattern used in emitToolProgress. This ensures consistency across all emit functions so future untrusted fields are automatically covered. Co-Authored-By: Claude Opus 4.6 --- internal/runtime/claude_progress.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/runtime/claude_progress.go b/internal/runtime/claude_progress.go index 998b987e6..f5faf02bb 100644 --- a/internal/runtime/claude_progress.go +++ b/internal/runtime/claude_progress.go @@ -239,11 +239,11 @@ func emitResultProgress(printer *ui.Printer, evt streamEvent, start time.Time, m var msg string if evt.IsError { - subtype := sanitizeOutput(evt.Subtype) + subtype := evt.Subtype if subtype == "" { subtype = "error" } - msg = fmt.Sprintf("Result: %s (%s, %d tools)", subtype, elapsed, toolCount) + msg = sanitizeOutput(fmt.Sprintf("Result: %s (%s, %d tools)", subtype, elapsed, toolCount)) if isCI { fmt.Fprintf(os.Stderr, "::warning::%s\n", msg) } @@ -260,11 +260,11 @@ func emitResultProgress(printer *ui.Printer, evt streamEvent, start time.Time, m // emitSystemProgress reports system-level events (e.g., session initialization). func emitSystemProgress(printer *ui.Printer, evt streamEvent, start time.Time, isCI bool) { elapsed := time.Since(start).Truncate(time.Second) - subtype := sanitizeOutput(evt.Subtype) + subtype := evt.Subtype if subtype == "" { subtype = "system" } - msg := fmt.Sprintf("System: %s (%s)", subtype, elapsed) + msg := sanitizeOutput(fmt.Sprintf("System: %s (%s)", subtype, elapsed)) if isCI { fmt.Fprintf(os.Stderr, "::notice::%s\n", msg) }