package opencode import ( "context" "encoding/json" "os" "path/filepath" "reflect" "sync/atomic" "testing" "github.com/chenhg5/cc-connect/core" ) // TestOpencodeSessionEntry_Unmarshal verifies that OpenCode's // `session list --format json` output can be correctly parsed. // // OpenCode returns `updated` and `created` as Unix timestamps in // milliseconds (int64), not strings. This test prevents regression // of the unmarshal error: // // json: cannot unmarshal number into Go struct field opencodeSessionEntry.updated of type string func TestOpencodeSessionEntry_Unmarshal(t *testing.T) { jsonData := `[ { "id": "ses_2eb11bb11ffeYwQZOj25mlmGMc", "title": "Test Session", "updated": 1774174646445, "created": 1774172652782, "projectId": "b80385ead03e8b450bdb2016d434aad318f93c16", "directory": "/path/to/project" } ]` var entries []opencodeSessionEntry if err := json.Unmarshal([]byte(jsonData), &entries); err != nil { t.Fatalf("Failed to unmarshal OpenCode session list: %v", err) } if len(entries) != 1 { t.Fatalf("Expected 1 entry, got %d", len(entries)) } e := entries[0] if e.ID != "ses_2eb11bb11ffeYwQZOj25mlmGMc" { t.Errorf("ID = %q, want %q", e.ID, "ses_2eb11bb11ffeYwQZOj25mlmGMc") } if e.Title != "Test Session" { t.Errorf("Title = %q, want %q", e.Title, "Test Session") } if e.Updated != 1774174646445 { t.Errorf("Updated = %d, want %d", e.Updated, 1774174646445) } if e.Created != 1774172652782 { t.Errorf("Created = %d, want %d", e.Created, 1774172652782) } } // TestNewOpencodeSession_ContinueSessionTreatedAsFresh verifies that // the ContinueSession sentinel (__continue__) is not passed as a literal // session ID to the CLI. This was fixed in PR #249. func TestNewOpencodeSession_ContinueSessionTreatedAsFresh(t *testing.T) { s, err := newOpencodeSession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil) if err != nil { t.Fatalf("newOpencodeSession: %v", err) } defer s.Close() if got := s.CurrentSessionID(); got != "" { t.Errorf("ContinueSession should be treated as fresh: chatID = %q, want empty", got) } } func TestOpencodeSessionStageImages(t *testing.T) { dir := t.TempDir() s := &opencodeSession{workDir: dir} prompt, imagePaths, err := s.stageImages("", []core.ImageAttachment{ {MimeType: "image/jpeg", Data: []byte{0xff, 0xd8, 0xff}}, {MimeType: "image/webp", Data: []byte("webp")}, }) if err != nil { t.Fatalf("stageImages: %v", err) } if prompt != "Please analyze the attached image(s)." { t.Fatalf("prompt = %q", prompt) } if len(imagePaths) != 2 { t.Fatalf("imagePaths len = %d, want 2", len(imagePaths)) } if filepath.Ext(imagePaths[0]) != ".jpg" { t.Fatalf("first ext = %q, want .jpg", filepath.Ext(imagePaths[0])) } if filepath.Ext(imagePaths[1]) != ".webp" { t.Fatalf("second ext = %q, want .webp", filepath.Ext(imagePaths[1])) } for _, path := range imagePaths { if _, err := os.Stat(path); err != nil { t.Fatalf("expected staged image %s: %v", path, err) } } } func TestOpencodeSessionBuildRunArgsIncludesImagesAsFiles(t *testing.T) { s := &opencodeSession{workDir: "/repo", model: "provider/model"} got := s.buildRunArgs("describe these images", []string{"/tmp/a.png", "/tmp/b.jpg"}, "ses_123") want := []string{ "run", "--format", "json", "--session", "ses_123", "--model", "provider/model", "--dir", "/repo", "--thinking", "--file", "/tmp/a.png", "--file", "/tmp/b.jpg", } if !reflect.DeepEqual(got, want) { t.Fatalf("args = %#v, want %#v", got, want) } } // TestHandleStepStart_SessionIDFromTopLevel verifies that handleStepStart // prefers the sessionID from the top-level JSON field when both top-level // and part-level sessionID are present. This matches OpenCode's stdout format. func TestHandleStepStart_SessionIDFromTopLevel(t *testing.T) { jsonData := `{"type":"step_start","sessionID":"ses_top_level","part":{"sessionID":"ses_part_level"}}` var raw map[string]any if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { t.Fatalf("unmarshal: %v", err) } s := &opencodeSession{} s.handleStepStart(raw) if got := s.CurrentSessionID(); got != "ses_top_level" { t.Errorf("sessionID = %q, want %q (should prefer top-level)", got, "ses_top_level") } } // TestHandleStepStart_SessionIDFromPart verifies that handleStepStart // falls back to the sessionID inside part when top-level sessionID is absent. func TestHandleStepStart_SessionIDFromPart(t *testing.T) { jsonData := `{"type":"step_start","part":{"sessionID":"ses_part_level"}}` var raw map[string]any if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { t.Fatalf("unmarshal: %v", err) } s := &opencodeSession{} s.handleStepStart(raw) if got := s.CurrentSessionID(); got != "ses_part_level" { t.Errorf("sessionID = %q, want %q (should fallback to part)", got, "ses_part_level") } } // TestHandleStepStopSendsEventResult verifies that handleStepFinish sends // an EventResult when reason="stop", signaling turn completion to the engine. func TestHandleStepStopSendsEventResult(t *testing.T) { jsonData := `{"type":"step_finish","part":{"reason":"stop"}}` var raw map[string]any if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { t.Fatalf("unmarshal: %v", err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() s := &opencodeSession{events: make(chan core.Event, 1), ctx: ctx} s.handleStepFinish(raw) select { case evt := <-s.events: if evt.Type != core.EventResult { t.Errorf("event type = %q, want EventResult", evt.Type) } if !evt.Done { t.Errorf("event.Done = false, want true") } default: t.Error("expected EventResult to be sent when reason=stop") } } // TestHandleStepToolCallsNoEventResult verifies that handleStepFinish does NOT // send EventResult when reason="tool-calls", allowing the agent to continue // with subsequent tool execution steps. func TestHandleStepToolCallsNoEventResult(t *testing.T) { jsonData := `{"type":"step_finish","part":{"reason":"tool-calls"}}` var raw map[string]any if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { t.Fatalf("unmarshal: %v", err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() s := &opencodeSession{events: make(chan core.Event, 1), ctx: ctx} s.handleStepFinish(raw) select { case evt := <-s.events: t.Errorf("unexpected event sent when reason=tool-calls: %v", evt) default: } } // TestHandleStepDuplicateEventResultPrevented verifies that calling // handleStepFinish multiple times with reason="stop" only sends one // EventResult, preventing duplicate completion signals to the engine. func TestHandleStepDuplicateEventResultPrevented(t *testing.T) { jsonData := `{"type":"step_finish","part":{"reason":"stop"}}` var raw map[string]any if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { t.Fatalf("unmarshal: %v", err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() s := &opencodeSession{ events: make(chan core.Event, 2), ctx: ctx, resultSent: atomic.Bool{}, } s.handleStepFinish(raw) s.handleStepFinish(raw) count := 0 for len(s.events) > 0 { evt := <-s.events if evt.Type == core.EventResult { count++ } } if count != 1 { t.Errorf("EventResult count = %d, want 1 (duplicate should be prevented)", count) } } // verify Agent implements core.Agent var _ core.Agent = (*Agent)(nil)