Sundeep/eng 1672 seeing situations where taskgroup loaders remain displayed (#381)

* fix: Handle Task tool array content format in claudecode-go (ENG-1672)

Task tools send content as an array format while regular tools use string format,
causing unmarshal failures that silently drop Task tool results. This left
spinners running indefinitely in the WUI.

Changes:
- Add custom ContentField type with UnmarshalJSON to handle both formats
- Update Content struct to use ContentField instead of string
- Add error logging for unmarshal failures in parseStreamingJSON
- Update hld references to use content.Content.Value
- Add comprehensive tests for both content formats
- Update WUI TaskGroup styling for better focus indication

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* mct

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Sundeep Malladi
2025-07-30 11:06:50 -05:00
committed by GitHub
parent db20f6f0db
commit 88da1b7076
6 changed files with 272 additions and 17 deletions

View File

@@ -324,6 +324,7 @@ func (s *Session) parseStreamingJSON(stdout, stderr io.Reader) {
var event StreamEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
// Log parse error but continue
log.Printf("WARNING: Failed to unmarshal event, dropping it: %v\nRaw data: %s", err, line)
continue
}

View File

@@ -1,7 +1,10 @@
package claudecode
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
"sync"
"time"
)
@@ -103,6 +106,45 @@ type Message struct {
Usage *Usage `json:"usage,omitempty"`
}
// ContentField handles both string and array content formats
type ContentField struct {
Value string
}
// UnmarshalJSON implements custom unmarshaling to handle both string and array formats
func (c *ContentField) UnmarshalJSON(data []byte) error {
// First try to unmarshal as string
var str string
if err := json.Unmarshal(data, &str); err == nil {
c.Value = str
return nil
}
// If that fails, try array format
var arr []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(data, &arr); err == nil {
// Concatenate all text elements
var texts []string
for _, item := range arr {
if item.Type == "text" && item.Text != "" {
texts = append(texts, item.Text)
}
}
c.Value = strings.Join(texts, "\n")
return nil
}
return fmt.Errorf("content field is neither string nor array format")
}
// MarshalJSON implements custom marshaling to always output as string
func (c ContentField) MarshalJSON() ([]byte, error) {
return json.Marshal(c.Value)
}
// Content can be text or tool use
type Content struct {
Type string `json:"type"`
@@ -112,7 +154,7 @@ type Content struct {
Name string `json:"name,omitempty"`
Input map[string]interface{} `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
Content ContentField `json:"content,omitempty"`
}
// ServerToolUse tracks server-side tool usage

209
claudecode-go/types_test.go Normal file
View File

@@ -0,0 +1,209 @@
package claudecode
import (
"encoding/json"
"testing"
)
func TestContentFieldUnmarshal(t *testing.T) {
tests := []struct {
name string
input string
expected string
wantErr bool
}{
{
name: "string content",
input: `"simple string content"`,
expected: "simple string content",
wantErr: false,
},
{
name: "array content with single text",
input: `[{"type": "text", "text": "array content"}]`,
expected: "array content",
wantErr: false,
},
{
name: "array content with multiple texts",
input: `[{"type": "text", "text": "line1"}, {"type": "text", "text": "line2"}]`,
expected: "line1\nline2",
wantErr: false,
},
{
name: "array content with non-text types filtered",
input: `[{"type": "image", "text": "ignored"}, {"type": "text", "text": "kept"}]`,
expected: "kept",
wantErr: false,
},
{
name: "empty array",
input: `[]`,
expected: "",
wantErr: false,
},
{
name: "empty string",
input: `""`,
expected: "",
wantErr: false,
},
{
name: "null value",
input: `null`,
expected: "",
wantErr: false,
},
{
name: "invalid content format - object",
input: `{"invalid": "object"}`,
expected: "",
wantErr: true,
},
{
name: "invalid content format - number",
input: `12345`,
expected: "",
wantErr: true,
},
{
name: "invalid content format - boolean",
input: `true`,
expected: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var c ContentField
err := json.Unmarshal([]byte(tt.input), &c)
if (err != nil) != tt.wantErr {
t.Errorf("ContentField.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if c.Value != tt.expected {
t.Errorf("ContentField.UnmarshalJSON() = %v, want %v", c.Value, tt.expected)
}
})
}
}
func TestContentFieldMarshal(t *testing.T) {
tests := []struct {
name string
field ContentField
expected string
}{
{
name: "marshal simple string",
field: ContentField{Value: "test content"},
expected: `"test content"`,
},
{
name: "marshal empty string",
field: ContentField{Value: ""},
expected: `""`,
},
{
name: "marshal multiline string",
field: ContentField{Value: "line1\nline2"},
expected: `"line1\nline2"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := json.Marshal(tt.field)
if err != nil {
t.Errorf("ContentField.MarshalJSON() error = %v", err)
return
}
if string(result) != tt.expected {
t.Errorf("ContentField.MarshalJSON() = %v, want %v", string(result), tt.expected)
}
})
}
}
func TestContentStructWithContentField(t *testing.T) {
// Test the full Content struct with ContentField
tests := []struct {
name string
input string
expected Content
wantErr bool
}{
{
name: "tool result with string content",
input: `{
"type": "tool_result",
"tool_use_id": "tool_123",
"content": "Tool execution successful"
}`,
expected: Content{
Type: "tool_result",
ToolUseID: "tool_123",
Content: ContentField{Value: "Tool execution successful"},
},
wantErr: false,
},
{
name: "tool result with array content (Task tool format)",
input: `{
"type": "tool_result",
"tool_use_id": "task_456",
"content": [{"type": "text", "text": "Task completed successfully"}]
}`,
expected: Content{
Type: "tool_result",
ToolUseID: "task_456",
Content: ContentField{Value: "Task completed successfully"},
},
wantErr: false,
},
{
name: "text content without tool result",
input: `{
"type": "text",
"text": "Regular text content"
}`,
expected: Content{
Type: "text",
Text: "Regular text content",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var c Content
err := json.Unmarshal([]byte(tt.input), &c)
if (err != nil) != tt.wantErr {
t.Errorf("Content unmarshal error = %v, wantErr %v", err, tt.wantErr)
return
}
if c.Type != tt.expected.Type {
t.Errorf("Content.Type = %v, want %v", c.Type, tt.expected.Type)
}
if c.ToolUseID != tt.expected.ToolUseID {
t.Errorf("Content.ToolUseID = %v, want %v", c.ToolUseID, tt.expected.ToolUseID)
}
if c.Content.Value != tt.expected.Content.Value {
t.Errorf("Content.Content.Value = %v, want %v", c.Content.Value, tt.expected.Content.Value)
}
if c.Text != tt.expected.Text {
t.Errorf("Content.Text = %v, want %v", c.Text, tt.expected.Text)
}
})
}
}

