Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 76 additions & 15 deletions internal/runtime/claude_progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] sanitization-consistency

emitResultProgress and emitSystemProgress sanitize evt.Subtype individually before interpolation, while emitToolProgress sanitizes the full composed message. Both approaches are correct since the only untrusted input (Subtype) is sanitized and the remaining values (elapsed, toolCount) are computed. The inconsistency is a maintenance consideration — if a future change adds another untrusted field, a developer following the local pattern might not add full-message sanitization.

Suggested fix: Consider applying sanitizeOutput to the final msg in emitResultProgress and emitSystemProgress for consistency with emitToolProgress, or document the per-field approach as intentional.

toolCount := metrics.ToolCalls.Load()

var msg string
if evt.IsError {
subtype := evt.Subtype
if subtype == "" {
subtype = "error"
}
msg = sanitizeOutput(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 := evt.Subtype
if subtype == "" {
subtype = "system"
}
msg := sanitizeOutput(fmt.Sprintf("System: %s (%s)", subtype, elapsed))
if isCI {
fmt.Fprintf(os.Stderr, "::notice::%s\n", msg)
}
printer.Heartbeat(msg)
}
150 changes: 150 additions & 0 deletions internal/runtime/claude_progress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading