mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
209
claudecode-go/types_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user