Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,26 +332,46 @@ func main() {
cliAgent = ca
}

// One-shot mode
if *oneShot != "" {
result, err := ag.Run(*oneShot)
// One-shot mode and positional-arg mode honour the configured CLI backend.
// Prior to the QUA-576 fix these paths always called ag.Run (Anthropic API),
// silently ignoring backend=cc and backend=codex — which meant every CI /
// scripting / cloud-session invocation bypassed the cc backend and hit the
// Anthropic API. Now we route through cliAgent when configured, falling
// back to the API agent only when no CLI backend is active.
runOneShot := func(prompt string) error {
if cliAgent != nil {
// CLI backends stream their own output (tool icons, glamour-rendered
// text) via the Terminal; FinishMarkdown handles final rendering.
// Build a Terminal here since main never created one (repl.Run owns
// the interactive Terminal instance).
term := tui.NewTerminal()
defer term.Close()
_, err := cliAgent.Run(prompt, term)
return err
}
// Direct-API path: non-streaming. Print the returned text ourselves.
result, err := ag.Run(prompt)
if err != nil {
return err
}
fmt.Println(result)
return nil
}

if *oneShot != "" {
if err := runOneShot(*oneShot); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println(result)
return
}

// Also handle positional args as a prompt: qmax-code "test the login flow"
if remaining := flag.Args(); len(remaining) > 0 {
prompt := strings.Join(remaining, " ")
result, err := ag.Run(prompt)
if err != nil {
if err := runOneShot(strings.Join(remaining, " ")); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println(result)
return
}

Expand Down
101 changes: 101 additions & 0 deletions main_oneshot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package main

import (
"errors"
"testing"

"github.com/qualitymax/qmax-code/internal/agent"
"github.com/qualitymax/qmax-code/internal/tui"
)

// fakeCLIAgent is a tiny CLIAgent implementation that records whether its
// Run method was called. Used to verify the QUA-576 dispatch fix without
// needing a real claude/codex binary.
type fakeCLIAgent struct {
called bool
prompt string
result string
runErr error
}

func (f *fakeCLIAgent) Run(userMsg string, _ *tui.Terminal) (string, error) {
f.called = true
f.prompt = userMsg
return f.result, f.runErr
}
func (f *fakeCLIAgent) Cancel() {}
func (f *fakeCLIAgent) Cleanup() {}
func (f *fakeCLIAgent) SetOutputVerbose(bool) {}

// dispatchForTest mirrors the dispatch logic in main.go's `runOneShot`
// closure. Keeping it in sync with that closure is the regression contract
// of TestOneShotDispatch_* below.
//
// The real closure (main.go) also creates a tui.Terminal when cliAgent is
// non-nil; for unit testing we pass nil since fakeCLIAgent doesn't use it.
func dispatchForTest(prompt string, cliAgent agent.CLIAgent, apiCallback func(string) (string, error)) error {
if cliAgent != nil {
_, err := cliAgent.Run(prompt, nil)
return err
}
_, err := apiCallback(prompt)
return err
}

// TestOneShotDispatch_PrefersCLIAgent is the regression test for QUA-576.
// Before the fix, -p and positional-arg modes called ag.Run directly even
// when backend=cc was configured. This test asserts that when a CLIAgent
// is present, it is the agent that handles the one-shot prompt.
func TestOneShotDispatch_PrefersCLIAgent(t *testing.T) {
fake := &fakeCLIAgent{result: "cli-result"}
apiCalled := false
apiCallback := func(string) (string, error) {
apiCalled = true
return "api-result", nil
}

if err := dispatchForTest("hello", fake, apiCallback); err != nil {
t.Fatalf("dispatch returned err: %v", err)
}
if !fake.called {
t.Error("expected fakeCLIAgent.Run to be called, but it was not")
}
if apiCalled {
t.Error("expected API callback NOT to be called when cliAgent is set, but it was")
}
if fake.prompt != "hello" {
t.Errorf("prompt passed to CLI agent: got %q, want %q", fake.prompt, "hello")
}
}

// TestOneShotDispatch_FallsBackToAPI confirms the no-CLI-backend fallback.
func TestOneShotDispatch_FallsBackToAPI(t *testing.T) {
apiCalled := false
apiCallback := func(string) (string, error) {
apiCalled = true
return "api-result", nil
}

if err := dispatchForTest("hello", nil, apiCallback); err != nil {
t.Fatalf("dispatch returned err: %v", err)
}
if !apiCalled {
t.Error("expected API callback to be called when cliAgent is nil, but it was not")
}
}

// TestOneShotDispatch_PropagatesCLIError ensures errors from the CLI agent
// surface to the caller (which exits non-zero in main.go).
func TestOneShotDispatch_PropagatesCLIError(t *testing.T) {
want := errors.New("cc subprocess failed")
fake := &fakeCLIAgent{runErr: want}
apiCallback := func(string) (string, error) {
t.Fatal("API callback must not be called when cliAgent is present")
return "", nil
}

err := dispatchForTest("hello", fake, apiCallback)
if err == nil || err.Error() != want.Error() {
t.Errorf("dispatch error: got %v, want %v", err, want)
}
}
Loading