Files
chenhg5-cc-connect/agent/opencode/session_test.go
Han 355793eaee refactor(agent): centralize cmd/env option parsing into core with unified cmd field (#1297)
* refactor: centralize cmd/env option parsing into core

- Add core.ParseCmdOpts() to unify cmd/cli_path/command option
  parsing across all agents, with deprecation warnings for old keys
- Add core.ParseConfigEnv() to parse [projects.agent.options.env]
  from config into []string KEY=VALUE format
- Rename cliBin/command struct fields to cmd consistently
- Add cliExtraArgs support so cmd="binary arg1 arg2" works
- Add configEnv field for static env that persists across SetSessionEnv
- Fix env merge order: configEnv < providerEnv < sessionEnv (was
  inconsistent in copilot and opencode agents)
- Update all tests for renamed fields

* fix(claudecode): add backward compat for deprecated cli_args_flag

Users with cli_args_flag in their config.toml will see a deprecation
warning directing them to the new cmd_args_flag key.

* docs(config): update config examples to use cmd instead of deprecated keys

- config.example.toml: cli_path → cmd, command(S) → S(6 occurrences)
- claudecode/claudecode.go: comment cli_path → cmd
- copilot/copilot_test.go: comment cliBin/cli_path → cmd

* test(core): add direct unit tests for ParseCmdOpts and ParseConfigEnv

Address review feedback on PR #1297 (P2). The two helper functions in
core/cmdopts.go are depended on by 13 agent packages; previously their
behavior was only covered indirectly via agent-level New() tests. This
commit adds direct unit tests covering:

- ParseCmdOpts: four-tier priority (cmd / cli_path / command / default),
  empty/whitespace boundaries, tokenization of extra args, no-warning
  for canonical cmd field, and capture of deprecation warnings.
- ParseConfigEnv: nil / missing key, map[string]string, map[string]any
  (TOML parser output), non-string value filtering, unsupported types,
  and input non-mutation.

Also adds structured slog attrs (deprecated_key, new_key) to all three
deprecation warnings (cli_path, command, cli_args_flag) so that future
log aggregation can scan deprecated-key usage without code changes
(review feedback P3).

Ref: PR #1297 review.

* fix(codex): use local cmd var (not stale cliBin name) in struct init

Post-rebase fixup: the struct field was renamed cliBin -> cmd in
PR #1297, and the local variable returned by core.ParseCmdOpts is
also named cmd. The rebased struct initializer still referenced
the old name 'cliBin', which no longer exists in scope. Assign
the local cmd variable to the cmd struct field.
2026-06-23 07:05:16 +08:00

339 lines
11 KiB
Go

package opencode
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"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", nil, "/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)
}
}
// TestHandleToolUsePermissionDeniedEmitsEventText verifies that when opencode
// rejects a tool call (status="error"), the error message is emitted as an
// EventText so the engine has something meaningful to deliver instead of
// the generic "(空响应)" / "(empty response)" placeholder.
// Reproduces the scenario in issue #178 where running bash commands in
// default mode silently produced an empty response.
func TestHandleToolUsePermissionDeniedEmitsEventText(t *testing.T) {
jsonData := `{"type":"tool_use","part":{"tool":"bash","state":{"status":"error","error":"The user rejected permission to use this specific tool call.","input":{"command":"ls","description":"List files in current directory"}}}}`
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, 4), ctx: ctx}
s.handleToolUse(raw)
var events []core.Event
for len(s.events) > 0 {
events = append(events, <-s.events)
}
if len(events) < 2 {
t.Fatalf("expected at least 2 events (EventToolUse + EventText), got %d: %v", len(events), events)
}
if events[0].Type != core.EventToolUse {
t.Errorf("events[0].Type = %v, want EventToolUse", events[0].Type)
}
if events[1].Type != core.EventText {
t.Errorf("events[1].Type = %v, want EventText (error text so engine has content)", events[1].Type)
}
if !strings.Contains(events[1].Content, "rejected permission") {
t.Errorf("EventText.Content = %q, want it to contain the rejection reason", events[1].Content)
}
}
// TestHandleToolUseCompletedDoesNotEmitExtraText verifies that a successfully
// completed tool call does NOT emit an EventText (regression guard).
func TestHandleToolUseCompletedDoesNotEmitExtraText(t *testing.T) {
jsonData := `{"type":"tool_use","part":{"tool":"bash","state":{"status":"completed","output":"file1.txt file2.txt","input":{"command":"ls"}}}}`
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, 4), ctx: ctx}
s.handleToolUse(raw)
var events []core.Event
for len(s.events) > 0 {
events = append(events, <-s.events)
}
for _, evt := range events {
if evt.Type == core.EventText {
t.Errorf("unexpected EventText for completed tool: %q", evt.Content)
}
}
if len(events) < 2 {
t.Errorf("expected EventToolUse + EventToolResult for completed tool, got %d events", len(events))
}
}
// TestHandleToolUseErrorNoMessageNoText verifies that a tool error with empty
// error message does NOT emit a spurious empty EventText.
func TestHandleToolUseErrorNoMessageNoText(t *testing.T) {
jsonData := `{"type":"tool_use","part":{"tool":"bash","state":{"status":"error"}}}`
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, 4), ctx: ctx}
s.handleToolUse(raw)
for len(s.events) > 0 {
evt := <-s.events
if evt.Type == core.EventText {
t.Errorf("unexpected EventText for error with no message: %q", evt.Content)
}
}
}
// verify Agent implements core.Agent
var _ core.Agent = (*Agent)(nil)