package tui import ( "encoding/json" "errors" "testing" tea "charm.land/bubbletea/v2" "github.com/smallnest/pigo/internal/agentcore" ) // drain collects every msg queued on ch without blocking, stopping at the first // would-block. The bridge sends are synchronous into a buffered channel, so once // the fake sequence has been driven the msgs are all present and this returns // them in order. func drain(ch chan tea.Msg) []tea.Msg { var out []tea.Msg for { select { case m := <-ch: out = append(out, m) default: return out } } } // TestStreamHandlerConversion drives a synthetic event sequence directly through // the StreamHandler the bridge builds and asserts each callback produces the // matching tea.Msg, in order. It fakes the run entirely (no provider), exercising // the callback→msg conversion + channel ordering in isolation. func TestStreamHandlerConversion(t *testing.T) { ch := newEventChan() h := newStreamHandler(ch, nil) // A representative sequence: two text deltas, a tool start/update/end, a // telemetry summary, a compaction, and a turn end. h.OnText("Hello ") h.OnText("world") h.OnEvent(agentcore.ToolExecutionStartEvent{ ToolCallID: "call-1", ToolName: "read_file", Args: map[string]any{"path": "/tmp/x"}, }) h.OnEvent(agentcore.ToolExecutionUpdateEvent{ ToolCallID: "call-1", ToolName: "read_file", PartialResult: agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("partial")}}, }) h.OnEvent(agentcore.ToolExecutionEndEvent{ ToolCallID: "call-1", ToolName: "read_file", Result: agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("done")}}, IsError: false, }) h.OnEvent(agentcore.TelemetryEvent{Turns: 3}) h.OnEvent(agentcore.CompactionEvent{Reason: "threshold"}) h.OnTurnEnd( agentcore.AssistantMessage{Content: agentcore.ContentList{agentcore.NewTextContent("Hello world")}}, []agentcore.ToolResultMessage{{ToolCallID: "call-1"}}, ) // Simulate drain-done: the pump appends runEndMsg after DrainStream returns. ch <- runEndMsg{err: nil} got := drain(ch) if len(got) != 9 { t.Fatalf("expected 9 msgs, got %d: %#v", len(got), got) } if m, ok := got[0].(textDeltaMsg); !ok || m.delta != "Hello " { t.Errorf("msg[0] = %#v, want textDeltaMsg{delta:%q}", got[0], "Hello ") } if m, ok := got[1].(textDeltaMsg); !ok || m.delta != "world" { t.Errorf("msg[1] = %#v, want textDeltaMsg{delta:%q}", got[1], "world") } if m, ok := got[2].(toolStartMsg); !ok || m.id != "call-1" || m.name != "read_file" || m.input["path"] != "/tmp/x" { t.Errorf("msg[2] = %#v, want toolStartMsg for call-1", got[2]) } if m, ok := got[3].(toolUpdateMsg); !ok || m.id != "call-1" || m.partial != "partial" { t.Errorf("msg[3] = %#v, want toolUpdateMsg{partial:%q}", got[3], "partial") } if m, ok := got[4].(toolEndMsg); !ok || m.id != "call-1" || !m.ok || m.result != "done" { t.Errorf("msg[4] = %#v, want toolEndMsg{ok:true, result:%q}", got[4], "done") } if m, ok := got[5].(telemetryMsg); !ok || m.ev.Turns != 3 { t.Errorf("msg[5] = %#v, want telemetryMsg{Turns:3}", got[5]) } if _, ok := got[6].(compactionMsg); !ok { t.Errorf("msg[6] = %#v, want compactionMsg", got[6]) } if m, ok := got[7].(turnEndMsg); !ok || len(m.results) != 1 || m.results[0].ToolCallID != "call-1" { t.Errorf("msg[7] = %#v, want turnEndMsg with one result", got[7]) } if m, ok := got[8].(runEndMsg); !ok || m.err != nil { t.Errorf("msg[8] = %#v, want runEndMsg{err:nil}", got[8]) } } // TestToolEndError verifies the ok flag inverts IsError. func TestToolEndError(t *testing.T) { ch := newEventChan() h := newStreamHandler(ch, nil) h.OnEvent(agentcore.ToolExecutionEndEvent{ ToolCallID: "c", Result: agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("boom")}}, IsError: true, }) got := drain(ch) if len(got) != 1 { t.Fatalf("expected 1 msg, got %d", len(got)) } m, ok := got[0].(toolEndMsg) if !ok || m.ok || m.result != "boom" { t.Errorf("got %#v, want toolEndMsg{ok:false, result:%q}", got[0], "boom") } } // TestArgsToMap covers the object / non-object coercion of tool call args. func TestArgsToMap(t *testing.T) { if m := argsToMap(map[string]any{"k": "v"}); m == nil || m["k"] != "v" { t.Errorf("object args: got %#v, want map with k=v", m) } if m := argsToMap("not-an-object"); m != nil { t.Errorf("non-object args: got %#v, want nil", m) } if m := argsToMap(nil); m != nil { t.Errorf("nil args: got %#v, want nil", m) } // The tool executor emits Args as json.RawMessage; the card must decode it. if m := argsToMap(json.RawMessage(`{"command":"ls -la"}`)); m == nil || m["command"] != "ls -la" { t.Errorf("raw JSON object args: got %#v, want map with command=ls -la", m) } if m := argsToMap(json.RawMessage(`"just a string"`)); m != nil { t.Errorf("raw JSON non-object: got %#v, want nil", m) } if m := argsToMap(json.RawMessage(nil)); m != nil { t.Errorf("empty raw JSON: got %#v, want nil", m) } } // TestSubAgentProgressConversion verifies a SubAgentProgressEvent maps to a // subagentProgressMsg carrying the id (parent task tool-call id), description, // activity, and token estimate. func TestSubAgentProgressConversion(t *testing.T) { ch := newEventChan() h := newStreamHandler(ch, nil) h.OnEvent(agentcore.SubAgentProgressEvent{ ToolCallID: "task-1", Description: "build parser", Activity: "Editing", Tokens: 256, }) got := drain(ch) if len(got) != 1 { t.Fatalf("expected 1 msg, got %d", len(got)) } m, ok := got[0].(subagentProgressMsg) if !ok { t.Fatalf("got %#v, want subagentProgressMsg", got[0]) } if m.id != "task-1" || m.desc != "build parser" || m.activity != "Editing" || m.tokens != 256 { t.Errorf("got %#v, want {id:task-1 desc:build parser activity:Editing tokens:256}", m) } } // TestWaitForEvent verifies the pump Cmd returns the next queued msg. func TestWaitForEvent(t *testing.T) { ch := newEventChan() want := runEndMsg{err: errors.New("stop")} ch <- want cmd := waitForEvent(ch) if cmd == nil { t.Fatal("waitForEvent returned nil Cmd") } got, ok := cmd().(runEndMsg) if !ok || got.err == nil || got.err.Error() != "stop" { t.Errorf("cmd() = %#v, want runEndMsg{err:stop}", got) } }