Skip to content
Merged
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
99 changes: 99 additions & 0 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,105 @@ func TestCodexBuildArgsModelWithReasoning(t *testing.T) {
assert.Contains(t, cmdLine, `-c model_reasoning_effort="high"`)
}

func TestSessionAgentsPreserveStateAcrossCloneMethods(t *testing.T) {
tests := []struct {
name string
agent Agent
verify func(*testing.T, Agent)
}{
{
name: "codex",
agent: NewCodexAgent("codex").
WithSessionID("session-123").
WithModel("o4-mini").
WithReasoning(ReasoningThorough).
WithAgentic(true),
verify: func(t *testing.T, a Agent) {
codex, ok := a.(*CodexAgent)
require.True(t, ok)
assert.Equal(t, "session-123", codex.SessionID)
assert.Equal(t, "o4-mini", codex.Model)
assert.Equal(t, ReasoningThorough, codex.Reasoning)
assert.True(t, codex.Agentic)
},
},
{
name: "claude",
agent: NewClaudeAgent("claude").
WithSessionID("session-123").
WithModel("opus").
WithReasoning(ReasoningThorough).
WithAgentic(true),
verify: func(t *testing.T, a Agent) {
claude, ok := a.(*ClaudeAgent)
require.True(t, ok)
assert.Equal(t, "session-123", claude.SessionID)
assert.Equal(t, "opus", claude.Model)
assert.Equal(t, ReasoningThorough, claude.Reasoning)
assert.True(t, claude.Agentic)
},
},
{
name: "opencode",
agent: NewOpenCodeAgent("opencode").
WithSessionID("session-123").
WithModel("anthropic/claude-sonnet-4").
WithReasoning(ReasoningThorough).
WithAgentic(true),
verify: func(t *testing.T, a Agent) {
opencode, ok := a.(*OpenCodeAgent)
require.True(t, ok)
assert.Equal(t, "session-123", opencode.SessionID)
assert.Equal(t, "anthropic/claude-sonnet-4", opencode.Model)
assert.Equal(t, ReasoningThorough, opencode.Reasoning)
assert.True(t, opencode.Agentic)
},
},
{
name: "kilo",
agent: NewKiloAgent("kilo").
WithSessionID("session-123").
WithModel("anthropic/claude-sonnet-4").
WithReasoning(ReasoningThorough).
WithAgentic(true),
verify: func(t *testing.T, a Agent) {
kilo, ok := a.(*KiloAgent)
require.True(t, ok)
assert.Equal(t, "session-123", kilo.SessionID)
assert.Equal(t, "anthropic/claude-sonnet-4", kilo.Model)
assert.Equal(t, ReasoningThorough, kilo.Reasoning)
assert.True(t, kilo.Agentic)
},
},
{
name: "pi",
agent: func() Agent {
pi := NewPiAgent("pi").WithProvider("anthropic").(*PiAgent)
return pi.
WithSessionID("session-123").
WithModel("claude-sonnet-4").
WithReasoning(ReasoningThorough).
WithAgentic(true)
}(),
verify: func(t *testing.T, a Agent) {
pi, ok := a.(*PiAgent)
require.True(t, ok)
assert.Equal(t, "anthropic", pi.Provider)
assert.Equal(t, "session-123", pi.SessionID)
assert.Equal(t, "claude-sonnet-4", pi.Model)
assert.Equal(t, ReasoningThorough, pi.Reasoning)
assert.True(t, pi.Agentic)
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.verify(t, tt.agent)
})
}
}

