mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-04 02:20:42 +08:00
- Add worktree module for git worktree management - Refactor do skill scripts from shell to Python for better maintainability - Add install.py for do skill installation - Update stop-hook to Python implementation - Enhance executor with additional configuration options - Update CLAUDE.md with first-principles thinking guidelines Generated with SWE-Agent.ai Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
4860 lines
132 KiB
Go
4860 lines
132 KiB
Go
package wrapper
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
config "codeagent-wrapper/internal/config"
|
|
executor "codeagent-wrapper/internal/executor"
|
|
|
|
"github.com/goccy/go-json"
|
|
)
|
|
|
|
// Helper to reset test hooks
|
|
func resetTestHooks() {
|
|
stdinReader = os.Stdin
|
|
isTerminalFn = defaultIsTerminal
|
|
codexCommand = "codex"
|
|
cleanupHook = nil
|
|
cleanupLogsFn = cleanupOldLogs
|
|
startupCleanupAsync = false
|
|
config.ResetModelsConfigCacheForTest()
|
|
_ = executor.SetSelectBackendFn(nil)
|
|
buildCodexArgsFn = buildCodexArgs
|
|
selectBackendFn = selectBackend
|
|
_ = executor.SetCommandContextFn(nil)
|
|
_ = executor.SetNewCommandRunner(nil)
|
|
_ = executor.SetForceKillDelay(5)
|
|
_ = closeLogger()
|
|
runTaskFn = runCodexTask
|
|
runCodexTaskFn = defaultRunCodexTaskFn
|
|
exitFn = os.Exit
|
|
}
|
|
|
|
type capturedStdout struct {
|
|
buf bytes.Buffer
|
|
old *os.File
|
|
reader *os.File
|
|
writer *os.File
|
|
}
|
|
|
|
type errReader struct {
|
|
err error
|
|
}
|
|
|
|
func (e errReader) Read([]byte) (int, error) {
|
|
return 0, e.err
|
|
}
|
|
|
|
type testBackend struct {
|
|
name string
|
|
command string
|
|
argsFn func(*Config, string) []string
|
|
}
|
|
|
|
func (t testBackend) Name() string {
|
|
if t.name != "" {
|
|
return t.name
|
|
}
|
|
return "test-backend"
|
|
}
|
|
|
|
func (t testBackend) BuildArgs(cfg *Config, targetArg string) []string {
|
|
if t.argsFn != nil {
|
|
return t.argsFn(cfg, targetArg)
|
|
}
|
|
return []string{targetArg}
|
|
}
|
|
|
|
func (t testBackend) Command() string {
|
|
if t.command != "" {
|
|
return t.command
|
|
}
|
|
return "echo"
|
|
}
|
|
|
|
func (t testBackend) Env(baseURL, apiKey string) map[string]string { return nil }
|
|
|
|
func withBackend(command string, argsFn func(*Config, string) []string) func() {
|
|
prev := selectBackendFn
|
|
selectBackendFn = func(name string) (Backend, error) {
|
|
return testBackend{name: name, command: command, argsFn: argsFn}, nil
|
|
}
|
|
return func() { selectBackendFn = prev }
|
|
}
|
|
|
|
func captureStdoutPipe() *capturedStdout {
|
|
r, w, _ := os.Pipe()
|
|
state := &capturedStdout{old: os.Stdout, reader: r, writer: w}
|
|
os.Stdout = w
|
|
return state
|
|
}
|
|
|
|
func restoreStdoutPipe(c *capturedStdout) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.writer.Close()
|
|
os.Stdout = c.old
|
|
_, _ = io.Copy(&c.buf, c.reader)
|
|
}
|
|
|
|
func (c *capturedStdout) String() string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
return c.buf.String()
|
|
}
|
|
|
|
func captureOutput(t *testing.T, fn func()) string {
|
|
t.Helper()
|
|
r, w, _ := os.Pipe()
|
|
old := os.Stdout
|
|
os.Stdout = w
|
|
fn()
|
|
w.Close()
|
|
os.Stdout = old
|
|
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, r); err != nil {
|
|
t.Fatalf("io.Copy() error = %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
func captureStderr(t *testing.T, fn func()) string {
|
|
t.Helper()
|
|
r, w, _ := os.Pipe()
|
|
old := os.Stderr
|
|
os.Stderr = w
|
|
fn()
|
|
w.Close()
|
|
os.Stderr = old
|
|
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, r); err != nil {
|
|
t.Fatalf("io.Copy() error = %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
type ctxAwareReader struct {
|
|
reader io.ReadCloser
|
|
mu sync.Mutex
|
|
reason string
|
|
closed bool
|
|
}
|
|
|
|
func newCtxAwareReader(r io.ReadCloser) *ctxAwareReader {
|
|
return &ctxAwareReader{reader: r}
|
|
}
|
|
|
|
func (r *ctxAwareReader) Read(p []byte) (int, error) {
|
|
if r.reader == nil {
|
|
return 0, io.EOF
|
|
}
|
|
return r.reader.Read(p)
|
|
}
|
|
|
|
func (r *ctxAwareReader) Close() error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.closed || r.reader == nil {
|
|
r.closed = true
|
|
return nil
|
|
}
|
|
r.closed = true
|
|
return r.reader.Close()
|
|
}
|
|
|
|
func (r *ctxAwareReader) CloseWithReason(reason string) error {
|
|
r.mu.Lock()
|
|
if !r.closed {
|
|
r.reason = reason
|
|
}
|
|
r.mu.Unlock()
|
|
return r.Close()
|
|
}
|
|
|
|
func (r *ctxAwareReader) Reason() string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.reason
|
|
}
|
|
|
|
type drainBlockingStdout struct {
|
|
inner *ctxAwareReader
|
|
}
|
|
|
|
func newDrainBlockingStdout(inner *ctxAwareReader) *drainBlockingStdout {
|
|
return &drainBlockingStdout{inner: inner}
|
|
}
|
|
|
|
func (d *drainBlockingStdout) Read(p []byte) (int, error) {
|
|
return d.inner.Read(p)
|
|
}
|
|
|
|
func (d *drainBlockingStdout) Close() error {
|
|
return d.inner.Close()
|
|
}
|
|
|
|
func (d *drainBlockingStdout) CloseWithReason(reason string) error {
|
|
if reason != stdoutCloseReasonDrain {
|
|
return nil
|
|
}
|
|
return d.inner.CloseWithReason(reason)
|
|
}
|
|
|
|
type drainBlockingCmd struct {
|
|
inner *fakeCmd
|
|
injected atomic.Bool
|
|
}
|
|
|
|
func newDrainBlockingCmd(inner *fakeCmd) *drainBlockingCmd {
|
|
return &drainBlockingCmd{inner: inner}
|
|
}
|
|
|
|
func (d *drainBlockingCmd) Start() error {
|
|
return d.inner.Start()
|
|
}
|
|
|
|
func (d *drainBlockingCmd) Wait() error {
|
|
return d.inner.Wait()
|
|
}
|
|
|
|
func (d *drainBlockingCmd) StdoutPipe() (io.ReadCloser, error) {
|
|
stdout, err := d.inner.StdoutPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctxReader, ok := stdout.(*ctxAwareReader)
|
|
if !ok {
|
|
return stdout, nil
|
|
}
|
|
d.injected.Store(true)
|
|
return newDrainBlockingStdout(ctxReader), nil
|
|
}
|
|
|
|
func (d *drainBlockingCmd) StderrPipe() (io.ReadCloser, error) {
|
|
return d.inner.StderrPipe()
|
|
}
|
|
|
|
func (d *drainBlockingCmd) StdinPipe() (io.WriteCloser, error) {
|
|
return d.inner.StdinPipe()
|
|
}
|
|
|
|
func (d *drainBlockingCmd) SetStderr(w io.Writer) {
|
|
d.inner.SetStderr(w)
|
|
}
|
|
|
|
func (d *drainBlockingCmd) SetDir(dir string) {
|
|
d.inner.SetDir(dir)
|
|
}
|
|
|
|
func (d *drainBlockingCmd) SetEnv(env map[string]string) {
|
|
d.inner.SetEnv(env)
|
|
}
|
|
|
|
func (d *drainBlockingCmd) Process() executor.ProcessHandle {
|
|
return d.inner.Process()
|
|
}
|
|
|
|
type bufferWriteCloser struct {
|
|
buf bytes.Buffer
|
|
mu sync.Mutex
|
|
closed bool
|
|
}
|
|
|
|
func newBufferWriteCloser() *bufferWriteCloser {
|
|
return &bufferWriteCloser{}
|
|
}
|
|
|
|
func (b *bufferWriteCloser) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.closed {
|
|
return 0, io.ErrClosedPipe
|
|
}
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *bufferWriteCloser) Close() error {
|
|
b.mu.Lock()
|
|
b.closed = true
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (b *bufferWriteCloser) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
type fakeProcess struct {
|
|
pid int
|
|
killed atomic.Bool
|
|
mu sync.Mutex
|
|
signals []os.Signal
|
|
signalCount atomic.Int32
|
|
killCount atomic.Int32
|
|
onSignal func(os.Signal)
|
|
onKill func()
|
|
}
|
|
|
|
func newFakeProcess(pid int) *fakeProcess {
|
|
if pid == 0 {
|
|
pid = 4242
|
|
}
|
|
return &fakeProcess{pid: pid}
|
|
}
|
|
|
|
func (p *fakeProcess) Pid() int {
|
|
if runtime.GOOS == "windows" {
|
|
return 0
|
|
}
|
|
return p.pid
|
|
}
|
|
|
|
func (p *fakeProcess) Kill() error {
|
|
p.killed.Store(true)
|
|
p.killCount.Add(1)
|
|
if p.onKill != nil {
|
|
p.onKill()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *fakeProcess) Signal(sig os.Signal) error {
|
|
p.mu.Lock()
|
|
p.signals = append(p.signals, sig)
|
|
p.mu.Unlock()
|
|
p.signalCount.Add(1)
|
|
if p.onSignal != nil {
|
|
p.onSignal(sig)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *fakeProcess) Signals() []os.Signal {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
cp := make([]os.Signal, len(p.signals))
|
|
copy(cp, p.signals)
|
|
return cp
|
|
}
|
|
|
|
func (p *fakeProcess) Killed() bool {
|
|
return p.killed.Load()
|
|
}
|
|
|
|
func (p *fakeProcess) SignalCount() int {
|
|
return int(p.signalCount.Load())
|
|
}
|
|
|
|
func (p *fakeProcess) KillCount() int {
|
|
return int(p.killCount.Load())
|
|
}
|
|
|
|
type fakeStdoutEvent struct {
|
|
Delay time.Duration
|
|
Data string
|
|
}
|
|
|
|
type fakeCmdConfig struct {
|
|
StdoutPlan []fakeStdoutEvent
|
|
WaitDelay time.Duration
|
|
WaitErr error
|
|
StartErr error
|
|
PID int
|
|
KeepStdoutOpen bool
|
|
BlockWait bool
|
|
ReleaseWaitOnKill bool
|
|
ReleaseWaitOnSignal bool
|
|
}
|
|
|
|
type fakeCmd struct {
|
|
mu sync.Mutex
|
|
|
|
stdout *ctxAwareReader
|
|
stdoutWriter *io.PipeWriter
|
|
stdoutPlan []fakeStdoutEvent
|
|
stdoutOnce sync.Once
|
|
stdoutClaim bool
|
|
keepStdoutOpen bool
|
|
|
|
stdoutWriteMu sync.Mutex
|
|
|
|
stdinWriter *bufferWriteCloser
|
|
stdinClaim bool
|
|
|
|
stderr *ctxAwareReader
|
|
stderrWriter *io.PipeWriter
|
|
stderrOnce sync.Once
|
|
stderrClaim bool
|
|
|
|
env map[string]string
|
|
|
|
waitDelay time.Duration
|
|
waitErr error
|
|
startErr error
|
|
|
|
waitOnce sync.Once
|
|
waitDone chan struct{}
|
|
waitResult error
|
|
waitReleaseCh chan struct{}
|
|
waitReleaseOnce sync.Once
|
|
waitBlocked bool
|
|
|
|
started bool
|
|
|
|
startCount atomic.Int32
|
|
waitCount atomic.Int32
|
|
stdoutPipeCount atomic.Int32
|
|
|
|
process *fakeProcess
|
|
}
|
|
|
|
func newFakeCmd(cfg fakeCmdConfig) *fakeCmd {
|
|
r, w := io.Pipe()
|
|
stderrR, stderrW := io.Pipe()
|
|
cmd := &fakeCmd{
|
|
stdout: newCtxAwareReader(r),
|
|
stdoutWriter: w,
|
|
stdoutPlan: append([]fakeStdoutEvent(nil), cfg.StdoutPlan...),
|
|
stdinWriter: newBufferWriteCloser(),
|
|
waitDelay: cfg.WaitDelay,
|
|
waitErr: cfg.WaitErr,
|
|
startErr: cfg.StartErr,
|
|
waitDone: make(chan struct{}),
|
|
keepStdoutOpen: cfg.KeepStdoutOpen,
|
|
stderr: newCtxAwareReader(stderrR),
|
|
stderrWriter: stderrW,
|
|
process: newFakeProcess(cfg.PID),
|
|
}
|
|
if len(cmd.stdoutPlan) == 0 {
|
|
cmd.stdoutPlan = nil
|
|
}
|
|
if cfg.BlockWait {
|
|
cmd.waitBlocked = true
|
|
cmd.waitReleaseCh = make(chan struct{})
|
|
releaseOnSignal := cfg.ReleaseWaitOnSignal
|
|
releaseOnKill := cfg.ReleaseWaitOnKill
|
|
if !releaseOnSignal && !releaseOnKill {
|
|
releaseOnKill = true
|
|
}
|
|
cmd.process.onSignal = func(os.Signal) {
|
|
if releaseOnSignal {
|
|
cmd.releaseWait()
|
|
}
|
|
}
|
|
cmd.process.onKill = func() {
|
|
if releaseOnKill {
|
|
cmd.releaseWait()
|
|
}
|
|
}
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
func (f *fakeCmd) Start() error {
|
|
f.mu.Lock()
|
|
if f.started {
|
|
f.mu.Unlock()
|
|
return errors.New("start already called")
|
|
}
|
|
f.started = true
|
|
f.mu.Unlock()
|
|
|
|
f.startCount.Add(1)
|
|
|
|
if f.startErr != nil {
|
|
f.waitOnce.Do(func() {
|
|
f.waitResult = f.startErr
|
|
close(f.waitDone)
|
|
})
|
|
return f.startErr
|
|
}
|
|
|
|
go f.runStdoutScript()
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeCmd) Wait() error {
|
|
f.waitCount.Add(1)
|
|
f.waitOnce.Do(func() {
|
|
if f.waitBlocked && f.waitReleaseCh != nil {
|
|
<-f.waitReleaseCh
|
|
} else if f.waitDelay > 0 {
|
|
time.Sleep(f.waitDelay)
|
|
}
|
|
f.waitResult = f.waitErr
|
|
close(f.waitDone)
|
|
})
|
|
<-f.waitDone
|
|
return f.waitResult
|
|
}
|
|
|
|
func (f *fakeCmd) StdoutPipe() (io.ReadCloser, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.stdoutClaim {
|
|
return nil, errors.New("stdout pipe already claimed")
|
|
}
|
|
f.stdoutClaim = true
|
|
f.stdoutPipeCount.Add(1)
|
|
return f.stdout, nil
|
|
}
|
|
|
|
func (f *fakeCmd) StderrPipe() (io.ReadCloser, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.stderrClaim {
|
|
return nil, errors.New("stderr pipe already claimed")
|
|
}
|
|
f.stderrClaim = true
|
|
return f.stderr, nil
|
|
}
|
|
|
|
func (f *fakeCmd) StdinPipe() (io.WriteCloser, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.stdinClaim {
|
|
return nil, errors.New("stdin pipe already claimed")
|
|
}
|
|
f.stdinClaim = true
|
|
return f.stdinWriter, nil
|
|
}
|
|
|
|
func (f *fakeCmd) SetStderr(w io.Writer) {
|
|
_ = w
|
|
}
|
|
|
|
func (f *fakeCmd) SetDir(string) {}
|
|
|
|
func (f *fakeCmd) SetEnv(env map[string]string) {
|
|
if len(env) == 0 {
|
|
return
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.env == nil {
|
|
f.env = make(map[string]string, len(env))
|
|
}
|
|
for k, v := range env {
|
|
f.env[k] = v
|
|
}
|
|
}
|
|
|
|
func (f *fakeCmd) Process() executor.ProcessHandle {
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
return f.process
|
|
}
|
|
|
|
func (f *fakeCmd) runStdoutScript() {
|
|
if len(f.stdoutPlan) == 0 {
|
|
if !f.keepStdoutOpen {
|
|
f.CloseStdout(nil)
|
|
f.CloseStderr(nil)
|
|
}
|
|
return
|
|
}
|
|
for _, ev := range f.stdoutPlan {
|
|
if ev.Delay > 0 {
|
|
time.Sleep(ev.Delay)
|
|
}
|
|
f.WriteStdout(ev.Data)
|
|
}
|
|
if !f.keepStdoutOpen {
|
|
f.CloseStdout(nil)
|
|
f.CloseStderr(nil)
|
|
}
|
|
}
|
|
|
|
func (f *fakeCmd) releaseWait() {
|
|
if f.waitReleaseCh == nil {
|
|
return
|
|
}
|
|
f.waitReleaseOnce.Do(func() {
|
|
close(f.waitReleaseCh)
|
|
})
|
|
}
|
|
|
|
func (f *fakeCmd) WriteStdout(data string) {
|
|
if data == "" {
|
|
return
|
|
}
|
|
f.stdoutWriteMu.Lock()
|
|
defer f.stdoutWriteMu.Unlock()
|
|
if f.stdoutWriter != nil {
|
|
_, _ = io.WriteString(f.stdoutWriter, data)
|
|
}
|
|
}
|
|
|
|
func (f *fakeCmd) CloseStdout(err error) {
|
|
f.stdoutOnce.Do(func() {
|
|
if f.stdoutWriter == nil {
|
|
return
|
|
}
|
|
if err != nil {
|
|
_ = f.stdoutWriter.CloseWithError(err)
|
|
return
|
|
}
|
|
_ = f.stdoutWriter.Close()
|
|
})
|
|
}
|
|
|
|
func (f *fakeCmd) CloseStderr(err error) {
|
|
f.stderrOnce.Do(func() {
|
|
if f.stderrWriter == nil {
|
|
return
|
|
}
|
|
if err != nil {
|
|
_ = f.stderrWriter.CloseWithError(err)
|
|
return
|
|
}
|
|
_ = f.stderrWriter.Close()
|
|
})
|
|
}
|
|
|
|
func (f *fakeCmd) StdinContents() string {
|
|
if f.stdinWriter == nil {
|
|
return ""
|
|
}
|
|
return f.stdinWriter.String()
|
|
}
|
|
|
|
func createFakeCodexScript(t *testing.T, threadID, message string) string {
|
|
t.Helper()
|
|
tempDir := t.TempDir()
|
|
|
|
// Add small sleep to ensure parser goroutine has time to read stdout before
|
|
// the process exits and closes the pipe. This prevents race conditions in CI
|
|
// where fast shell script execution can close stdout before parsing completes.
|
|
if runtime.GOOS == "windows" {
|
|
scriptPath := filepath.Join(tempDir, "codex.bat")
|
|
script := fmt.Sprintf("@echo off\r\n"+
|
|
"echo {\"type\":\"thread.started\",\"thread_id\":\"%s\"}\r\n"+
|
|
"echo {\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"%s\"}}\r\n"+
|
|
"exit /b 0\r\n", threadID, message)
|
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("failed to create fake codex script: %v", err)
|
|
}
|
|
return scriptPath
|
|
}
|
|
|
|
scriptPath := filepath.Join(tempDir, "codex.sh")
|
|
script := fmt.Sprintf(`#!/bin/sh
|
|
printf '%%s\n' '{"type":"thread.started","thread_id":"%s"}'
|
|
printf '%%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"%s"}}'
|
|
sleep 0.05
|
|
`, threadID, message)
|
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("failed to create fake codex script: %v", err)
|
|
}
|
|
return scriptPath
|
|
}
|
|
|
|
func TestFakeCmdInfra(t *testing.T) {
|
|
t.Run("pipes and wait scheduling", func(t *testing.T) {
|
|
fake := newFakeCmd(fakeCmdConfig{
|
|
StdoutPlan: []fakeStdoutEvent{
|
|
{Data: "line1\n"},
|
|
{Delay: 5 * time.Millisecond, Data: "line2\n"},
|
|
},
|
|
WaitDelay: 20 * time.Millisecond,
|
|
})
|
|
|
|
stdout, err := fake.StdoutPipe()
|
|
if err != nil {
|
|
t.Fatalf("StdoutPipe() error = %v", err)
|
|
}
|
|
|
|
if err := fake.Start(); err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
var lines []string
|
|
for scanner.Scan() {
|
|
lines = append(lines, scanner.Text())
|
|
if len(lines) == 2 {
|
|
break
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
t.Fatalf("scanner error: %v", err)
|
|
}
|
|
if len(lines) != 2 || lines[0] != "line1" || lines[1] != "line2" {
|
|
t.Fatalf("unexpected stdout lines: %v", lines)
|
|
}
|
|
|
|
ctxReader, ok := stdout.(*ctxAwareReader)
|
|
if !ok {
|
|
t.Fatalf("stdout pipe is %T, want *ctxAwareReader", stdout)
|
|
}
|
|
if err := ctxReader.CloseWithReason("test-complete"); err != nil {
|
|
t.Fatalf("CloseWithReason error: %v", err)
|
|
}
|
|
if ctxReader.Reason() != "test-complete" {
|
|
t.Fatalf("CloseWithReason reason mismatch: %q", ctxReader.Reason())
|
|
}
|
|
|
|
waitStart := time.Now()
|
|
if err := fake.Wait(); err != nil {
|
|
t.Fatalf("Wait() error = %v", err)
|
|
}
|
|
if elapsed := time.Since(waitStart); elapsed < 20*time.Millisecond {
|
|
t.Fatalf("Wait() returned too early: %v", elapsed)
|
|
}
|
|
|
|
if fake.startCount.Load() != 1 {
|
|
t.Fatalf("Start() count = %d, want 1", fake.startCount.Load())
|
|
}
|
|
if fake.waitCount.Load() != 1 {
|
|
t.Fatalf("Wait() count = %d, want 1", fake.waitCount.Load())
|
|
}
|
|
if fake.stdoutPipeCount.Load() != 1 {
|
|
t.Fatalf("StdoutPipe() count = %d, want 1", fake.stdoutPipeCount.Load())
|
|
}
|
|
})
|
|
|
|
t.Run("integration with runCodexTask", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
fake := newFakeCmd(fakeCmdConfig{
|
|
StdoutPlan: []fakeStdoutEvent{
|
|
{Data: `{"type":"thread.started","thread_id":"fake-thread"}` + "\n"},
|
|
{
|
|
Delay: time.Millisecond,
|
|
Data: `{"type":"item.completed","item":{"type":"agent_message","text":"fake-msg"}}` + "\n",
|
|
},
|
|
},
|
|
WaitDelay: 5 * time.Millisecond,
|
|
})
|
|
|
|
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return fake })
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string {
|
|
return []string{targetArg}
|
|
}
|
|
codexCommand = "fake-cmd"
|
|
|
|
res := runCodexTask(TaskSpec{Task: "ignored"}, false, 2)
|
|
if res.ExitCode != 0 {
|
|
t.Fatalf("runCodexTask exit = %d, want 0 (%s)", res.ExitCode, res.Error)
|
|
}
|
|
if res.Message != "fake-msg" {
|
|
t.Fatalf("message = %q, want fake-msg", res.Message)
|
|
}
|
|
if res.SessionID != "fake-thread" {
|
|
t.Fatalf("sessionID = %q, want fake-thread", res.SessionID)
|
|
}
|
|
if fake.startCount.Load() != 1 {
|
|
t.Fatalf("Start() count = %d, want 1", fake.startCount.Load())
|
|
}
|
|
if fake.waitCount.Load() != 1 {
|
|
t.Fatalf("Wait() count = %d, want 1", fake.waitCount.Load())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRunCodexTask_WaitBeforeParse(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
const (
|
|
threadID = "wait-first-thread"
|
|
message = "wait-first-message"
|
|
waitDelay = 100 * time.Millisecond
|
|
extraDelay = 2 * time.Second
|
|
)
|
|
|
|
fake := newFakeCmd(fakeCmdConfig{
|
|
StdoutPlan: []fakeStdoutEvent{
|
|
{Data: fmt.Sprintf(`{"type":"thread.started","thread_id":"%s"}`+"\n", threadID)},
|
|
{Data: fmt.Sprintf(`{"type":"item.completed","item":{"type":"agent_message","text":"%s"}}`+"\n", message)},
|
|
{Delay: extraDelay},
|
|
},
|
|
WaitDelay: waitDelay,
|
|
})
|
|
|
|
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return fake })
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string {
|
|
return []string{targetArg}
|
|
}
|
|
codexCommand = "fake-cmd"
|
|
|
|
start := time.Now()
|
|
result := runCodexTask(TaskSpec{Task: "ignored"}, false, 5)
|
|
elapsed := time.Since(start)
|
|
|
|
if result.ExitCode != 0 {
|
|
t.Fatalf("runCodexTask exit = %d, want 0 (%s)", result.ExitCode, result.Error)
|
|
}
|
|
if result.Message != message {
|
|
t.Fatalf("message = %q, want %q", result.Message, message)
|
|
}
|
|
if result.SessionID != threadID {
|
|
t.Fatalf("sessionID = %q, want %q", result.SessionID, threadID)
|
|
}
|
|
if elapsed >= extraDelay {
|
|
t.Fatalf("runCodexTask took %v, want < %v", elapsed, extraDelay)
|
|
}
|
|
|
|
if fake.stdout == nil {
|
|
t.Fatalf("stdout reader not initialized")
|
|
}
|
|
if reason := fake.stdout.Reason(); reason != stdoutCloseReasonWait {
|
|
t.Fatalf("stdout close reason = %q, want %q", reason, stdoutCloseReasonWait)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_ParseStall(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
const threadID = "stall-thread"
|
|
startG := runtime.NumGoroutine()
|
|
|
|
fake := newFakeCmd(fakeCmdConfig{
|
|
StdoutPlan: []fakeStdoutEvent{
|
|
{Data: fmt.Sprintf(`{"type":"thread.started","thread_id":"%s"}`+"\n", threadID)},
|
|
},
|
|
KeepStdoutOpen: true,
|
|
})
|
|
|
|
blockingCmd := newDrainBlockingCmd(fake)
|
|
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return blockingCmd })
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string {
|
|
return []string{targetArg}
|
|
}
|
|
codexCommand = "fake-cmd"
|
|
|
|
start := time.Now()
|
|
result := runCodexTask(TaskSpec{Task: "stall"}, false, 60)
|
|
elapsed := time.Since(start)
|
|
if !blockingCmd.injected.Load() {
|
|
t.Fatalf("stdout wrapper was not installed")
|
|
}
|
|
|
|
if result.ExitCode == 0 || result.Error == "" {
|
|
t.Fatalf("expected runCodexTask to error when parse stalls, got %+v", result)
|
|
}
|
|
errText := strings.ToLower(result.Error)
|
|
if !strings.Contains(errText, "drain timeout") && !strings.Contains(errText, "agent_message") {
|
|
t.Fatalf("error %q does not mention drain timeout or missing agent_message", result.Error)
|
|
}
|
|
|
|
if elapsed < stdoutDrainTimeout {
|
|
t.Fatalf("runCodexTask returned after %v (reason=%s), want >= %v to confirm drainTimer firing", elapsed, fake.stdout.Reason(), stdoutDrainTimeout)
|
|
}
|
|
maxDuration := stdoutDrainTimeout + time.Second
|
|
if elapsed >= maxDuration {
|
|
t.Fatalf("runCodexTask took %v, want < %v", elapsed, maxDuration)
|
|
}
|
|
|
|
if fake.stdout == nil {
|
|
t.Fatalf("stdout reader not initialized")
|
|
}
|
|
if !fake.stdout.closed {
|
|
t.Fatalf("stdout reader still open; drainTimer should force close")
|
|
}
|
|
if reason := fake.stdout.Reason(); reason != stdoutCloseReasonDrain {
|
|
t.Fatalf("stdout close reason = %q, want %q", reason, stdoutCloseReasonDrain)
|
|
}
|
|
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
allowed := startG + 8
|
|
finalG := runtime.NumGoroutine()
|
|
for finalG > allowed && time.Now().Before(deadline) {
|
|
runtime.Gosched()
|
|
time.Sleep(10 * time.Millisecond)
|
|
runtime.GC()
|
|
finalG = runtime.NumGoroutine()
|
|
}
|
|
if finalG > allowed {
|
|
t.Fatalf("goroutines leaked: before=%d after=%d", startG, finalG)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_ContextTimeout(t *testing.T) {
|
|
defer resetTestHooks()
|
|
_ = executor.SetForceKillDelay(0)
|
|
|
|
fake := newFakeCmd(fakeCmdConfig{
|
|
KeepStdoutOpen: true,
|
|
BlockWait: true,
|
|
ReleaseWaitOnKill: true,
|
|
ReleaseWaitOnSignal: false,
|
|
})
|
|
|
|
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return fake })
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string {
|
|
return []string{targetArg}
|
|
}
|
|
codexCommand = "fake-cmd"
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
|
|
result := runCodexTaskWithContext(ctx, TaskSpec{Task: "ctx-timeout", WorkDir: defaultWorkdir}, nil, nil, false, false, 60)
|
|
|
|
if result.ExitCode != 124 {
|
|
t.Fatalf("exit code = %d, want 124 (%s)", result.ExitCode, result.Error)
|
|
}
|
|
if !strings.Contains(strings.ToLower(result.Error), "timeout") {
|
|
t.Fatalf("error %q does not mention timeout", result.Error)
|
|
}
|
|
if fake.process == nil {
|
|
t.Fatalf("fake process not initialized")
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if fake.process.KillCount() == 0 {
|
|
t.Fatalf("expected Kill to be called, got 0")
|
|
}
|
|
} else {
|
|
if fake.process.SignalCount() == 0 {
|
|
t.Fatalf("expected SIGTERM to be sent, got 0")
|
|
}
|
|
if fake.process.KillCount() == 0 {
|
|
t.Fatalf("expected Kill to eventually run, got 0")
|
|
}
|
|
}
|
|
if fake.stdout == nil {
|
|
t.Fatalf("stdout reader not initialized")
|
|
}
|
|
if reason := fake.stdout.Reason(); reason != stdoutCloseReasonCtx {
|
|
t.Fatalf("stdout close reason = %q, want %q", reason, stdoutCloseReasonCtx)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_ForcesStopAfterCompletion(t *testing.T) {
|
|
defer resetTestHooks()
|
|
_ = executor.SetForceKillDelay(0)
|
|
|
|
fake := newFakeCmd(fakeCmdConfig{
|
|
StdoutPlan: []fakeStdoutEvent{
|
|
{Data: `{"type":"item.completed","item":{"type":"agent_message","text":"done"}}` + "\n"},
|
|
{Data: `{"type":"thread.completed","thread_id":"tid"}` + "\n"},
|
|
},
|
|
KeepStdoutOpen: true,
|
|
BlockWait: true,
|
|
ReleaseWaitOnSignal: true,
|
|
ReleaseWaitOnKill: true,
|
|
})
|
|
|
|
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return fake })
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
|
|
codexCommand = "fake-cmd"
|
|
|
|
start := time.Now()
|
|
result := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "done", WorkDir: defaultWorkdir}, nil, nil, false, false, 60)
|
|
duration := time.Since(start)
|
|
|
|
if result.ExitCode != 0 || result.Message != "done" {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
if duration > 2*time.Second {
|
|
t.Fatalf("runCodexTaskWithContext took too long: %v", duration)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if fake.process.KillCount() == 0 {
|
|
t.Fatalf("expected Kill to be called, got 0")
|
|
}
|
|
} else if fake.process.SignalCount() == 0 {
|
|
t.Fatalf("expected SIGTERM to be sent, got %d", fake.process.SignalCount())
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_ForcesStopAfterTurnCompleted(t *testing.T) {
|
|
defer resetTestHooks()
|
|
_ = executor.SetForceKillDelay(0)
|
|
|
|
fake := newFakeCmd(fakeCmdConfig{
|
|
StdoutPlan: []fakeStdoutEvent{
|
|
{Data: `{"type":"item.completed","item":{"type":"agent_message","text":"done"}}` + "\n"},
|
|
{Data: `{"type":"turn.completed"}` + "\n"},
|
|
},
|
|
KeepStdoutOpen: true,
|
|
BlockWait: true,
|
|
ReleaseWaitOnSignal: true,
|
|
ReleaseWaitOnKill: true,
|
|
})
|
|
|
|
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return fake })
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
|
|
codexCommand = "fake-cmd"
|
|
|
|
start := time.Now()
|
|
result := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "done", WorkDir: defaultWorkdir}, nil, nil, false, false, 60)
|
|
duration := time.Since(start)
|
|
|
|
if result.ExitCode != 0 || result.Message != "done" {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
if duration > 2*time.Second {
|
|
t.Fatalf("runCodexTaskWithContext took too long: %v", duration)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if fake.process.KillCount() == 0 {
|
|
t.Fatalf("expected Kill to be called, got 0")
|
|
}
|
|
} else if fake.process.SignalCount() == 0 {
|
|
t.Fatalf("expected SIGTERM to be sent, got %d", fake.process.SignalCount())
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_DoesNotTerminateBeforeThreadCompleted(t *testing.T) {
|
|
defer resetTestHooks()
|
|
_ = executor.SetForceKillDelay(0)
|
|
|
|
fake := newFakeCmd(fakeCmdConfig{
|
|
StdoutPlan: []fakeStdoutEvent{
|
|
{Data: `{"type":"item.completed","item":{"type":"agent_message","text":"intermediate"}}` + "\n"},
|
|
{Delay: 1100 * time.Millisecond, Data: `{"type":"item.completed","item":{"type":"agent_message","text":"final"}}` + "\n"},
|
|
{Data: `{"type":"thread.completed","thread_id":"tid"}` + "\n"},
|
|
},
|
|
KeepStdoutOpen: true,
|
|
BlockWait: true,
|
|
ReleaseWaitOnSignal: true,
|
|
ReleaseWaitOnKill: true,
|
|
})
|
|
|
|
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return fake })
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
|
|
codexCommand = "fake-cmd"
|
|
|
|
start := time.Now()
|
|
result := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "done", WorkDir: defaultWorkdir}, nil, nil, false, false, 60)
|
|
duration := time.Since(start)
|
|
|
|
if result.ExitCode != 0 || result.Message != "final" {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
if duration > 5*time.Second {
|
|
t.Fatalf("runCodexTaskWithContext took too long: %v", duration)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if fake.process.KillCount() == 0 {
|
|
t.Fatalf("expected Kill to be called, got 0")
|
|
}
|
|
} else if fake.process.SignalCount() == 0 {
|
|
t.Fatalf("expected SIGTERM to be sent, got %d", fake.process.SignalCount())
|
|
}
|
|
}
|
|
|
|
func TestBackendParseArgs_NewMode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want *Config
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "simple task",
|
|
args: []string{"codeagent-wrapper", "analyze code"},
|
|
want: &Config{Mode: "new", Task: "analyze code", WorkDir: ".", ExplicitStdin: false, Backend: defaultBackendName},
|
|
},
|
|
{
|
|
name: "task with workdir",
|
|
args: []string{"codeagent-wrapper", "analyze code", "/path/to/dir"},
|
|
want: &Config{Mode: "new", Task: "analyze code", WorkDir: "/path/to/dir", ExplicitStdin: false, Backend: defaultBackendName},
|
|
},
|
|
{
|
|
name: "explicit stdin mode",
|
|
args: []string{"codeagent-wrapper", "-"},
|
|
want: &Config{Mode: "new", Task: "-", WorkDir: ".", ExplicitStdin: true, Backend: defaultBackendName},
|
|
},
|
|
{
|
|
name: "stdin with workdir",
|
|
args: []string{"codeagent-wrapper", "-", "/some/dir"},
|
|
want: &Config{Mode: "new", Task: "-", WorkDir: "/some/dir", ExplicitStdin: true, Backend: defaultBackendName},
|
|
},
|
|
{
|
|
name: "stdin with dash workdir rejected",
|
|
args: []string{"codeagent-wrapper", "-", "-"},
|
|
wantErr: true,
|
|
},
|
|
{name: "no args", args: []string{"codeagent-wrapper"}, wantErr: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Args = tt.args
|
|
cfg, err := parseArgs()
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("parseArgs() expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("parseArgs() unexpected error: %v", err)
|
|
}
|
|
if cfg.Mode != tt.want.Mode {
|
|
t.Errorf("Mode = %v, want %v", cfg.Mode, tt.want.Mode)
|
|
}
|
|
if cfg.Task != tt.want.Task {
|
|
t.Errorf("Task = %v, want %v", cfg.Task, tt.want.Task)
|
|
}
|
|
if cfg.WorkDir != tt.want.WorkDir {
|
|
t.Errorf("WorkDir = %v, want %v", cfg.WorkDir, tt.want.WorkDir)
|
|
}
|
|
if cfg.ExplicitStdin != tt.want.ExplicitStdin {
|
|
t.Errorf("ExplicitStdin = %v, want %v", cfg.ExplicitStdin, tt.want.ExplicitStdin)
|
|
}
|
|
if cfg.Backend != tt.want.Backend {
|
|
t.Errorf("Backend = %v, want %v", cfg.Backend, tt.want.Backend)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendParseArgs_ResumeMode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want *Config
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "resume with task",
|
|
args: []string{"codeagent-wrapper", "resume", "session-123", "continue task"},
|
|
want: &Config{Mode: "resume", SessionID: "session-123", Task: "continue task", WorkDir: ".", ExplicitStdin: false, Backend: defaultBackendName},
|
|
},
|
|
{
|
|
name: "resume with workdir",
|
|
args: []string{"codeagent-wrapper", "resume", "session-456", "task", "/work"},
|
|
want: &Config{Mode: "resume", SessionID: "session-456", Task: "task", WorkDir: "/work", ExplicitStdin: false, Backend: defaultBackendName},
|
|
},
|
|
{
|
|
name: "resume with stdin",
|
|
args: []string{"codeagent-wrapper", "resume", "session-789", "-"},
|
|
want: &Config{Mode: "resume", SessionID: "session-789", Task: "-", WorkDir: ".", ExplicitStdin: true, Backend: defaultBackendName},
|
|
},
|
|
{name: "resume missing session_id", args: []string{"codeagent-wrapper", "resume"}, wantErr: true},
|
|
{name: "resume missing task", args: []string{"codeagent-wrapper", "resume", "session-123"}, wantErr: true},
|
|
{name: "resume empty session_id", args: []string{"codeagent-wrapper", "resume", "", "task"}, wantErr: true},
|
|
{name: "resume whitespace session_id", args: []string{"codeagent-wrapper", "resume", " ", "task"}, wantErr: true},
|
|
{name: "resume with dash workdir rejected", args: []string{"codeagent-wrapper", "resume", "session-123", "task", "-"}, wantErr: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Args = tt.args
|
|
cfg, err := parseArgs()
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("parseArgs() expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("parseArgs() unexpected error: %v", err)
|
|
}
|
|
if cfg.Mode != tt.want.Mode || cfg.SessionID != tt.want.SessionID || cfg.Task != tt.want.Task || cfg.WorkDir != tt.want.WorkDir || cfg.ExplicitStdin != tt.want.ExplicitStdin {
|
|
t.Errorf("parseArgs() mismatch: %+v vs %+v", cfg, tt.want)
|
|
}
|
|
if cfg.Backend != tt.want.Backend {
|
|
t.Errorf("Backend = %v, want %v", cfg.Backend, tt.want.Backend)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendParseArgs_BackendFlag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "claude backend",
|
|
args: []string{"codeagent-wrapper", "--backend", "claude", "task"},
|
|
want: "claude",
|
|
},
|
|
{
|
|
name: "gemini resume",
|
|
args: []string{"codeagent-wrapper", "--backend", "gemini", "resume", "sid", "task"},
|
|
want: "gemini",
|
|
},
|
|
{
|
|
name: "backend equals syntax",
|
|
args: []string{"codeagent-wrapper", "--backend=claude", "task"},
|
|
want: "claude",
|
|
},
|
|
{
|
|
name: "missing backend value",
|
|
args: []string{"codeagent-wrapper", "--backend"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "backend equals missing value",
|
|
args: []string{"codeagent-wrapper", "--backend=", "task"},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Args = tt.args
|
|
cfg, err := parseArgs()
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if cfg.Backend != tt.want {
|
|
t.Fatalf("Backend = %q, want %q", cfg.Backend, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendParseArgs_ModelFlag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "model flag",
|
|
args: []string{"codeagent-wrapper", "--model", "opus", "task"},
|
|
want: "opus",
|
|
},
|
|
{
|
|
name: "model equals syntax",
|
|
args: []string{"codeagent-wrapper", "--model=opus", "task"},
|
|
want: "opus",
|
|
},
|
|
{
|
|
name: "model trimmed",
|
|
args: []string{"codeagent-wrapper", "--model", " opus ", "task"},
|
|
want: "opus",
|
|
},
|
|
{
|
|
name: "model with resume mode",
|
|
args: []string{"codeagent-wrapper", "--model", "sonnet", "resume", "sid", "task"},
|
|
want: "sonnet",
|
|
},
|
|
{
|
|
name: "missing model value",
|
|
args: []string{"codeagent-wrapper", "--model"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "model equals missing value",
|
|
args: []string{"codeagent-wrapper", "--model=", "task"},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Args = tt.args
|
|
cfg, err := parseArgs()
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if cfg.Model != tt.want {
|
|
t.Fatalf("Model = %q, want %q", cfg.Model, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendParseArgs_ReasoningEffortFlag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "reasoning-effort flag",
|
|
args: []string{"codeagent-wrapper", "--reasoning-effort", "low", "task"},
|
|
want: "low",
|
|
},
|
|
{
|
|
name: "reasoning-effort equals syntax",
|
|
args: []string{"codeagent-wrapper", "--reasoning-effort=medium", "task"},
|
|
want: "medium",
|
|
},
|
|
{
|
|
name: "reasoning-effort trimmed",
|
|
args: []string{"codeagent-wrapper", "--reasoning-effort", " high ", "task"},
|
|
want: "high",
|
|
},
|
|
{
|
|
name: "reasoning-effort with resume mode",
|
|
args: []string{"codeagent-wrapper", "--reasoning-effort", "low", "resume", "sid", "task"},
|
|
want: "low",
|
|
},
|
|
{
|
|
name: "missing reasoning-effort value",
|
|
args: []string{"codeagent-wrapper", "--reasoning-effort"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "reasoning-effort equals missing value",
|
|
args: []string{"codeagent-wrapper", "--reasoning-effort=", "task"},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Args = tt.args
|
|
cfg, err := parseArgs()
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if cfg.ReasoningEffort != tt.want {
|
|
t.Fatalf("ReasoningEffort = %q, want %q", cfg.ReasoningEffort, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendParseArgs_PromptFileFlag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "prompt file flag",
|
|
args: []string{"codeagent-wrapper", "--prompt-file", "/tmp/prompt.md", "task"},
|
|
want: "/tmp/prompt.md",
|
|
},
|
|
{
|
|
name: "prompt file equals syntax",
|
|
args: []string{"codeagent-wrapper", "--prompt-file=/tmp/prompt.md", "task"},
|
|
want: "/tmp/prompt.md",
|
|
},
|
|
{
|
|
name: "prompt file trimmed",
|
|
args: []string{"codeagent-wrapper", "--prompt-file", " /tmp/prompt.md ", "task"},
|
|
want: "/tmp/prompt.md",
|
|
},
|
|
{
|
|
name: "prompt file missing value",
|
|
args: []string{"codeagent-wrapper", "--prompt-file"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "prompt file equals missing value",
|
|
args: []string{"codeagent-wrapper", "--prompt-file=", "task"},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Args = tt.args
|
|
cfg, err := parseArgs()
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if cfg.PromptFile != tt.want {
|
|
t.Fatalf("PromptFile = %q, want %q", cfg.PromptFile, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendParseArgs_PromptFileOverridesAgent(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Cleanup(config.ResetModelsConfigCacheForTest)
|
|
config.ResetModelsConfigCacheForTest()
|
|
|
|
configDir := filepath.Join(home, ".codeagent")
|
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
|
"agents": {
|
|
"develop": { "backend": "codex", "model": "gpt-test" }
|
|
}
|
|
}`), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--prompt-file", "/tmp/custom.md", "--agent", "develop", "task"}
|
|
cfg, err := parseArgs()
|
|
if err != nil {
|
|
t.Fatalf("parseArgs() unexpected error: %v", err)
|
|
}
|
|
if cfg.PromptFile != "/tmp/custom.md" {
|
|
t.Fatalf("PromptFile = %q, want %q", cfg.PromptFile, "/tmp/custom.md")
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--agent", "develop", "--prompt-file", "/tmp/custom.md", "task"}
|
|
cfg, err = parseArgs()
|
|
if err != nil {
|
|
t.Fatalf("parseArgs() unexpected error: %v", err)
|
|
}
|
|
if cfg.PromptFile != "/tmp/custom.md" {
|
|
t.Fatalf("PromptFile = %q, want %q", cfg.PromptFile, "/tmp/custom.md")
|
|
}
|
|
}
|
|
|
|
func TestBackendParseArgs_SkipPermissions(t *testing.T) {
|
|
const envKey = "CODEAGENT_SKIP_PERMISSIONS"
|
|
t.Setenv(envKey, "true")
|
|
os.Args = []string{"codeagent-wrapper", "task"}
|
|
cfg, err := parseArgs()
|
|
if err != nil {
|
|
t.Fatalf("parseArgs() unexpected error: %v", err)
|
|
}
|
|
if !cfg.SkipPermissions {
|
|
t.Fatalf("SkipPermissions should default to true when env is set")
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--skip-permissions=false", "task"}
|
|
cfg, err = parseArgs()
|
|
if err != nil {
|
|
t.Fatalf("parseArgs() unexpected error: %v", err)
|
|
}
|
|
if cfg.SkipPermissions {
|
|
t.Fatalf("SkipPermissions should be false when flag overrides env")
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--skip-permissions", "task"}
|
|
cfg, err = parseArgs()
|
|
if err != nil {
|
|
t.Fatalf("parseArgs() unexpected error: %v", err)
|
|
}
|
|
if !cfg.SkipPermissions {
|
|
t.Fatalf("SkipPermissions should be true for plain --skip-permissions flag")
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--dangerously-skip-permissions", "task"}
|
|
cfg, err = parseArgs()
|
|
if err != nil {
|
|
t.Fatalf("parseArgs() unexpected error: %v", err)
|
|
}
|
|
if !cfg.SkipPermissions {
|
|
t.Fatalf("SkipPermissions should be true for dangerous flag")
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--dangerously-skip-permissions=false", "task"}
|
|
cfg, err = parseArgs()
|
|
if err != nil {
|
|
t.Fatalf("parseArgs() unexpected error: %v", err)
|
|
}
|
|
if cfg.SkipPermissions {
|
|
t.Fatalf("SkipPermissions should be false when dangerous flag is set to false")
|
|
}
|
|
}
|
|
|
|
func TestBackendParseBoolFlag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
val string
|
|
def bool
|
|
want bool
|
|
}{
|
|
{"true literal", "true", false, true},
|
|
{"false literal", "false", true, false},
|
|
{"default on unknown", "maybe", true, true},
|
|
{"empty uses default", "", false, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := config.ParseBoolFlag(tt.val, tt.def); got != tt.want {
|
|
t.Fatalf("parseBoolFlag(%q,%v) = %v, want %v", tt.val, tt.def, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendEnvFlagEnabled(t *testing.T) {
|
|
const key = "TEST_FLAG_ENABLED"
|
|
t.Setenv(key, "")
|
|
if config.EnvFlagEnabled(key) {
|
|
t.Fatalf("envFlagEnabled should be false when unset")
|
|
}
|
|
|
|
t.Setenv(key, "true")
|
|
if !config.EnvFlagEnabled(key) {
|
|
t.Fatalf("envFlagEnabled should be true for 'true'")
|
|
}
|
|
|
|
t.Setenv(key, "no")
|
|
if config.EnvFlagEnabled(key) {
|
|
t.Fatalf("envFlagEnabled should be false for 'no'")
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_Success(t *testing.T) {
|
|
input := `---TASK---
|
|
id: task-1
|
|
dependencies: task-0
|
|
---CONTENT---
|
|
do something`
|
|
|
|
cfg, err := parseParallelConfig([]byte(input))
|
|
if err != nil {
|
|
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Tasks) != 1 {
|
|
t.Fatalf("expected 1 task, got %d", len(cfg.Tasks))
|
|
}
|
|
task := cfg.Tasks[0]
|
|
if task.ID != "task-1" || task.Task != "do something" || task.WorkDir != defaultWorkdir || len(task.Dependencies) != 1 || task.Dependencies[0] != "task-0" {
|
|
t.Fatalf("task mismatch: %+v", task)
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_Backend(t *testing.T) {
|
|
input := `---TASK---
|
|
id: task-1
|
|
backend: gemini
|
|
session_id: sess-123
|
|
---CONTENT---
|
|
do something`
|
|
|
|
cfg, err := parseParallelConfig([]byte(input))
|
|
if err != nil {
|
|
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Tasks) != 1 {
|
|
t.Fatalf("expected 1 task, got %d", len(cfg.Tasks))
|
|
}
|
|
task := cfg.Tasks[0]
|
|
if task.Backend != "gemini" {
|
|
t.Fatalf("backend = %q, want gemini", task.Backend)
|
|
}
|
|
if task.Mode != "resume" || task.SessionID != "sess-123" {
|
|
t.Fatalf("expected resume mode with session, got mode=%q session=%q", task.Mode, task.SessionID)
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_Model(t *testing.T) {
|
|
input := `---TASK---
|
|
id: task-1
|
|
model: opus
|
|
---CONTENT---
|
|
do something`
|
|
|
|
cfg, err := parseParallelConfig([]byte(input))
|
|
if err != nil {
|
|
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Tasks) != 1 {
|
|
t.Fatalf("expected 1 task, got %d", len(cfg.Tasks))
|
|
}
|
|
task := cfg.Tasks[0]
|
|
if task.Model != "opus" {
|
|
t.Fatalf("model = %q, want opus", task.Model)
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_SkipPermissions(t *testing.T) {
|
|
input := `---TASK---
|
|
id: task-1
|
|
skip_permissions: true
|
|
---CONTENT---
|
|
do something`
|
|
|
|
cfg, err := parseParallelConfig([]byte(input))
|
|
if err != nil {
|
|
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Tasks) != 1 {
|
|
t.Fatalf("expected 1 task, got %d", len(cfg.Tasks))
|
|
}
|
|
task := cfg.Tasks[0]
|
|
if !task.SkipPermissions {
|
|
t.Fatalf("SkipPermissions = %v, want true", task.SkipPermissions)
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_Worktree(t *testing.T) {
|
|
input := `---TASK---
|
|
id: task-1
|
|
worktree: true
|
|
---CONTENT---
|
|
do something`
|
|
|
|
cfg, err := parseParallelConfig([]byte(input))
|
|
if err != nil {
|
|
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Tasks) != 1 {
|
|
t.Fatalf("expected 1 task, got %d", len(cfg.Tasks))
|
|
}
|
|
task := cfg.Tasks[0]
|
|
if !task.Worktree {
|
|
t.Fatalf("Worktree = %v, want true", task.Worktree)
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_WorktreeBooleanValue(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
want bool
|
|
}{
|
|
{"true", "true", true},
|
|
{"1", "1", true},
|
|
{"yes", "yes", true},
|
|
{"false", "false", false},
|
|
{"0", "0", false},
|
|
{"no", "no", false},
|
|
{"empty", "", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
input := fmt.Sprintf(`---TASK---
|
|
id: task-1
|
|
worktree: %s
|
|
---CONTENT---
|
|
do something`, tt.value)
|
|
|
|
cfg, err := parseParallelConfig([]byte(input))
|
|
if err != nil {
|
|
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
|
}
|
|
if cfg.Tasks[0].Worktree != tt.want {
|
|
t.Fatalf("Worktree = %v, want %v for value %q", cfg.Tasks[0].Worktree, tt.want, tt.value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_EmptySessionID(t *testing.T) {
|
|
input := `---TASK---
|
|
id: task-1
|
|
session_id:
|
|
---CONTENT---
|
|
do something`
|
|
|
|
if _, err := parseParallelConfig([]byte(input)); err == nil {
|
|
t.Fatalf("expected error for empty session_id, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_InvalidFormat(t *testing.T) {
|
|
if _, err := parseParallelConfig([]byte("invalid format")); err == nil {
|
|
t.Fatalf("expected error for invalid format, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_EmptyTasks(t *testing.T) {
|
|
input := `---TASK---
|
|
id: empty
|
|
---CONTENT---
|
|
`
|
|
if _, err := parseParallelConfig([]byte(input)); err == nil {
|
|
t.Fatalf("expected error for empty tasks array, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_MissingID(t *testing.T) {
|
|
input := `---TASK---
|
|
---CONTENT---
|
|
do something`
|
|
if _, err := parseParallelConfig([]byte(input)); err == nil {
|
|
t.Fatalf("expected error for missing id, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_MissingTask(t *testing.T) {
|
|
input := `---TASK---
|
|
id: task-1
|
|
---CONTENT---
|
|
`
|
|
if _, err := parseParallelConfig([]byte(input)); err == nil {
|
|
t.Fatalf("expected error for missing task, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_DuplicateID(t *testing.T) {
|
|
input := `---TASK---
|
|
id: dup
|
|
---CONTENT---
|
|
one
|
|
---TASK---
|
|
id: dup
|
|
---CONTENT---
|
|
two`
|
|
if _, err := parseParallelConfig([]byte(input)); err == nil {
|
|
t.Fatalf("expected error for duplicate id, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParallelParseConfig_DelimiterFormat(t *testing.T) {
|
|
input := `---TASK---
|
|
id: T1
|
|
workdir: /tmp
|
|
---CONTENT---
|
|
echo 'test'
|
|
---TASK---
|
|
id: T2
|
|
dependencies: T1
|
|
---CONTENT---
|
|
code with special chars: $var "quotes"`
|
|
|
|
cfg, err := parseParallelConfig([]byte(input))
|
|
if err != nil {
|
|
t.Fatalf("parseParallelConfig() error = %v", err)
|
|
}
|
|
if len(cfg.Tasks) != 2 {
|
|
t.Fatalf("expected 2 tasks, got %d", len(cfg.Tasks))
|
|
}
|
|
}
|
|
|
|
func TestClaudeModel_DefaultsFromSettings(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
|
|
dir := filepath.Join(home, ".claude")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
settingsModel := "claude-opus-4-5-20250929"
|
|
path := filepath.Join(dir, "settings.json")
|
|
data := []byte(fmt.Sprintf(`{"model":%q,"env":{"FOO":"bar"}}`, settingsModel))
|
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
makeRunner := func(gotName *string, gotArgs *[]string, fake **fakeCmd) func(context.Context, string, ...string) executor.CommandRunner {
|
|
return func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
|
*gotName = name
|
|
*gotArgs = append([]string(nil), args...)
|
|
cmd := newFakeCmd(fakeCmdConfig{
|
|
PID: 123,
|
|
StdoutPlan: []fakeStdoutEvent{
|
|
{Data: "{\"type\":\"result\",\"session_id\":\"sid\",\"result\":\"ok\"}\n"},
|
|
},
|
|
})
|
|
*fake = cmd
|
|
return cmd
|
|
}
|
|
}
|
|
|
|
t.Run("new mode inherits model when unset", func(t *testing.T) {
|
|
var (
|
|
gotName string
|
|
gotArgs []string
|
|
fake *fakeCmd
|
|
)
|
|
restore := executor.SetNewCommandRunner(makeRunner(&gotName, &gotArgs, &fake))
|
|
t.Cleanup(restore)
|
|
|
|
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "hi", Mode: "new", WorkDir: defaultWorkdir}, ClaudeBackend{}, nil, false, true, 5)
|
|
if res.ExitCode != 0 || res.Message != "ok" {
|
|
t.Fatalf("unexpected result: %+v", res)
|
|
}
|
|
if gotName != "claude" {
|
|
t.Fatalf("command = %q, want claude", gotName)
|
|
}
|
|
found := false
|
|
for i := 0; i+1 < len(gotArgs); i++ {
|
|
if gotArgs[i] == "--model" && gotArgs[i+1] == settingsModel {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected --model %q in args, got %v", settingsModel, gotArgs)
|
|
}
|
|
if fake == nil || fake.env["FOO"] != "bar" {
|
|
t.Fatalf("expected env to include FOO=bar, got %v", fake.env)
|
|
}
|
|
})
|
|
|
|
t.Run("explicit model overrides settings", func(t *testing.T) {
|
|
var (
|
|
gotName string
|
|
gotArgs []string
|
|
fake *fakeCmd
|
|
)
|
|
restore := executor.SetNewCommandRunner(makeRunner(&gotName, &gotArgs, &fake))
|
|
t.Cleanup(restore)
|
|
|
|
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "hi", Mode: "new", WorkDir: defaultWorkdir, Model: "sonnet"}, ClaudeBackend{}, nil, false, true, 5)
|
|
if res.ExitCode != 0 || res.Message != "ok" {
|
|
t.Fatalf("unexpected result: %+v", res)
|
|
}
|
|
found := false
|
|
for i := 0; i+1 < len(gotArgs); i++ {
|
|
if gotArgs[i] == "--model" && gotArgs[i+1] == "sonnet" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected --model sonnet in args, got %v", gotArgs)
|
|
}
|
|
})
|
|
|
|
t.Run("resume mode does not inherit model by default", func(t *testing.T) {
|
|
var (
|
|
gotName string
|
|
gotArgs []string
|
|
fake *fakeCmd
|
|
)
|
|
restore := executor.SetNewCommandRunner(makeRunner(&gotName, &gotArgs, &fake))
|
|
t.Cleanup(restore)
|
|
|
|
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "hi", Mode: "resume", SessionID: "sid-123", WorkDir: defaultWorkdir}, ClaudeBackend{}, nil, false, true, 5)
|
|
if res.ExitCode != 0 || res.Message != "ok" {
|
|
t.Fatalf("unexpected result: %+v", res)
|
|
}
|
|
for i := 0; i < len(gotArgs); i++ {
|
|
if gotArgs[i] == "--model" {
|
|
t.Fatalf("did not expect --model in resume args, got %v", gotArgs)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRunShouldUseStdin(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
task string
|
|
piped bool
|
|
want bool
|
|
}{
|
|
{"simple task", "analyze code", false, false},
|
|
{"piped input", "analyze code", true, true},
|
|
{"contains newline", "line1\nline2", false, true},
|
|
{"contains backslash", "path\\to\\file", false, true},
|
|
{"contains double quote", `say "hi"`, false, true},
|
|
{"contains single quote", "it's tricky", false, true},
|
|
{"contains backtick", "use `code`", false, true},
|
|
{"contains dollar", "price is $5", false, true},
|
|
{"long task", strings.Repeat("a", 801), false, true},
|
|
{"exactly 800 chars", strings.Repeat("a", 800), false, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := shouldUseStdin(tt.task, tt.piped)
|
|
if got != tt.want {
|
|
t.Errorf("shouldUseStdin(%q, %v) = %v, want %v", truncate(tt.task, 20), tt.piped, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRun_PromptFilePrefixesTask(t *testing.T) {
|
|
t.Run("absolute path", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
selectBackendFn = func(name string) (Backend, error) {
|
|
return testBackend{
|
|
name: name,
|
|
command: "echo",
|
|
argsFn: func(cfg *Config, targetArg string) []string {
|
|
return []string{targetArg}
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
var gotTask string
|
|
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
|
gotTask = task.Task
|
|
return TaskResult{ExitCode: 0, Message: "ok"}
|
|
}
|
|
|
|
isTerminalFn = func() bool { return true }
|
|
stdinReader = strings.NewReader("")
|
|
|
|
promptPath := filepath.Join(t.TempDir(), "prompt.md")
|
|
prompt := "LINE1\nLINE2\n"
|
|
if err := os.WriteFile(promptPath, []byte(prompt), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--prompt-file", promptPath, "do"}
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run() exit=%d, want 0", code)
|
|
}
|
|
|
|
want := "<agent-prompt>\nLINE1\nLINE2\n</agent-prompt>\n\ndo"
|
|
if gotTask != want {
|
|
t.Fatalf("task mismatch:\n got=%q\nwant=%q", gotTask, want)
|
|
}
|
|
})
|
|
|
|
t.Run("tilde expansion", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
|
|
selectBackendFn = func(name string) (Backend, error) {
|
|
return testBackend{
|
|
name: name,
|
|
command: "echo",
|
|
argsFn: func(cfg *Config, targetArg string) []string {
|
|
return []string{targetArg}
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
var gotTask string
|
|
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
|
gotTask = task.Task
|
|
return TaskResult{ExitCode: 0, Message: "ok"}
|
|
}
|
|
|
|
isTerminalFn = func() bool { return true }
|
|
stdinReader = strings.NewReader("")
|
|
|
|
promptPath := filepath.Join(home, "prompt.md")
|
|
if err := os.WriteFile(promptPath, []byte("P\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--prompt-file", "~/prompt.md", "do"}
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run() exit=%d, want 0", code)
|
|
}
|
|
|
|
want := "<agent-prompt>\nP\n</agent-prompt>\n\ndo"
|
|
if gotTask != want {
|
|
t.Fatalf("task mismatch:\n got=%q\nwant=%q", gotTask, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRun_PassesReasoningEffortToTaskSpec(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return true }
|
|
|
|
var got TaskSpec
|
|
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
|
got = task
|
|
return TaskResult{ExitCode: 0, Message: "ok"}
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--reasoning-effort", "high", "task"}
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run exit = %d, want 0", code)
|
|
}
|
|
if got.ReasoningEffort != "high" {
|
|
t.Fatalf("ReasoningEffort = %q, want %q", got.ReasoningEffort, "high")
|
|
}
|
|
}
|
|
|
|
func TestRun_NoOutputMessage_ReturnsExitCode1AndWritesStderr(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
setTempDirEnv(t, t.TempDir())
|
|
|
|
selectBackendFn = func(name string) (Backend, error) {
|
|
return testBackend{name: name, command: "echo"}, nil
|
|
}
|
|
|
|
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
|
return TaskResult{ExitCode: 0, Message: ""}
|
|
}
|
|
|
|
isTerminalFn = func() bool { return true }
|
|
stdinReader = strings.NewReader("")
|
|
|
|
os.Args = []string{"codeagent-wrapper", "task"}
|
|
|
|
var code int
|
|
errOutput := captureStderr(t, func() {
|
|
code = run()
|
|
})
|
|
|
|
if code != 1 {
|
|
t.Fatalf("run() exit=%d, want 1", code)
|
|
}
|
|
if !strings.Contains(errOutput, "no output message") {
|
|
t.Fatalf("stderr missing sentinel error text; got:\n%s", errOutput)
|
|
}
|
|
}
|
|
|
|
func TestRunBuildCodexArgs_NewMode(t *testing.T) {
|
|
const key = "CODEX_BYPASS_SANDBOX"
|
|
t.Setenv(key, "false")
|
|
|
|
cfg := &Config{Mode: "new", WorkDir: "/test/dir"}
|
|
args := buildCodexArgs(cfg, "my task")
|
|
expected := []string{
|
|
"e",
|
|
"--skip-git-repo-check",
|
|
"-C", "/test/dir",
|
|
"--json",
|
|
"my task",
|
|
}
|
|
if len(args) != len(expected) {
|
|
t.Fatalf("len mismatch")
|
|
}
|
|
for i := range args {
|
|
if args[i] != expected[i] {
|
|
t.Fatalf("args[%d]=%s, want %s", i, args[i], expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunBuildCodexArgs_NewMode_WithReasoningEffort(t *testing.T) {
|
|
const key = "CODEX_BYPASS_SANDBOX"
|
|
t.Setenv(key, "false")
|
|
|
|
cfg := &Config{Mode: "new", WorkDir: "/test/dir", ReasoningEffort: "high"}
|
|
args := buildCodexArgs(cfg, "my task")
|
|
expected := []string{
|
|
"e",
|
|
"-c", "model_reasoning_effort=high",
|
|
"--skip-git-repo-check",
|
|
"-C", "/test/dir",
|
|
"--json",
|
|
"my task",
|
|
}
|
|
if len(args) != len(expected) {
|
|
t.Fatalf("len mismatch")
|
|
}
|
|
for i := range args {
|
|
if args[i] != expected[i] {
|
|
t.Fatalf("args[%d]=%s, want %s", i, args[i], expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTaskWithContext_CodexReasoningEffort(t *testing.T) {
|
|
defer resetTestHooks()
|
|
t.Setenv("CODEX_BYPASS_SANDBOX", "false")
|
|
|
|
var gotArgs []string
|
|
restore := executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
|
gotArgs = append([]string(nil), args...)
|
|
return newFakeCmd(fakeCmdConfig{
|
|
PID: 123,
|
|
StdoutPlan: []fakeStdoutEvent{
|
|
{Data: "{\"type\":\"result\",\"session_id\":\"sid\",\"result\":\"ok\"}\n"},
|
|
},
|
|
})
|
|
})
|
|
t.Cleanup(restore)
|
|
|
|
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "hi", Mode: "new", WorkDir: defaultWorkdir, ReasoningEffort: "high"}, nil, nil, false, true, 5)
|
|
if res.ExitCode != 0 || res.Message != "ok" {
|
|
t.Fatalf("unexpected result: %+v", res)
|
|
}
|
|
|
|
found := false
|
|
for i := 0; i+1 < len(gotArgs); i++ {
|
|
if gotArgs[i] == "-c" && gotArgs[i+1] == "model_reasoning_effort=high" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected -c model_reasoning_effort=high in args, got %v", gotArgs)
|
|
}
|
|
}
|
|
|
|
func TestRunBuildCodexArgs_ResumeMode(t *testing.T) {
|
|
const key = "CODEX_BYPASS_SANDBOX"
|
|
t.Setenv(key, "false")
|
|
|
|
cfg := &Config{Mode: "resume", SessionID: "session-abc"}
|
|
args := buildCodexArgs(cfg, "-")
|
|
expected := []string{
|
|
"e",
|
|
"--skip-git-repo-check",
|
|
"--json",
|
|
"resume",
|
|
"session-abc",
|
|
"-",
|
|
}
|
|
if len(args) != len(expected) {
|
|
t.Fatalf("len mismatch")
|
|
}
|
|
for i := range args {
|
|
if args[i] != expected[i] {
|
|
t.Fatalf("args[%d]=%s, want %s", i, args[i], expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunBuildCodexArgs_ResumeMode_EmptySessionHandledGracefully(t *testing.T) {
|
|
const key = "CODEX_BYPASS_SANDBOX"
|
|
t.Setenv(key, "false")
|
|
|
|
cfg := &Config{Mode: "resume", SessionID: " ", WorkDir: "/test/dir"}
|
|
args := buildCodexArgs(cfg, "task")
|
|
expected := []string{"e", "--skip-git-repo-check", "-C", "/test/dir", "--json", "task"}
|
|
if len(args) != len(expected) {
|
|
t.Fatalf("len mismatch")
|
|
}
|
|
for i := range args {
|
|
if args[i] != expected[i] {
|
|
t.Fatalf("args[%d]=%s, want %s", i, args[i], expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunBuildCodexArgs_BypassSandboxEnvTrue(t *testing.T) {
|
|
defer resetTestHooks()
|
|
setTempDirEnv(t, t.TempDir())
|
|
|
|
logger, err := NewLogger()
|
|
if err != nil {
|
|
t.Fatalf("NewLogger() error = %v", err)
|
|
}
|
|
setLogger(logger)
|
|
defer func() { _ = closeLogger() }()
|
|
|
|
t.Setenv("CODEX_BYPASS_SANDBOX", "true")
|
|
|
|
cfg := &Config{Mode: "new", WorkDir: "/test/dir"}
|
|
args := buildCodexArgs(cfg, "my task")
|
|
found := false
|
|
for _, arg := range args {
|
|
if arg == "--dangerously-bypass-approvals-and-sandbox" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected bypass flag in args, got %v", args)
|
|
}
|
|
|
|
logger.Flush()
|
|
data, err := os.ReadFile(logger.Path())
|
|
if err != nil {
|
|
t.Fatalf("failed to read log file: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "CODEX_BYPASS_SANDBOX enabled") {
|
|
t.Fatalf("expected bypass warning log, got: %s", string(data))
|
|
}
|
|
}
|
|
|
|
func TestBackendSelectBackend(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
kind Backend
|
|
}{
|
|
{"codex", "codex", CodexBackend{}},
|
|
{"claude mixed case", "ClAuDe", ClaudeBackend{}},
|
|
{"gemini", "gemini", GeminiBackend{}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := selectBackend(tt.in)
|
|
if err != nil {
|
|
t.Fatalf("selectBackend() error = %v", err)
|
|
}
|
|
switch tt.kind.(type) {
|
|
case CodexBackend:
|
|
if _, ok := got.(CodexBackend); !ok {
|
|
t.Fatalf("expected CodexBackend, got %T", got)
|
|
}
|
|
case ClaudeBackend:
|
|
if _, ok := got.(ClaudeBackend); !ok {
|
|
t.Fatalf("expected ClaudeBackend, got %T", got)
|
|
}
|
|
case GeminiBackend:
|
|
if _, ok := got.(GeminiBackend); !ok {
|
|
t.Fatalf("expected GeminiBackend, got %T", got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendSelectBackend_Invalid(t *testing.T) {
|
|
if _, err := selectBackend("unknown"); err == nil {
|
|
t.Fatalf("expected error for invalid backend")
|
|
}
|
|
}
|
|
|
|
func TestBackendSelectBackend_DefaultOnEmpty(t *testing.T) {
|
|
backend, err := selectBackend("")
|
|
if err != nil {
|
|
t.Fatalf("selectBackend(\"\") error = %v", err)
|
|
}
|
|
if _, ok := backend.(CodexBackend); !ok {
|
|
t.Fatalf("expected default CodexBackend, got %T", backend)
|
|
}
|
|
}
|
|
|
|
func TestBackendBuildArgs_CodexBackend(t *testing.T) {
|
|
t.Setenv("CODEX_BYPASS_SANDBOX", "false")
|
|
backend := CodexBackend{}
|
|
cfg := &Config{Mode: "new", WorkDir: "/test/dir"}
|
|
got := backend.BuildArgs(cfg, "task")
|
|
want := []string{
|
|
"e",
|
|
"--skip-git-repo-check",
|
|
"-C", "/test/dir",
|
|
"--json",
|
|
"task",
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("length mismatch")
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("index %d got %s want %s", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBackendBuildArgs_ClaudeBackend(t *testing.T) {
|
|
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
|
backend := ClaudeBackend{}
|
|
cfg := &Config{Mode: "new", WorkDir: defaultWorkdir}
|
|
got := backend.BuildArgs(cfg, "todo")
|
|
want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "todo"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("args length=%d, want %d: %v", len(got), len(want), got)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("index %d got %q want %q (args=%v)", i, got[i], want[i], got)
|
|
}
|
|
}
|
|
|
|
if backend.BuildArgs(nil, "ignored") != nil {
|
|
t.Fatalf("nil config should return nil args")
|
|
}
|
|
}
|
|
|
|
func TestClaudeBackendBuildArgs_OutputValidation(t *testing.T) {
|
|
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
|
backend := ClaudeBackend{}
|
|
cfg := &Config{Mode: "resume"}
|
|
target := "ensure-flags"
|
|
|
|
args := backend.BuildArgs(cfg, target)
|
|
want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", target}
|
|
if len(args) != len(want) {
|
|
t.Fatalf("args length=%d, want %d: %v", len(args), len(want), args)
|
|
}
|
|
for i := range want {
|
|
if args[i] != want[i] {
|
|
t.Fatalf("index %d got %q want %q (args=%v)", i, args[i], want[i], args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBackendBuildArgs_GeminiBackend(t *testing.T) {
|
|
backend := GeminiBackend{}
|
|
cfg := &Config{Mode: "new"}
|
|
got := backend.BuildArgs(cfg, "task")
|
|
want := []string{"-o", "stream-json", "-y", "task"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("length mismatch")
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("index %d got %s want %s", i, got[i], want[i])
|
|
}
|
|
}
|
|
|
|
if backend.BuildArgs(nil, "ignored") != nil {
|
|
t.Fatalf("nil config should return nil args")
|
|
}
|
|
}
|
|
|
|
func TestGeminiBackendBuildArgs_OutputValidation(t *testing.T) {
|
|
backend := GeminiBackend{}
|
|
cfg := &Config{Mode: "resume"}
|
|
target := "prompt-data"
|
|
|
|
args := backend.BuildArgs(cfg, target)
|
|
expected := []string{"-o", "stream-json", "-y"}
|
|
|
|
if len(args) != len(expected)+1 {
|
|
t.Fatalf("args length=%d, want %d", len(args), len(expected)+1)
|
|
}
|
|
for i, val := range expected {
|
|
if args[i] != val {
|
|
t.Fatalf("args[%d]=%q, want %q", i, args[i], val)
|
|
}
|
|
}
|
|
if args[len(args)-1] != target {
|
|
t.Fatalf("last arg=%q, want target %q", args[len(args)-1], target)
|
|
}
|
|
}
|
|
|
|
func TestBackendNamesAndCommands(t *testing.T) {
|
|
tests := []Backend{CodexBackend{}, ClaudeBackend{}, GeminiBackend{}}
|
|
expected := []struct {
|
|
name string
|
|
command string
|
|
}{
|
|
{"codex", "codex"},
|
|
{"claude", "claude"},
|
|
{"gemini", "gemini"},
|
|
}
|
|
|
|
for i, backend := range tests {
|
|
if backend.Name() != expected[i].name {
|
|
t.Fatalf("backend %d name = %s, want %s", i, backend.Name(), expected[i].name)
|
|
}
|
|
if backend.Command() != expected[i].command {
|
|
t.Fatalf("backend %d command = %s, want %s", i, backend.Command(), expected[i].command)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunResolveTimeout(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
envVal string
|
|
want int
|
|
}{
|
|
{"empty env", "", 7200},
|
|
{"milliseconds", "7200000", 7200},
|
|
{"seconds", "3600", 3600},
|
|
{"invalid", "invalid", 7200},
|
|
{"negative", "-100", 7200},
|
|
{"zero", "0", 7200},
|
|
{"small milliseconds", "5000", 5000},
|
|
{"boundary", "10000", 10000},
|
|
{"above boundary", "10001", 10},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Setenv("CODEX_TIMEOUT", tt.envVal)
|
|
got := resolveTimeout()
|
|
if got != tt.want {
|
|
t.Errorf("resolveTimeout() with env=%q = %v, want %v", tt.envVal, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunNormalizeText(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
want string
|
|
}{
|
|
{"string", "hello world", "hello world"},
|
|
{"string array", []interface{}{"hello", " ", "world"}, "hello world"},
|
|
{"empty array", []interface{}{}, ""},
|
|
{"mixed array", []interface{}{"text", 123, "more"}, "textmore"},
|
|
{"nil", nil, ""},
|
|
{"number", 123, ""},
|
|
{"empty string", "", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := normalizeText(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("normalizeText(%v) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream(t *testing.T) {
|
|
type testCase struct {
|
|
name string
|
|
input string
|
|
wantMessage string
|
|
wantThreadID string
|
|
}
|
|
|
|
longText := strings.Repeat("a", 2*1024*1024)
|
|
|
|
tests := []testCase{
|
|
{"thread started and agent message", `{"type":"thread.started","thread_id":"abc-123"}
|
|
{"type":"item.completed","item":{"type":"agent_message","text":"Hello world"}}`, "Hello world", "abc-123"},
|
|
{"multiple agent messages", `{"type":"item.completed","item":{"type":"agent_message","text":"First"}}
|
|
{"type":"item.completed","item":{"type":"agent_message","text":"Second"}}`, "Second", ""},
|
|
{"text as array", `{"type":"item.completed","item":{"type":"agent_message","text":["Hello"," ","World"]}}`, "Hello World", ""},
|
|
{"ignore other event types", `{"type":"other.event","data":"ignored"}
|
|
{"type":"item.completed","item":{"type":"other_type","text":"ignored"}}
|
|
{"type":"item.completed","item":{"type":"agent_message","text":"Valid"}}`, "Valid", ""},
|
|
{"super long single line", `{"type":"item.completed","item":{"type":"agent_message","text":"` + longText + `"}}`, longText, ""},
|
|
{"empty input", "", "", ""},
|
|
{"item completed with nil item", strings.Join([]string{`{"type":"thread.started","thread_id":"nil-item-thread"}`, `{"type":"item.completed","item":null}`}, "\n"), "", "nil-item-thread"},
|
|
{"agent message with non-string text", `{"type":"item.completed","item":{"type":"agent_message","text":12345}}`, "", ""},
|
|
{"corrupted json does not break stream", strings.Join([]string{`{"type":"item.completed","item":{"type":"agent_message","text":"before"}}`, `{"type":"item.completed","item":{"type":"agent_message","text":"broken"}`, `{"type":"thread.started","thread_id":"after-thread"}`, `{"type":"item.completed","item":{"type":"agent_message","text":"after"}}`}, "\n"), "after", "after-thread"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotMessage, gotThreadID := parseJSONStream(strings.NewReader(tt.input))
|
|
if gotMessage != tt.wantMessage {
|
|
t.Errorf("message = %q, want %q", gotMessage, tt.wantMessage)
|
|
}
|
|
if gotThreadID != tt.wantThreadID {
|
|
t.Errorf("threadID = %q, want %q", gotThreadID, tt.wantThreadID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream_ClaudeEvents(t *testing.T) {
|
|
input := `{"type":"system","subtype":"init","session_id":"abc123"}
|
|
{"type":"result","subtype":"success","result":"Hello!","session_id":"abc123"}`
|
|
|
|
message, threadID := parseJSONStream(strings.NewReader(input))
|
|
|
|
if message != "Hello!" {
|
|
t.Fatalf("message=%q, want %q", message, "Hello!")
|
|
}
|
|
if threadID != "abc123" {
|
|
t.Fatalf("threadID=%q, want %q", threadID, "abc123")
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream_ClaudeEvents_ItemDoesNotForceCodex(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{
|
|
name: "null item",
|
|
input: `{"type":"result","result":"OK","session_id":"abc123","item":null}`,
|
|
},
|
|
{
|
|
name: "empty object item",
|
|
input: `{"type":"result","subtype":"x","result":"OK","session_id":"abc123","item":{}}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
message, threadID := parseJSONStream(strings.NewReader(tt.input))
|
|
if message != "OK" {
|
|
t.Fatalf("message=%q, want %q", message, "OK")
|
|
}
|
|
if threadID != "abc123" {
|
|
t.Fatalf("threadID=%q, want %q", threadID, "abc123")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream_GeminiEvents(t *testing.T) {
|
|
input := `{"type":"init","session_id":"xyz789"}
|
|
{"type":"message","role":"assistant","content":"Hi","delta":true,"session_id":"xyz789"}
|
|
{"type":"message","role":"assistant","content":" there","delta":true}
|
|
{"type":"result","status":"success","session_id":"xyz789"}`
|
|
|
|
message, threadID := parseJSONStream(strings.NewReader(input))
|
|
|
|
if message != "Hi there" {
|
|
t.Fatalf("message=%q, want %q", message, "Hi there")
|
|
}
|
|
if threadID != "xyz789" {
|
|
t.Fatalf("threadID=%q, want %q", threadID, "xyz789")
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream_GeminiInitEventSessionID(t *testing.T) {
|
|
input := `{"type":"init","session_id":"gemini-abc123"}`
|
|
|
|
_, threadID := parseJSONStream(strings.NewReader(input))
|
|
|
|
if threadID != "gemini-abc123" {
|
|
t.Fatalf("threadID=%q, want %q", threadID, "gemini-abc123")
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream_GeminiEvents_DeltaFalseStillDetected(t *testing.T) {
|
|
input := `{"type":"init","session_id":"xyz789"}
|
|
{"type":"message","content":"Hi","delta":false,"session_id":"xyz789"}
|
|
{"type":"result","status":"success","session_id":"xyz789"}`
|
|
|
|
message, threadID := parseJSONStream(strings.NewReader(input))
|
|
|
|
if message != "Hi" {
|
|
t.Fatalf("message=%q, want %q", message, "Hi")
|
|
}
|
|
if threadID != "xyz789" {
|
|
t.Fatalf("threadID=%q, want %q", threadID, "xyz789")
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream_GeminiEvents_OnMessageTriggeredOnStatus(t *testing.T) {
|
|
input := `{"type":"init","session_id":"xyz789"}
|
|
{"type":"message","role":"assistant","content":"Hi","delta":true,"session_id":"xyz789"}
|
|
{"type":"message","content":" there","delta":true}
|
|
{"type":"result","status":"success","session_id":"xyz789"}`
|
|
|
|
var called int
|
|
message, threadID := parseJSONStreamInternal(strings.NewReader(input), nil, nil, func() {
|
|
called++
|
|
}, nil)
|
|
|
|
if message != "Hi there" {
|
|
t.Fatalf("message=%q, want %q", message, "Hi there")
|
|
}
|
|
if threadID != "xyz789" {
|
|
t.Fatalf("threadID=%q, want %q", threadID, "xyz789")
|
|
}
|
|
if called != 1 {
|
|
t.Fatalf("onMessage called=%d, want %d", called, 1)
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStreamWithWarn_InvalidLine(t *testing.T) {
|
|
var warnings []string
|
|
warnFn := func(msg string) { warnings = append(warnings, msg) }
|
|
message, threadID := parseJSONStreamWithWarn(strings.NewReader("not-json"), warnFn)
|
|
if message != "" || threadID != "" {
|
|
t.Fatalf("expected empty output, got message=%q thread=%q", message, threadID)
|
|
}
|
|
if len(warnings) == 0 {
|
|
t.Fatalf("expected warning to be emitted")
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream_OnMessage(t *testing.T) {
|
|
var called int
|
|
message, threadID := parseJSONStreamInternal(strings.NewReader(`{"type":"item.completed","item":{"type":"agent_message","text":"hook"}}`), nil, nil, func() {
|
|
called++
|
|
}, nil)
|
|
if message != "hook" {
|
|
t.Fatalf("message = %q, want hook", message)
|
|
}
|
|
if threadID != "" {
|
|
t.Fatalf("threadID = %q, want empty", threadID)
|
|
}
|
|
if called == 0 {
|
|
t.Fatalf("onMessage hook not invoked")
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream_OnComplete_CodexThreadCompleted(t *testing.T) {
|
|
input := `{"type":"item.completed","item":{"type":"agent_message","text":"first"}}` + "\n" +
|
|
`{"type":"item.completed","item":{"type":"agent_message","text":"second"}}` + "\n" +
|
|
`{"type":"thread.completed","thread_id":"t-1"}`
|
|
|
|
var onMessageCalls int
|
|
var onCompleteCalls int
|
|
message, threadID := parseJSONStreamInternal(strings.NewReader(input), nil, nil, func() {
|
|
onMessageCalls++
|
|
}, func() {
|
|
onCompleteCalls++
|
|
})
|
|
if message != "second" {
|
|
t.Fatalf("message = %q, want second", message)
|
|
}
|
|
if threadID != "t-1" {
|
|
t.Fatalf("threadID = %q, want t-1", threadID)
|
|
}
|
|
if onMessageCalls != 2 {
|
|
t.Fatalf("onMessage calls = %d, want 2", onMessageCalls)
|
|
}
|
|
if onCompleteCalls != 1 {
|
|
t.Fatalf("onComplete calls = %d, want 1", onCompleteCalls)
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream_OnComplete_ClaudeResult(t *testing.T) {
|
|
input := `{"type":"message","subtype":"stream","session_id":"s-1"}` + "\n" +
|
|
`{"type":"result","result":"OK","session_id":"s-1"}`
|
|
|
|
var onMessageCalls int
|
|
var onCompleteCalls int
|
|
message, threadID := parseJSONStreamInternal(strings.NewReader(input), nil, nil, func() {
|
|
onMessageCalls++
|
|
}, func() {
|
|
onCompleteCalls++
|
|
})
|
|
if message != "OK" {
|
|
t.Fatalf("message = %q, want OK", message)
|
|
}
|
|
if threadID != "s-1" {
|
|
t.Fatalf("threadID = %q, want s-1", threadID)
|
|
}
|
|
if onMessageCalls != 1 {
|
|
t.Fatalf("onMessage calls = %d, want 1", onMessageCalls)
|
|
}
|
|
if onCompleteCalls != 1 {
|
|
t.Fatalf("onComplete calls = %d, want 1", onCompleteCalls)
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream_OnComplete_GeminiTerminalResultStatus(t *testing.T) {
|
|
input := `{"type":"message","role":"assistant","content":"Hi","delta":true,"session_id":"g-1"}` + "\n" +
|
|
`{"type":"result","status":"success","session_id":"g-1"}`
|
|
|
|
var onMessageCalls int
|
|
var onCompleteCalls int
|
|
message, threadID := parseJSONStreamInternal(strings.NewReader(input), nil, nil, func() {
|
|
onMessageCalls++
|
|
}, func() {
|
|
onCompleteCalls++
|
|
})
|
|
if message != "Hi" {
|
|
t.Fatalf("message = %q, want Hi", message)
|
|
}
|
|
if threadID != "g-1" {
|
|
t.Fatalf("threadID = %q, want g-1", threadID)
|
|
}
|
|
if onMessageCalls != 1 {
|
|
t.Fatalf("onMessage calls = %d, want 1", onMessageCalls)
|
|
}
|
|
if onCompleteCalls != 1 {
|
|
t.Fatalf("onComplete calls = %d, want 1", onCompleteCalls)
|
|
}
|
|
}
|
|
|
|
func TestBackendParseJSONStream_ScannerError(t *testing.T) {
|
|
var warnings []string
|
|
warnFn := func(msg string) { warnings = append(warnings, msg) }
|
|
message, threadID := parseJSONStreamInternal(errReader{err: errors.New("scan-fail")}, warnFn, nil, nil, nil)
|
|
if message != "" || threadID != "" {
|
|
t.Fatalf("expected empty output on scanner error, got message=%q threadID=%q", message, threadID)
|
|
}
|
|
if len(warnings) == 0 {
|
|
t.Fatalf("expected warning on scanner error")
|
|
}
|
|
}
|
|
|
|
func TestBackendDiscardInvalidJSON(t *testing.T) {
|
|
reader := bufio.NewReader(strings.NewReader("line1\nline2\n"))
|
|
newReader, err := discardInvalidJSON(nil, reader)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
line, _ := newReader.ReadString('\n')
|
|
if strings.TrimSpace(line) != "line2" {
|
|
t.Fatalf("expected to continue with remaining data, got %q", line)
|
|
}
|
|
|
|
readerNoNewline := bufio.NewReader(strings.NewReader("no newline"))
|
|
if _, err := discardInvalidJSON(nil, readerNoNewline); err == nil {
|
|
t.Fatalf("expected error when no newline present")
|
|
}
|
|
}
|
|
|
|
func TestBackendHasKey(t *testing.T) {
|
|
raw := map[string]json.RawMessage{
|
|
"present": json.RawMessage(`true`),
|
|
}
|
|
|
|
if !hasKey(raw, "present") {
|
|
t.Fatalf("expected key 'present' to be found")
|
|
}
|
|
if hasKey(raw, "absent") {
|
|
t.Fatalf("did not expect key 'absent' to be found")
|
|
}
|
|
}
|
|
|
|
func TestRunGetEnv(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
key string
|
|
defaultVal string
|
|
envVal string
|
|
setEnv bool
|
|
want string
|
|
}{
|
|
{"env set", "TEST_KEY", "default", "custom", true, "custom"},
|
|
{"env not set", "TEST_KEY_MISSING", "default", "", false, "default"},
|
|
{"env empty", "TEST_KEY_EMPTY", "default", "", true, "default"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.setEnv {
|
|
t.Setenv(tt.key, tt.envVal)
|
|
} else {
|
|
t.Setenv(tt.key, "")
|
|
}
|
|
|
|
got := getEnv(tt.key, tt.defaultVal)
|
|
if got != tt.want {
|
|
t.Errorf("getEnv(%q, %q) = %q, want %q", tt.key, tt.defaultVal, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunTruncate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
maxLen int
|
|
want string
|
|
}{
|
|
{"short string", "hello", 10, "hello"},
|
|
{"exact length", "hello", 5, "hello"},
|
|
{"truncate", "hello world", 5, "hello..."},
|
|
{"empty", "", 5, ""},
|
|
{"zero maxLen", "hello", 0, "..."},
|
|
{"negative maxLen", "hello", -1, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := truncate(tt.input, tt.maxLen)
|
|
if got != tt.want {
|
|
t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
if got := truncate("data", -1); got != "" {
|
|
t.Fatalf("truncate should return empty string for negative maxLen, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunMin(t *testing.T) {
|
|
tests := []struct {
|
|
a, b, want int
|
|
}{{1, 2, 1}, {2, 1, 1}, {5, 5, 5}, {-1, 0, -1}, {0, -1, -1}}
|
|
for _, tt := range tests {
|
|
t.Run("", func(t *testing.T) {
|
|
got := min(tt.a, tt.b)
|
|
if got != tt.want {
|
|
t.Errorf("min(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTailBufferWrite(t *testing.T) {
|
|
buf := &tailBuffer{limit: 5}
|
|
if n, _ := buf.Write([]byte("123")); n != 3 || buf.String() != "123" {
|
|
t.Fatalf("unexpected buffer content %q", buf.String())
|
|
}
|
|
|
|
if _, _ = buf.Write([]byte("4567")); buf.String() != "34567" {
|
|
t.Fatalf("overflow case mismatch, got %q", buf.String())
|
|
}
|
|
|
|
if _, _ = buf.Write([]byte("abcdefgh")); buf.String() != "defgh" {
|
|
t.Fatalf("len>=limit case mismatch, got %q", buf.String())
|
|
}
|
|
|
|
noLimit := &tailBuffer{limit: 0}
|
|
if _, _ = noLimit.Write([]byte("ignored")); noLimit.String() != "" {
|
|
t.Fatalf("limit<=0 should not retain data")
|
|
}
|
|
}
|
|
|
|
func TestRunLogFunctions(t *testing.T) {
|
|
defer resetTestHooks()
|
|
setTempDirEnv(t, t.TempDir())
|
|
|
|
logger, err := NewLogger()
|
|
if err != nil {
|
|
t.Fatalf("NewLogger() error = %v", err)
|
|
}
|
|
setLogger(logger)
|
|
defer func() { _ = closeLogger() }()
|
|
|
|
logInfo("info message")
|
|
logWarn("warn message")
|
|
logError("error message")
|
|
logger.Flush()
|
|
|
|
data, err := os.ReadFile(logger.Path())
|
|
if err != nil {
|
|
t.Fatalf("failed to read log file: %v", err)
|
|
}
|
|
|
|
output := string(data)
|
|
if !strings.Contains(output, "info message") {
|
|
t.Errorf("logInfo output missing, got: %s", output)
|
|
}
|
|
if !strings.Contains(output, "warn message") {
|
|
t.Errorf("logWarn output missing, got: %s", output)
|
|
}
|
|
if !strings.Contains(output, "error message") {
|
|
t.Errorf("logError output missing, got: %s", output)
|
|
}
|
|
}
|
|
|
|
func TestLoggerPathAndRemoveNil(t *testing.T) {
|
|
var logger *Logger
|
|
if logger.Path() != "" {
|
|
t.Fatalf("nil logger path should be empty")
|
|
}
|
|
if err := logger.RemoveLogFile(); err != nil {
|
|
t.Fatalf("expected nil logger RemoveLogFile to be no-op, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoggerLogDropOnDone(t *testing.T) {
|
|
t.Skip("internal logger behavior moved to internal/logger; exercise via public methods instead")
|
|
}
|
|
|
|
func TestLoggerLogAfterClose(t *testing.T) {
|
|
defer resetTestHooks()
|
|
setTempDirEnv(t, t.TempDir())
|
|
|
|
logger, err := NewLogger()
|
|
if err != nil {
|
|
t.Fatalf("NewLogger error: %v", err)
|
|
}
|
|
logPath := logger.Path()
|
|
t.Cleanup(func() { _ = os.Remove(logPath) })
|
|
|
|
logger.Info("before close")
|
|
logger.Flush()
|
|
|
|
if err := logger.Close(); err != nil {
|
|
t.Fatalf("Close error: %v", err)
|
|
}
|
|
|
|
logger.Info("should be ignored")
|
|
logger.Flush()
|
|
|
|
data, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read log file: %v", err)
|
|
}
|
|
if strings.Contains(string(data), "should be ignored") {
|
|
t.Fatalf("expected log message to be dropped after Close, got: %s", string(data))
|
|
}
|
|
}
|
|
|
|
func TestLogWriterLogLine(t *testing.T) {
|
|
defer resetTestHooks()
|
|
logger, err := NewLogger()
|
|
if err != nil {
|
|
t.Fatalf("NewLogger error: %v", err)
|
|
}
|
|
setLogger(logger)
|
|
lw := &logWriter{prefix: "P:", maxLen: 3}
|
|
lw.buf.WriteString("abcdef")
|
|
lw.logLine(false)
|
|
lw.logLine(false) // empty buffer path
|
|
logger.Flush()
|
|
data, _ := os.ReadFile(logger.Path())
|
|
if !strings.Contains(string(data), "P:abc") {
|
|
t.Fatalf("log output missing truncated entry, got %q", string(data))
|
|
}
|
|
_ = closeLogger()
|
|
}
|
|
|
|
func TestNewLogWriterDefaultMaxLen(t *testing.T) {
|
|
lw := newLogWriter("X:", 0)
|
|
if lw.maxLen != codexLogLineLimit {
|
|
t.Fatalf("expected default maxLen %d, got %d", codexLogLineLimit, lw.maxLen)
|
|
}
|
|
}
|
|
|
|
func TestBackendPrintHelp(t *testing.T) {
|
|
oldStdout := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
printHelp()
|
|
w.Close()
|
|
os.Stdout = oldStdout
|
|
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, r); err != nil {
|
|
t.Fatalf("io.Copy() error = %v", err)
|
|
}
|
|
output := buf.String()
|
|
|
|
expected := []string{"codeagent-wrapper", "Usage:", "resume", "CODEX_TIMEOUT", "Exit Codes:"}
|
|
for _, phrase := range expected {
|
|
if !strings.Contains(output, phrase) {
|
|
t.Errorf("printHelp() missing phrase %q", phrase)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunIsTerminal(t *testing.T) {
|
|
defer resetTestHooks()
|
|
tests := []struct {
|
|
name string
|
|
mockFn func() bool
|
|
want bool
|
|
}{{"is terminal", func() bool { return true }, true}, {"is not terminal", func() bool { return false }, false}}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
isTerminalFn = tt.mockFn
|
|
got := isTerminal()
|
|
if got != tt.want {
|
|
t.Errorf("isTerminal() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunReadPipedTask(t *testing.T) {
|
|
defer resetTestHooks()
|
|
tests := []struct {
|
|
name string
|
|
isTerminal bool
|
|
stdin io.Reader
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{"terminal mode", true, strings.NewReader("ignored"), "", false},
|
|
{"piped with data", false, strings.NewReader("task from pipe"), "task from pipe", false},
|
|
{"piped empty", false, strings.NewReader(""), "", false},
|
|
{"piped read error", false, errReader{errors.New("boom")}, "", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
isTerminalFn = func() bool { return tt.isTerminal }
|
|
stdinReader = tt.stdin
|
|
got, err := readPipedTask()
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("readPipedTask() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_CommandNotFound(t *testing.T) {
|
|
defer resetTestHooks()
|
|
codexCommand = "nonexistent-command-xyz"
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
|
|
res := runCodexTask(TaskSpec{Task: "task"}, false, 10)
|
|
if res.ExitCode != 127 {
|
|
t.Errorf("exitCode = %d, want 127", res.ExitCode)
|
|
}
|
|
if res.Error == "" {
|
|
t.Errorf("expected error message")
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_StartError(t *testing.T) {
|
|
defer resetTestHooks()
|
|
tmpFile, err := os.CreateTemp("", "start-error")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp file: %v", err)
|
|
}
|
|
defer os.Remove(tmpFile.Name())
|
|
|
|
codexCommand = tmpFile.Name()
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
|
|
|
res := runCodexTask(TaskSpec{Task: "task"}, false, 1)
|
|
if res.ExitCode != 1 || !strings.Contains(res.Error, "failed to start") {
|
|
t.Fatalf("unexpected result: %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_WithEcho(t *testing.T) {
|
|
defer resetTestHooks()
|
|
codexCommand = createFakeCodexScript(t, "test-session", "Test output")
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
|
|
|
res := runCodexTask(TaskSpec{Task: "ignored"}, false, 10)
|
|
if res.ExitCode != 0 || res.Message != "Test output" || res.SessionID != "test-session" {
|
|
t.Fatalf("unexpected result: %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTaskFn_UsesTaskBackend(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
fake := newFakeCmd(fakeCmdConfig{
|
|
StdoutPlan: []fakeStdoutEvent{
|
|
{Data: `{"type":"thread.started","thread_id":"backend-thread"}` + "\n"},
|
|
{Data: `{"type":"item.completed","item":{"type":"agent_message","text":"backend-msg"}}` + "\n"},
|
|
},
|
|
})
|
|
|
|
var seenName string
|
|
var seenArgs []string
|
|
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
|
seenName = name
|
|
seenArgs = append([]string(nil), args...)
|
|
return fake
|
|
})
|
|
_ = executor.SetSelectBackendFn(func(name string) (Backend, error) {
|
|
return testBackend{
|
|
name: strings.ToLower(name),
|
|
command: "custom-cli",
|
|
argsFn: func(cfg *Config, targetArg string) []string {
|
|
return []string{"do", targetArg}
|
|
},
|
|
}, nil
|
|
})
|
|
|
|
res := runCodexTaskFn(TaskSpec{ID: "task-1", Task: "payload", Backend: "Custom"}, 5)
|
|
|
|
if res.ExitCode != 0 || res.Message != "backend-msg" || res.SessionID != "backend-thread" {
|
|
t.Fatalf("unexpected result: %+v", res)
|
|
}
|
|
if seenName != "custom-cli" {
|
|
t.Fatalf("command name = %q, want custom-cli", seenName)
|
|
}
|
|
expectedArgs := []string{"do", "payload"}
|
|
if len(seenArgs) != len(expectedArgs) {
|
|
t.Fatalf("args len = %d, want %d", len(seenArgs), len(expectedArgs))
|
|
}
|
|
for i, want := range expectedArgs {
|
|
if seenArgs[i] != want {
|
|
t.Fatalf("args[%d]=%q, want %q", i, seenArgs[i], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTaskFn_InvalidBackend(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
_ = executor.SetSelectBackendFn(func(name string) (Backend, error) {
|
|
return nil, fmt.Errorf("invalid backend: %s", name)
|
|
})
|
|
|
|
res := runCodexTaskFn(TaskSpec{ID: "bad-task", Task: "noop", Backend: "unknown"}, 5)
|
|
if res.ExitCode == 0 {
|
|
t.Fatalf("expected failure for invalid backend")
|
|
}
|
|
if res.TaskID != "bad-task" {
|
|
t.Fatalf("TaskID = %q, want bad-task", res.TaskID)
|
|
}
|
|
if !strings.Contains(res.Error, "invalid backend") {
|
|
t.Fatalf("error %q missing backend message", res.Error)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_LogPathWithActiveLogger(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
logger, err := NewLoggerWithSuffix("active-logpath")
|
|
if err != nil {
|
|
t.Fatalf("failed to create logger: %v", err)
|
|
}
|
|
setLogger(logger)
|
|
|
|
codexCommand = createFakeCodexScript(t, "fake-thread", "ok")
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
|
|
|
result := runCodexTask(TaskSpec{Task: "ignored"}, false, 5)
|
|
if result.LogPath != logger.Path() {
|
|
t.Fatalf("LogPath = %q, want %q", result.LogPath, logger.Path())
|
|
}
|
|
if result.ExitCode != 0 {
|
|
t.Fatalf("exit = %d, want 0 (%s)", result.ExitCode, result.Error)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_LogPathWithTempLogger(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
codexCommand = createFakeCodexScript(t, "temp-thread", "temp")
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
|
|
|
result := runCodexTask(TaskSpec{Task: "ignored"}, true, 5)
|
|
t.Cleanup(func() {
|
|
if result.LogPath != "" {
|
|
os.Remove(result.LogPath)
|
|
}
|
|
})
|
|
if result.LogPath == "" {
|
|
t.Fatalf("LogPath should not be empty for temp logger")
|
|
}
|
|
if _, err := os.Stat(result.LogPath); err != nil {
|
|
t.Fatalf("log file %q should exist (err=%v)", result.LogPath, err)
|
|
}
|
|
if activeLogger() != nil {
|
|
t.Fatalf("active logger should be cleared after silent run")
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_LogPathOnStartError(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
logger, err := NewLoggerWithSuffix("start-error")
|
|
if err != nil {
|
|
t.Fatalf("failed to create logger: %v", err)
|
|
}
|
|
setLogger(logger)
|
|
|
|
tmpFile, err := os.CreateTemp("", "start-error")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp file: %v", err)
|
|
}
|
|
defer os.Remove(tmpFile.Name())
|
|
|
|
codexCommand = tmpFile.Name()
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
|
|
|
result := runCodexTask(TaskSpec{Task: "ignored"}, false, 5)
|
|
if result.ExitCode == 0 {
|
|
t.Fatalf("expected non-zero exit")
|
|
}
|
|
if result.LogPath != logger.Path() {
|
|
t.Fatalf("LogPath = %q, want %q", result.LogPath, logger.Path())
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_NoMessage(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
fake := newFakeCmd(fakeCmdConfig{
|
|
StdoutPlan: []fakeStdoutEvent{
|
|
{Data: `{"type":"thread.started","thread_id":"test-session"}` + "\n"},
|
|
},
|
|
WaitDelay: 5 * time.Millisecond,
|
|
})
|
|
restore := executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return fake })
|
|
t.Cleanup(restore)
|
|
|
|
codexCommand = "fake-cmd"
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
|
res := runCodexTask(TaskSpec{Task: "ignored"}, false, 10)
|
|
if res.ExitCode != 1 || res.Error == "" {
|
|
t.Fatalf("expected error for missing agent_message, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_WithStdin(t *testing.T) {
|
|
defer resetTestHooks()
|
|
codexCommand = "cat"
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
|
jsonInput := `{"type":"item.completed","item":{"type":"agent_message","text":"from stdin"}}`
|
|
res := runCodexTask(TaskSpec{Task: jsonInput, UseStdin: true}, false, 10)
|
|
if res.ExitCode != 0 || res.Message != "from stdin" {
|
|
t.Fatalf("unexpected result: %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexProcess_WithStdin(t *testing.T) {
|
|
defer resetTestHooks()
|
|
codexCommand = "cat"
|
|
jsonOutput := `{"type":"thread.started","thread_id":"proc"}`
|
|
jsonOutput += "\n"
|
|
jsonOutput += `{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`
|
|
|
|
msg, tid, exit := runCodexProcess(context.Background(), []string{}, jsonOutput, true, 5)
|
|
if exit != 0 {
|
|
t.Fatalf("exit code %d, want 0", exit)
|
|
}
|
|
if msg != "ok" || tid != "proc" {
|
|
t.Fatalf("unexpected output msg=%q tid=%q", msg, tid)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_ExitError(t *testing.T) {
|
|
defer resetTestHooks()
|
|
codexCommand = "false"
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
|
res := runCodexTask(TaskSpec{Task: "noop"}, false, 10)
|
|
if res.ExitCode == 0 || res.Error == "" {
|
|
t.Fatalf("expected failure, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_StdinPipeError(t *testing.T) {
|
|
defer resetTestHooks()
|
|
_ = executor.SetCommandContextFn(func(ctx context.Context, name string, args ...string) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, "cat")
|
|
cmd.Stdin = os.Stdin
|
|
return cmd
|
|
})
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
|
res := runCodexTask(TaskSpec{Task: "data", UseStdin: true}, false, 1)
|
|
if res.ExitCode != 1 || !strings.Contains(res.Error, "stdin pipe") {
|
|
t.Fatalf("expected stdin pipe error, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_StdoutPipeError(t *testing.T) {
|
|
defer resetTestHooks()
|
|
_ = executor.SetCommandContextFn(func(ctx context.Context, name string, args ...string) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, "echo", "noop")
|
|
cmd.Stdout = os.Stdout
|
|
return cmd
|
|
})
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
|
res := runCodexTask(TaskSpec{Task: "noop"}, false, 1)
|
|
if res.ExitCode != 1 || !strings.Contains(res.Error, "stdout pipe") {
|
|
t.Fatalf("expected stdout pipe error, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_Timeout(t *testing.T) {
|
|
defer resetTestHooks()
|
|
codexCommand = "sleep"
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{"2"} }
|
|
res := runCodexTask(TaskSpec{Task: "ignored"}, false, 1)
|
|
if res.ExitCode != 124 || !strings.Contains(res.Error, "timeout") {
|
|
t.Fatalf("expected timeout, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexTask_SignalHandling(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("signal-based test is not supported on Windows")
|
|
}
|
|
|
|
defer resetTestHooks()
|
|
codexCommand = "sleep"
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{"5"} }
|
|
|
|
resultCh := make(chan TaskResult, 1)
|
|
go func() { resultCh <- runCodexTask(TaskSpec{Task: "ignored"}, false, 5) }()
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
if proc, err := os.FindProcess(os.Getpid()); err == nil && proc != nil {
|
|
_ = proc.Signal(syscall.SIGTERM)
|
|
}
|
|
|
|
res := <-resultCh
|
|
signal.Reset(syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
if res.ExitCode == 0 || res.Error == "" {
|
|
t.Fatalf("expected non-zero exit after signal, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexProcess(t *testing.T) {
|
|
defer resetTestHooks()
|
|
script := createFakeCodexScript(t, "proc-thread", "proc-msg")
|
|
codexCommand = script
|
|
|
|
msg, threadID, exitCode := runCodexProcess(context.Background(), nil, "ignored", false, 5)
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit = %d, want 0", exitCode)
|
|
}
|
|
if msg != "proc-msg" {
|
|
t.Fatalf("message = %q, want proc-msg", msg)
|
|
}
|
|
if threadID != "proc-thread" {
|
|
t.Fatalf("threadID = %q, want proc-thread", threadID)
|
|
}
|
|
}
|
|
|
|
func TestRunSilentMode(t *testing.T) {
|
|
defer resetTestHooks()
|
|
tmpDir := t.TempDir()
|
|
setTempDirEnv(t, tmpDir)
|
|
jsonOutput := `{"type":"thread.started","thread_id":"silent-session"}
|
|
{"type":"item.completed","item":{"type":"agent_message","text":"quiet"}}`
|
|
codexCommand = "fake-cmd"
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
|
|
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
|
return newFakeCmd(fakeCmdConfig{
|
|
StdoutPlan: []fakeStdoutEvent{{Data: jsonOutput + "\n"}},
|
|
})
|
|
})
|
|
|
|
capture := func(silent bool) string {
|
|
oldStderr := os.Stderr
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("os.Pipe() error = %v", err)
|
|
}
|
|
os.Stderr = w
|
|
defer func() {
|
|
os.Stderr = oldStderr
|
|
_ = w.Close()
|
|
_ = r.Close()
|
|
}()
|
|
|
|
res := runCodexTask(TaskSpec{Task: "ignored"}, silent, 10)
|
|
if res.ExitCode != 0 {
|
|
t.Fatalf("unexpected exitCode %d: %s", res.ExitCode, res.Error)
|
|
}
|
|
_ = w.Close()
|
|
os.Stderr = oldStderr
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, r); err != nil {
|
|
t.Fatalf("io.Copy() error = %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
verbose := capture(false)
|
|
quiet := capture(true)
|
|
|
|
// After refactoring, logs are only written to file, not stderr
|
|
// Both silent and non-silent modes should produce no stderr output
|
|
if quiet != "" {
|
|
t.Fatalf("silent mode should suppress stderr, got: %q", quiet)
|
|
}
|
|
if verbose != "" {
|
|
t.Fatalf("non-silent mode should also suppress stderr (logs go to file), got: %q", verbose)
|
|
}
|
|
}
|
|
|
|
func TestRunGenerateFinalOutput(t *testing.T) {
|
|
results := []TaskResult{{TaskID: "a", ExitCode: 0, Message: "ok"}, {TaskID: "b", ExitCode: 1, Error: "boom"}, {TaskID: "c", ExitCode: 0}}
|
|
out := generateFinalOutput(results)
|
|
if out == "" {
|
|
t.Fatalf("generateFinalOutput() returned empty string")
|
|
}
|
|
// New format: "X tasks | Y passed | Z failed"
|
|
if !strings.Contains(out, "3 tasks") || !strings.Contains(out, "2 passed") || !strings.Contains(out, "1 failed") {
|
|
t.Fatalf("summary missing, got %q", out)
|
|
}
|
|
// New format uses ### task-id for each task
|
|
if !strings.Contains(out, "### a") || !strings.Contains(out, "### b") {
|
|
t.Fatalf("task entries missing in structured format")
|
|
}
|
|
// Should have Summary section
|
|
if !strings.Contains(out, "## Summary") {
|
|
t.Fatalf("Summary section missing, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestRunGenerateFinalOutput_LogPath(t *testing.T) {
|
|
results := []TaskResult{
|
|
{
|
|
TaskID: "a",
|
|
ExitCode: 0,
|
|
Message: "ok",
|
|
SessionID: "sid",
|
|
LogPath: "/tmp/log-a",
|
|
},
|
|
{
|
|
TaskID: "b",
|
|
ExitCode: 7,
|
|
Error: "bad",
|
|
LogPath: "/tmp/log-b",
|
|
},
|
|
}
|
|
// Test summary mode (default) - should contain log paths
|
|
out := generateFinalOutput(results)
|
|
if !strings.Contains(out, "/tmp/log-b") {
|
|
t.Fatalf("summary output missing log path for failed task: %q", out)
|
|
}
|
|
// Test full output mode - shows Session: and Log: lines
|
|
out = generateFinalOutputWithMode(results, false)
|
|
if !strings.Contains(out, "Session: sid") || !strings.Contains(out, "Log: /tmp/log-a") {
|
|
t.Fatalf("full output missing log line after session: %q", out)
|
|
}
|
|
if !strings.Contains(out, "Log: /tmp/log-b") {
|
|
t.Fatalf("full output missing log line for failed task: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestRunTopologicalSort_LinearChain(t *testing.T) {
|
|
tasks := []TaskSpec{{ID: "a"}, {ID: "b", Dependencies: []string{"a"}}, {ID: "c", Dependencies: []string{"b"}}}
|
|
layers, err := topologicalSort(tasks)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(layers) != 3 {
|
|
t.Fatalf("expected 3 layers, got %d", len(layers))
|
|
}
|
|
}
|
|
|
|
func TestRunTopologicalSort_Branching(t *testing.T) {
|
|
tasks := []TaskSpec{{ID: "root"}, {ID: "left", Dependencies: []string{"root"}}, {ID: "right", Dependencies: []string{"root"}}, {ID: "leaf", Dependencies: []string{"left", "right"}}}
|
|
layers, err := topologicalSort(tasks)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(layers) != 3 || len(layers[1]) != 2 {
|
|
t.Fatalf("unexpected layers: %+v", layers)
|
|
}
|
|
}
|
|
|
|
func TestParallelTopologicalSortTasks(t *testing.T) {
|
|
tasks := []TaskSpec{{ID: "a"}, {ID: "b"}, {ID: "c"}}
|
|
layers, err := topologicalSort(tasks)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(layers) != 1 || len(layers[0]) != 3 {
|
|
t.Fatalf("unexpected result: %+v", layers)
|
|
}
|
|
}
|
|
|
|
func TestRunTopologicalSort_CycleDetection(t *testing.T) {
|
|
tasks := []TaskSpec{{ID: "a", Dependencies: []string{"b"}}, {ID: "b", Dependencies: []string{"a"}}}
|
|
if _, err := topologicalSort(tasks); err == nil || !strings.Contains(err.Error(), "cycle detected") {
|
|
t.Fatalf("expected cycle error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunTopologicalSort_IndirectCycle(t *testing.T) {
|
|
tasks := []TaskSpec{{ID: "a", Dependencies: []string{"c"}}, {ID: "b", Dependencies: []string{"a"}}, {ID: "c", Dependencies: []string{"b"}}}
|
|
if _, err := topologicalSort(tasks); err == nil || !strings.Contains(err.Error(), "cycle detected") {
|
|
t.Fatalf("expected cycle error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunTopologicalSort_MissingDependency(t *testing.T) {
|
|
tasks := []TaskSpec{{ID: "a", Dependencies: []string{"missing"}}}
|
|
if _, err := topologicalSort(tasks); err == nil || !strings.Contains(err.Error(), "dependency \"missing\" not found") {
|
|
t.Fatalf("expected missing dependency error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunTopologicalSort_LargeGraph(t *testing.T) {
|
|
const count = 200
|
|
tasks := make([]TaskSpec, count)
|
|
for i := 0; i < count; i++ {
|
|
id := fmt.Sprintf("task-%d", i)
|
|
if i == 0 {
|
|
tasks[i] = TaskSpec{ID: id}
|
|
continue
|
|
}
|
|
prev := fmt.Sprintf("task-%d", i-1)
|
|
tasks[i] = TaskSpec{ID: id, Dependencies: []string{prev}}
|
|
}
|
|
|
|
layers, err := topologicalSort(tasks)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(layers) != count {
|
|
t.Fatalf("expected %d layers, got %d", count, len(layers))
|
|
}
|
|
}
|
|
|
|
func TestParallelExecuteConcurrent(t *testing.T) {
|
|
orig := runCodexTaskFn
|
|
defer func() { runCodexTaskFn = orig }()
|
|
|
|
var maxParallel int64
|
|
var current int64
|
|
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
|
cur := atomic.AddInt64(¤t, 1)
|
|
for {
|
|
prev := atomic.LoadInt64(&maxParallel)
|
|
if cur <= prev || atomic.CompareAndSwapInt64(&maxParallel, prev, cur) {
|
|
break
|
|
}
|
|
}
|
|
time.Sleep(150 * time.Millisecond)
|
|
atomic.AddInt64(¤t, -1)
|
|
return TaskResult{TaskID: task.ID}
|
|
}
|
|
|
|
start := time.Now()
|
|
layers := [][]TaskSpec{{{ID: "a"}, {ID: "b"}, {ID: "c"}}}
|
|
results := executeConcurrent(layers, 10)
|
|
elapsed := time.Since(start)
|
|
|
|
if len(results) != 3 {
|
|
t.Fatalf("expected 3 results, got %d", len(results))
|
|
}
|
|
if elapsed >= 400*time.Millisecond {
|
|
t.Fatalf("expected concurrent execution, took %v", elapsed)
|
|
}
|
|
if maxParallel < 2 {
|
|
t.Fatalf("expected parallelism >=2, got %d", maxParallel)
|
|
}
|
|
}
|
|
|
|
func TestRunExecuteConcurrent_LayerOrdering(t *testing.T) {
|
|
orig := runCodexTaskFn
|
|
defer func() { runCodexTaskFn = orig }()
|
|
|
|
var mu sync.Mutex
|
|
var order []string
|
|
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
|
mu.Lock()
|
|
order = append(order, task.ID)
|
|
mu.Unlock()
|
|
return TaskResult{TaskID: task.ID}
|
|
}
|
|
|
|
layers := [][]TaskSpec{{{ID: "first-1"}, {ID: "first-2"}}, {{ID: "second"}}}
|
|
executeConcurrent(layers, 10)
|
|
|
|
if len(order) != 3 || order[2] != "second" {
|
|
t.Fatalf("unexpected order: %+v", order)
|
|
}
|
|
}
|
|
|
|
func TestRunExecuteConcurrent_ErrorIsolation(t *testing.T) {
|
|
orig := runCodexTaskFn
|
|
defer func() { runCodexTaskFn = orig }()
|
|
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
|
if task.ID == "fail" {
|
|
return TaskResult{TaskID: task.ID, ExitCode: 2, Error: "boom"}
|
|
}
|
|
return TaskResult{TaskID: task.ID, ExitCode: 0}
|
|
}
|
|
|
|
layers := [][]TaskSpec{{{ID: "ok"}, {ID: "fail"}}, {{ID: "after"}}}
|
|
results := executeConcurrent(layers, 10)
|
|
|
|
if len(results) != 3 {
|
|
t.Fatalf("expected 3 results, got %d", len(results))
|
|
}
|
|
|
|
var failed, succeeded bool
|
|
for _, res := range results {
|
|
if res.TaskID == "fail" && res.ExitCode == 2 {
|
|
failed = true
|
|
}
|
|
if res.TaskID == "after" && res.ExitCode == 0 {
|
|
succeeded = true
|
|
}
|
|
}
|
|
|
|
if !failed || !succeeded {
|
|
t.Fatalf("expected failure isolation, got %+v", results)
|
|
}
|
|
}
|
|
|
|
func TestRunExecuteConcurrent_PanicRecovered(t *testing.T) {
|
|
orig := runCodexTaskFn
|
|
defer func() { runCodexTaskFn = orig }()
|
|
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
|
panic("boom")
|
|
}
|
|
|
|
results := executeConcurrent([][]TaskSpec{{{ID: "panic"}}}, 10)
|
|
if len(results) != 1 || results[0].Error == "" || results[0].ExitCode == 0 {
|
|
t.Fatalf("panic should be captured, got %+v", results[0])
|
|
}
|
|
}
|
|
|
|
func TestRunExecuteConcurrent_LargeFanout(t *testing.T) {
|
|
orig := runCodexTaskFn
|
|
defer func() { runCodexTaskFn = orig }()
|
|
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult { return TaskResult{TaskID: task.ID} }
|
|
layer := make([]TaskSpec, 0, 1200)
|
|
for i := 0; i < 1200; i++ {
|
|
layer = append(layer, TaskSpec{ID: fmt.Sprintf("id-%d", i)})
|
|
}
|
|
results := executeConcurrent([][]TaskSpec{layer}, 10)
|
|
if len(results) != 1200 {
|
|
t.Fatalf("expected 1200 results, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestParallelBackendPropagation(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
orig := runCodexTaskFn
|
|
var mu sync.Mutex
|
|
seen := make(map[string]string)
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
|
mu.Lock()
|
|
seen[task.ID] = task.Backend
|
|
mu.Unlock()
|
|
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "ok"}
|
|
}
|
|
t.Cleanup(func() { runCodexTaskFn = orig })
|
|
|
|
stdinReader = strings.NewReader(`---TASK---
|
|
id: first
|
|
---CONTENT---
|
|
do one
|
|
|
|
---TASK---
|
|
id: second
|
|
backend: gemini
|
|
---CONTENT---
|
|
do two`)
|
|
os.Args = []string{"codeagent-wrapper", "--backend", "claude", "--parallel"}
|
|
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run exit = %d, want 0", code)
|
|
}
|
|
|
|
mu.Lock()
|
|
firstBackend, firstOK := seen["first"]
|
|
secondBackend, secondOK := seen["second"]
|
|
mu.Unlock()
|
|
|
|
if !firstOK || firstBackend != "claude" {
|
|
t.Fatalf("first backend = %q (present=%v), want claude", firstBackend, firstOK)
|
|
}
|
|
if !secondOK || secondBackend != "gemini" {
|
|
t.Fatalf("second backend = %q (present=%v), want gemini", secondBackend, secondOK)
|
|
}
|
|
}
|
|
|
|
func TestParallelModelPropagation(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
orig := runCodexTaskFn
|
|
var mu sync.Mutex
|
|
seen := make(map[string]string)
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
|
mu.Lock()
|
|
seen[task.ID] = task.Model
|
|
mu.Unlock()
|
|
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "ok"}
|
|
}
|
|
t.Cleanup(func() { runCodexTaskFn = orig })
|
|
|
|
stdinReader = strings.NewReader(`---TASK---
|
|
id: first
|
|
---CONTENT---
|
|
do one
|
|
|
|
---TASK---
|
|
id: second
|
|
model: opus
|
|
---CONTENT---
|
|
do two`)
|
|
os.Args = []string{"codeagent-wrapper", "--parallel", "--model", "sonnet"}
|
|
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run exit = %d, want 0", code)
|
|
}
|
|
|
|
mu.Lock()
|
|
firstModel, firstOK := seen["first"]
|
|
secondModel, secondOK := seen["second"]
|
|
mu.Unlock()
|
|
|
|
if !firstOK || firstModel != "sonnet" {
|
|
t.Fatalf("first model = %q (present=%v), want sonnet", firstModel, firstOK)
|
|
}
|
|
if !secondOK || secondModel != "opus" {
|
|
t.Fatalf("second model = %q (present=%v), want opus", secondModel, secondOK)
|
|
}
|
|
}
|
|
|
|
func TestParallelFlag(t *testing.T) {
|
|
defer resetTestHooks()
|
|
oldArgs := os.Args
|
|
defer func() { os.Args = oldArgs }()
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--parallel"}
|
|
jsonInput := `---TASK---
|
|
id: T1
|
|
---CONTENT---
|
|
test`
|
|
stdinReader = strings.NewReader(jsonInput)
|
|
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
|
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "test output"}
|
|
}
|
|
|
|
exitCode := run()
|
|
if exitCode != 0 {
|
|
t.Errorf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRunParallelWithFullOutput(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
oldArgs := os.Args
|
|
t.Cleanup(func() { os.Args = oldArgs })
|
|
os.Args = []string{"codeagent-wrapper", "--parallel", "--full-output"}
|
|
|
|
stdinReader = strings.NewReader(`---TASK---
|
|
id: T1
|
|
---CONTENT---
|
|
noop`)
|
|
t.Cleanup(func() { stdinReader = os.Stdin })
|
|
|
|
orig := runCodexTaskFn
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
|
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "full output marker"}
|
|
}
|
|
t.Cleanup(func() { runCodexTaskFn = orig })
|
|
|
|
out := captureOutput(t, func() {
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run exit = %d, want 0", code)
|
|
}
|
|
})
|
|
|
|
if !strings.Contains(out, "=== Parallel Execution Summary ===") {
|
|
t.Fatalf("output missing full-output header, got %q", out)
|
|
}
|
|
if !strings.Contains(out, "--- Task: T1 ---") {
|
|
t.Fatalf("output missing task block, got %q", out)
|
|
}
|
|
if !strings.Contains(out, "full output marker") {
|
|
t.Fatalf("output missing task message, got %q", out)
|
|
}
|
|
if strings.Contains(out, "=== Execution Report ===") {
|
|
t.Fatalf("output should not include summary-only header, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestParallelInvalidBackend(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
stdinReader = strings.NewReader(`---TASK---
|
|
id: only
|
|
---CONTENT---
|
|
noop`)
|
|
os.Args = []string{"codeagent-wrapper", "--parallel", "--backend", "unknown"}
|
|
|
|
if code := run(); code == 0 {
|
|
t.Fatalf("expected non-zero exit for invalid backend in parallel mode")
|
|
}
|
|
}
|
|
|
|
func TestParallelTriggersCleanup(t *testing.T) {
|
|
defer resetTestHooks()
|
|
oldArgs := os.Args
|
|
defer func() { os.Args = oldArgs }()
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--parallel"}
|
|
stdinReader = strings.NewReader(`---TASK---
|
|
id: only
|
|
---CONTENT---
|
|
noop`)
|
|
|
|
cleanupCalls := 0
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
cleanupCalls++
|
|
return CleanupStats{}, nil
|
|
}
|
|
|
|
orig := runCodexTaskFn
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
|
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "ok"}
|
|
}
|
|
defer func() { runCodexTaskFn = orig }()
|
|
|
|
if exitCode := run(); exitCode != 0 {
|
|
t.Fatalf("exit = %d, want 0", exitCode)
|
|
}
|
|
if cleanupCalls != 1 {
|
|
t.Fatalf("cleanup called %d times, want 1", cleanupCalls)
|
|
}
|
|
}
|
|
|
|
func TestVersionFlag(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper", "--version"}
|
|
output := captureOutput(t, func() {
|
|
if code := run(); code != 0 {
|
|
t.Errorf("exit = %d, want 0", code)
|
|
}
|
|
})
|
|
|
|
if !strings.HasPrefix(output, "codeagent-wrapper version ") {
|
|
t.Fatalf("output = %q, want prefix %q", output, "codeagent-wrapper version ")
|
|
}
|
|
}
|
|
|
|
func TestVersionShortFlag(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper", "-v"}
|
|
output := captureOutput(t, func() {
|
|
if code := run(); code != 0 {
|
|
t.Errorf("exit = %d, want 0", code)
|
|
}
|
|
})
|
|
|
|
if !strings.HasPrefix(output, "codeagent-wrapper version ") {
|
|
t.Fatalf("output = %q, want prefix %q", output, "codeagent-wrapper version ")
|
|
}
|
|
}
|
|
|
|
func TestVersionLegacyAlias(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper", "--version"}
|
|
output := captureOutput(t, func() {
|
|
if code := run(); code != 0 {
|
|
t.Errorf("exit = %d, want 0", code)
|
|
}
|
|
})
|
|
|
|
if !strings.HasPrefix(output, "codeagent-wrapper version ") {
|
|
t.Fatalf("output = %q, want prefix %q", output, "codeagent-wrapper version ")
|
|
}
|
|
}
|
|
|
|
func TestRun_Help(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper", "--help"}
|
|
if code := run(); code != 0 {
|
|
t.Errorf("exit = %d, want 0", code)
|
|
}
|
|
}
|
|
|
|
func TestRun_HelpShort(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper", "-h"}
|
|
if code := run(); code != 0 {
|
|
t.Errorf("exit = %d, want 0", code)
|
|
}
|
|
}
|
|
|
|
func TestRun_HelpDoesNotTriggerCleanup(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper", "--help"}
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
t.Fatalf("cleanup should not run for --help")
|
|
return CleanupStats{}, nil
|
|
}
|
|
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("exit = %d, want 0", code)
|
|
}
|
|
}
|
|
|
|
func TestVersionDoesNotTriggerCleanup(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper", "--version"}
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
t.Fatalf("cleanup should not run for --version")
|
|
return CleanupStats{}, nil
|
|
}
|
|
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("exit = %d, want 0", code)
|
|
}
|
|
}
|
|
|
|
func TestVersionCoverageFullRun(t *testing.T) {
|
|
t.Run("cleanupHelpers", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = nil
|
|
runStartupCleanup()
|
|
if code := runCleanupMode(); code == 0 {
|
|
t.Fatalf("runCleanupMode exit = %d, want non-zero when cleanup is nil", code)
|
|
}
|
|
|
|
logger, err := NewLoggerWithSuffix("version-coverage")
|
|
if err != nil {
|
|
t.Fatalf("failed to create logger: %v", err)
|
|
}
|
|
setLogger(logger)
|
|
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
return CleanupStats{
|
|
Scanned: 2,
|
|
Deleted: 1,
|
|
Kept: 1,
|
|
DeletedFiles: []string{"old.log"},
|
|
KeptFiles: []string{"keep.log"},
|
|
Errors: 1,
|
|
}, fmt.Errorf("warn")
|
|
}
|
|
runStartupCleanup()
|
|
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
panic("panic cleanup")
|
|
}
|
|
runStartupCleanup()
|
|
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
return CleanupStats{
|
|
Scanned: 2,
|
|
Deleted: 1,
|
|
Kept: 1,
|
|
DeletedFiles: []string{"old.log"},
|
|
KeptFiles: []string{"keep.log"},
|
|
Errors: 1,
|
|
}, nil
|
|
}
|
|
if code := runCleanupMode(); code != 0 {
|
|
t.Fatalf("runCleanupMode exit = %d, want 0", code)
|
|
}
|
|
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
return CleanupStats{}, fmt.Errorf("expected failure")
|
|
}
|
|
if code := runCleanupMode(); code == 0 {
|
|
t.Fatalf("runCleanupMode exit = %d, want non-zero on error", code)
|
|
}
|
|
|
|
printHelp()
|
|
|
|
_ = closeLogger()
|
|
_ = logger.RemoveLogFile()
|
|
setLogger(nil)
|
|
})
|
|
|
|
t.Run("parseArgsError", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
cleanupCalled := false
|
|
cleanupHook = func() { cleanupCalled = true }
|
|
|
|
selectBackendFn = func(name string) (Backend, error) {
|
|
return testBackend{name: name, command: "echo"}, nil
|
|
}
|
|
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
|
return TaskResult{ExitCode: 0}
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper"}
|
|
if code := run(); code == 0 {
|
|
t.Fatalf("run exit = %d, want non-zero for missing task", code)
|
|
}
|
|
if !cleanupCalled {
|
|
t.Fatalf("cleanup hook not invoked on error path")
|
|
}
|
|
})
|
|
|
|
t.Run("helpAndCleanup", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--help"}
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run exit = %d, want 0 for help", code)
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--cleanup"}
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run exit = %d, want 0 for cleanup", code)
|
|
}
|
|
})
|
|
|
|
t.Run("happyPath", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupHook = func() {}
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
selectBackendFn = func(name string) (Backend, error) {
|
|
return testBackend{
|
|
name: name,
|
|
command: "echo",
|
|
argsFn: func(cfg *Config, targetArg string) []string {
|
|
return []string{"--task", targetArg, "--workdir", cfg.WorkDir}
|
|
},
|
|
}, nil
|
|
}
|
|
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
|
return TaskResult{TaskID: "task-id", ExitCode: 0, Message: "ok", SessionID: "sess-123"}
|
|
}
|
|
|
|
stdinReader = strings.NewReader("task line with $ and \\\nnext line with `tick` and \"quote\" and 'single'")
|
|
isTerminalFn = func() bool { return false }
|
|
os.Args = []string{"codeagent-wrapper", "-", "/tmp/workdir"}
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run exit = %d, want 0", code)
|
|
}
|
|
})
|
|
|
|
t.Run("nonExplicitTaskFailure", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupCalled := false
|
|
cleanupHook = func() { cleanupCalled = true }
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
selectBackendFn = func(name string) (Backend, error) {
|
|
return testBackend{
|
|
name: name,
|
|
command: "echo",
|
|
argsFn: func(cfg *Config, targetArg string) []string {
|
|
return []string{"--task", targetArg}
|
|
},
|
|
}, nil
|
|
}
|
|
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
|
return TaskResult{TaskID: "fail", ExitCode: 2, Message: "error"}
|
|
}
|
|
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return true }
|
|
os.Args = []string{"codeagent-wrapper", "raw-task"}
|
|
if code := run(); code != 2 {
|
|
t.Fatalf("run exit = %d, want 2", code)
|
|
}
|
|
if !cleanupCalled {
|
|
t.Fatalf("cleanup hook not invoked on failure path")
|
|
}
|
|
})
|
|
|
|
t.Run("pipedTaskLongInput", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
selectBackendFn = func(name string) (Backend, error) {
|
|
return testBackend{
|
|
name: name,
|
|
command: "echo",
|
|
argsFn: func(cfg *Config, targetArg string) []string {
|
|
return []string{"--task", targetArg}
|
|
},
|
|
}, nil
|
|
}
|
|
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
|
return TaskResult{TaskID: "piped", ExitCode: 0, Message: "ok"}
|
|
}
|
|
|
|
stdinReader = strings.NewReader(strings.Repeat("x", 900))
|
|
isTerminalFn = func() bool { return false }
|
|
os.Args = []string{"codeagent-wrapper", "ignored"}
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run exit = %d, want 0 for piped input", code)
|
|
}
|
|
})
|
|
|
|
t.Run("explicitStdinReadError", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
|
return TaskResult{ExitCode: 0}
|
|
}
|
|
|
|
stdinReader = errReader{err: errors.New("read-fail")}
|
|
os.Args = []string{"codeagent-wrapper", "-", "/tmp/workdir"}
|
|
if code := run(); code == 0 {
|
|
t.Fatalf("run exit = %d, want non-zero on stdin read error", code)
|
|
}
|
|
})
|
|
|
|
t.Run("parallelFlow", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupHook = func() {}
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
|
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "ok"}
|
|
}
|
|
|
|
stdinReader = strings.NewReader(`---TASK---
|
|
id: first
|
|
---CONTENT---
|
|
do one
|
|
|
|
---TASK---
|
|
id: second
|
|
dependencies: first
|
|
---CONTENT---
|
|
do two`)
|
|
os.Args = []string{"codeagent-wrapper", "--parallel"}
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run exit = %d, want 0", code)
|
|
}
|
|
})
|
|
|
|
t.Run("parallelSkipPermissions", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupHook = func() {}
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
|
|
|
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
|
if !task.SkipPermissions {
|
|
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "SkipPermissions not propagated"}
|
|
}
|
|
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "ok"}
|
|
}
|
|
|
|
stdinReader = strings.NewReader(`---TASK---
|
|
id: only
|
|
backend: claude
|
|
---CONTENT---
|
|
do one`)
|
|
os.Args = []string{"codeagent-wrapper", "--parallel", "--skip-permissions"}
|
|
if code := run(); code != 0 {
|
|
t.Fatalf("run exit = %d, want 0", code)
|
|
}
|
|
})
|
|
|
|
t.Run("parallelErrors", func(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--parallel", "extra"}
|
|
if code := run(); code == 0 {
|
|
t.Fatalf("run exit = %d, want error for extra args", code)
|
|
}
|
|
|
|
stdinReader = strings.NewReader("invalid format")
|
|
os.Args = []string{"codeagent-wrapper", "--parallel"}
|
|
if code := run(); code == 0 {
|
|
t.Fatalf("run exit = %d, want error for invalid config", code)
|
|
}
|
|
|
|
stdinReader = strings.NewReader(`---TASK---
|
|
id: second
|
|
dependencies: missing
|
|
---CONTENT---
|
|
task`)
|
|
if code := run(); code == 0 {
|
|
t.Fatalf("run exit = %d, want error for invalid DAG", code)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestVersionMainWrapper(t *testing.T) {
|
|
defer resetTestHooks()
|
|
exitCalled := -1
|
|
exitFn = func(code int) { exitCalled = code }
|
|
os.Args = []string{"codeagent-wrapper", "--version"}
|
|
Main()
|
|
if exitCalled != 0 {
|
|
t.Fatalf("main exit = %d, want 0", exitCalled)
|
|
}
|
|
}
|
|
|
|
func TestBackendCleanupMode_Success(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
return CleanupStats{
|
|
Scanned: 5,
|
|
Deleted: 3,
|
|
Kept: 2,
|
|
DeletedFiles: []string{"codeagent-wrapper-111.log", "codeagent-wrapper-222.log", "codeagent-wrapper-333.log"},
|
|
KeptFiles: []string{"codeagent-wrapper-444.log", "codeagent-wrapper-555.log"},
|
|
}, nil
|
|
}
|
|
|
|
var exitCode int
|
|
output := captureOutput(t, func() {
|
|
exitCode = runCleanupMode()
|
|
})
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit = %d, want 0", exitCode)
|
|
}
|
|
want := "Cleanup completed\nFiles scanned: 5\nFiles deleted: 3\n - codeagent-wrapper-111.log\n - codeagent-wrapper-222.log\n - codeagent-wrapper-333.log\nFiles kept: 2\n - codeagent-wrapper-444.log\n - codeagent-wrapper-555.log\n"
|
|
if output != want {
|
|
t.Fatalf("output = %q, want %q", output, want)
|
|
}
|
|
}
|
|
|
|
func TestBackendCleanupMode_SuccessWithErrorsLine(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
return CleanupStats{
|
|
Scanned: 2,
|
|
Deleted: 1,
|
|
Kept: 0,
|
|
Errors: 1,
|
|
DeletedFiles: []string{"codeagent-wrapper-123.log"},
|
|
}, nil
|
|
}
|
|
|
|
var exitCode int
|
|
output := captureOutput(t, func() {
|
|
exitCode = runCleanupMode()
|
|
})
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit = %d, want 0", exitCode)
|
|
}
|
|
want := "Cleanup completed\nFiles scanned: 2\nFiles deleted: 1\n - codeagent-wrapper-123.log\nFiles kept: 0\nDeletion errors: 1\n"
|
|
if output != want {
|
|
t.Fatalf("output = %q, want %q", output, want)
|
|
}
|
|
}
|
|
|
|
func TestBackendCleanupMode_ZeroStatsOutput(t *testing.T) {
|
|
defer resetTestHooks()
|
|
calls := 0
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
calls++
|
|
return CleanupStats{}, nil
|
|
}
|
|
|
|
var exitCode int
|
|
output := captureOutput(t, func() {
|
|
exitCode = runCleanupMode()
|
|
})
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit = %d, want 0", exitCode)
|
|
}
|
|
want := "Cleanup completed\nFiles scanned: 0\nFiles deleted: 0\nFiles kept: 0\n"
|
|
if output != want {
|
|
t.Fatalf("output = %q, want %q", output, want)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("cleanup called %d times, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func TestBackendCleanupMode_Error(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
return CleanupStats{}, fmt.Errorf("boom")
|
|
}
|
|
|
|
var exitCode int
|
|
errOutput := captureStderr(t, func() {
|
|
exitCode = runCleanupMode()
|
|
})
|
|
if exitCode != 1 {
|
|
t.Fatalf("exit = %d, want 1", exitCode)
|
|
}
|
|
if !strings.Contains(errOutput, "Cleanup failed") || !strings.Contains(errOutput, "boom") {
|
|
t.Fatalf("stderr = %q, want error message", errOutput)
|
|
}
|
|
}
|
|
|
|
func TestBackendCleanupMode_MissingFn(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = nil
|
|
|
|
var exitCode int
|
|
errOutput := captureStderr(t, func() {
|
|
exitCode = runCleanupMode()
|
|
})
|
|
if exitCode != 1 {
|
|
t.Fatalf("exit = %d, want 1", exitCode)
|
|
}
|
|
if !strings.Contains(errOutput, "log cleanup function not configured") {
|
|
t.Fatalf("stderr = %q, want missing-fn message", errOutput)
|
|
}
|
|
}
|
|
|
|
func TestRun_CleanupFlag(t *testing.T) {
|
|
defer resetTestHooks()
|
|
oldArgs := os.Args
|
|
defer func() { os.Args = oldArgs }()
|
|
|
|
os.Args = []string{"codeagent-wrapper", "--cleanup"}
|
|
|
|
calls := 0
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
calls++
|
|
return CleanupStats{Scanned: 1, Deleted: 1}, nil
|
|
}
|
|
|
|
var exitCode int
|
|
output := captureOutput(t, func() {
|
|
exitCode = run()
|
|
})
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit = %d, want 0", exitCode)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("cleanup called %d times, want 1", calls)
|
|
}
|
|
want := "Cleanup completed\nFiles scanned: 1\nFiles deleted: 1\nFiles kept: 0\n"
|
|
if output != want {
|
|
t.Fatalf("output = %q, want %q", output, want)
|
|
}
|
|
if logger := activeLogger(); logger != nil {
|
|
t.Fatalf("logger should not initialize for --cleanup mode")
|
|
}
|
|
}
|
|
|
|
func TestRun_NoArgs(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper"}
|
|
if code := run(); code != 1 {
|
|
t.Errorf("exit = %d, want 1", code)
|
|
}
|
|
}
|
|
|
|
func TestRun_ExplicitStdinEmpty(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper", "-"}
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return false }
|
|
if code := run(); code != 1 {
|
|
t.Errorf("exit = %d, want 1", code)
|
|
}
|
|
}
|
|
|
|
func TestRun_ExplicitStdinReadError(t *testing.T) {
|
|
defer resetTestHooks()
|
|
tempDir := setTempDirEnv(t, t.TempDir())
|
|
logPath := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
|
|
|
var logOutput string
|
|
cleanupHook = func() {
|
|
data, err := os.ReadFile(logPath)
|
|
if err == nil {
|
|
logOutput = string(data)
|
|
}
|
|
}
|
|
|
|
os.Args = []string{"codeagent-wrapper", "-"}
|
|
stdinReader = errReader{errors.New("broken stdin")}
|
|
isTerminalFn = func() bool { return false }
|
|
|
|
exitCode := run()
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("exit code %d, want 1", exitCode)
|
|
}
|
|
if !strings.Contains(logOutput, "Failed to read stdin: broken stdin") {
|
|
t.Fatalf("log missing read error entry, got %q", logOutput)
|
|
}
|
|
// Log file is always removed after completion (new behavior)
|
|
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
|
|
t.Fatalf("log file should be removed after completion")
|
|
}
|
|
}
|
|
|
|
func TestRun_CommandFails(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper", "task"}
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return true }
|
|
restore := withBackend("false", func(cfg *Config, targetArg string) []string { return []string{} })
|
|
defer restore()
|
|
if code := run(); code == 0 {
|
|
t.Errorf("expected non-zero")
|
|
}
|
|
}
|
|
|
|
func TestRun_InvalidBackend(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper", "--backend", "unknown", "task"}
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return true }
|
|
if code := run(); code == 0 {
|
|
t.Fatalf("expected non-zero exit for invalid backend")
|
|
}
|
|
}
|
|
|
|
func TestRun_SuccessfulExecution(t *testing.T) {
|
|
defer resetTestHooks()
|
|
stdout := captureStdoutPipe()
|
|
|
|
restore := withBackend(createFakeCodexScript(t, "tid-123", "ok"), buildCodexArgs)
|
|
defer restore()
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return true }
|
|
os.Args = []string{"codeagent-wrapper", "task"}
|
|
|
|
exitCode := run()
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit=%d, want 0", exitCode)
|
|
}
|
|
|
|
restoreStdoutPipe(stdout)
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "ok") || !strings.Contains(output, "SESSION_ID: tid-123") {
|
|
t.Fatalf("unexpected output: %q", output)
|
|
}
|
|
}
|
|
|
|
func TestRun_ExplicitStdinSuccess(t *testing.T) {
|
|
defer resetTestHooks()
|
|
stdout := captureStdoutPipe()
|
|
|
|
restore := withBackend(createFakeCodexScript(t, "tid-stdin", "from-stdin"), buildCodexArgs)
|
|
defer restore()
|
|
stdinReader = strings.NewReader("line1\nline2")
|
|
isTerminalFn = func() bool { return false }
|
|
os.Args = []string{"codeagent-wrapper", "-"}
|
|
|
|
exitCode := run()
|
|
restoreStdoutPipe(stdout)
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit=%d, want 0", exitCode)
|
|
}
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "from-stdin") || !strings.Contains(output, "SESSION_ID: tid-stdin") {
|
|
t.Fatalf("unexpected output: %q", output)
|
|
}
|
|
}
|
|
|
|
func TestRun_PipedTaskReadError(t *testing.T) {
|
|
defer resetTestHooks()
|
|
tempDir := setTempDirEnv(t, t.TempDir())
|
|
logPath := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
|
|
|
var logOutput string
|
|
cleanupHook = func() {
|
|
data, err := os.ReadFile(logPath)
|
|
if err == nil {
|
|
logOutput = string(data)
|
|
}
|
|
}
|
|
|
|
restore := withBackend(createFakeCodexScript(t, "tid-pipe", "piped-task"), buildCodexArgs)
|
|
defer restore()
|
|
isTerminalFn = func() bool { return false }
|
|
stdinReader = errReader{errors.New("pipe failure")}
|
|
os.Args = []string{"codeagent-wrapper", "cli-task"}
|
|
|
|
exitCode := run()
|
|
if exitCode != 1 {
|
|
t.Fatalf("exit=%d, want 1", exitCode)
|
|
}
|
|
if !strings.Contains(logOutput, "Failed to read piped stdin: read stdin: pipe failure") {
|
|
t.Fatalf("log missing piped read error, got %q", logOutput)
|
|
}
|
|
// Log file is always removed after completion (new behavior)
|
|
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
|
|
t.Fatalf("log file should be removed after completion")
|
|
}
|
|
}
|
|
|
|
func TestRun_PipedTaskSuccess(t *testing.T) {
|
|
defer resetTestHooks()
|
|
stdout := captureStdoutPipe()
|
|
|
|
restore := withBackend(createFakeCodexScript(t, "tid-pipe", "piped-task"), buildCodexArgs)
|
|
defer restore()
|
|
isTerminalFn = func() bool { return false }
|
|
stdinReader = strings.NewReader("piped task text")
|
|
os.Args = []string{"codeagent-wrapper", "cli-task"}
|
|
|
|
exitCode := run()
|
|
restoreStdoutPipe(stdout)
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit=%d, want 0", exitCode)
|
|
}
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "piped-task") || !strings.Contains(output, "SESSION_ID: tid-pipe") {
|
|
t.Fatalf("unexpected output: %q", output)
|
|
}
|
|
}
|
|
|
|
func TestRun_LoggerLifecycle(t *testing.T) {
|
|
defer resetTestHooks()
|
|
tempDir := setTempDirEnv(t, t.TempDir())
|
|
logPath := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
|
|
|
stdout := captureStdoutPipe()
|
|
|
|
restore := withBackend(createFakeCodexScript(t, "tid-logger", "ok"), buildCodexArgs)
|
|
defer restore()
|
|
isTerminalFn = func() bool { return true }
|
|
stdinReader = strings.NewReader("")
|
|
os.Args = []string{"codeagent-wrapper", "task"}
|
|
|
|
var fileExisted bool
|
|
cleanupHook = func() {
|
|
if _, err := os.Stat(logPath); err == nil {
|
|
fileExisted = true
|
|
}
|
|
}
|
|
|
|
exitCode := run()
|
|
restoreStdoutPipe(stdout)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit=%d, want 0", exitCode)
|
|
}
|
|
if !fileExisted {
|
|
t.Fatalf("log file was not present during run")
|
|
}
|
|
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
|
|
t.Fatalf("log file should be removed on success, but it exists")
|
|
}
|
|
}
|
|
|
|
func TestRun_LoggerRemovedOnSignal(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("signal-based test is not supported on Windows")
|
|
}
|
|
|
|
// Skip in CI due to unreliable signal delivery in containerized environments
|
|
if os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" {
|
|
t.Skip("Skipping signal test in CI environment")
|
|
}
|
|
|
|
defer resetTestHooks()
|
|
defer signal.Reset(syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
// Set shorter delays for faster test
|
|
_ = executor.SetForceKillDelay(1)
|
|
|
|
tempDir := setTempDirEnv(t, t.TempDir())
|
|
logPath := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
|
|
|
scriptPath := filepath.Join(tempDir, "sleepy-codex.sh")
|
|
script := `#!/bin/sh
|
|
printf '%s\n' '{"type":"thread.started","thread_id":"sig-thread"}'
|
|
sleep 2
|
|
printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"late"}}'`
|
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("failed to write script: %v", err)
|
|
}
|
|
|
|
restore := withBackend(scriptPath, buildCodexArgs)
|
|
defer restore()
|
|
isTerminalFn = func() bool { return true }
|
|
stdinReader = strings.NewReader("")
|
|
os.Args = []string{"codeagent-wrapper", "task"}
|
|
|
|
exitCh := make(chan int, 1)
|
|
go func() { exitCh <- run() }()
|
|
|
|
deadline := time.Now().Add(1 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(logPath); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
if proc, err := os.FindProcess(os.Getpid()); err == nil && proc != nil {
|
|
_ = proc.Signal(syscall.SIGINT)
|
|
}
|
|
|
|
var exitCode int
|
|
select {
|
|
case exitCode = <-exitCh:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatalf("run() did not return after signal")
|
|
}
|
|
|
|
if exitCode != 130 {
|
|
t.Fatalf("exit code = %d, want 130", exitCode)
|
|
}
|
|
// Log file is always removed after completion (new behavior)
|
|
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
|
|
t.Fatalf("log file should be removed after completion")
|
|
}
|
|
}
|
|
|
|
func TestRun_CleanupHookAlwaysCalled(t *testing.T) {
|
|
defer resetTestHooks()
|
|
called := false
|
|
cleanupHook = func() { called = true }
|
|
// Use a command that goes through normal flow, not --version which returns early
|
|
scriptPath := createFakeCodexScript(t, "x", "ok")
|
|
restore := withBackend(scriptPath, func(cfg *Config, targetArg string) []string { return []string{} })
|
|
defer restore()
|
|
os.Args = []string{"codeagent-wrapper", "task"}
|
|
if exitCode := run(); exitCode != 0 {
|
|
t.Fatalf("exit = %d, want 0", exitCode)
|
|
}
|
|
if !called {
|
|
t.Fatalf("cleanup hook not invoked")
|
|
}
|
|
}
|
|
|
|
func TestBackendStartupCleanupNil(t *testing.T) {
|
|
defer resetTestHooks()
|
|
cleanupLogsFn = nil
|
|
runStartupCleanup()
|
|
}
|
|
|
|
func TestBackendStartupCleanupErrorLogged(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
logger, err := NewLoggerWithSuffix("startup-error")
|
|
if err != nil {
|
|
t.Fatalf("failed to create logger: %v", err)
|
|
}
|
|
setLogger(logger)
|
|
t.Cleanup(func() {
|
|
logger.Flush()
|
|
logger.Close()
|
|
os.Remove(logger.Path())
|
|
})
|
|
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
return CleanupStats{}, errors.New("zapped")
|
|
}
|
|
|
|
runStartupCleanup()
|
|
}
|
|
|
|
func TestRun_CleanupFailureDoesNotBlock(t *testing.T) {
|
|
defer resetTestHooks()
|
|
stdout := captureStdoutPipe()
|
|
defer restoreStdoutPipe(stdout)
|
|
|
|
cleanupCalled := 0
|
|
cleanupLogsFn = func() (CleanupStats, error) {
|
|
cleanupCalled++
|
|
panic("boom")
|
|
}
|
|
|
|
codexCommand = createFakeCodexScript(t, "tid-cleanup", "ok")
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return true }
|
|
os.Args = []string{"codeagent-wrapper", "task"}
|
|
|
|
if exit := run(); exit != 0 {
|
|
t.Fatalf("exit = %d, want 0", exit)
|
|
}
|
|
if cleanupCalled != 1 {
|
|
t.Fatalf("cleanup called %d times, want 1", cleanupCalled)
|
|
}
|
|
}
|
|
|
|
// Coverage helper reused by logger_test to keep focused runs exercising core paths.
|
|
func TestBackendParseJSONStream_CoverageSuite(t *testing.T) {
|
|
suite := []struct {
|
|
name string
|
|
fn func(*testing.T)
|
|
}{
|
|
{"TestBackendParseJSONStream", TestBackendParseJSONStream},
|
|
{"TestRunNormalizeText", TestRunNormalizeText},
|
|
{"TestRunTruncate", TestRunTruncate},
|
|
{"TestRunMin", TestRunMin},
|
|
{"TestRunGetEnv", TestRunGetEnv},
|
|
}
|
|
|
|
for _, tc := range suite {
|
|
t.Run(tc.name, tc.fn)
|
|
}
|
|
}
|
|
|
|
func TestRunHello(t *testing.T) {
|
|
if got := hello(); got != "hello world" {
|
|
t.Fatalf("hello() = %q, want %q", got, "hello world")
|
|
}
|
|
}
|
|
|
|
func TestRunGreet(t *testing.T) {
|
|
if got := greet("Linus"); got != "hello Linus" {
|
|
t.Fatalf("greet() = %q, want %q", got, "hello Linus")
|
|
}
|
|
}
|
|
|
|
func TestRunFarewell(t *testing.T) {
|
|
if got := farewell("Linus"); got != "goodbye Linus" {
|
|
t.Fatalf("farewell() = %q, want %q", got, "goodbye Linus")
|
|
}
|
|
}
|
|
|
|
func TestRunFarewellEmpty(t *testing.T) {
|
|
if got := farewell(""); got != "goodbye " {
|
|
t.Fatalf("farewell(\"\") = %q, want %q", got, "goodbye ")
|
|
}
|
|
}
|
|
|
|
func TestRunTailBuffer(t *testing.T) {
|
|
tb := &tailBuffer{limit: 5}
|
|
if n, err := tb.Write([]byte("abcd")); err != nil || n != 4 {
|
|
t.Fatalf("Write returned (%d, %v), want (4, nil)", n, err)
|
|
}
|
|
if n, err := tb.Write([]byte("efg")); err != nil || n != 3 {
|
|
t.Fatalf("Write returned (%d, %v), want (3, nil)", n, err)
|
|
}
|
|
if got := tb.String(); got != "cdefg" {
|
|
t.Fatalf("tail buffer = %q, want %q", got, "cdefg")
|
|
}
|
|
if n, err := tb.Write([]byte("0123456")); err != nil || n != 7 {
|
|
t.Fatalf("Write returned (%d, %v), want (7, nil)", n, err)
|
|
}
|
|
if got := tb.String(); got != "23456" {
|
|
t.Fatalf("tail buffer = %q, want %q", got, "23456")
|
|
}
|
|
}
|
|
|
|
func TestRunLogWriter(t *testing.T) {
|
|
defer resetTestHooks()
|
|
logger, err := NewLoggerWithSuffix("logwriter")
|
|
if err != nil {
|
|
t.Fatalf("failed to create logger: %v", err)
|
|
}
|
|
setLogger(logger)
|
|
|
|
lw := newLogWriter("TEST: ", 10)
|
|
if _, err := lw.Write([]byte("hello\n")); err != nil {
|
|
t.Fatalf("write hello failed: %v", err)
|
|
}
|
|
if _, err := lw.Write([]byte("world-is-long")); err != nil {
|
|
t.Fatalf("write world failed: %v", err)
|
|
}
|
|
lw.Flush()
|
|
|
|
logger.Flush()
|
|
logger.Close()
|
|
|
|
data, err := os.ReadFile(logger.Path())
|
|
if err != nil {
|
|
t.Fatalf("failed to read log file: %v", err)
|
|
}
|
|
text := string(data)
|
|
if !strings.Contains(text, "TEST: hello") {
|
|
t.Fatalf("log missing hello entry: %s", text)
|
|
}
|
|
if !strings.Contains(text, "TEST: world-i...") {
|
|
t.Fatalf("log missing truncated entry: %s", text)
|
|
}
|
|
os.Remove(logger.Path())
|
|
}
|
|
|
|
func TestNewLogWriterDefaultLimit(t *testing.T) {
|
|
lw := newLogWriter("TEST: ", 0)
|
|
if lw.maxLen != codexLogLineLimit {
|
|
t.Fatalf("newLogWriter maxLen = %d, want %d", lw.maxLen, codexLogLineLimit)
|
|
}
|
|
lw = newLogWriter("TEST: ", -5)
|
|
if lw.maxLen != codexLogLineLimit {
|
|
t.Fatalf("negative maxLen should default, got %d", lw.maxLen)
|
|
}
|
|
}
|
|
|
|
func TestBackendDiscardInvalidJSONBuffer(t *testing.T) {
|
|
reader := bufio.NewReader(strings.NewReader("bad line\n{\"type\":\"ok\"}\n"))
|
|
next, err := discardInvalidJSON(nil, reader)
|
|
if err != nil {
|
|
t.Fatalf("discardInvalidJSON error: %v", err)
|
|
}
|
|
line, err := next.ReadString('\n')
|
|
if err != nil {
|
|
t.Fatalf("failed to read next line: %v", err)
|
|
}
|
|
if strings.TrimSpace(line) != `{"type":"ok"}` {
|
|
t.Fatalf("unexpected remaining line: %q", line)
|
|
}
|
|
|
|
t.Run("no newline", func(t *testing.T) {
|
|
reader := bufio.NewReader(strings.NewReader("partial"))
|
|
decoder := json.NewDecoder(strings.NewReader(""))
|
|
if _, err := discardInvalidJSON(decoder, reader); !errors.Is(err, io.EOF) {
|
|
t.Fatalf("expected EOF when no newline, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Backend-focused coverage suite to ensure run() paths stay exercised under the focused pattern.
|
|
func TestBackendRunCoverage(t *testing.T) {
|
|
suite := []struct {
|
|
name string
|
|
fn func(*testing.T)
|
|
}{
|
|
{"SuccessfulExecution", TestRun_SuccessfulExecution},
|
|
{"ExplicitStdinSuccess", TestRun_ExplicitStdinSuccess},
|
|
{"PipedTaskSuccess", TestRun_PipedTaskSuccess},
|
|
{"LoggerLifecycle", TestRun_LoggerLifecycle},
|
|
{"CleanupFlag", TestRun_CleanupFlag},
|
|
{"NoArgs", TestRun_NoArgs},
|
|
{"CommandFails", TestRun_CommandFails},
|
|
{"CleanupHookAlwaysCalled", TestRun_CleanupHookAlwaysCalled},
|
|
{"VersionFlag", TestVersionFlag},
|
|
{"VersionShortFlag", TestVersionShortFlag},
|
|
{"VersionLegacyAlias", TestVersionLegacyAlias},
|
|
{"Help", TestRun_Help},
|
|
{"HelpShort", TestRun_HelpShort},
|
|
{"HelpDoesNotTriggerCleanup", TestRun_HelpDoesNotTriggerCleanup},
|
|
{"VersionDoesNotTriggerCleanup", TestVersionDoesNotTriggerCleanup},
|
|
{"VersionCoverageFullRun", TestVersionCoverageFullRun},
|
|
{"ExplicitStdinEmpty", TestRun_ExplicitStdinEmpty},
|
|
{"ExplicitStdinReadError", TestRun_ExplicitStdinReadError},
|
|
{"PipedTaskReadError", TestRun_PipedTaskReadError},
|
|
{"VersionMainWrapper", TestVersionMainWrapper},
|
|
}
|
|
|
|
for _, tc := range suite {
|
|
t.Run(tc.name, tc.fn)
|
|
}
|
|
}
|
|
|
|
func TestParallelLogPathInSerialMode(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
tempDir := setTempDirEnv(t, t.TempDir())
|
|
|
|
os.Args = []string{"codeagent-wrapper", "do-stuff"}
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return true }
|
|
codexCommand = createFakeCodexScript(t, "cli-session", "ok")
|
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
|
|
|
var exitCode int
|
|
stderr := captureStderr(t, func() {
|
|
_ = captureOutput(t, func() {
|
|
exitCode = run()
|
|
})
|
|
})
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit = %d, want 0", exitCode)
|
|
}
|
|
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
|
wantLine := fmt.Sprintf("Log: %s", expectedLog)
|
|
if !strings.Contains(stderr, wantLine) {
|
|
t.Fatalf("stderr missing %q, got: %q", wantLine, stderr)
|
|
}
|
|
}
|
|
|
|
func TestRun_CLI_Success(t *testing.T) {
|
|
defer resetTestHooks()
|
|
os.Args = []string{"codeagent-wrapper", "do-things"}
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return true }
|
|
|
|
scriptPath := createFakeCodexScript(t, "cli-session", "ok")
|
|
restore := withBackend(scriptPath, func(cfg *Config, targetArg string) []string { return []string{} })
|
|
defer restore()
|
|
|
|
var exitCode int
|
|
output := captureOutput(t, func() { exitCode = run() })
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() exit=%d, want 0", exitCode)
|
|
}
|
|
if !strings.Contains(output, "ok") || !strings.Contains(output, "SESSION_ID: cli-session") {
|
|
t.Fatalf("unexpected output: %q", output)
|
|
}
|
|
}
|
|
|
|
func TestResolveMaxParallelWorkers(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
envValue string
|
|
want int
|
|
}{
|
|
{"empty env returns unlimited", "", 0},
|
|
{"valid value", "4", 4},
|
|
{"zero value", "0", 0},
|
|
{"at limit", "100", 100},
|
|
{"exceeds limit capped", "150", 100},
|
|
{"negative falls back to unlimited", "-1", 0},
|
|
{"invalid string falls back to unlimited", "abc", 0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Setenv("CODEAGENT_MAX_PARALLEL_WORKERS", tt.envValue)
|
|
|
|
got := config.ResolveMaxParallelWorkers()
|
|
if got != tt.want {
|
|
t.Errorf("resolveMaxParallelWorkers() = %d, want %d", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|