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
22 changes: 13 additions & 9 deletions internal/agent/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func ValidateClaudeCLICommand(command string) error {
// RunTurn runs a single claude turn as a subprocess.
//
// First turn (sessionID == nil): claude -p <prompt> --output-format stream-json
// Continuation (sessionID != nil): claude --resume <sessionID> --output-format stream-json
// Continuation (sessionID != nil): claude --resume <sessionID> -p <prompt> --output-format stream-json
//
// readTimeoutMs is the per-line idle deadline; if no output arrives within that
// window the turn is aborted as a stall. turnTimeoutMs is the hard wall-clock
Expand Down Expand Up @@ -218,17 +218,21 @@ const sharedFlagsStr = " --output-format stream-json --verbose --dangerously-ski
var sharedFlagsSlice = []string{"--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions"}

// buildDirectArgs returns CLI args for direct (non-shell) invocation.
//
// `-p <prompt>` is always emitted. Claude Code ≥ 2.1.119 rejects `--resume`
// with no prompt unless the resumed transcript ends in a deferred-tool marker;
// callers choose the prompt text for resumed turns.
func buildDirectArgs(sessionID *string, prompt string) []string {
base := append([]string{}, sharedFlagsSlice...)
args := append([]string{}, sharedFlagsSlice...)
if sessionID != nil && *sessionID != "" {
return append(base, "--resume", *sessionID)
args = append(args, "--resume", *sessionID)
}
return append(base, "-p", prompt)
return append(args, "-p", prompt)
}

// buildShellCmd returns the full shell command string for bash/zsh -lc.
// The prompt is passed via a shell variable to avoid quoting issues with
// special characters (backticks, $, !, quotes) in the rendered template.
// The prompt is shell-quoted to preserve special characters (backticks, $, !,
// quotes) in the rendered template.
//
// Defensive: if command is empty or whitespace, fall back to "claude" and
// log a warning. Without this, sharedFlagsStr's leading space would produce
Expand All @@ -240,11 +244,11 @@ func buildShellCmd(command string, sessionID *string, prompt string) string {
slog.Warn("agent: empty command resolved at dispatch — falling back to 'claude'. Check WORKFLOW.md agent.command and any profile.command fields.")
command = "claude"
}
base := command + sharedFlagsStr
cmd := command + sharedFlagsStr
if sessionID != nil && *sessionID != "" {
return base + " --resume " + shellQuote(*sessionID)
cmd += " --resume " + shellQuote(*sessionID)
}
return base + " -p " + shellQuote(prompt)
return cmd + " -p " + shellQuote(prompt)
}

