Skip to content

Commit 3265708

Browse files
Merge pull request #61 from priyanshujain/feat-codex-support
feat: add Codex CLI support for delegate_task
2 parents 99f9890 + e24ef37 commit 3265708

File tree

5 files changed

+241
-27
lines changed

5 files changed

+241
-27
lines changed

agent/tools/agent_runner.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ func (r *AgentRunner) buildArgs(opts runOptions) []string {
111111
return args
112112
case AgentGemini:
113113
return []string{"-p"}
114+
case AgentCodex:
115+
return []string{"exec"}
114116
default:
115117
return nil
116118
}

agent/tools/agent_runner_test.go

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func TestDetectAgents_Priority(t *testing.T) {
8282
}
8383

8484
func TestAgentRunner_BuildsClaudeArgs(t *testing.T) {
85-
r := NewAgentRunner(AgentInfo{Kind: AgentClaude, Binary: "/usr/local/bin/claude"})
85+
r := NewAgentRunner(AgentInfo{Kind: AgentClaude, Binary: "claude"})
8686
args := r.buildArgs(runOptions{})
8787
want := []string{"--print", "--output-format", "text"}
8888
if len(args) != len(want) {
@@ -96,7 +96,7 @@ func TestAgentRunner_BuildsClaudeArgs(t *testing.T) {
9696
}
9797

9898
func TestAgentRunner_BuildsGeminiArgs(t *testing.T) {
99-
r := NewAgentRunner(AgentInfo{Kind: AgentGemini, Binary: "/usr/local/bin/gemini"})
99+
r := NewAgentRunner(AgentInfo{Kind: AgentGemini, Binary: "gemini"})
100100
args := r.buildArgs(runOptions{})
101101
want := []string{"-p"}
102102
if len(args) != len(want) {
@@ -107,9 +107,21 @@ func TestAgentRunner_BuildsGeminiArgs(t *testing.T) {
107107
}
108108
}
109109

110+
func TestAgentRunner_BuildsCodexArgs(t *testing.T) {
111+
r := NewAgentRunner(AgentInfo{Kind: AgentCodex, Binary: "codex"})
112+
args := r.buildArgs(runOptions{})
113+
want := []string{"exec"}
114+
if len(args) != len(want) {
115+
t.Fatalf("args = %v, want %v", args, want)
116+
}
117+
if args[0] != "exec" {
118+
t.Errorf("args[0] = %q, want %q", args[0], "exec")
119+
}
120+
}
121+
110122
func TestAgentRunner_StripsCLAUDECODE(t *testing.T) {
111123
t.Setenv("CLAUDECODE", "1")
112-
r := NewAgentRunner(AgentInfo{Kind: AgentClaude, Binary: "/usr/local/bin/claude"})
124+
r := NewAgentRunner(AgentInfo{Kind: AgentClaude, Binary: "claude"})
113125
env := r.buildEnv()
114126
for _, e := range env {
115127
if e == "CLAUDECODE=1" {
@@ -120,7 +132,7 @@ func TestAgentRunner_StripsCLAUDECODE(t *testing.T) {
120132

121133
func TestAgentRunner_GeminiKeepsCLAUDECODE(t *testing.T) {
122134
t.Setenv("CLAUDECODE", "1")
123-
r := NewAgentRunner(AgentInfo{Kind: AgentGemini, Binary: "/usr/local/bin/gemini"})
135+
r := NewAgentRunner(AgentInfo{Kind: AgentGemini, Binary: "gemini"})
124136
env := r.buildEnv()
125137
found := false
126138
for _, e := range env {
@@ -134,6 +146,22 @@ func TestAgentRunner_GeminiKeepsCLAUDECODE(t *testing.T) {
134146
}
135147
}
136148

149+
func TestAgentRunner_CodexKeepsCLAUDECODE(t *testing.T) {
150+
t.Setenv("CLAUDECODE", "1")
151+
r := NewAgentRunner(AgentInfo{Kind: AgentCodex, Binary: "codex"})
152+
env := r.buildEnv()
153+
found := false
154+
for _, e := range env {
155+
if e == "CLAUDECODE=1" {
156+
found = true
157+
break
158+
}
159+
}
160+
if !found {
161+
t.Error("CLAUDECODE should NOT be stripped for codex")
162+
}
163+
}
164+
137165
func TestAgentRunner_Timeout(t *testing.T) {
138166
r := NewAgentRunner(AgentInfo{Kind: AgentClaude, Binary: "sleep"})
139167
_, err := r.Run(context.Background(), "", 100*time.Millisecond)
@@ -201,3 +229,28 @@ func TestAgentRunner_RealGemini(t *testing.T) {
201229
t.Error("expected non-empty output")
202230
}
203231
}
232+
233+
func TestAgentRunner_RealCodex(t *testing.T) {
234+
if _, err := exec.LookPath("codex"); err != nil {
235+
t.Skip("codex not on PATH")
236+
}
237+
agents := DetectAgents()
238+
var info AgentInfo
239+
for _, a := range agents {
240+
if a.Kind == AgentCodex {
241+
info = a
242+
break
243+
}
244+
}
245+
r := NewAgentRunner(info)
246+
out, err := r.Run(context.Background(), "Say hello in exactly one word.", 30*time.Second)
247+
if err != nil {
248+
if strings.Contains(err.Error(), "auth") || strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "login") {
249+
t.Skipf("codex auth not configured: %v", err)
250+
}
251+
t.Fatalf("Run: %v", err)
252+
}
253+
if out == "" {
254+
t.Error("expected non-empty output")
255+
}
256+
}

agent/tools/agent_stream.go

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ func (r *StreamRunner) buildStreamArgs(opts runOptions) []string {
113113
return args
114114
case AgentGemini:
115115
return []string{"-o", "stream-json"}
116+
case AgentCodex:
117+
return []string{"exec", "--json"}
116118
default:
117119
return nil
118120
}
@@ -126,25 +128,49 @@ func (r *StreamRunner) buildEnv() []string {
126128
return env
127129
}
128130

129-
// streamJSON is the minimal structure of a Claude stream-json event.
131+
// streamJSON is a unified structure for parsing streaming events from all CLIs.
130132
type streamJSON struct {
131133
Type string `json:"type"`
132134
Content string `json:"content"`
133135
Result string `json:"result"`
136+
Role string `json:"role"`
137+
Delta bool `json:"delta"`
138+
Item struct {
139+
Type string `json:"type"`
140+
Text string `json:"text"`
141+
} `json:"item"`
134142
}
135143

136144
func parseStreamLine(line []byte) StreamEvent {
137145
var sj streamJSON
138146
if err := json.Unmarshal(line, &sj); err != nil {
139147
return StreamEvent{}
140148
}
149+
if sj.Type == "" {
150+
return StreamEvent{}
151+
}
152+
153+
// Gemini: {"type":"message","role":"assistant","content":"Hello","delta":true}
154+
if sj.Type == "message" && sj.Role == "assistant" {
155+
return StreamEvent{Type: "text", Content: sj.Content}
156+
}
157+
158+
// Codex: {"type":"item.completed","item":{"type":"agent_message","text":"Hello"}}
159+
if sj.Type == "item.completed" && sj.Item.Type == "agent_message" {
160+
return StreamEvent{Type: "text", Content: sj.Item.Text}
161+
}
162+
163+
// Drop non-text Gemini/Codex protocol events (user messages, reasoning, etc.)
164+
if sj.Type == "message" || sj.Type == "item.completed" ||
165+
sj.Type == "init" || sj.Type == "thread.started" ||
166+
sj.Type == "turn.started" || sj.Type == "turn.completed" {
167+
return StreamEvent{}
168+
}
169+
170+
// Claude: {"type":"text"|"result"|"tool_use", "content":"...", "result":"..."}
141171
content := sj.Content
142172
if content == "" {
143173
content = sj.Result
144174
}
145-
typ := sj.Type
146-
if typ == "" {
147-
return StreamEvent{}
148-
}
149-
return StreamEvent{Type: typ, Content: content}
175+
return StreamEvent{Type: sj.Type, Content: content}
150176
}

agent/tools/agent_stream_test.go

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tools
33
import (
44
"context"
55
"os/exec"
6+
"strings"
67
"testing"
78
"time"
89
)
@@ -45,6 +46,68 @@ func TestParseStreamLine_InvalidJSON(t *testing.T) {
4546
}
4647
}
4748

49+
func TestParseStreamLine_GeminiMessage(t *testing.T) {
50+
line := []byte(`{"type":"message","role":"assistant","content":"Hello","delta":true}`)
51+
evt := parseStreamLine(line)
52+
if evt.Type != "text" {
53+
t.Errorf("Type = %q, want text", evt.Type)
54+
}
55+
if evt.Content != "Hello" {
56+
t.Errorf("Content = %q", evt.Content)
57+
}
58+
}
59+
60+
func TestParseStreamLine_GeminiResult(t *testing.T) {
61+
line := []byte(`{"type":"result","status":"success","stats":{"total_tokens":100}}`)
62+
evt := parseStreamLine(line)
63+
if evt.Type != "result" {
64+
t.Errorf("Type = %q, want result", evt.Type)
65+
}
66+
}
67+
68+
func TestParseStreamLine_GeminiUserMessage(t *testing.T) {
69+
line := []byte(`{"type":"message","role":"user","content":"prompt"}`)
70+
evt := parseStreamLine(line)
71+
if evt.Type != "" {
72+
t.Errorf("user messages should be dropped, got Type=%q", evt.Type)
73+
}
74+
}
75+
76+
func TestParseStreamLine_CodexItemCompleted(t *testing.T) {
77+
line := []byte(`{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hello"}}`)
78+
evt := parseStreamLine(line)
79+
if evt.Type != "text" {
80+
t.Errorf("Type = %q, want text", evt.Type)
81+
}
82+
if evt.Content != "Hello" {
83+
t.Errorf("Content = %q", evt.Content)
84+
}
85+
}
86+
87+
func TestParseStreamLine_CodexReasoningIgnored(t *testing.T) {
88+
line := []byte(`{"type":"item.completed","item":{"type":"reasoning","text":"thinking..."}}`)
89+
evt := parseStreamLine(line)
90+
if evt.Type != "" {
91+
t.Errorf("reasoning items should be dropped, got Type=%q", evt.Type)
92+
}
93+
}
94+
95+
func TestParseStreamLine_CodexTurnCompleted(t *testing.T) {
96+
line := []byte(`{"type":"turn.completed","usage":{"input_tokens":100}}`)
97+
evt := parseStreamLine(line)
98+
if evt.Type != "" {
99+
t.Errorf("turn.completed should be dropped, got Type=%q", evt.Type)
100+
}
101+
}
102+
103+
func TestParseStreamLine_GeminiInit(t *testing.T) {
104+
line := []byte(`{"type":"init","session_id":"abc"}`)
105+
evt := parseStreamLine(line)
106+
if evt.Type != "" {
107+
t.Errorf("init should be dropped, got Type=%q", evt.Type)
108+
}
109+
}
110+
48111
func TestParseStreamLine_EmptyType(t *testing.T) {
49112
line := []byte(`{"content":"orphan"}`)
50113
evt := parseStreamLine(line)
@@ -54,7 +117,7 @@ func TestParseStreamLine_EmptyType(t *testing.T) {
54117
}
55118

56119
func TestStreamRunner_BuildsClaudeStreamArgs(t *testing.T) {
57-
r := NewStreamRunner(AgentInfo{Kind: AgentClaude, Binary: "/usr/local/bin/claude"})
120+
r := NewStreamRunner(AgentInfo{Kind: AgentClaude, Binary: "claude"})
58121
args := r.buildStreamArgs(runOptions{})
59122
want := []string{"--print", "--verbose", "--output-format", "stream-json"}
60123
if len(args) != len(want) {
@@ -68,7 +131,7 @@ func TestStreamRunner_BuildsClaudeStreamArgs(t *testing.T) {
68131
}
69132

70133
func TestStreamRunner_BuildsGeminiStreamArgs(t *testing.T) {
71-
r := NewStreamRunner(AgentInfo{Kind: AgentGemini, Binary: "/usr/local/bin/gemini"})
134+
r := NewStreamRunner(AgentInfo{Kind: AgentGemini, Binary: "gemini"})
72135
args := r.buildStreamArgs(runOptions{})
73136
want := []string{"-o", "stream-json"}
74137
if len(args) != len(want) {
@@ -81,8 +144,22 @@ func TestStreamRunner_BuildsGeminiStreamArgs(t *testing.T) {
81144
}
82145
}
83146

147+
func TestStreamRunner_BuildsCodexStreamArgs(t *testing.T) {
148+
r := NewStreamRunner(AgentInfo{Kind: AgentCodex, Binary: "codex"})
149+
args := r.buildStreamArgs(runOptions{})
150+
want := []string{"exec", "--json"}
151+
if len(args) != len(want) {
152+
t.Fatalf("args = %v, want %v", args, want)
153+
}
154+
for i, a := range args {
155+
if a != want[i] {
156+
t.Errorf("args[%d] = %q, want %q", i, a, want[i])
157+
}
158+
}
159+
}
160+
84161
func TestStreamRunner_BuildsClaudeStreamArgsWithBudget(t *testing.T) {
85-
r := NewStreamRunner(AgentInfo{Kind: AgentClaude, Binary: "/usr/local/bin/claude"})
162+
r := NewStreamRunner(AgentInfo{Kind: AgentClaude, Binary: "claude"})
86163
args := r.buildStreamArgs(runOptions{maxBudgetUSD: 0.50})
87164
found := false
88165
for i, a := range args {
@@ -154,3 +231,59 @@ func TestStreamRunner_RealClaude(t *testing.T) {
154231
t.Error("expected at least one event")
155232
}
156233
}
234+
235+
func TestStreamRunner_RealGemini(t *testing.T) {
236+
if _, err := exec.LookPath("gemini"); err != nil {
237+
t.Skip("gemini not on PATH")
238+
}
239+
agents := DetectAgents()
240+
var info AgentInfo
241+
for _, a := range agents {
242+
if a.Kind == AgentGemini {
243+
info = a
244+
break
245+
}
246+
}
247+
r := NewStreamRunner(info)
248+
var eventCount int
249+
out, err := r.RunStream(context.Background(), "Say hello in exactly one word.", 30*time.Second, func(evt StreamEvent) {
250+
eventCount++
251+
})
252+
if err != nil {
253+
if strings.Contains(err.Error(), "Permission") || strings.Contains(err.Error(), "denied") || strings.Contains(err.Error(), "auth") {
254+
t.Skipf("gemini auth not configured: %v", err)
255+
}
256+
t.Fatalf("RunStream: %v", err)
257+
}
258+
if out == "" {
259+
t.Error("expected non-empty output")
260+
}
261+
}
262+
263+
func TestStreamRunner_RealCodex(t *testing.T) {
264+
if _, err := exec.LookPath("codex"); err != nil {
265+
t.Skip("codex not on PATH")
266+
}
267+
agents := DetectAgents()
268+
var info AgentInfo
269+
for _, a := range agents {
270+
if a.Kind == AgentCodex {
271+
info = a
272+
break
273+
}
274+
}
275+
r := NewStreamRunner(info)
276+
var eventCount int
277+
out, err := r.RunStream(context.Background(), "Say hello in exactly one word.", 30*time.Second, func(evt StreamEvent) {
278+
eventCount++
279+
})
280+
if err != nil {
281+
if strings.Contains(err.Error(), "auth") || strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "login") {
282+
t.Skipf("codex auth not configured: %v", err)
283+
}
284+
t.Fatalf("RunStream: %v", err)
285+
}
286+
if out == "" {
287+
t.Error("expected non-empty output")
288+
}
289+
}

0 commit comments

Comments
 (0)