package pi import ( "bytes" "context" "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/chenhg5/cc-connect/core" ) // ── normalizeMode ──────────────────────────────────────────── func TestNormalizeMode(t *testing.T) { tests := []struct { in string want string }{ {"", "default"}, {"default", "default"}, {"yolo", "yolo"}, {"YOLO", "yolo"}, {"bypass", "yolo"}, {"auto-approve", "yolo"}, {" Yolo ", "yolo"}, {" Bypass ", "yolo"}, {"unknown", "default"}, {"something", "default"}, } for _, tt := range tests { if got := normalizeMode(tt.in); got != tt.want { t.Errorf("normalizeMode(%q) = %q, want %q", tt.in, got, tt.want) } } } // ── Agent constructor ──────────────────────────────────────── func TestNew_DefaultValues(t *testing.T) { // Use a command that exists on all systems. ag, err := New(map[string]any{"cmd": "echo"}) if err != nil { t.Fatalf("New() error = %v", err) } a := ag.(*Agent) if a.workDir != "." { t.Errorf("workDir = %q, want \".\"", a.workDir) } if a.model != "" { t.Errorf("model = %q, want empty", a.model) } if a.mode != "default" { t.Errorf("mode = %q, want \"default\"", a.mode) } if a.cmd != "echo" { t.Errorf("cmd = %q, want \"echo\"", a.cmd) } } func TestNew_CustomOptions(t *testing.T) { ag, err := New(map[string]any{ "cmd": "echo", "work_dir": "/tmp", "model": "qwen3.5-plus", "mode": "yolo", }) if err != nil { t.Fatalf("New() error = %v", err) } a := ag.(*Agent) if a.workDir != "/tmp" { t.Errorf("workDir = %q", a.workDir) } if a.model != "qwen3.5-plus" { t.Errorf("model = %q", a.model) } if a.mode != "yolo" { t.Errorf("mode = %q", a.mode) } } func TestNew_CmdNotFound(t *testing.T) { _, err := New(map[string]any{"cmd": "nonexistent-binary-xyz-12345"}) if err == nil { t.Fatal("expected error for missing binary") } if !strings.Contains(err.Error(), "not found in PATH") { t.Errorf("unexpected error: %v", err) } } func TestNew_DefaultCmd(t *testing.T) { // When cmd is not specified, it defaults to "pi". // This will fail if pi is not installed, which is expected in CI. _, err := New(map[string]any{"cmd": ""}) if err == nil { // pi is installed — verify the cmd was set return } if !strings.Contains(err.Error(), "'pi' not found") { t.Errorf("unexpected error: %v", err) } } // ── Agent interface methods ────────────────────────────────── func TestAgent_NameAndDisplay(t *testing.T) { a := &Agent{} if a.Name() != "pi" { t.Errorf("Name() = %q", a.Name()) } if a.CLIBinaryName() != "pi" { t.Errorf("CLIBinaryName() = %q", a.CLIBinaryName()) } if a.CLIDisplayName() != "Pi" { t.Errorf("CLIDisplayName() = %q", a.CLIDisplayName()) } } func TestAgent_ModelGetSet(t *testing.T) { a := &Agent{} if a.GetModel() != "" { t.Errorf("initial model = %q", a.GetModel()) } a.SetModel("gpt-4o") if a.GetModel() != "gpt-4o" { t.Errorf("after SetModel = %q", a.GetModel()) } } func TestAgent_ModeGetSet(t *testing.T) { a := &Agent{mode: "default"} if a.GetMode() != "default" { t.Errorf("initial mode = %q", a.GetMode()) } a.SetMode("yolo") if a.GetMode() != "yolo" { t.Errorf("after SetMode(yolo) = %q", a.GetMode()) } a.SetMode("bypass") if a.GetMode() != "yolo" { t.Errorf("after SetMode(bypass) = %q", a.GetMode()) } a.SetMode("unknown") if a.GetMode() != "default" { t.Errorf("after SetMode(unknown) = %q", a.GetMode()) } } func TestAgent_AvailableModels(t *testing.T) { a := &Agent{} if models := a.AvailableModels(context.Background()); models != nil { t.Errorf("AvailableModels() = %v, want nil", models) } } func TestAgent_SetSessionEnv(t *testing.T) { a := &Agent{} a.SetSessionEnv([]string{"FOO=bar", "BAZ=qux"}) a.mu.Lock() defer a.mu.Unlock() if len(a.sessionEnv) != 2 { t.Errorf("sessionEnv len = %d, want 2", len(a.sessionEnv)) } } func TestAgent_ListSessions(t *testing.T) { a := &Agent{} sessions, err := a.ListSessions(context.Background()) if err != nil { t.Errorf("ListSessions() error = %v", err) } if sessions != nil { t.Errorf("ListSessions() = %v, want nil", sessions) } } func TestAgent_Stop(t *testing.T) { a := &Agent{} if err := a.Stop(); err != nil { t.Errorf("Stop() error = %v", err) } } func TestAgent_PermissionModes(t *testing.T) { a := &Agent{} modes := a.PermissionModes() if len(modes) != 2 { t.Fatalf("PermissionModes() len = %d, want 2", len(modes)) } if modes[0].Key != "default" { t.Errorf("modes[0].Key = %q", modes[0].Key) } if modes[1].Key != "yolo" { t.Errorf("modes[1].Key = %q", modes[1].Key) } } func TestAgent_MemoryFiles(t *testing.T) { a := &Agent{workDir: "/tmp/test-project"} proj := a.ProjectMemoryFile() if !strings.HasSuffix(proj, "AGENTS.md") { t.Errorf("ProjectMemoryFile() = %q, want suffix AGENTS.md", proj) } if !strings.Contains(proj, "test-project") { t.Errorf("ProjectMemoryFile() = %q, want to contain work_dir", proj) } global := a.GlobalMemoryFile() if !strings.HasSuffix(global, filepath.Join(".pi", "AGENTS.md")) { t.Errorf("GlobalMemoryFile() = %q", global) } } func TestAgent_StartSession(t *testing.T) { a := &Agent{cmd: "echo", workDir: "/tmp", model: "test-model", mode: "yolo"} a.SetSessionEnv([]string{"TEST_VAR=1"}) sess, err := a.StartSession(context.Background(), "resume-123") if err != nil { t.Fatalf("StartSession() error = %v", err) } defer sess.Close() ps := sess.(*piSession) if ps.cmd != "echo" { t.Errorf("cmd = %q", ps.cmd) } if ps.model != "test-model" { t.Errorf("model = %q", ps.model) } if ps.mode != "yolo" { t.Errorf("mode = %q", ps.mode) } if ps.CurrentSessionID() != "resume-123" { t.Errorf("sessionID = %q, want resume-123", ps.CurrentSessionID()) } if !ps.Alive() { t.Error("session should be alive") } } // ── extractToolInput ───────────────────────────────────────── func TestExtractToolInput(t *testing.T) { tests := []struct { name string item map[string]any want string }{ {"nil arguments", map[string]any{}, ""}, {"description", map[string]any{"arguments": map[string]any{"description": "List files"}}, "List files"}, {"command", map[string]any{"arguments": map[string]any{"command": "ls -la"}}, "ls -la"}, {"file_path", map[string]any{"arguments": map[string]any{"file_path": "/tmp/foo.go"}}, "/tmp/foo.go"}, {"pattern", map[string]any{"arguments": map[string]any{"pattern": "*.go"}}, "*.go"}, {"query", map[string]any{"arguments": map[string]any{"query": "find errors"}}, "find errors"}, {"description takes priority", map[string]any{"arguments": map[string]any{ "description": "desc", "command": "cmd", }}, "desc"}, {"fallback to json", map[string]any{"arguments": map[string]any{"foo": "bar"}}, `{"foo":"bar"}`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractToolInput(tt.item) if got != tt.want { t.Errorf("extractToolInput() = %q, want %q", got, tt.want) } }) } } func TestExtractToolInput_LongFallbackTruncated(t *testing.T) { longVal := strings.Repeat("x", 300) item := map[string]any{"arguments": map[string]any{"data": longVal}} got := extractToolInput(item) if len(got) > 210 { // 200 + "..." t.Errorf("expected truncated output, got len=%d", len(got)) } if !strings.HasSuffix(got, "...") { t.Error("expected '...' suffix") } } // ── truncStr ───────────────────────────────────────────────── func TestTruncStr(t *testing.T) { tests := []struct { in string max int want string wantSame bool }{ {"hello", 10, "hello", true}, {"hello", 5, "hello", true}, {"hello", 3, "hel...", false}, {"", 5, "", true}, {"日本語テスト", 3, "日本語...", false}, {"日本語テスト", 10, "日本語テスト", true}, } for _, tt := range tests { got := truncStr(tt.in, tt.max) if got != tt.want { t.Errorf("truncStr(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want) } } } // ── saveImagesToDisk ───────────────────────────────────────── func TestSaveImagesToDisk(t *testing.T) { attachDir := filepath.Join(t.TempDir(), ".cc-connect", "attachments", "pi-test") images := []core.ImageAttachment{ {MimeType: "image/png", Data: []byte("png-data"), FileName: "test.png"}, {MimeType: "image/jpeg", Data: []byte("jpg-data")}, {MimeType: "image/gif", Data: []byte("gif-data")}, {MimeType: "image/webp", Data: []byte("webp-data")}, {MimeType: "image/bmp", Data: []byte("bmp-data")}, // unknown mime → .png default } paths := saveImagesToDisk(attachDir, images) if len(paths) != 5 { t.Fatalf("got %d paths, want 5", len(paths)) } // First file should use the provided filename. if filepath.Base(paths[0]) != "test.png" { t.Errorf("paths[0] base = %q, want test.png", filepath.Base(paths[0])) } // Check extensions of auto-named files. if !strings.HasSuffix(paths[1], ".jpg") { t.Errorf("jpeg path = %q, want .jpg suffix", paths[1]) } if !strings.HasSuffix(paths[2], ".gif") { t.Errorf("gif path = %q, want .gif suffix", paths[2]) } if !strings.HasSuffix(paths[3], ".webp") { t.Errorf("webp path = %q, want .webp suffix", paths[3]) } if !strings.HasSuffix(paths[4], ".png") { t.Errorf("unknown mime path = %q, want .png suffix", paths[4]) } // Verify file contents. data, err := os.ReadFile(paths[0]) if err != nil { t.Fatalf("ReadFile: %v", err) } if string(data) != "png-data" { t.Errorf("file content = %q", data) } } func TestSaveImagesToDisk_Empty(t *testing.T) { paths := saveImagesToDisk(t.TempDir(), nil) if len(paths) != 0 { t.Errorf("expected empty, got %d", len(paths)) } } // TestSaveImagesToDisk_RejectsPathTraversal is a regression test for a path // traversal vulnerability in saveImagesToDisk: the user-supplied // ImageAttachment.FileName (sourced from IM upload metadata) was passed // directly to filepath.Join, so a malicious uploader could escape the // attachments directory by using `../` segments. This mirrors the same // issue and fix in core.SaveFilesToDisk. func TestSaveImagesToDisk_RejectsPathTraversal(t *testing.T) { workDir := t.TempDir() attachDir := filepath.Join(workDir, ".cc-connect", "attachments") images := []core.ImageAttachment{ // Two levels up — escapes attachments/ and .cc-connect/. {MimeType: "image/png", Data: []byte("payload"), FileName: "../../escape.png"}, // Three levels up — would land outside workDir entirely. {MimeType: "image/png", Data: []byte("payload"), FileName: "../../../way-up.png"}, // Windows-style separators must also be stripped on Linux. {MimeType: "image/png", Data: []byte("payload"), FileName: `..\..\winescape.png`}, // Plain name still works. {MimeType: "image/png", Data: []byte("payload"), FileName: "ok.png"}, // "." sanitizes to empty so the generated-name fallback kicks in, // not a write to the attachments directory itself. {MimeType: "image/png", Data: []byte("payload"), FileName: "."}, } paths := saveImagesToDisk(attachDir, images) // Every returned path must live inside attachDir. for _, p := range paths { if !strings.HasPrefix(p, attachDir+string(filepath.Separator)) { t.Errorf("saveImagesToDisk wrote outside attachments dir: %q (attachDir=%q)", p, attachDir) } } // Walk workDir and assert no file exists outside attachDir. if err := filepath.Walk(workDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } if !strings.HasPrefix(path, attachDir+string(filepath.Separator)) { t.Errorf("found stray attachment outside attachments dir: %q", path) } return nil }); err != nil { t.Fatalf("walk: %v", err) } // Sanity: legitimate "ok.png" must still have been saved. if _, err := os.Stat(filepath.Join(attachDir, "ok.png")); err != nil { t.Errorf("legitimate ok.png not saved: %v", err) } } func TestSanitizePiAttachmentName(t *testing.T) { tests := []struct { in, want string }{ {"image.png", "image.png"}, {"subdir/file.png", "file.png"}, {"../../escape.png", "escape.png"}, {`..\..\winescape.png`, "winescape.png"}, {"/etc/passwd", "passwd"}, {"..", ""}, {".", ""}, {"", ""}, } for _, tt := range tests { t.Run(tt.in, func(t *testing.T) { got := sanitizePiAttachmentName(tt.in) if got != tt.want { t.Errorf("sanitizePiAttachmentName(%q) = %q, want %q", tt.in, got, tt.want) } }) } } // ── cleanAttachments ───────────────────────────────────────── func TestCleanAttachments(t *testing.T) { tmpDir := t.TempDir() attachDir := filepath.Join(tmpDir, ".cc-connect", "attachments") os.MkdirAll(attachDir, 0o755) // Create some files. os.WriteFile(filepath.Join(attachDir, "old1.png"), []byte("data"), 0o644) os.WriteFile(filepath.Join(attachDir, "old2.jpg"), []byte("data"), 0o644) // Verify files exist. entries, _ := os.ReadDir(attachDir) if len(entries) != 2 { t.Fatalf("expected 2 files, got %d", len(entries)) } cleanAttachments(attachDir) // Directory should be removed. if _, err := os.Stat(attachDir); !os.IsNotExist(err) { t.Errorf("expected attachments dir removed after clean, got err=%v", err) } } func TestCleanAttachments_NonexistentDir(t *testing.T) { // Should not panic or error on non-existent directory. cleanAttachments("/nonexistent/path/xyz") } func TestPiSessionAttachmentDirsAreIsolated(t *testing.T) { workDir := t.TempDir() s1, err := newPiSession(context.Background(), "pi", workDir, "", "", "", "", nil) if err != nil { t.Fatal(err) } s2, err := newPiSession(context.Background(), "pi", workDir, "", "", "", "", nil) if err != nil { t.Fatal(err) } if s1.attachDir == s2.attachDir { t.Fatalf("expected distinct attachment dirs, got %q", s1.attachDir) } if err := os.MkdirAll(s1.attachDir, 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(s2.attachDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(s1.attachDir, "one.txt"), []byte("one"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(s2.attachDir, "two.txt"), []byte("two"), 0o644); err != nil { t.Fatal(err) } cleanAttachments(s1.attachDir) if _, err := os.Stat(filepath.Join(s2.attachDir, "two.txt")); err != nil { t.Fatalf("cleaning one session removed another session's attachment: %v", err) } } // ── handleEvent ────────────────────────────────────────────── func newTestSession() *piSession { ctx, cancel := context.WithCancel(context.Background()) s := &piSession{ events: make(chan core.Event, 64), ctx: ctx, cancel: cancel, } s.alive.Store(true) return s } func drainEvents(s *piSession) []core.Event { var evts []core.Event for { select { case e := <-s.events: evts = append(evts, e) default: return evts } } } func TestHandleEvent_Session(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{"type": "session", "id": "sess-abc-123"}) if s.CurrentSessionID() != "sess-abc-123" { t.Errorf("sessionID = %q", s.CurrentSessionID()) } } func TestHandleEvent_SessionEmptyID(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{"type": "session", "id": ""}) if s.CurrentSessionID() != "" { t.Errorf("sessionID = %q, want empty", s.CurrentSessionID()) } } func TestHandleEvent_SessionNoID(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{"type": "session"}) if s.CurrentSessionID() != "" { t.Errorf("sessionID = %q, want empty", s.CurrentSessionID()) } } func TestHandleEvent_LifecycleEventsNoOp(t *testing.T) { s := newTestSession() defer s.cancel() for _, evType := range []string{"agent_start", "agent_end", "turn_start", "turn_end", "message_start"} { s.handleEvent(map[string]any{"type": evType}) } evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events, got %d", len(evts)) } } func TestHandleEvent_UnhandledType(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{"type": "unknown_event"}) evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events, got %d", len(evts)) } } // ── handleMessageUpdate: text_delta ────────────────────────── func TestHandleMessageUpdate_TextDelta(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{ "type": "text_delta", "delta": "Hello world", }, }) evts := drainEvents(s) if len(evts) != 1 { t.Fatalf("got %d events, want 1", len(evts)) } if evts[0].Type != core.EventText || evts[0].Content != "Hello world" { t.Errorf("event = %+v", evts[0]) } } func TestHandleMessageUpdate_TextDeltaEmpty(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{ "type": "text_delta", "delta": "", }, }) evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events for empty delta, got %d", len(evts)) } } // ── handleMessageUpdate: thinking accumulation ─────────────── func TestHandleMessageUpdate_ThinkingAccumulation(t *testing.T) { s := newTestSession() defer s.cancel() // Multiple thinking deltas should be accumulated. s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{"type": "thinking_delta", "delta": "Let me "}, }) s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{"type": "thinking_delta", "delta": "think about "}, }) s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{"type": "thinking_delta", "delta": "this."}, }) // No events should be emitted yet. evts := drainEvents(s) if len(evts) != 0 { t.Fatalf("expected no events before thinking_end, got %d", len(evts)) } // thinking_end triggers the accumulated event. s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{"type": "thinking_end"}, }) evts = drainEvents(s) if len(evts) != 1 { t.Fatalf("got %d events, want 1", len(evts)) } if evts[0].Type != core.EventThinking { t.Errorf("type = %s, want EventThinking", evts[0].Type) } if evts[0].Content != "Let me think about this." { t.Errorf("content = %q", evts[0].Content) } } func TestHandleMessageUpdate_ThinkingEndEmpty(t *testing.T) { s := newTestSession() defer s.cancel() // thinking_end with no prior deltas should not emit. s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{"type": "thinking_end"}, }) evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events for empty thinking, got %d", len(evts)) } } func TestHandleMessageUpdate_ThinkingDeltaEmpty(t *testing.T) { s := newTestSession() defer s.cancel() // Empty deltas should not grow the buffer. s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{"type": "thinking_delta", "delta": ""}, }) if s.thinkingBuf.Len() != 0 { t.Errorf("thinkingBuf.Len() = %d, want 0", s.thinkingBuf.Len()) } } // ── handleMessageUpdate: toolcall_end ──────────────────────── func TestHandleMessageUpdate_ToolcallEnd(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{ "type": "toolcall_end", "contentIndex": float64(1), "message": map[string]any{ "content": []any{ map[string]any{"type": "thinking", "thinking": "..."}, map[string]any{ "type": "toolCall", "name": "bash", "arguments": map[string]any{"command": "ls -la"}, }, }, }, }, }) evts := drainEvents(s) if len(evts) != 1 { t.Fatalf("got %d events, want 1", len(evts)) } if evts[0].Type != core.EventToolUse { t.Errorf("type = %s", evts[0].Type) } if evts[0].ToolName != "bash" { t.Errorf("toolName = %q", evts[0].ToolName) } if evts[0].ToolInput != "ls -la" { t.Errorf("toolInput = %q", evts[0].ToolInput) } } func TestHandleMessageUpdate_ToolcallEnd_UsesPartialFallback(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{ "type": "toolcall_end", "contentIndex": float64(0), "partial": map[string]any{ "content": []any{ map[string]any{ "type": "toolCall", "name": "read", "arguments": map[string]any{"file_path": "/tmp/foo.txt"}, }, }, }, }, }) evts := drainEvents(s) if len(evts) != 1 { t.Fatalf("got %d events, want 1", len(evts)) } if evts[0].ToolName != "read" || evts[0].ToolInput != "/tmp/foo.txt" { t.Errorf("event = %+v", evts[0]) } } func TestHandleMessageUpdate_ToolcallEnd_NonToolCallItem(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{ "type": "toolcall_end", "contentIndex": float64(0), "message": map[string]any{ "content": []any{ map[string]any{"type": "text", "text": "hello"}, }, }, }, }) evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events for non-toolCall item, got %d", len(evts)) } } func TestHandleMessageUpdate_ToolcallEnd_OutOfBoundsIndex(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{ "type": "toolcall_end", "contentIndex": float64(5), "message": map[string]any{ "content": []any{ map[string]any{"type": "toolCall", "name": "bash"}, }, }, }, }) evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events for out-of-bounds index, got %d", len(evts)) } } func TestHandleMessageUpdate_ToolcallEnd_NilMessage(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{ "type": "toolcall_end", // no "message" or "partial" }, }) evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events, got %d", len(evts)) } } func TestHandleMessageUpdate_NilAssistantEvent(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{"type": "message_update"}) evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events, got %d", len(evts)) } } func TestHandleMessageUpdate_UnknownSubType(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{ "type": "some_unknown_delta", }, }) evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events for unknown sub-type, got %d", len(evts)) } } // ── handleMessageEnd ───────────────────────────────────────── func TestHandleMessageEnd_ToolResult(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_end", "message": map[string]any{ "role": "toolResult", "toolName": "bash", "content": []any{ map[string]any{"type": "text", "text": "file1.go\nfile2.go"}, }, }, }) evts := drainEvents(s) if len(evts) != 1 { t.Fatalf("got %d events, want 1", len(evts)) } if evts[0].Type != core.EventToolResult { t.Errorf("type = %s", evts[0].Type) } if evts[0].ToolName != "bash" { t.Errorf("toolName = %q", evts[0].ToolName) } if evts[0].Content != "file1.go\nfile2.go" { t.Errorf("content = %q", evts[0].Content) } } func TestHandleMessageEnd_ToolResultLongOutput(t *testing.T) { s := newTestSession() defer s.cancel() longOutput := strings.Repeat("x", 600) s.handleEvent(map[string]any{ "type": "message_end", "message": map[string]any{ "role": "toolResult", "toolName": "bash", "content": []any{ map[string]any{"type": "text", "text": longOutput}, }, }, }) evts := drainEvents(s) if len(evts) != 1 { t.Fatalf("got %d events, want 1", len(evts)) } if len(evts[0].Content) > 510 { t.Errorf("content should be truncated, got len=%d", len(evts[0].Content)) } } func TestHandleMessageEnd_ToolResultEmptyContent(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_end", "message": map[string]any{ "role": "toolResult", "toolName": "bash", "content": []any{}, }, }) evts := drainEvents(s) if len(evts) != 1 { t.Fatalf("got %d events, want 1", len(evts)) } if evts[0].Content != "" { t.Errorf("content = %q, want empty", evts[0].Content) } } func TestHandleMessageEnd_AssistantError(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_end", "message": map[string]any{ "role": "assistant", "errorMessage": "400 model not supported", }, }) evts := drainEvents(s) if len(evts) != 1 { t.Fatalf("got %d events, want 1", len(evts)) } if evts[0].Type != core.EventError { t.Errorf("type = %s", evts[0].Type) } if evts[0].Error == nil || !strings.Contains(evts[0].Error.Error(), "400") { t.Errorf("error = %v", evts[0].Error) } } func TestHandleMessageEnd_AssistantNoError(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_end", "message": map[string]any{ "role": "assistant", }, }) evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events for assistant without error, got %d", len(evts)) } } func TestHandleMessageEnd_NilMessage(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{"type": "message_end"}) evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events, got %d", len(evts)) } } func TestHandleMessageEnd_UserRole(t *testing.T) { s := newTestSession() defer s.cancel() s.handleEvent(map[string]any{ "type": "message_end", "message": map[string]any{ "role": "user", }, }) evts := drainEvents(s) if len(evts) != 0 { t.Errorf("expected no events for user role, got %d", len(evts)) } } // ── piSession lifecycle ────────────────────────────────────── func TestPiSession_NewWithResumeID(t *testing.T) { s, err := newPiSession(context.Background(), "echo", "/tmp", "model", "default", "", "resume-id", nil) if err != nil { t.Fatalf("newPiSession: %v", err) } defer s.Close() if s.CurrentSessionID() != "resume-id" { t.Errorf("sessionID = %q", s.CurrentSessionID()) } } func TestPiSession_ContinueSessionTreatedAsFresh(t *testing.T) { // ContinueSession ("__continue__") is a sentinel used by the engine to tell // Claude Code to pick up the latest CLI session via --continue. Agents that // don't support --continue must treat it as "" (fresh session), otherwise // they pass the literal "__continue__" as a session ID which always fails. s, err := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", core.ContinueSession, nil) if err != nil { t.Fatalf("newPiSession: %v", err) } defer s.Close() if got := s.CurrentSessionID(); got != "" { t.Errorf("ContinueSession should be treated as fresh: sessionID = %q, want empty", got) } } func TestPiSession_NewWithoutResumeID(t *testing.T) { s, err := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", "", nil) if err != nil { t.Fatalf("newPiSession: %v", err) } defer s.Close() if s.CurrentSessionID() != "" { t.Errorf("sessionID = %q, want empty", s.CurrentSessionID()) } } func TestPiSession_SendWhenClosed(t *testing.T) { s, _ := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", "", nil) s.Close() err := s.Send("hello", nil, nil) if err == nil || !strings.Contains(err.Error(), "closed") { t.Errorf("expected 'closed' error, got %v", err) } } func TestPiSession_RespondPermission(t *testing.T) { s := newTestSession() defer s.cancel() if err := s.RespondPermission("id", core.PermissionResult{}); err != nil { t.Errorf("RespondPermission() error = %v", err) } } func TestPiSession_Events(t *testing.T) { s := newTestSession() defer s.cancel() ch := s.Events() if ch == nil { t.Fatal("Events() returned nil") } } func TestPiSession_Close(t *testing.T) { s, _ := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", "", nil) if err := s.Close(); err != nil { t.Fatalf("Close() error = %v", err) } if s.Alive() { t.Error("session should not be alive after Close()") } } // ── Full event stream simulation ───────────────────────────── func TestHandleEvent_FullConversation(t *testing.T) { s := newTestSession() defer s.cancel() // Simulate a full pi conversation: session → thinking → text → tool → tool result → text → done events := []map[string]any{ {"type": "session", "id": "conv-123"}, {"type": "agent_start"}, {"type": "turn_start"}, {"type": "message_start"}, {"type": "message_update", "assistantMessageEvent": map[string]any{ "type": "thinking_delta", "delta": "I need to ", }}, {"type": "message_update", "assistantMessageEvent": map[string]any{ "type": "thinking_delta", "delta": "list files.", }}, {"type": "message_update", "assistantMessageEvent": map[string]any{ "type": "thinking_end", }}, {"type": "message_update", "assistantMessageEvent": map[string]any{ "type": "toolcall_end", "contentIndex": float64(1), "message": map[string]any{ "content": []any{ map[string]any{"type": "thinking"}, map[string]any{"type": "toolCall", "name": "bash", "arguments": map[string]any{"command": "ls"}}, }, }, }}, {"type": "message_end", "message": map[string]any{ "role": "assistant", "stopReason": "toolUse", }}, {"type": "message_end", "message": map[string]any{ "role": "toolResult", "toolName": "bash", "content": []any{map[string]any{"type": "text", "text": "file1.go"}}, }}, {"type": "turn_end"}, {"type": "turn_start"}, {"type": "message_update", "assistantMessageEvent": map[string]any{ "type": "text_delta", "delta": "Here are your files.", }}, {"type": "message_end", "message": map[string]any{ "role": "assistant", "stopReason": "stop", }}, {"type": "turn_end"}, {"type": "agent_end"}, } for _, ev := range events { s.handleEvent(ev) } evts := drainEvents(s) // Expected: thinking, tool_use, tool_result, text if len(evts) != 4 { var types []string for _, e := range evts { types = append(types, string(e.Type)) } t.Fatalf("got %d events %v, want 4", len(evts), types) } if evts[0].Type != core.EventThinking || evts[0].Content != "I need to list files." { t.Errorf("evts[0] = %+v", evts[0]) } if evts[1].Type != core.EventToolUse || evts[1].ToolName != "bash" { t.Errorf("evts[1] = %+v", evts[1]) } if evts[2].Type != core.EventToolResult || evts[2].Content != "file1.go" { t.Errorf("evts[2] = %+v", evts[2]) } if evts[3].Type != core.EventText || evts[3].Content != "Here are your files." { t.Errorf("evts[3] = %+v", evts[3]) } if s.CurrentSessionID() != "conv-123" { t.Errorf("sessionID = %q", s.CurrentSessionID()) } } // ── readLoop with real process ─────────────────────────────── func TestPiSession_ReadLoopWithEcho(t *testing.T) { // Use sh -c to simulate pi JSON output on stdout. sessionEvent := map[string]any{"type": "session", "id": "echo-sess"} textEvent := map[string]any{ "type": "message_update", "assistantMessageEvent": map[string]any{ "type": "text_delta", "delta": "hi", }, } line1, _ := json.Marshal(sessionEvent) line2, _ := json.Marshal(textEvent) s, err := newPiSession(context.Background(), "sh", "/tmp", "", "default", "", "", nil) if err != nil { t.Fatalf("newPiSession: %v", err) } // sh -c 'echo ...; echo ...' will output our JSON lines. // We need to override how Send builds args. Instead, call readLoop directly. // Actually, just use Send with sh -c and craft the prompt as the script. script := "echo '" + string(line1) + "'; echo '" + string(line2) + "'" // Manually build the command since Send adds extra flags for pi. s.cmd = "sh" s.model = "" // prevent --model flag // Directly test readLoop via Send by crafting args that sh understands. // Send will run: sh --mode json -p