View File

@@ -746,7 +746,7 @@ func (m *Manager) processStreamEvent(ctx context.Context, sessionID string, clau
EventType: store.EventTypeToolResult,
Role: "user",
ToolResultForID: content.ToolUseID,
ToolResultContent: content.Content,
ToolResultContent: content.Content.Value,
}
if err := m.store.AddConversationEvent(ctx, convEvent); err != nil {
return err
@@ -754,7 +754,7 @@ func (m *Manager) processStreamEvent(ctx context.Context, sessionID string, clau
// Asynchronously capture file snapshot for Read tool results
if toolCall, err := m.store.GetToolCallByID(ctx, content.ToolUseID); err == nil && toolCall != nil && toolCall.ToolName == "Read" {
go m.captureFileSnapshot(ctx, sessionID, content.ToolUseID, toolCall.ToolInputJSON, content.Content)
go m.captureFileSnapshot(ctx, sessionID, content.ToolUseID, toolCall.ToolInputJSON, content.Content.Value)
}
// Update session activity timestamp for tool results
@@ -769,7 +769,7 @@ func (m *Manager) processStreamEvent(ctx context.Context, sessionID string, clau
"claude_session_id": claudeSessionID,
"event_type": "tool_result",
"tool_result_for_id": content.ToolUseID,
"tool_result_content": content.Content,
"tool_result_content": content.Content.Value,
"content_type": "tool_result",
},
})

View File

@@ -131,7 +131,7 @@ func (c *mockClaudeClient) CreateSession(config claudecode.SessionConfig) (*clau
{
Type: "tool_result",
ToolUseID: "toolu_01ABC123",
Content: "File created successfully",
Content: claudecode.ContentField{Value: "File created successfully"},
},
},
},
@@ -670,7 +670,7 @@ func TestSessionManagerEventProcessing(t *testing.T) {
{
Type: "tool_result",
ToolUseID: "toolu_corr_test",
Content: "Tool executed successfully",
Content: claudecode.ContentField{Value: "Tool executed successfully"},
},
},
},

View File

@@ -57,13 +57,15 @@ export function TaskGroup({
const isCompleted = parentTask.isCompleted
return (
<div className="p-4 TaskGroup">
<div
className={`p-4 TaskGroup cursor-pointer transition-all duration-200 ${
focusedEventId === parentTask.id ? 'shadow-[inset_2px_0_0_0_var(--terminal-accent)]' : ''
}`}
>
{/* Task Header with Preview */}
<div
data-event-id={parentTask.id}
className={`flex items-start gap-2 rounded-md cursor-pointer hover:bg-muted/10 transition-all duration-200 ${
focusedEventId === parentTask.id ? 'shadow-[inset_2px_0_0_0_var(--terminal-accent)]' : ''
}`}
className={`flex items-start gap-2 rounded-md cursor-pointer hover:bg-muted/10 transition-all duration-200 `}
onClick={onToggle}
onMouseEnter={() => {
if (shouldIgnoreMouseEvent?.()) return
@@ -165,7 +167,7 @@ export function TaskGroup({
{/* Expanded Sub-task Events */}
{isExpanded && (
<div className="ml-6 mt-2 pl-4 border-l-2 border-border/50 TaskGroupExpanded">
<div className="ml-6 mt-2 TaskGroupExpanded">
{group.subTaskEvents.map(subEvent => {
const displayObject = eventToDisplayObject(
subEvent,
@@ -186,7 +188,12 @@ export function TaskGroup({
if (!displayObject) return null
return (
<div key={subEvent.id} className="relative mb-2">
<div
key={subEvent.id}
className={`relative pl-4 transition-all duration-200 border-l-2 ${
focusedEventId === displayObject.id ? 'border-[var(--terminal-accent)]' : ''
}`}
>
<div
data-event-id={displayObject.id}
onMouseEnter={() => {
@@ -211,11 +218,7 @@ export function TaskGroup({
}
}
}}
className={`group py-2 px-2 cursor-pointer transition-shadow duration-200 ${
focusedEventId === displayObject.id
? 'shadow-[inset_2px_0_0_0_var(--terminal-accent)]'
: ''
}`}
className={`group py-2 px-2 cursor-pointer`}
>
{/* Main content container with flexbox */}
<div className="flex gap-4">