// todoItems parses a TodoWrite input and returns the content of each todo.
Expand Down
29 changes: 26 additions & 3 deletions internal/agent/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,34 @@ func TestBuildShellCmdNewSession(t *testing.T) {

func TestBuildShellCmdResume(t *testing.T) {
id := "sess-abc"
cmd := buildShellCmd("claude", &id, "ignored prompt")
cmd := buildShellCmd("claude", &id, "next-turn prompt")
assert.Contains(t, cmd, "--resume")
assert.Contains(t, cmd, "sess-abc")
// When resuming, the new-session flag ` -p ` should not appear (note spaces).
assert.NotContains(t, cmd, " -p ")
// Claude Code ≥ 2.1.119 requires `-p` alongside `--resume` when the
// resumed transcript has no deferred-tool marker. Issue #30.
assert.Contains(t, cmd, " -p ")
assert.Contains(t, cmd, "next-turn prompt")
}

// --- buildDirectArgs ---

func TestBuildDirectArgsNewSession(t *testing.T) {
args := buildDirectArgs(nil, "do the thing")
assert.Contains(t, args, "--output-format")
assert.Contains(t, args, "stream-json")
assert.Contains(t, args, "-p")
assert.Contains(t, args, "do the thing")
assert.NotContains(t, args, "--resume")
}

func TestBuildDirectArgsResume(t *testing.T) {
id := "sess-abc"
args := buildDirectArgs(&id, "next-turn prompt")
assert.Contains(t, args, "--resume")
assert.Contains(t, args, "sess-abc")
// Issue #30: prompt must be present alongside --resume.
assert.Contains(t, args, "-p")
assert.Contains(t, args, "next-turn prompt")
}

// Regression: an empty command must not produce a shell line that starts with
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ Your job:

Be concise in your review comments. Focus on real problems, not style nits.`

// DefaultResumePrompt is used for Claude resume turns when resume_prompt is absent from WORKFLOW.md.
const DefaultResumePrompt = "Continue working on the previously assigned issue using the existing session context. Do not restart from scratch; proceed from where the prior turn left off."

// AgentProfile holds settings for a named agent profile.
type AgentProfile struct {
// Command overrides the default agent CLI command (e.g. "claude --model ...").
Expand All @@ -104,6 +107,9 @@ type AgentConfig struct {
// Backend optionally overrides runner selection for the default agent command
// when it cannot be inferred from the command string alone.
Backend string
// ResumePrompt is the Liquid template sent to Claude resume turns instead of
// re-sending the full WORKFLOW.md prompt. Codex resumes keep backend-native behavior.
ResumePrompt string
// TurnTimeoutMs is the hard wall-clock limit for an entire agent session
// (all turns combined). When the limit is exceeded the subprocess is killed
// and the issue is scheduled for retry. Default: 3 600 000 ms (1 hour).
Expand Down Expand Up @@ -280,6 +286,7 @@ func fromWorkflow(wf *workflow.Workflow) *Config {
cfg.Agent.MaxTurns = positiveIntField(agent, "max_turns", 20)
cfg.Agent.Command = strField(agent, "command", "claude")
cfg.Agent.Backend = strField(agent, "backend", "")
cfg.Agent.ResumePrompt = strField(agent, "resume_prompt", DefaultResumePrompt)
cfg.Agent.TurnTimeoutMs = intField(agent, "turn_timeout_ms", 3600000)
cfg.Agent.ReadTimeoutMs = positiveIntField(agent, "read_timeout_ms", 30000)
cfg.Agent.StallTimeoutMs = intField(agent, "stall_timeout_ms", 300000)
Expand Down
23 changes: 23 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func TestDefaults(t *testing.T) {
assert.Equal(t, 5, cfg.Agent.MaxRetries)
assert.Equal(t, "", cfg.Tracker.FailedState)
assert.Equal(t, "claude", cfg.Agent.Command)
assert.Equal(t, config.DefaultResumePrompt, cfg.Agent.ResumePrompt)
assert.Equal(t, 3600000, cfg.Agent.TurnTimeoutMs)
assert.Equal(t, 30000, cfg.Agent.ReadTimeoutMs)
assert.Equal(t, 300000, cfg.Agent.StallTimeoutMs)
Expand Down Expand Up @@ -187,6 +188,28 @@ func TestAgentBackendField(t *testing.T) {
assert.Equal(t, "codex", cfg.Agent.Backend)
}

func TestAgentResumePromptField(t *testing.T) {
content := minimal(`agent:
resume_prompt: "Continue {{ issue.identifier }}"
`)
path := workflowWithContent(t, content)
cfg, err := config.Load(path)
require.NoError(t, err)
assert.Equal(t, "Continue {{ issue.identifier }}", cfg.Agent.ResumePrompt)
}

func TestValidateDispatchRejectsInvalidResumePromptTemplate(t *testing.T) {
content := minimal(`agent:
resume_prompt: "{% for %}"
`)
path := workflowWithContent(t, content)
cfg, err := config.Load(path)
require.NoError(t, err)
err = config.ValidateDispatch(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "agent.resume_prompt")
}

func TestWorktreeDefaultsFalse(t *testing.T) {
path := workflowWithContent(t, minimal(""))
cfg, err := config.Load(path)
Expand Down
8 changes: 8 additions & 0 deletions internal/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ func ValidateDispatch(cfg *Config) error {
}
}

// Check 6b: resume_prompt is a valid Liquid template.
if rp := cfg.Agent.ResumePrompt; rp != "" {
eng := liquid.NewEngine()
if _, err := eng.ParseTemplate([]byte(rp)); err != nil {
return fmt.Errorf("agent.resume_prompt: invalid Liquid template: %w", err)
}
}

// Check 7: ssh_hosts must not start with '-' or contain whitespace (prevents SSH flag injection)
for _, host := range cfg.Agent.SSHHosts {
if strings.HasPrefix(host, "-") || strings.ContainsAny(host, " \t") {
Expand Down
102 changes: 102 additions & 0 deletions internal/orchestrator/event_loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1931,3 +1931,105 @@ func TestManualPauseResumeWithoutSession(t *testing.T) {
}
}
}

type resumePromptRunner struct {
mu sync.Mutex
once sync.Once
done chan struct{}
prompts []string
sessionIDs []string
}

func (r *resumePromptRunner) RunTurn(_ context.Context, _ agent.Logger, _ func(agent.TurnResult), sessionID *string, prompt, _, _, _, _ string, _, _ int) (agent.TurnResult, error) {
r.mu.Lock()
sid := ""
if sessionID != nil {
sid = *sessionID
}
r.prompts = append(r.prompts, prompt)
r.sessionIDs = append(r.sessionIDs, sid)
callNum := len(r.prompts)
r.mu.Unlock()

if callNum == 1 {
return agent.TurnResult{
SessionID: "agent-session-xyz",
InputTokens: 10,
OutputTokens: 10,
TotalTokens: 20,
ResultText: "first turn",
}, nil
}

r.once.Do(func() { close(r.done) })
return agent.TurnResult{
SessionID: "agent-session-xyz",
ResultText: "session concluded",
}, nil
}

func (r *resumePromptRunner) snapshot() (prompts []string, sessionIDs []string) {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string{}, r.prompts...), append([]string{}, r.sessionIDs...)
}

func TestClaudeResumeTurnsUseResumePrompt(t *testing.T) {
cfg := baseConfig()
cfg.Polling.IntervalMs = 20
cfg.Agent.MaxTurns = 2
cfg.Agent.Command = "claude"
cfg.Agent.ResumePrompt = "Resume {{ issue.identifier }} from existing context."
cfg.PromptTemplate = "Full workflow prompt for {{ issue.identifier }}."
mt := singleIssueTracker(t, "In Progress")
runner := &resumePromptRunner{done: make(chan struct{})}
orch := orchestrator.New(cfg, mt, runner, nil)

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
go orch.Run(ctx) //nolint:errcheck

select {
case <-runner.done:
case <-ctx.Done():
t.Fatal("runner did not receive a Claude resume turn within 3s")
}

prompts, sessionIDs := runner.snapshot()
require.Len(t, prompts, 2)
require.Len(t, sessionIDs, 2)
assert.Equal(t, "Full workflow prompt for ENG-1.", prompts[0])
assert.Equal(t, "", sessionIDs[0])
assert.Equal(t, "Resume ENG-1 from existing context.", prompts[1])
assert.Equal(t, "agent-session-xyz", sessionIDs[1])
}

func TestClaudeResumePromptRenderErrorFallsBackToWorkflowPrompt(t *testing.T) {
cfg := baseConfig()
cfg.Polling.IntervalMs = 20
cfg.Agent.MaxTurns = 2
cfg.Agent.Command = "claude"
cfg.Agent.ResumePrompt = "Resume {{ issue.missing_field }}."
cfg.PromptTemplate = "Full workflow prompt for {{ issue.identifier }}."
mt := singleIssueTracker(t, "In Progress")
runner := &resumePromptRunner{done: make(chan struct{})}
orch := orchestrator.New(cfg, mt, runner, nil)

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
go orch.Run(ctx) //nolint:errcheck

select {
case <-runner.done:
case <-ctx.Done():
t.Fatal("runner did not receive a Claude resume turn within 3s")
}

prompts, sessionIDs := runner.snapshot()
require.Len(t, prompts, 2)
require.Len(t, sessionIDs, 2)
assert.Equal(t, "Full workflow prompt for ENG-1.", prompts[0])
assert.Equal(t, "", sessionIDs[0])
assert.Equal(t, "Full workflow prompt for ENG-1.", prompts[1])
assert.Equal(t, "agent-session-xyz", sessionIDs[1])
}
81 changes: 81 additions & 0 deletions internal/orchestrator/subagents_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package orchestrator

import (
"context"
"log/slog"
"testing"

"github.com/stretchr/testify/assert"
"github.com/vnovick/itervox/internal/agent"
"github.com/vnovick/itervox/internal/config"
"github.com/vnovick/itervox/internal/domain"
"github.com/vnovick/itervox/internal/logbuffer"
"github.com/vnovick/itervox/internal/tracker"
)

func TestBuildSubAgentContext_UsesCodexToolName(t *testing.T) {
Expand All @@ -28,6 +32,83 @@ func TestBuildSubAgentContext_SkipsActiveProfile(t *testing.T) {
assert.NotContains(t, ctx, "**active**")
}

func TestShouldUseClaudeResumePrompt(t *testing.T) {
id := "sess-1"
empty := ""

assert.True(t, shouldUseClaudeResumePrompt("", &id))
assert.True(t, shouldUseClaudeResumePrompt("claude", &id))
assert.False(t, shouldUseClaudeResumePrompt("codex", &id))
assert.False(t, shouldUseClaudeResumePrompt("claude", &empty))
assert.False(t, shouldUseClaudeResumePrompt("claude", nil))
}

func TestRenderClaudeResumePrompt(t *testing.T) {
got, err := renderClaudeResumePrompt("Continue {{ issue.identifier }}", domain.Issue{Identifier: "ENG-1"}, nil)
assert.NoError(t, err)
assert.Equal(t, "Continue ENG-1", got)
}

func TestRenderClaudeResumePromptDefault(t *testing.T) {
got, err := renderClaudeResumePrompt("", domain.Issue{Identifier: "ENG-1"}, nil)
assert.NoError(t, err)
assert.Equal(t, config.DefaultResumePrompt, got)
}

func TestAppendPromptSections(t *testing.T) {
got := appendPromptSections("Resume", "Profile context", "", "## Open PR Context\nPR: https://github.com/acme/repo/pull/1")

assert.Equal(t, "Resume\n\nProfile context\n\n## Open PR Context\nPR: https://github.com/acme/repo/pull/1", got)
}

type promptCapturingRunner struct {
prompt string
sessionID string
}

func (r *promptCapturingRunner) RunTurn(_ context.Context, _ agent.Logger, _ func(agent.TurnResult), sessionID *string, prompt, _, _, _, _ string, _, _ int) (agent.TurnResult, error) {
r.prompt = prompt
if sessionID != nil {
r.sessionID = *sessionID
}
return agent.TurnResult{SessionID: r.sessionID, ResultText: "done"}, nil
}

func TestManualClaudeResumePromptIncludesFirstTurnContext(t *testing.T) {
cfg := &config.Config{
PromptTemplate: "Full prompt for {{ issue.identifier }}.",
}
cfg.Tracker.ActiveStates = []string{"In Progress"}
cfg.Tracker.TerminalStates = []string{"Done"}
cfg.Agent.MaxTurns = 1
cfg.Agent.Command = "claude"
cfg.Agent.ResumePrompt = "Resume {{ issue.identifier }}."
cfg.Agent.AgentMode = "teams"
cfg.Agent.Profiles = map[string]config.AgentProfile{
"lead": {
Command: "claude",
Prompt: "Lead role for {{ issue.identifier }}.",
},
"research": {
Command: "claude",
Prompt: "Research helper.",
},
}
issue := domain.Issue{ID: "id1", Identifier: "ENG-1", Title: "T", State: "In Progress"}
mt := tracker.NewMemoryTracker([]domain.Issue{issue}, cfg.Tracker.ActiveStates, cfg.Tracker.TerminalStates)
runner := &promptCapturingRunner{}
o := New(cfg, mt, runner, nil)

o.runWorker(context.Background(), issue, 0, "", "claude", "claude", "lead", true, "sess-1")

assert.Equal(t, "sess-1", runner.sessionID)
assert.Contains(t, runner.prompt, "Resume ENG-1.")
assert.NotContains(t, runner.prompt, "Full prompt for ENG-1.")
assert.Contains(t, runner.prompt, "Lead role for ENG-1.")
assert.Contains(t, runner.prompt, "## Available Sub-Agents")
assert.Contains(t, runner.prompt, "research")
}

// --- formatBufLine / makeBufLine (JSON output) ---

func TestFormatBufLine_IncludesLevelAndMessage(t *testing.T) {
Expand Down
Loading