func assertArgsContain(t *testing.T, cmdLine, flag, value string) {
t.Helper()
tokens := strings.Fields(cmdLine)
Expand Down
50 changes: 22 additions & 28 deletions internal/agent/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,51 +34,45 @@ func NewClaudeAgent(command string) *ClaudeAgent {
return &ClaudeAgent{Command: command, Reasoning: ReasoningStandard}
}

// WithReasoning returns a copy of the agent with the specified reasoning level.
func (a *ClaudeAgent) WithReasoning(level ReasoningLevel) Agent {
func (a *ClaudeAgent) clone(opts ...agentCloneOption) *ClaudeAgent {
cfg := newAgentCloneConfig(
a.Command,
a.Model,
a.Reasoning,
a.Agentic,
a.SessionID,
opts...,
)
return &ClaudeAgent{
Command: a.Command,
Model: a.Model,
Reasoning: level,
Agentic: a.Agentic,
SessionID: a.SessionID,
Command: cfg.Command,
Model: cfg.Model,
Reasoning: cfg.Reasoning,
Agentic: cfg.Agentic,
SessionID: cfg.SessionID,
}
}

// WithReasoning returns a copy of the agent with the specified reasoning level.
func (a *ClaudeAgent) WithReasoning(level ReasoningLevel) Agent {
return a.clone(withClonedReasoning(level))
}

// WithAgentic returns a copy of the agent configured for agentic mode.
func (a *ClaudeAgent) WithAgentic(agentic bool) Agent {
return &ClaudeAgent{
Command: a.Command,
Model: a.Model,
Reasoning: a.Reasoning,
Agentic: agentic,
SessionID: a.SessionID,
}
return a.clone(withClonedAgentic(agentic))
}

// WithModel returns a copy of the agent configured to use the specified model.
func (a *ClaudeAgent) WithModel(model string) Agent {
if model == "" {
return a
}
return &ClaudeAgent{
Command: a.Command,
Model: model,
Reasoning: a.Reasoning,
Agentic: a.Agentic,
SessionID: a.SessionID,
}
return a.clone(withClonedModel(model))
}

// WithSessionID returns a copy of the agent configured to resume a prior session.
func (a *ClaudeAgent) WithSessionID(sessionID string) Agent {
return &ClaudeAgent{
Command: a.Command,
Model: a.Model,
Reasoning: a.Reasoning,
Agentic: a.Agentic,
SessionID: sanitizedResumeSessionID(sessionID),
}
return a.clone(withClonedSessionID(sessionID))
}

// claudeEffort maps ReasoningLevel to Claude Code's --effort flag values
Expand Down
55 changes: 55 additions & 0 deletions internal/agent/clone_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package agent

type agentCloneConfig struct {
Command string
Model string
Reasoning ReasoningLevel
Agentic bool
SessionID string
}

type agentCloneOption func(*agentCloneConfig)

func newAgentCloneConfig(
command, model string,
reasoning ReasoningLevel,
agentic bool,
sessionID string,
opts ...agentCloneOption,
) agentCloneConfig {
cfg := agentCloneConfig{
Command: command,
Model: model,
Reasoning: reasoning,
Agentic: agentic,
SessionID: sessionID,
}
for _, opt := range opts {
opt(&cfg)
}
return cfg
}

func withClonedReasoning(level ReasoningLevel) agentCloneOption {
return func(cfg *agentCloneConfig) {
cfg.Reasoning = level
}
}

func withClonedAgentic(agentic bool) agentCloneOption {
return func(cfg *agentCloneConfig) {
cfg.Agentic = agentic
}
}

func withClonedModel(model string) agentCloneOption {
return func(cfg *agentCloneConfig) {
cfg.Model = model
}
}

func withClonedSessionID(sessionID string) agentCloneOption {
return func(cfg *agentCloneConfig) {
cfg.SessionID = sanitizedResumeSessionID(sessionID)
}
}
44 changes: 22 additions & 22 deletions internal/agent/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,45 +40,45 @@ func NewCodexAgent(command string) *CodexAgent {
return &CodexAgent{Command: command, Reasoning: ReasoningStandard}
}

func (a *CodexAgent) clone(opts ...agentCloneOption) *CodexAgent {
cfg := newAgentCloneConfig(
a.Command,
a.Model,
a.Reasoning,
a.Agentic,
a.SessionID,
opts...,
)
return &CodexAgent{
Command: cfg.Command,
Model: cfg.Model,
Reasoning: cfg.Reasoning,
Agentic: cfg.Agentic,
SessionID: cfg.SessionID,
}
}

// WithReasoning returns a copy of the agent with the specified reasoning level
func (a *CodexAgent) WithReasoning(level ReasoningLevel) Agent {
return &CodexAgent{Command: a.Command, Model: a.Model, Reasoning: level, Agentic: a.Agentic, SessionID: a.SessionID}
return a.clone(withClonedReasoning(level))
}

// WithAgentic returns a copy of the agent configured for agentic mode.
func (a *CodexAgent) WithAgentic(agentic bool) Agent {
return &CodexAgent{
Command: a.Command,
Model: a.Model,
Reasoning: a.Reasoning,
Agentic: agentic,
SessionID: a.SessionID,
}
return a.clone(withClonedAgentic(agentic))
}

// WithModel returns a copy of the agent configured to use the specified model.
func (a *CodexAgent) WithModel(model string) Agent {
if model == "" {
return a
}
return &CodexAgent{
Command: a.Command,
Model: model,
Reasoning: a.Reasoning,
Agentic: a.Agentic,
SessionID: a.SessionID,
}
return a.clone(withClonedModel(model))
}

// WithSessionID returns a copy of the agent configured to resume a prior session.
func (a *CodexAgent) WithSessionID(sessionID string) Agent {
return &CodexAgent{
Command: a.Command,
Model: a.Model,
Reasoning: a.Reasoning,
Agentic: a.Agentic,
SessionID: sanitizedResumeSessionID(sessionID),
}
return a.clone(withClonedSessionID(sessionID))
}

// codexReasoningEffort maps ReasoningLevel to codex-specific effort values
Expand Down
38 changes: 20 additions & 18 deletions internal/agent/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,39 +78,41 @@ func NewCopilotAgent(command string) *CopilotAgent {
return &CopilotAgent{Command: command, Reasoning: ReasoningStandard}
}

// WithReasoning returns a copy of the agent with the model preserved (reasoning not yet supported).
func (a *CopilotAgent) WithReasoning(level ReasoningLevel) Agent {
func (a *CopilotAgent) clone(opts ...agentCloneOption) *CopilotAgent {
cfg := newAgentCloneConfig(
a.Command,
a.Model,
a.Reasoning,
a.Agentic,
"",
opts...,
)
return &CopilotAgent{
Command: a.Command,
Model: a.Model,
Reasoning: level,
Agentic: a.Agentic,
Command: cfg.Command,
Model: cfg.Model,
Reasoning: cfg.Reasoning,
Agentic: cfg.Agentic,
}
}

// WithReasoning returns a copy of the agent with the model preserved (reasoning not yet supported).
func (a *CopilotAgent) WithReasoning(level ReasoningLevel) Agent {
return a.clone(withClonedReasoning(level))
}

// WithAgentic returns a copy of the agent configured for agentic mode.
// In agentic mode, all tools are allowed without restriction. In review mode
// (default), destructive tools are denied via --deny-tool flags.
func (a *CopilotAgent) WithAgentic(agentic bool) Agent {
return &CopilotAgent{
Command: a.Command,
Model: a.Model,
Reasoning: a.Reasoning,
Agentic: agentic,
}
return a.clone(withClonedAgentic(agentic))
}

// WithModel returns a copy of the agent configured to use the specified model.
func (a *CopilotAgent) WithModel(model string) Agent {
if model == "" {
return a
}
return &CopilotAgent{
Command: a.Command,
Model: model,
Reasoning: a.Reasoning,
Agentic: a.Agentic,
}
return a.clone(withClonedModel(model))
}

func (a *CopilotAgent) Name() string {
Expand Down
Loading
Loading