diff --git a/CHANGELOG.md b/CHANGELOG.md index c097afed..a2f8eeb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +- **F083**: Conversation mode redesigned as an interactive user-driven loop — removes all automated multi-turn fields (`max_turns`, `max_context_tokens`, `strategy`, `stop_condition`, `inject_context`) and the `initial_prompt` agent field. Workflows using these fields now fail YAML parsing silently (fields ignored); update them as follows: + - `initial_prompt: X` → use `prompt: X` (serves as the first user message in conversation mode) + - `conversation.max_turns`, `conversation.max_context_tokens`, `conversation.strategy`, `conversation.stop_condition`, `conversation.inject_context` → remove all five; the user now drives turn count and exit by typing into stdin (empty line, `exit`, or `quit`) + - `mode: conversation` now requires a terminal (reads from stdin) and the `ConversationManager` adds a `StdinInputReader` wiring `os.Stdin`/`os.Stdout`; headless/CI usage must pipe stdin (e.g., `echo "" | awf run ...`) + - Only `conversation.continue_from` is preserved, for cross-step session resume + - New `StopReasonUserExit` replaces the removed `StopReasonCondition`, `StopReasonMaxTurns`, `StopReasonMaxTokens` constants +- **F083**: `conversation:` sub-struct now usable in `mode: single` (the default) to opt a step into session tracking — a plain agent step with `conversation: {}` runs `provider.ExecuteConversation` once (no interactive loop), establishing a session that downstream steps can resume via `continue_from`; without the sub-struct, `mode: single` behaves exactly as before (no session, no tracking); this changes the semantic of `conversation:` from "conversation mode only" to "session tracking marker" - **F081**: Codex model validation is stricter — only models with prefixes `gpt-`, `codex-`, or o-series pattern (e.g., `o1`, `o3-mini`) are accepted; workflows using non-OpenAI models (e.g., `code-davinci`, `text-davinci`) will fail validation; update YAML to use valid OpenAI model names or switch to a different provider - **F079**: Stored `ConversationState.SessionID` values from prior runs are invalidated for Gemini and Codex — old sentinel values (`"latest"`, `"last"`, `codex-*` prefixed IDs) do not match any real session and cause resume to skip (safe fallback to stateless mode); no migration needed, conversations restart cleanly on first run after upgrade - **F078**: CLI provider invocation flags updated to match current binary APIs — Claude and Gemini `output_format: json` now maps to `--output-format stream-json` (was `--output-format json`); Codex invocation changed from `codex --prompt "" --quiet` to `codex exec --json ""`; `quiet` option removed from Codex (silently ignored); Codex conversation resume changed from `codex resume --prompt ""` to `codex resume --json ""`; workflows using `output_format: json` require no YAML changes (mapping is automatic); workflows using `quiet: true` for Codex should remove the option (no-op) diff --git a/docs/user-guide/agent-steps.md b/docs/user-guide/agent-steps.md index 960ba283..6daa24fb 100644 --- a/docs/user-guide/agent-steps.md +++ b/docs/user-guide/agent-steps.md @@ -731,83 +731,80 @@ handle_json_error: ## Multi-Turn Conversations -There are two approaches for multi-turn conversations: +AWF offers three approaches for multi-turn interactions, from stateless to fully session-tracked. -### Chaining Steps (Manual State Passing) +### 1. State Passing (Chained Steps, Stateless) -For simple multi-turn workflows, chain agent steps with state passing: +Chain agent steps via template interpolation. Each agent call is stateless — the provider has no memory of prior steps — but the next prompt carries the prior step's output as text. Cheapest and simplest. ```yaml -name: code-review-conversation -version: "1.0.0" +initial_review: + type: agent + provider: claude + prompt: | + Review this code for issues: + {{.inputs.code}} + on_success: follow_up -inputs: - - name: code - type: string - required: true +follow_up: + type: agent + provider: claude + prompt: | + Based on your previous analysis: + {{.states.initial_review.Output}} -states: - initial: initial_review + Can you elaborate on performance concerns? + on_success: done +``` - initial_review: - type: agent - provider: claude - prompt: | - Review this code for issues: - {{.inputs.code}} - on_success: ask_about_performance +Use this when the prior output is small and the agent doesn't need implicit memory of prior conversation turns. - ask_about_performance: - type: agent - provider: claude - prompt: | - Based on your previous analysis: - {{.states.initial_review.Output}} +### 2. Cross-Step Session Tracking - Can you elaborate on performance concerns? - on_success: suggest_improvements +Add a `conversation:` sub-struct to an agent step (still `mode: single`, the default) to have AWF call `provider.ExecuteConversation` — one turn only, but the provider's session ID is captured. A later step with `conversation: {continue_from: prior_step}` clones that session state and resumes the actual provider-side conversation. - suggest_improvements: - type: agent - provider: claude - prompt: | - Based on the previous discussion, suggest 3 specific improvements to: - {{.inputs.code}} - on_success: done +```yaml +seed: + type: agent + provider: claude + system_prompt: "You are a memory test assistant." + prompt: | + Remember this secret: BANANA42. + Reply "stored". + conversation: {} # opt into session tracking + on_success: recall - done: - type: terminal +recall: + type: agent + provider: claude + prompt: "What was the secret?" + conversation: + continue_from: seed # resume seed's session + on_success: done ``` -Each step can reference previous agent outputs and build on the conversation without maintaining session state. +No interactive loop, no stdin. Each step runs exactly one agent turn. The provider retains the conversation between steps via its native session store (`claude -r`, `gemini --resume`, `codex resume`, `opencode -s`). + +Use this when the agent needs implicit memory of earlier turns or when prior context is large and you want to avoid re-sending it in each prompt. -### Conversation Mode (Built-In Multi-Turn) +### 3. Interactive Conversation Mode -For iterative refinement within a single step, use **conversation mode** with automatic context window management: +`mode: conversation` spawns a live user-driven chat loop: the agent replies, AWF prompts for your next message via stdin, and the loop continues until you submit an empty line, `exit`, or `quit`. ```yaml -refine_code: +chat: type: agent provider: claude mode: conversation - system_prompt: "You are a code reviewer. Iterate until code is approved." - initial_prompt: | - Review this code: - {{.inputs.code}} - conversation: - max_turns: 10 - max_context_tokens: 100000 - stop_condition: "response contains 'APPROVED'" + system_prompt: "You are a concise technical assistant." + prompt: "{{.inputs.topic}}" + timeout: 600 on_success: done ``` -**Key differences:** -- **Automatic turn management** — No need to manually chain steps -- **Context window handling** — Automatically truncates old turns when token limit approached -- **Stop conditions** — Exit conversation early when specific condition met -- **Single step** — Simpler workflows for iterative refinement +Requires a TTY. Use this for human-in-the-loop clarification sessions or iterative prompting driven by a user. -See [Conversation Mode Guide](conversation-steps.md) for detailed documentation, examples, and best practices. +See [Conversation Mode & Session Tracking](conversation-steps.md) for the complete reference, including `continue_from` rules, cross-provider limitations, and observability fields. ## Error Handling diff --git a/docs/user-guide/conversation-steps.md b/docs/user-guide/conversation-steps.md index fe850584..79a6ef5d 100644 --- a/docs/user-guide/conversation-steps.md +++ b/docs/user-guide/conversation-steps.md @@ -1,627 +1,338 @@ --- -title: "Agent Conversation Mode" +title: "Conversation Mode & Session Tracking" --- -Enable multi-turn conversations with AI agents, featuring automatic context window management, token counting, and stop conditions. +AWF supports two distinct conversation-related features on agent steps: -## Overview +1. **Interactive conversation mode** (`mode: conversation`) — a live, user-driven chat loop where the user types messages at each turn and ends the session with `exit`, `quit`, or an empty line. +2. **Cross-step session tracking** (`conversation:` sub-struct on any agent step) — opts a single-turn agent step into session tracking so another step can resume its conversation via `continue_from`. -While [agent steps](agent-steps.md) invoke agents once per step, **conversation mode** maintains conversation history across multiple turns within a single step. This allows iterative refinement, clarification loops, and complex reasoning without chaining multiple workflow steps. +These two features are independent. You can use either, both, or neither. -**Key features:** -- **Automatic turn management** - Maintain conversation history without explicit state passing -- **Context window handling** - Automatically truncate old turns when reaching token limits -- **Stop conditions** - Exit conversation early when a condition is met -- **Token tracking** - Monitor input/output token usage across turns -- **System prompt preservation** - Protect system instructions during truncation +## When to Use Which -## When to Use +| You want to... | Use | +|---|---| +| Chat with an agent from a terminal, one question at a time, until you decide to stop | `mode: conversation` | +| Run a single-turn agent call whose session a later step will resume | `mode: single` + `conversation: {}` | +| Run a single-turn agent call that resumes a prior step's session | `mode: single` + `conversation: {continue_from: prior_step}` | +| Run a plain one-shot agent call with no session at all | plain agent step, no `conversation:` sub-struct | -Use conversation mode when: -- **Iterative refinement** — Multiple rounds of feedback on generated content -- **Clarification loops** — Agent asks questions, workflow provides answers -- **Complex reasoning** — Chain-of-thought across multiple turns -- **Interactive workflows** — Step-by-step problem solving with the agent +## Interactive Conversation Mode -For simple single-turn interactions, use standard [agent steps](agent-steps.md) instead. +`mode: conversation` spawns an interactive loop: the agent replies, AWF prints a `> ` prompt, you type your next message, repeat. The session ends when you submit an empty line, `exit`, or `quit`. -## Basic Syntax +### Example ```yaml -name: code-review-conversation +name: interactive-clarify version: "1.0.0" inputs: - - name: code + - name: topic type: string - required: true + default: "explain Go channels" states: - initial: refine_code + initial: chat - refine_code: + chat: type: agent provider: claude mode: conversation system_prompt: | - You are a code reviewer. Iterate on the code until it meets quality standards. - Say "APPROVED" when done. - initial_prompt: | - Review this code: - {{.inputs.code}} + You are a concise technical assistant. Ask one clarifying question at a time. + prompt: | + {{.inputs.topic}} options: - model: claude-sonnet-4-20250514 - conversation: - max_turns: 10 - max_context_tokens: 100000 - strategy: sliding_window - stop_condition: "response contains 'APPROVED'" + model: claude-haiku-4-5 + timeout: 600 on_success: done done: type: terminal + status: success ``` -## Configuration +Run it: -### Step-Level Options +```bash +awf run interactive-clarify +``` -| Option | Type | Required | Default | Description | -|--------|------|----------|---------|-------------| -| `type` | string | Yes | — | Must be `agent` | -| `mode` | string | No | `step` | Set to `conversation` to enable multi-turn mode | -| `provider` | string | Yes | — | Agent provider: `claude`, `codex`, `gemini`, `opencode`, `openai_compatible`. Supports dynamic interpolation: `{{.inputs.agent}}` | -| `system_prompt` | string | No | — | System message for the entire conversation (preserved during truncation) | -| `initial_prompt` | string | Yes | — | Initial user message to start the conversation | -| `prompt` | string | No | — | Used when injecting context mid-conversation (see [Injecting Context](#injecting-context-mid-conversation)) | -| `options` | object | No | — | Provider-specific options (varies by provider — see [Agent Steps](agent-steps.md) for each provider's supported options) | -| `timeout` | duration | No | `300s` | Timeout for each turn | -| `on_success` | string | Yes | — | Next state on successful completion | -| `on_failure` | string | No | — | Next state on error | +You'll see the agent's first reply, then a `> ` prompt where you can type. When you're done, press Enter on an empty line or type `exit`. -### Conversation Configuration +### Required and Optional Fields -```yaml -conversation: - max_turns: 10 # Maximum number of turns (default: 10) - max_context_tokens: 100000 # Token budget for conversation (default: model limit) - strategy: sliding_window # Context window strategy (only sliding_window supported) - stop_condition: "expression" # Exit condition (optional) -``` +| Field | Required | Description | +|---|---|---| +| `provider` | Yes | Agent provider (`claude`, `gemini`, `codex`, `opencode`, `openai_compatible`) | +| `mode` | Yes | Must be `conversation` | +| `prompt` | Yes | First user message — sent automatically as turn 1 | +| `system_prompt` | No | System message preserved for the whole session | +| `options` | No | Provider-specific options (model, allowed_tools, etc.) | +| `timeout` | No | Per-turn timeout in seconds (default: 300) | -#### max_turns +### Terminal Requirement -Maximum number of conversation turns before automatic termination. +`mode: conversation` reads from `os.Stdin`. It requires a TTY or piped stdin. In CI/headless runs, pipe an empty input to exit immediately after turn 1: -```yaml -conversation: - max_turns: 5 # Conversation stops after 5 turns +```bash +echo "" | awf run interactive-clarify ``` -Useful for preventing runaway conversations and controlling costs. +For fully non-interactive workflows, prefer [cross-step session tracking](#cross-step-session-tracking) below. -#### max_context_tokens +### Exit Signals -Token budget for the entire conversation. When approaching this limit, context window strategy applies. +The conversation ends when: +- The user submits an **empty line** +- The user types `exit` or `quit` (case-insensitive) +- `stdin` returns EOF (e.g., a closed pipe) +- The step timeout fires -```yaml -conversation: - max_context_tokens: 100000 # Limit total tokens to 100k -``` +The step completes with `stopped_by: user_exit` in the recorded state. -Prevents exceeding model token limits. When exceeded, old turns are dropped using the configured strategy. +## Cross-Step Session Tracking -#### strategy +For automated workflows, you rarely want an interactive loop. You want step A to establish a conversation with an agent, and step B (later in the workflow) to resume that same session with additional context — without a human in the loop. -Context window strategy when token limit is reached: +This is what the `conversation:` sub-struct on a **single-mode** agent step provides. -- **`sliding_window`** - Drop oldest turns, preserving system prompt and most recent context. If omitted, no context management is applied. +### Example: Seed and Recall ```yaml -strategy: sliding_window -# Conversation with 5 turns reaches token limit -# Drop: Turn 1, Keep: System prompt, Turn 2, Turn 3, Turn 4, Turn 5 -``` +name: session-resume-demo +version: "1.0.0" -Future strategies: `summarize` (compress old turns), `truncate_middle` (keep first and last turns). These strategies are validated but not yet implemented — using them will produce a validation error suggesting `sliding_window` instead. +states: + initial: seed -#### stop_condition + seed: + type: agent + provider: claude + system_prompt: "You are a memory test assistant." + prompt: | + Remember this secret: the magic word is BANANA42. + Reply with exactly: "stored". + conversation: {} # opt into session tracking + options: + model: claude-haiku-4-5 + on_success: recall -Expression to evaluate after each turn. When true, conversation exits. + recall: + type: agent + provider: claude + prompt: | + What was the magic word I told you to remember? + conversation: + continue_from: seed # resume seed's session + on_success: done -```yaml -stop_condition: "response contains 'APPROVED'" + done: + type: terminal + status: success ``` -**Expression Syntax**: Supports comparison operators and string functions: -- `response contains 'text'` — Check if response contains substring -- `response matches 'regex'` — Match against regex pattern -- `turn_count >= 5` — Check number of turns -- `tokens_used > 50000` — Check token consumption - -See [Stop Condition Expressions](#stop-condition-expressions) for examples. +Both steps run as `mode: single` (the default — no `mode:` line needed). There is no interactive loop. Each step runs exactly one agent turn. -## Accessing Conversation State +- `seed` has `conversation: {}`. This marks the step as session-tracked: AWF calls `provider.ExecuteConversation` (instead of `provider.Execute`), the provider runs one turn, and the session ID returned by the CLI is captured into `state.conversation.session_id`. +- `recall` has `conversation: {continue_from: seed}`. AWF clones the conversation state from `seed` (session ID + turn history) and passes it to the provider, which resumes the session via its native flag (`claude -r `, `gemini --resume `, `codex resume `, `opencode -s `). -Conversation state is stored in the step state and accessible in subsequent steps: +### Why the Empty `conversation: {}`? -```yaml -analyze_conversation: - type: agent - provider: claude - mode: conversation - initial_prompt: "Start conversation" - on_success: review_conversation - -review_conversation: - type: step - command: | - echo "Conversation lasted {{.states.analyze_conversation.conversation.total_turns}} turns" - echo "Total tokens: {{.states.analyze_conversation.conversation.total_tokens}}" - on_success: done -``` +The presence of the `conversation:` sub-struct — even empty — is the marker that opts the step into session tracking. Without it, a single-mode agent step uses `provider.Execute` and produces no session state, so no other step can ever resume it. -### Conversation State Structure +Think of `conversation:` as a **flag** meaning *"track this step's session"*, not as "enable multi-turn mode". The field name is historical; its F083 meaning is session metadata. -> **Note:** The `role` field is read-only execution metadata assigned by AWF during execution. `system_prompt` maps to `system`, `initial_prompt`/`prompt` to `user`, and CLI responses to `assistant`. You do not write roles in workflow YAML — they appear only in the runtime state output. - -```yaml -states: - analyze_conversation: - output: "Final response text" - conversation: - turns: - - role: system - content: "You are a code reviewer..." - tokens: 50 - - role: user - content: "Review this code..." - tokens: 500 - - role: assistant - content: "I found these issues..." - tokens: 800 - - role: user - content: "Fix the issues" - tokens: 20 - - role: assistant - content: "Here's the fixed code... APPROVED" - tokens: 600 - total_turns: 5 - total_tokens: 1970 - stopped_by: "condition" # or "max_turns", "max_tokens" -``` +### ContinueFrom Rules -## Stop Condition Expressions +`continue_from` references another step by name. At runtime, AWF enforces: -Exit conversations early with programmatic conditions. +1. The referenced step must have **already executed** in the current run (forward references fail). +2. Its `state.conversation` must be non-nil — i.e., it must itself have been session-tracked (either `mode: conversation` or `mode: single` + `conversation: {}`). +3. The conversation state must have a non-empty `session_id` **or** at least one recorded turn. +4. For `provider: openai_compatible` (HTTP-based), at least one recorded turn is required since there is no server-side session. -### String Matching +Violating any of these produces a clear error: `continue_from: step "X" has no session ID or conversation history to resume`. -```yaml -# Exit when response contains exact text -stop_condition: "response contains 'DONE'" +### Cross-Provider Session Chains -# Exit when response matches pattern -stop_condition: "response matches 'APPROVED|COMPLETE'" +Each provider has its own session identifier format and CLI flag. A session established by Claude cannot be resumed by Gemini. Keep `provider` consistent across the seed and recall steps — or use distinct seed/recall pairs per provider, as in [`test-resume.yaml`](https://github.com/awf-project/cli/blob/main/.awf/workflows/test-resume.yaml). -# Case-insensitive matching -stop_condition: "response contains 'done' || response contains 'finished'" -``` +### Session Tracking vs. State Passing -### Token-Based Conditions +AWF has always supported chaining agent steps via template interpolation: ```yaml -# Exit when token count reaches threshold -stop_condition: "tokens_used > 80000" - -# Exit when approaching context limit -stop_condition: "tokens_used > max_context_tokens * 0.9" +step2: + type: agent + prompt: | + Based on: {{.states.step1.Output}} + Now answer: ... ``` -### Turn-Based Conditions +This is **state passing** — step2 gets step1's textual output but the agent has no memory of step1's conversation. Every step is stateless from the provider's perspective. -```yaml -# Exit after specific number of turns -stop_condition: "turn_count >= 5" +Session tracking is different: the provider itself retains the conversation (via its CLI's session store), so step2 resumes as if the agent never stopped. Benefits: -# Complex: exit after 5 turns OR if done -stop_condition: "turn_count >= 5 || response contains 'APPROVED'" -``` +- Large prior context doesn't need to be re-sent in the prompt (token savings) +- The agent can reference earlier parts of the session implicitly +- System prompt and tool state are retained by the provider -### Advanced Examples +Downsides: +- Coupled to the provider's session store (opaque, may expire) +- Only works within a single workflow run (sessions aren't persisted across runs) +- Fails gracefully to stateless if session ID extraction fails -```yaml -# Code review: approve after syntax fixes -stop_condition: "response contains 'All issues fixed' && turn_count >= 2" +Use state passing for simple chaining; use session tracking when the agent needs semantic continuity. -# Research loop: gather enough sources -stop_condition: "response contains 'sufficient sources' || turn_count >= 10" +## Common Configuration -# Conversation: stop on topic completion or token budget -stop_condition: "response contains 'Summary:' || tokens_used > 90000" -``` +### Fields Removed in F083 -## Continuing a Conversation from a Previous Step +If you're upgrading from an earlier AWF version, these fields no longer exist: -The `continue_from` field resumes a prior step's conversation session. For CLI providers (Claude, Gemini, Codex, OpenCode), AWF extracts the real session/thread ID from the first turn's output and passes it to the provider's native resume flag on subsequent turns. For `openai_compatible`, it loads the full conversation turn history as message context. +| Removed Field | Replacement | +|---|---| +| `initial_prompt` | Use `prompt` — it serves as the first user message | +| `conversation.max_turns` | The user drives turn count in interactive mode; `mode: single` is always one turn | +| `conversation.max_context_tokens` | Removed — context window management is deferred to the provider | +| `conversation.strategy` | Removed — no automatic truncation | +| `conversation.stop_condition` | Removed — user types `exit`/`quit` to stop interactive mode | +| `conversation.inject_context` | Removed — compose prompts with standard `{{.states.*}}` interpolation | -```yaml -states: - initial: refine_code +Workflows using any of these fields will silently ignore them (YAML lenient mode), which may produce unexpected behavior. **Remove them explicitly.** - refine_code: - type: agent - provider: claude - mode: conversation - system_prompt: "You are a code reviewer." - initial_prompt: | - Review this code: - {{.inputs.code}} - conversation: - max_turns: 5 - on_success: add_requirements +## Observability - add_requirements: - type: agent - provider: claude - mode: conversation - prompt: | - Also consider these requirements: - {{.inputs.additional_requirements}} - conversation: - max_turns: 3 - continue_from: refine_code - on_success: done +Both conversation features populate `state.conversation` on the step state with: - done: - type: terminal -``` +| Field | Description | +|---|---| +| `session_id` | Provider-assigned session identifier (empty if extraction failed) | +| `turns` | List of user and assistant messages recorded during the step | +| `total_turns` | Turn counter | +| `total_tokens` | Estimated token usage across the session | +| `stopped_by` | `user_exit` (user typed exit/quit/empty line) or `error` | -**How it works:** -- Step `refine_code` executes and extracts a session ID from CLI output (real UUID/thread ID, not a fabricated sentinel) -- Step `add_requirements` resumes the session from `refine_code` using `continue_from: refine_code` -- AWF passes the provider the resume flag with the extracted ID (e.g., `-r ` for Claude, `--resume ` for Gemini, `resume ` for Codex, `-s ` for OpenCode) -- The provider continues the existing conversation with full context from the prior step -- Each step stores its own `SessionID` — chains of 3+ steps work (B from A, C from B) -- Provider mismatch between source and target is allowed but may produce errors if incompatible +These fields are visible in `awf history ` output and in the step state files under `storage/states/`. -## Examples +## Complete Examples -### Iterative Code Review +### Interactive Clarification Loop + +A runnable example of `mode: conversation`. Paste into a file, run with `awf run `, answer the prompts, and type `exit` when done. ```yaml -name: iterative-code-review +name: clarify version: "1.0.0" +description: Interactive specification clarification session inputs: - - name: code - type: string - required: true - - name: requirements + - name: topic type: string - required: true + default: "explain Go channels in one sentence" states: - initial: review + initial: chat - review: + chat: type: agent provider: claude mode: conversation system_prompt: | - You are an expert code reviewer. Help improve the code quality step by step. - After each suggestion, wait for the user's response or revision. - Say "Code review complete!" when satisfied. - initial_prompt: | - Review this code and suggest the first improvement: - - {{.inputs.code}} - - Requirements to consider: - {{.inputs.requirements}} + You are a concise technical assistant. Ask one clarifying question + at a time and wait for the user's answer before continuing. When + the user types "exit", produce a final summary. + prompt: | + {{.inputs.topic}} options: - model: claude-sonnet-4-20250514 - conversation: - max_turns: 10 - max_context_tokens: 100000 - strategy: sliding_window - stop_condition: "response contains 'Code review complete!'" + model: claude-haiku-4-5 + timeout: 600 on_success: done done: type: terminal + status: success ``` -### Multi-Stage Problem Solving +### Cross-Step Session Resume Across Providers + +A non-interactive example exercising session tracking across Claude, Gemini, and OpenCode. Each provider gets a seed step (establishes a session with a secret) and a recall step (resumes the session and retrieves the secret). ```yaml -name: problem-solver +name: session-resume-demo version: "1.0.0" - -inputs: - - name: problem - type: string - required: true +description: Cross-step session resume across Claude, Gemini, and OpenCode states: - initial: analyze + initial: claude_seed - analyze: + claude_seed: type: agent provider: claude - mode: conversation - system_prompt: | - You are a problem-solving expert. Work through problems systematically. - Use the following stages: - 1. ANALYZE: Break down the problem - 2. PLAN: Outline approach - 3. IMPLEMENT: Provide solution - 4. VERIFY: Check solution - - End with "COMPLETE" when done. - initial_prompt: | - {{.inputs.problem}} - conversation: - max_turns: 8 - max_context_tokens: 50000 - stop_condition: "response contains 'COMPLETE'" - on_success: done - - done: - type: terminal -``` - -### Document Refinement Loop - -```yaml -name: document-refinement -version: "1.0.0" + system_prompt: "You are a memory test assistant. Answer briefly." + prompt: | + Remember this secret: the magic word is BANANA42. + Reply with exactly: "stored". + conversation: {} + options: + dangerously_skip_permissions: true + timeout: 60 + on_success: claude_recall -inputs: - - name: document - type: string - required: true + claude_recall: + type: agent + provider: claude + prompt: "What is the magic word I told you to remember?" + conversation: + continue_from: claude_seed + options: + dangerously_skip_permissions: true + timeout: 60 + on_success: gemini_seed -states: - initial: refine + gemini_seed: + type: agent + provider: gemini + system_prompt: "You are a memory test assistant. Answer briefly." + prompt: | + Remember this secret: the magic word is MANGO17. + Reply with exactly: "stored". + conversation: {} + options: + dangerously_skip_permissions: true + timeout: 60 + on_success: gemini_recall - refine: + gemini_recall: type: agent - provider: claude - mode: conversation - system_prompt: | - You are a professional editor. Improve the document iteratively. - Respond with the refined version and ask what specific improvements to focus on next. - initial_prompt: | - {{.inputs.document}} + provider: gemini + prompt: "What is the magic word I told you to remember?" conversation: - max_turns: 5 - max_context_tokens: 80000 - stop_condition: "turn_count >= 3" - on_success: summary + continue_from: gemini_seed + options: + dangerously_skip_permissions: true + timeout: 60 + on_success: verify - summary: + verify: type: step command: | - echo "Refinement complete." - echo "Turns: {{.states.refine.conversation.total_turns}}" - echo "Tokens: {{.states.refine.conversation.total_tokens}}" + echo "claude expected BANANA42 -> {{.states.claude_recall.Output}}" + echo "gemini expected MANGO17 -> {{.states.gemini_recall.Output}}" + continue_on_error: true on_success: done done: type: terminal + status: success ``` -## Best Practices - -### 1. Set Reasonable Turn Limits - -Always set `max_turns` to prevent runaway conversations: - -```yaml -conversation: - max_turns: 10 # Prevent infinite loops -``` - -### 2. Define Clear Stop Conditions - -Use specific, unambiguous conditions: - -```yaml -# ✅ Good: Specific completion signal -stop_condition: "response contains 'APPROVED'" - -# ❌ Vague: Could match unintended text -stop_condition: "response contains 'done'" -``` - -### 3. Monitor Token Usage - -Set appropriate `max_context_tokens`: - -```yaml -conversation: - max_context_tokens: 100000 # Model limit for Claude 3 Sonnet -``` - -Check token usage in subsequent steps: - -```yaml -log_tokens: - type: step - command: echo "Tokens used: {{.states.analyze.conversation.total_tokens}}" - on_success: done -``` - -### 4. Use System Prompt Effectively - -System prompt guides the agent's behavior across all turns. For CLI providers, the `--system-prompt` flag is passed on the first conversation turn. On resumed turns (2+), the provider retains the system prompt from the session. - -```yaml -system_prompt: | - You are a code reviewer. - Focus on security, performance, and readability. - Keep responses concise. - Use JSON format for structured feedback. -``` - -### 5. Test Stop Conditions - -Verify stop conditions work as expected: - -```bash -awf run workflow --dry-run -# Review the prompt and stop condition -``` - -### 6. Handle Errors Gracefully - -Add error handling for conversation failures: - -```yaml -refine: - type: agent - mode: conversation - initial_prompt: "Review this code" - on_success: done - on_failure: error - timeout: 120 - -error: - type: terminal - status: failure -``` - -## Troubleshooting - -### Conversation Runs Longer Than Expected - -**Problem**: Conversation continues past expected point - -**Solutions**: -- Review stop condition: `awf run workflow --dry-run` -- Lower `max_turns` if using as fallback -- Make stop condition more specific (avoid ambiguous phrases) -- Check provider CLI is matching expected output - -### Token Count Exceeds Limit - -**Problem**: Context window strategy truncates important turns - -**Solutions**: -- Increase `max_context_tokens` if model supports it -- Reduce initial prompt size -- Use shorter system prompt -- Lower `max_turns` to reduce conversation length - -### Conversation Fails After First Turn - -**Problem**: Error "prompt cannot be empty" on second turn - -**Solution**: This was a bug in versions prior to F051 (fixed in v0.1.0+). The implementation incorrectly set prompts to empty strings for subsequent conversation turns. - -**Workaround** (if on older version): -- Upgrade to latest version with `go install github.com/awf-project/cli/cmd/awf@latest` -- Or use single-turn agent steps with explicit state chaining instead - -**Fixed in**: F051 (See CHANGELOG.md for details) - -### CLI Providers Execute Turns Independently - -**Problem**: Agent doesn't maintain history across turns with CLI providers - -**Explanation**: CLI-based providers (`claude`, `codex`, `gemini`, `opencode`) execute each conversation turn as an independent process invocation. AWF passes only the current turn's prompt via `-p` (not serialized history). With session resume support (F073) and improved session ID extraction (F079), providers use native `--resume` flags to maintain context across turns: -- Each turn starts a fresh CLI process with provider-specific resume flags -- **Claude** uses `-r ` to resume -- **Gemini** uses `--resume ` (real UUID from `type: "init"` JSON event) -- **Codex** uses `resume ` (real ID from `type: "thread.started"` JSON event) -- **OpenCode** uses `-s ` (real ID from `type: "step_start"` JSON event, falls back to `-c` if extraction fails) -- Session IDs are extracted from CLI output after the first turn and used on subsequent turns - -**Solution**: CLI providers now support session resume natively and reliably (as of F079). If you need HTTP-based multi-turn conversation with full message history, use the `openai_compatible` provider, which sends the complete message history via the Chat Completions API: - -```yaml -refine: - type: agent - provider: openai_compatible - mode: conversation - options: - base_url: https://api.openai.com/v1 - model: gpt-4 - initial_prompt: "Review this code" - conversation: - max_turns: 10 - strategy: sliding_window - on_success: done -``` - -## Injecting Context Mid-Conversation - -The `inject_context` field enriches ongoing conversations with additional context after the first turn. This is useful for passing results from other steps or static reference material without cluttering `initial_prompt`. - -### How It Works - -- **Turn 1**: Only `initial_prompt` (or `prompt`) is sent — `inject_context` is excluded -- **Turn 2+**: The interpolated `inject_context` content is appended to the user prompt, separated by two newlines -- **Per-turn interpolation**: Template variables are re-evaluated each turn, so `{{.states.*}}` references reflect the latest available state - -### Example: Injecting Step Results - -```yaml -states: - initial: run_tests - - run_tests: - type: step - command: "make test" - on_success: review - - review: - type: agent - provider: claude - mode: conversation - initial_prompt: "Review the codebase for quality issues" - conversation: - max_turns: 5 - inject_context: | - Test results from the previous step: - {{.states.run_tests.output}} - stop_condition: "response contains 'APPROVED'" - on_success: done -``` - -In this example, the agent receives only the review prompt on turn 1. On turns 2 through 5, each user prompt also includes the interpolated test results. - -### Edge Cases - -- **Empty or whitespace-only**: Treated as no injection — nothing appended -- **Missing state references**: Template resolves to empty string (no error) -- **With `continue_from`**: Both work together — `continue_from` resumes the session, `inject_context` enriches subsequent turns - -## Limitations - -### Provider Compatibility - -All providers support conversation mode with context continuity: - -- **`openai_compatible`** maintains full conversation history via the Chat Completions API (messages array). -- **CLI providers** (`claude`, `codex`, `gemini`, `opencode`) use native session resume flags (`-r`, `resume`, `--resume`, `-s`) to maintain context across turns. Session IDs are extracted from provider-specific JSON events in CLI output after the first turn and passed on subsequent turns. Extraction is reliable as of F079 and uses real provider session/thread IDs; if extraction fails (rare), the provider falls back gracefully to stateless mode or fallback flags (e.g., OpenCode `-c` for "continue last session"). - -### Current Implementation - -- Only `sliding_window` strategy implemented (dropping oldest turns); `summarize` and `truncate_middle` are rejected at validation -- Stop conditions limited to string/token/turn expressions -- No branching conversations (single linear path) - -### Future Enhancements - -- `summarize` strategy (LLM-based compression of old turns) -- `truncate_middle` strategy (keep first and last turns) -- Conversation branching (explore multiple paths) +Run with `awf run session-resume-demo`. Each agent step runs exactly one turn; the recall steps prove the provider retained the seed step's session by recalling the secret without being re-told. ## See Also -- [Agent Steps Guide](agent-steps.md) - Standard single-turn agent invocation -- [Workflow Syntax Reference](workflow-syntax.md#agent-state) - Complete agent step options -- [Template Variables](../reference/interpolation.md) - Available variables in prompts -- [Examples](examples.md) - More workflow examples +- [Agent Steps](agent-steps.md) — complete reference for `type: agent`, including provider options, output formats, and single-turn usage +- [Workflow Syntax](workflow-syntax.md) — full YAML reference diff --git a/docs/user-guide/workflow-syntax.md b/docs/user-guide/workflow-syntax.md index f2a92f1a..e2a2ba83 100644 --- a/docs/user-guide/workflow-syntax.md +++ b/docs/user-guide/workflow-syntax.md @@ -389,60 +389,78 @@ analyze: ### Conversation Mode -Enable multi-turn conversations with automatic context management: +`mode: conversation` runs an **interactive user-driven loop**: the agent replies, AWF prompts for your next message, and the loop continues until you submit an empty line, `exit`, or `quit`. It requires a terminal (or piped stdin). ```yaml -refine_code: +chat: type: agent provider: claude mode: conversation system_prompt: | - You are a code reviewer. Iterate until code is approved. - Say "APPROVED" when done. - initial_prompt: | - Review this code: - {{.inputs.code}} + You are a concise technical assistant. + prompt: | + {{.inputs.topic}} options: - model: claude-sonnet-4-20250514 - conversation: - max_turns: 10 - max_context_tokens: 100000 - strategy: sliding_window - stop_condition: "response contains 'APPROVED'" - on_success: deploy - on_failure: error + model: claude-haiku-4-5 + timeout: 600 + on_success: done ``` +For **automated cross-step session resume** (no stdin loop), use `mode: single` (the default) with a `conversation:` sub-struct — see [Session Tracking](#session-tracking) below. + ### Agent Options | Option | Type | Required | Description | |--------|------|----------|-------------| | `provider` | string | Yes | Agent provider: `claude`, `codex`, `gemini`, `opencode`, `openai_compatible` | -| `mode` | string | No | Set to `conversation` for multi-turn mode | -| `prompt` | string | Yes* | Prompt template (supports `{{.inputs.*}}` and `{{.states.*}}` interpolation) | -| `prompt_file` | string | No* | Path to external prompt template file (mutually exclusive with `prompt`) | -| `system_prompt` | string | No | System message (for conversation mode, preserved across turns) | -| `initial_prompt` | string | No* | First user message (for conversation mode) | +| `mode` | string | No | `single` (default) or `conversation` (interactive user-driven loop) | +| `prompt` | string | Yes* | Prompt template (supports `{{.inputs.*}}` and `{{.states.*}}` interpolation); in `mode: conversation` this serves as the first user message | +| `prompt_file` | string | No* | Path to external prompt template file (mutually exclusive with `prompt`; not supported in `mode: conversation`) | +| `system_prompt` | string | No | System message preserved for the whole session | | `output_format` | string | No | Post-processing format: `json` (strip fences + validate JSON) or `text` (strip fences only) | -| `conversation` | object | No | Conversation configuration (required if mode=conversation) | +| `conversation` | object | No | Session tracking sub-struct. Presence opts the step into session tracking (marker flag); contents: see [Session Tracking](#session-tracking) | | `options` | map | No | Provider-specific options (varies by provider — see [Agent Steps](agent-steps.md) for each provider's supported options) | | `timeout` | int or string | No | Execution timeout — integer seconds (`30`) or Go duration string (`"1m30s"`, `"500ms"`). 0 = no timeout | | `on_success` | string | No | Next state on success | | `on_failure` | string or object | No | Next state on failure — string (named terminal ref) or inline object (see [Inline Error Shorthand](#inline-error-shorthand)) | | `retry` | object | No | Retry configuration (same as step retry) | -\* Use `prompt` or `prompt_file` for single-turn mode (mutually exclusive), `initial_prompt` for conversation mode. See [Agent Steps - External Prompt Files](agent-steps.md#external-prompt-files) for `prompt_file` details. +\* Use `prompt` or `prompt_file` (mutually exclusive). `prompt_file` is not allowed in `mode: conversation`. + +### Session Tracking -### Conversation Configuration +The `conversation:` sub-struct opts an agent step into session tracking. Its presence — even empty — switches the step from `provider.Execute` (no session) to `provider.ExecuteConversation` (captures the provider's session ID). This lets **another** step resume the same session later via `continue_from`. | Option | Type | Default | Description | |--------|------|---------|-------------| -| `max_turns` | int | 10 | Maximum conversation turns | -| `max_context_tokens` | int | model limit | Token budget for conversation | -| `strategy` | string | `-` | Context window strategy: `sliding_window`, `summarize` (not yet implemented), `truncate_middle` (not yet implemented). Omitting means no context management is applied | -| `stop_condition` | string | - | Expression to exit early | -| `continue_from` | string | - | Step name to continue conversation from — resumes prior step's session | -| `inject_context` | string | - | Additional context to inject into user prompts on turns 2+. Supports template variables (`{{.states.*}}`, `{{.inputs.*}}`, etc.). Re-interpolated per turn. | +| `continue_from` | string | - | Step name whose session this step should resume. Target step must have already run and been session-tracked. | + +**Three usage patterns:** + +```yaml +# 1. No session tracking (plain one-shot agent call) +step: + type: agent + provider: claude + prompt: "..." + +# 2. Session tracking, fresh session (enables downstream continue_from) +seed: + type: agent + provider: claude + prompt: "..." + conversation: {} + +# 3. Session tracking, resumes prior step's session +recall: + type: agent + provider: claude + prompt: "..." + conversation: + continue_from: seed +``` + +See [Conversation Mode & Session Tracking](conversation-steps.md) for the full reference and cross-provider examples. ### Available Providers @@ -468,46 +486,50 @@ Agent responses are captured in the step state: ### Multi-Turn Conversations -**Recommended**: Use conversation mode for iterative workflows: +Three approaches, from simplest to most stateful: + +**1. State passing** — chain agent steps via template interpolation. Each step is stateless; the agent has no memory of prior steps but the next prompt carries the prior output textually. ```yaml -review: +ask_question: type: agent provider: claude - mode: conversation - system_prompt: "You are a code reviewer." - initial_prompt: "Review: {{.inputs.code}}" - conversation: - max_turns: 10 - stop_condition: "response contains 'APPROVED'" + prompt: "Initial question here" + on_success: follow_up + +follow_up: + type: agent + provider: claude + prompt: | + Based on your previous response: + {{.states.ask_question.Output}} + + Please elaborate on point 3. on_success: done ``` -**Legacy**: Chain multiple agent steps with state passing: +**2. Cross-step session tracking** — use `conversation: {}` on the seed step and `conversation: {continue_from: seed}` on subsequent steps to have the provider itself retain the session. No stdin, no interactive loop. ```yaml -states: - initial: ask_question - - ask_question: - type: agent - provider: claude - prompt: "Initial question here" - on_success: follow_up +seed: + type: agent + provider: claude + prompt: "Analyze this code: {{.inputs.code}}" + conversation: {} + on_success: refine - follow_up: - type: agent - provider: claude - prompt: | - Based on your previous response: - {{.states.ask_question.Output}} +refine: + type: agent + provider: claude + prompt: "Now suggest 3 improvements based on that analysis." + conversation: + continue_from: seed + on_success: done +``` - Please elaborate on point 3. - on_success: done +**3. Interactive conversation mode** — `mode: conversation` for a live user-driven chat loop (requires stdin). - done: - type: terminal -``` +See [Conversation Mode & Session Tracking](conversation-steps.md) for the complete reference. **See Also:** [Conversation Mode Guide](conversation-steps.md) for detailed examples and best practices. diff --git a/internal/application/conversation_manager.go b/internal/application/conversation_manager.go index 11263f95..515fffba 100644 --- a/internal/application/conversation_manager.go +++ b/internal/application/conversation_manager.go @@ -12,49 +12,45 @@ import ( "github.com/awf-project/cli/pkg/interpolation" ) -// ConversationManager orchestrates multi-turn agent conversations with automatic -// context window management, token counting, and stop condition evaluation. +// ConversationManager orchestrates interactive user→agent→user conversations. // -// Following the LoopExecutor pattern, ConversationManager: -// - Manages turn iteration (analogous to loop iterations) -// - Evaluates stop conditions (analogous to break conditions) -// - Maintains conversation state (analogous to loop context) -// - Integrates with AgentProvider for turn execution +// Each turn: resolve initial prompt → send to provider → stream response → +// read user input → repeat until empty input or context cancellation. type ConversationManager struct { - logger ports.Logger - evaluator ports.ExpressionEvaluator - resolver interpolation.Resolver - tokenizer ports.Tokenizer - agentRegistry ports.AgentRegistry + logger ports.Logger + resolver interpolation.Resolver + agentRegistry ports.AgentRegistry + userInputReader ports.UserInputReader } func NewConversationManager( logger ports.Logger, - evaluator ports.ExpressionEvaluator, resolver interpolation.Resolver, - tokenizer ports.Tokenizer, agentRegistry ports.AgentRegistry, ) *ConversationManager { return &ConversationManager{ logger: logger, - evaluator: evaluator, resolver: resolver, - tokenizer: tokenizer, agentRegistry: agentRegistry, } } -// validateConversationInputs validates step and config inputs. +// SetUserInputReader wires the optional user input reader for interactive conversations. +// When nil, conversations that require user input will return an error. +func (m *ConversationManager) SetUserInputReader(r ports.UserInputReader) { + m.userInputReader = r +} + +// validateConversationInputs validates step and agent config inputs. +// ConversationConfig is optional — a nil config is treated as an empty config +// (no ContinueFrom reference). func (m *ConversationManager) validateConversationInputs( step *workflow.Step, - config *workflow.ConversationConfig, + _ *workflow.ConversationConfig, ) error { if step == nil || step.Agent == nil { return errors.New("step or agent config is nil") } - if config == nil { - return errors.New("conversation config is nil") - } return nil } @@ -69,7 +65,7 @@ func (m *ConversationManager) initializeConversationState( buildContext ContextBuilderFunc, ) (*workflow.ConversationState, string, error) { var state *workflow.ConversationState - if config.ContinueFrom != "" { + if config != nil && config.ContinueFrom != "" { priorStepState, ok := execCtx.GetStepState(config.ContinueFrom) if !ok { return nil, "", fmt.Errorf("continue_from: step %q not found in execution context", config.ContinueFrom) @@ -98,13 +94,8 @@ func (m *ConversationManager) initializeConversationState( state = workflow.NewConversationState(systemPrompt) } - initialPrompt := step.Agent.Prompt - if step.Agent.InitialPrompt != "" { - initialPrompt = step.Agent.InitialPrompt - } - intCtx := buildContext(execCtx) - resolvedPrompt, err := m.resolver.Resolve(initialPrompt, intCtx) + resolvedPrompt, err := m.resolver.Resolve(step.Agent.Prompt, intCtx) if err != nil { return nil, "", fmt.Errorf("step %s: resolve prompt: %w", step.Name, err) } @@ -135,63 +126,23 @@ func (m *ConversationManager) executeTurn( return result, nil } -// evaluateTurnCompletion evaluates stop conditions and max tokens, -// returns true if conversation should stop. -func (m *ConversationManager) evaluateTurnCompletion( - config *workflow.ConversationConfig, - state *workflow.ConversationState, - execCtx *workflow.ExecutionContext, - buildContext ContextBuilderFunc, -) bool { - if config.StopCondition != "" { - stopCtx := buildContext(execCtx) - if stopCtx.Inputs == nil { - stopCtx.Inputs = make(map[string]any) - } - stopCtx.Inputs["response"] = state.GetLastAssistantResponse() - stopCtx.Inputs["turn_count"] = state.TotalTurns - - shouldStop, err := m.evaluator.EvaluateBool(config.StopCondition, stopCtx) - if err != nil { - m.logger.Warn("failed to evaluate stop condition", "error", err) - } else if shouldStop { - state.StoppedBy = workflow.StopReasonCondition - return true - } - } - - if config.MaxContextTokens > 0 && state.TotalTokens >= config.MaxContextTokens { - state.StoppedBy = workflow.StopReasonMaxTokens - return true - } - - return false -} - -// ExecuteConversation orchestrates a multi-turn conversation according to the -// configuration in the agent step's conversation settings. +// ExecuteConversation orchestrates an interactive user→agent→user conversation. // // Flow: // 1. Initialize conversation state with system prompt (if provided) // 2. Execute initial user prompt to start conversation -// 3. For each turn: -// a. Execute agent provider with conversation history -// b. Add agent response to conversation state -// c. Count tokens and apply context window strategy if needed -// d. Evaluate stop condition -// e. Check max turns/tokens limits -// f. If continuing, prepare next user prompt -// 4. Return final ConversationResult +// 3. Read user input; empty input ends the conversation (StopReasonUserExit) +// 4. Repeat until empty input or context cancellation // // Parameters: // - ctx: context for cancellation and timeout // - step: agent step configuration with conversation settings -// - config: conversation configuration (max_turns, strategy, stop_condition) +// - config: conversation configuration (continue_from) // - execCtx: execution context with state and inputs // - buildContext: function to build interpolation context for template resolution // // Returns: -// - ConversationResult with final state, output, token counts, and stop reason +// - ConversationResult with final state, output, and stop reason // - error if conversation execution fails func (m *ConversationManager) ExecuteConversation( ctx context.Context, @@ -221,7 +172,7 @@ func (m *ConversationManager) ExecuteConversation( return nil, err } - // Clone options to preserve FR-009 immutability of step.Agent.Options, + // Clone options to preserve immutability of step.Agent.Options, // and inject output_format so baseCLIProvider can route display filtering // identically between executeAgentStep and conversation mode (F082). options := cloneAndInjectOutputFormat(step.Agent.Options, step.Agent.OutputFormat) @@ -229,46 +180,38 @@ func (m *ConversationManager) ExecuteConversation( options["system_prompt"] = step.Agent.SystemPrompt } - maxTurns := config.MaxTurns - if maxTurns <= 0 { - maxTurns = 10 + if m.userInputReader == nil { + return nil, errors.New("conversation mode requires a UserInputReader; none configured") } var lastResult *workflow.ConversationResult - for turnCount := 0; turnCount < maxTurns; turnCount++ { + + for { result, err := m.executeTurn(ctx, provider, state, resolvedPrompt, options, stdoutW, stderrW) if err != nil { - return nil, err + if lastResult != nil { + lastResult.State = state + lastResult.Error = err + } + return lastResult, err } state = result.State lastResult = result - if m.evaluateTurnCompletion(config, state, execCtx, buildContext) { - break - } - - intCtx := buildContext(execCtx) - resolvedPrompt, err = m.resolver.Resolve(step.Agent.Prompt, intCtx) + userInput, err := m.userInputReader.ReadInput(ctx) if err != nil { - return nil, err + state.StoppedBy = workflow.StopReasonError + lastResult.State = state + return lastResult, fmt.Errorf("reading user input: %w", err) } - if config.InjectContext != "" { - resolvedInjectContext, injectErr := m.resolver.Resolve(config.InjectContext, intCtx) - if injectErr != nil { - return nil, fmt.Errorf("inject_context: %w", injectErr) - } - if trimmed := strings.TrimSpace(resolvedInjectContext); trimmed != "" { - resolvedPrompt = resolvedPrompt + "\n\n" + trimmed - } + if strings.TrimSpace(userInput) == "" { + state.StoppedBy = workflow.StopReasonUserExit + break } - } - if state.StoppedBy == "" { - if state.TotalTurns >= maxTurns { - state.StoppedBy = workflow.StopReasonMaxTurns - } + resolvedPrompt = userInput } if lastResult != nil { diff --git a/internal/application/conversation_manager_helpers_test.go b/internal/application/conversation_manager_helpers_test.go index 51de6a63..83f905f7 100644 --- a/internal/application/conversation_manager_helpers_test.go +++ b/internal/application/conversation_manager_helpers_test.go @@ -2,673 +2,314 @@ package application import ( "context" - "errors" "io" "testing" - "github.com/awf-project/cli/internal/domain/ports" "github.com/awf-project/cli/internal/domain/workflow" + "github.com/awf-project/cli/internal/testutil/mocks" "github.com/awf-project/cli/pkg/interpolation" "github.com/stretchr/testify/assert" ) -// Note: mockLogger is defined in service_test.go and shared across package tests +// Simple resolver mock for testing +type testResolver struct{} -// newMockLogger creates a new mock logger instance -func newMockLogger() *mockLogger { - return &mockLogger{} +func (t *testResolver) Resolve(template string, ctx *interpolation.Context) (string, error) { + return template, nil } -// mockAgentProvider is a test double for agent providers -type mockAgentProvider struct { - name string - result *workflow.ConversationResult - err error -} +// TestValidateConversationInputs_HappyPath tests valid inputs are accepted +func TestValidateConversationInputs_HappyPath(t *testing.T) { + manager := &ConversationManager{} -func (m *mockAgentProvider) Name() string { - return m.name -} + step := &workflow.Step{ + Name: "chat", + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Hello", + }, + } + config := &workflow.ConversationConfig{} -func (m *mockAgentProvider) Execute(ctx context.Context, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.AgentResult, error) { - return nil, nil // Not used in conversation manager tests -} + err := manager.validateConversationInputs(step, config) -func (m *mockAgentProvider) ExecuteConversation( - ctx context.Context, - state *workflow.ConversationState, - prompt string, - options map[string]any, - stdout, stderr io.Writer, -) (*workflow.ConversationResult, error) { - if m.err != nil { - return nil, m.err - } - return m.result, nil + assert.NoError(t, err) } -func (m *mockAgentProvider) Validate() error { - return nil // Not used in conversation manager tests -} +// TestValidateConversationInputs_NilStep tests nil step is rejected +func TestValidateConversationInputs_NilStep(t *testing.T) { + manager := &ConversationManager{} + config := &workflow.ConversationConfig{} -// mockAgentRegistry is a test double for agent registry -type mockAgentRegistry struct { - provider ports.AgentProvider - err error -} + err := manager.validateConversationInputs(nil, config) -func (m *mockAgentRegistry) Register(provider ports.AgentProvider) error { - return nil + assert.Error(t, err) } -func (m *mockAgentRegistry) Get(name string) (ports.AgentProvider, error) { - if m.err != nil { - return nil, m.err - } - return m.provider, nil -} +// TestValidateConversationInputs_NilAgent tests nil agent config is rejected +func TestValidateConversationInputs_NilAgent(t *testing.T) { + manager := &ConversationManager{} -func (m *mockAgentRegistry) List() []string { - return []string{} -} + step := &workflow.Step{ + Name: "chat", + } + config := &workflow.ConversationConfig{} -func (m *mockAgentRegistry) Has(name string) bool { - return m.provider != nil -} + err := manager.validateConversationInputs(step, config) -// mockTokenizer is a test double for tokenizer -type mockTokenizer struct { - count int + assert.Error(t, err) } -func (m *mockTokenizer) CountTokens(text string) (int, error) { - return m.count, nil -} +// TestValidateConversationInputs_NilConfig_Allowed verifies that a nil +// ConversationConfig is accepted: all its fields are optional post-F083, so a +// nil config is treated as an empty config (no ContinueFrom reference). +func TestValidateConversationInputs_NilConfig_Allowed(t *testing.T) { + manager := &ConversationManager{} + + step := &workflow.Step{ + Name: "chat", + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Hello", + }, + } -func (m *mockTokenizer) CountTurnsTokens(turns []string) (int, error) { - return m.count * len(turns), nil -} + err := manager.validateConversationInputs(step, nil) -func (m *mockTokenizer) IsEstimate() bool { - return false + assert.NoError(t, err) } -func (m *mockTokenizer) ModelName() string { - return "mock" -} +// TestInitializeConversationState_FreshStart tests creating new conversation state +func TestInitializeConversationState_FreshStart(t *testing.T) { + logger := mocks.NewMockLogger() + resolver := &testResolver{} + registry := mocks.NewMockAgentRegistry() + manager := NewConversationManager(logger, resolver, registry) + + step := &workflow.Step{ + Name: "chat", + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Hello world", + }, + } + config := &workflow.ConversationConfig{} -// mockEvaluator is a test double for expression evaluator -// C042: Updated to implement ports.ExpressionEvaluator interface -type mockEvaluator struct { - result bool - err error -} + execCtx := workflow.NewExecutionContext("test-id", "test-workflow") + execCtx.States = make(map[string]workflow.StepState) -func (m *mockEvaluator) EvaluateBool(expr string, ctx *interpolation.Context) (bool, error) { - return m.result, m.err -} + buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { + return interpolation.NewContext() + } -func (m *mockEvaluator) EvaluateInt(expr string, ctx *interpolation.Context) (int, error) { - return 0, nil -} + state, resolvedPrompt, err := manager.initializeConversationState(step, "claude", config, execCtx, buildContext) -// mockResolverWithError is a test double for interpolation resolver that can return errors -type mockResolverWithError struct { - err error + assert.NoError(t, err) + assert.NotNil(t, state) + assert.Equal(t, "Hello world", resolvedPrompt) } -func (m *mockResolverWithError) Resolve(template string, ctx *interpolation.Context) (string, error) { - if m.err != nil { - return "", m.err +// TestInitializeConversationState_ContinueFrom_WithSessionID tests resuming conversation +func TestInitializeConversationState_ContinueFrom_WithSessionID(t *testing.T) { + logger := mocks.NewMockLogger() + resolver := &testResolver{} + registry := mocks.NewMockAgentRegistry() + manager := NewConversationManager(logger, resolver, registry) + + priorState := workflow.NewConversationState("") + priorState.SessionID = "session-abc123" + priorState.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, "First question")) + priorState.AddTurn(workflow.NewTurn(workflow.TurnRoleAssistant, "First answer")) + + step := &workflow.Step{ + Name: "follow_up", + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Continue from session", + }, + } + config := &workflow.ConversationConfig{ + ContinueFrom: "prior_step", } - return template, nil -} -// Feature: C006 - Reduce ExecuteConversation complexity from 29 to ≤18 - -// TestConversationManager_validateConversationInputs tests input validation -// for the ExecuteConversation method. -// Feature: C006 - Component T013 -func TestConversationManager_validateConversationInputs(t *testing.T) { - tests := []struct { - name string - step *workflow.Step - config *workflow.ConversationConfig - expectError bool - errorMsg string - }{ - { - name: "valid inputs", - step: &workflow.Step{ - Name: "test-step", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "test-provider", - Prompt: "test prompt", - }, - }, - config: &workflow.ConversationConfig{ - MaxTurns: 5, - }, - expectError: false, - }, - { - name: "nil step", - step: nil, - config: &workflow.ConversationConfig{MaxTurns: 5}, - expectError: true, - errorMsg: "step or agent config is nil", - }, - { - name: "nil agent config", - step: &workflow.Step{ - Name: "test-step", - Type: workflow.StepTypeAgent, - Agent: nil, - }, - config: &workflow.ConversationConfig{MaxTurns: 5}, - expectError: true, - errorMsg: "step or agent config is nil", - }, - { - name: "nil conversation config", - step: &workflow.Step{ - Name: "test-step", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "test-provider", - Prompt: "test prompt", - }, - }, - config: nil, - expectError: true, - errorMsg: "conversation config is nil", + execCtx := workflow.NewExecutionContext("test-id", "test-workflow") + execCtx.States = map[string]workflow.StepState{ + "prior_step": { + Name: "prior_step", + Conversation: priorState, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mgr := &ConversationManager{ - logger: newMockLogger(), - evaluator: &mockEvaluator{}, - resolver: newMockResolver(), - tokenizer: &mockTokenizer{}, - agentRegistry: &mockAgentRegistry{}, - } - - err := mgr.validateConversationInputs(tt.step, tt.config) - - if tt.expectError { - assert.Error(t, err) - if tt.errorMsg != "" { - assert.Contains(t, err.Error(), tt.errorMsg) - } - } else { - assert.NoError(t, err) - } - }) + buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { + return interpolation.NewContext() } + + state, resolvedPrompt, err := manager.initializeConversationState(step, "claude", config, execCtx, buildContext) + + assert.NoError(t, err) + assert.NotNil(t, state) + assert.Equal(t, "session-abc123", state.SessionID) + assert.Equal(t, "Continue from session", resolvedPrompt) + assert.Equal(t, 2, len(state.Turns)) } -// TestConversationManager_initializeConversationState tests conversation state -// initialization with system prompt and initial prompt resolution. -// Feature: C006 - Component T013 -func TestConversationManager_initializeConversationState(t *testing.T) { - tests := []struct { - name string - step *workflow.Step - buildContext ContextBuilderFunc - resolverError error - expectError bool - expectedPrompt string - expectedSystemPrompt string - }{ - { - name: "system prompt present with initial prompt", - step: &workflow.Step{ - Name: "test-step", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "test-provider", - SystemPrompt: "You are a helpful assistant", - InitialPrompt: "Hello, how can I help?", - Prompt: "fallback prompt", - }, - }, - buildContext: func(ctx *workflow.ExecutionContext) *interpolation.Context { - return &interpolation.Context{} - }, - expectError: false, - expectedPrompt: "Hello, how can I help?", - expectedSystemPrompt: "You are a helpful assistant", - }, - { - name: "empty system prompt", - step: &workflow.Step{ - Name: "test-step", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "test-provider", - SystemPrompt: "", - Prompt: "test prompt", - }, - }, - buildContext: func(ctx *workflow.ExecutionContext) *interpolation.Context { - return &interpolation.Context{} - }, - expectError: false, - expectedPrompt: "test prompt", - expectedSystemPrompt: "", - }, - { - name: "initial prompt priority over prompt", - step: &workflow.Step{ - Name: "test-step", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "test-provider", - InitialPrompt: "initial", - Prompt: "regular", - }, - }, - buildContext: func(ctx *workflow.ExecutionContext) *interpolation.Context { - return &interpolation.Context{} - }, - expectError: false, - expectedPrompt: "initial", - }, - { - name: "prompt fallback when no initial prompt", - step: &workflow.Step{ - Name: "test-step", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "test-provider", - Prompt: "fallback prompt", - }, - }, - buildContext: func(ctx *workflow.ExecutionContext) *interpolation.Context { - return &interpolation.Context{} - }, - expectError: false, - expectedPrompt: "fallback prompt", - }, - { - name: "interpolation error", - step: &workflow.Step{ - Name: "test-step", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "test-provider", - Prompt: "{{invalid}}", - }, - }, - buildContext: func(ctx *workflow.ExecutionContext) *interpolation.Context { - return &interpolation.Context{} - }, - resolverError: errors.New("interpolation failed"), - expectError: true, +// TestInitializeConversationState_ContinueFrom_StepNotFound tests missing prior step +func TestInitializeConversationState_ContinueFrom_StepNotFound(t *testing.T) { + logger := mocks.NewMockLogger() + resolver := &testResolver{} + registry := mocks.NewMockAgentRegistry() + manager := NewConversationManager(logger, resolver, registry) + + step := &workflow.Step{ + Name: "follow_up", + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Continue", }, } + config := &workflow.ConversationConfig{ + ContinueFrom: "nonexistent", + } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var resolver interpolation.Resolver - if tt.resolverError != nil { - resolver = &mockResolverWithError{err: tt.resolverError} - } else { - resolver = newMockResolver() - } - - mgr := &ConversationManager{ - logger: newMockLogger(), - evaluator: &mockEvaluator{}, - resolver: resolver, - tokenizer: &mockTokenizer{}, - agentRegistry: &mockAgentRegistry{}, - } - execCtx := workflow.NewExecutionContext("test-wf", "test") - - state, prompt, err := mgr.initializeConversationState(tt.step, tt.step.Agent.Provider, &workflow.ConversationConfig{}, execCtx, tt.buildContext) - - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.NotNil(t, state) - assert.Equal(t, tt.expectedPrompt, prompt) - if tt.expectedSystemPrompt != "" { - // Verify system prompt is in state - assert.NotNil(t, state) - } - } - }) + execCtx := workflow.NewExecutionContext("test-id", "test-workflow") + execCtx.States = make(map[string]workflow.StepState) + + buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { + return interpolation.NewContext() } + + state, _, err := manager.initializeConversationState(step, "claude", config, execCtx, buildContext) + + assert.Error(t, err) + assert.Nil(t, state) + assert.Contains(t, err.Error(), "not found") } -// TestConversationManager_executeTurn tests single turn execution with -// provider integration and state updates. -// Feature: C006 - Component T013 -func TestConversationManager_executeTurn(t *testing.T) { - tests := []struct { - name string - state *workflow.ConversationState - prompt string - options map[string]any - providerResult *workflow.ConversationResult - providerError error - expectError bool - contextCancel bool - }{ - { - name: "successful turn", - state: &workflow.ConversationState{ - Turns: []workflow.Turn{}, - TotalTurns: 0, - TotalTokens: 0, - }, - prompt: "test prompt", - options: map[string]any{ - "temperature": 0.7, - }, - providerResult: &workflow.ConversationResult{ - State: &workflow.ConversationState{ - Turns: []workflow.Turn{ - {Role: workflow.TurnRoleUser, Content: "test prompt"}, - {Role: workflow.TurnRoleAssistant, Content: "response"}, - }, - TotalTurns: 1, - TotalTokens: 100, - }, - Output: "response", - }, - expectError: false, - }, - { - name: "provider error", - state: &workflow.ConversationState{ - Turns: []workflow.Turn{}, - TotalTurns: 0, - TotalTokens: 0, - }, - prompt: "test prompt", - options: map[string]any{}, - providerError: errors.New("provider failed"), - expectError: true, - }, - { - name: "context cancellation", - state: &workflow.ConversationState{ - Turns: []workflow.Turn{}, - TotalTurns: 0, - TotalTokens: 0, - }, - prompt: "test prompt", - options: map[string]any{}, - expectError: true, - contextCancel: true, +// TestInitializeConversationState_ContinueFrom_NoConversationState tests prior step without conversation +func TestInitializeConversationState_ContinueFrom_NoConversationState(t *testing.T) { + logger := mocks.NewMockLogger() + resolver := &testResolver{} + registry := mocks.NewMockAgentRegistry() + manager := NewConversationManager(logger, resolver, registry) + + step := &workflow.Step{ + Name: "follow_up", + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Continue", }, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider := &mockAgentProvider{ - name: "test-provider", - result: tt.providerResult, - err: tt.providerError, - } - - mgr := &ConversationManager{ - logger: newMockLogger(), - evaluator: &mockEvaluator{}, - resolver: newMockResolver(), - tokenizer: &mockTokenizer{}, - agentRegistry: &mockAgentRegistry{ - provider: provider, - }, - } - - ctx := context.Background() - if tt.contextCancel { - var cancel context.CancelFunc - ctx, cancel = context.WithCancel(ctx) - cancel() // Cancel immediately - } - - result, err := mgr.executeTurn(ctx, provider, tt.state, tt.prompt, tt.options, nil, nil) - - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.NotNil(t, result) - assert.Equal(t, tt.providerResult, result) - } - }) + config := &workflow.ConversationConfig{ + ContinueFrom: "prior_step", } -} -// TestConversationManager_evaluateTurnCompletion tests stop condition evaluation -// and max tokens checking between conversation turns. -// Feature: C006 - Component T013 -func TestConversationManager_evaluateTurnCompletion(t *testing.T) { - tests := []struct { - name string - config *workflow.ConversationConfig - state *workflow.ConversationState - execCtx *workflow.ExecutionContext - buildContext ContextBuilderFunc - evaluatorResult bool - evaluatorError error - shouldStop bool - expectedReason workflow.StopReason - }{ - { - name: "no stop condition - continue", - config: &workflow.ConversationConfig{ - MaxTurns: 10, - }, - state: &workflow.ConversationState{ - Turns: []workflow.Turn{{Role: workflow.TurnRoleAssistant, Content: "response"}}, - TotalTurns: 1, - TotalTokens: 50, - }, - execCtx: workflow.NewExecutionContext("test-wf", "test"), - buildContext: func(ctx *workflow.ExecutionContext) *interpolation.Context { - return &interpolation.Context{} - }, - shouldStop: false, - }, - { - name: "stop condition met", - config: &workflow.ConversationConfig{ - MaxTurns: 10, - StopCondition: "response contains 'DONE'", - }, - state: &workflow.ConversationState{ - Turns: []workflow.Turn{ - {Role: workflow.TurnRoleAssistant, Content: "DONE"}, - }, - TotalTurns: 1, - TotalTokens: 50, - }, - execCtx: workflow.NewExecutionContext("test-wf", "test"), - buildContext: func(ctx *workflow.ExecutionContext) *interpolation.Context { - return &interpolation.Context{Inputs: make(map[string]any)} - }, - evaluatorResult: true, - shouldStop: true, - expectedReason: workflow.StopReasonCondition, - }, - { - name: "stop condition not met", - config: &workflow.ConversationConfig{ - MaxTurns: 10, - StopCondition: "response contains 'DONE'", - }, - state: &workflow.ConversationState{ - Turns: []workflow.Turn{ - {Role: workflow.TurnRoleAssistant, Content: "not done"}, - }, - TotalTurns: 1, - TotalTokens: 50, - }, - execCtx: workflow.NewExecutionContext("test-wf", "test"), - buildContext: func(ctx *workflow.ExecutionContext) *interpolation.Context { - return &interpolation.Context{Inputs: make(map[string]any)} - }, - evaluatorResult: false, - shouldStop: false, - }, - { - name: "evaluation error - log and continue", - config: &workflow.ConversationConfig{ - MaxTurns: 10, - StopCondition: "invalid condition", - }, - state: &workflow.ConversationState{ - Turns: []workflow.Turn{{Role: workflow.TurnRoleAssistant, Content: "response"}}, - TotalTurns: 1, - TotalTokens: 50, - }, - execCtx: workflow.NewExecutionContext("test-wf", "test"), - buildContext: func(ctx *workflow.ExecutionContext) *interpolation.Context { - return &interpolation.Context{Inputs: make(map[string]any)} - }, - evaluatorError: errors.New("evaluation failed"), - shouldStop: false, // Continue despite error - }, - { - name: "max tokens exceeded", - config: &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100, - }, - state: &workflow.ConversationState{ - Turns: []workflow.Turn{{Role: workflow.TurnRoleAssistant, Content: "response"}}, - TotalTurns: 1, - TotalTokens: 150, // Exceeds max - }, - execCtx: workflow.NewExecutionContext("test-wf", "test"), - buildContext: func(ctx *workflow.ExecutionContext) *interpolation.Context { - return &interpolation.Context{} - }, - shouldStop: true, - expectedReason: workflow.StopReasonMaxTokens, - }, - { - name: "max tokens not exceeded", - config: &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100, - }, - state: &workflow.ConversationState{ - Turns: []workflow.Turn{{Role: workflow.TurnRoleAssistant, Content: "response"}}, - TotalTurns: 1, - TotalTokens: 50, // Below max - }, - execCtx: workflow.NewExecutionContext("test-wf", "test"), - buildContext: func(ctx *workflow.ExecutionContext) *interpolation.Context { - return &interpolation.Context{} - }, - shouldStop: false, + execCtx := workflow.NewExecutionContext("test-id", "test-workflow") + execCtx.States = map[string]workflow.StepState{ + "prior_step": { + Name: "prior_step", }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mgr := &ConversationManager{ - logger: newMockLogger(), - evaluator: &mockEvaluator{ - result: tt.evaluatorResult, - err: tt.evaluatorError, - }, - resolver: newMockResolver(), - tokenizer: &mockTokenizer{}, - agentRegistry: &mockAgentRegistry{}, - } - - shouldStop := mgr.evaluateTurnCompletion(tt.config, tt.state, tt.execCtx, tt.buildContext) - - assert.Equal(t, tt.shouldStop, shouldStop) - if tt.shouldStop && tt.expectedReason != "" { - assert.Equal(t, tt.expectedReason, tt.state.StoppedBy) - } - }) + buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { + return interpolation.NewContext() } -} -// finalizeStopReason is a test helper that mirrors the inlined stop-reason logic -// in ConversationManager.ExecuteConversation. It was extracted here after being -// removed from production as dead code (the logic is inlined at the call site). -func (m *ConversationManager) finalizeStopReason( - state *workflow.ConversationState, - turnCount int, - maxTurns int, -) { - if state.StoppedBy == "" { - if turnCount >= maxTurns { - state.StoppedBy = workflow.StopReasonMaxTurns - } - } -} + state, _, err := manager.initializeConversationState(step, "claude", config, execCtx, buildContext) -// TestConversationManager_finalizeStopReason tests stop reason determination -// when conversation loop completes. -// Feature: C006 - Component T013 -func TestConversationManager_finalizeStopReason(t *testing.T) { - tests := []struct { - name string - state *workflow.ConversationState - turnCount int - maxTurns int - expectedReason workflow.StopReason - }{ - { - name: "max turns reached", - state: &workflow.ConversationState{ - TotalTurns: 10, - StoppedBy: "", // Not set yet - }, - turnCount: 10, - maxTurns: 10, - expectedReason: workflow.StopReasonMaxTurns, - }, - { - name: "already set - no override", - state: &workflow.ConversationState{ - TotalTurns: 5, - StoppedBy: workflow.StopReasonCondition, - }, - turnCount: 5, - maxTurns: 10, - expectedReason: workflow.StopReasonCondition, // Should remain unchanged - }, - { - name: "neither condition - remains empty", - state: &workflow.ConversationState{ - TotalTurns: 5, - StoppedBy: "", - }, - turnCount: 5, - maxTurns: 10, - expectedReason: "", // Should remain empty - }, - } + assert.Error(t, err) + assert.Nil(t, state) + assert.Contains(t, err.Error(), "no conversation state") +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mgr := &ConversationManager{ - logger: newMockLogger(), - evaluator: &mockEvaluator{}, - resolver: newMockResolver(), - tokenizer: &mockTokenizer{}, - agentRegistry: &mockAgentRegistry{}, - } +// TestExecuteTurn_HappyPath tests single turn execution succeeds +func TestExecuteTurn_HappyPath(t *testing.T) { + logger := mocks.NewMockLogger() + resolver := &testResolver{} + registry := mocks.NewMockAgentRegistry() + + provider := mocks.NewMockAgentProvider("claude") + provider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + result := workflow.NewConversationResult("claude") + result.State = state + state.AddTurn(workflow.NewTurn(workflow.TurnRoleAssistant, "Agent response")) + return result, nil + }) + _ = registry.Register(provider) + + manager := NewConversationManager(logger, resolver, registry) + + state := workflow.NewConversationState("") + options := map[string]any{} + + result, err := manager.executeTurn( + context.Background(), + provider, + state, + "Test prompt", + options, + io.Discard, + io.Discard, + ) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.NotNil(t, result.State) +} - mgr.finalizeStopReason(tt.state, tt.turnCount, tt.maxTurns) +// TestExecuteTurn_ContextCancellation tests context cancellation is respected +func TestExecuteTurn_ContextCancellation(t *testing.T) { + logger := mocks.NewMockLogger() + resolver := &testResolver{} + registry := mocks.NewMockAgentRegistry() + + provider := mocks.NewMockAgentProvider("claude") + manager := NewConversationManager(logger, resolver, registry) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + state := workflow.NewConversationState("") + options := map[string]any{} + + result, err := manager.executeTurn( + ctx, + provider, + state, + "Test prompt", + options, + io.Discard, + io.Discard, + ) + + assert.Error(t, err) + assert.Nil(t, result) +} - assert.Equal(t, tt.expectedReason, tt.state.StoppedBy) - }) - } +// TestExecuteTurn_ProviderError tests provider error is returned +func TestExecuteTurn_ProviderError(t *testing.T) { + logger := mocks.NewMockLogger() + resolver := &testResolver{} + registry := mocks.NewMockAgentRegistry() + + failingProvider := mocks.NewMockAgentProvider("claude") + failingProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + return nil, assert.AnError + }) + manager := NewConversationManager(logger, resolver, registry) + + state := workflow.NewConversationState("") + options := map[string]any{} + + result, err := manager.executeTurn( + context.Background(), + failingProvider, + state, + "Test prompt", + options, + io.Discard, + io.Discard, + ) + + assert.Error(t, err) + assert.Nil(t, result) } diff --git a/internal/application/conversation_manager_tdd_test.go b/internal/application/conversation_manager_tdd_test.go new file mode 100644 index 00000000..645440ca --- /dev/null +++ b/internal/application/conversation_manager_tdd_test.go @@ -0,0 +1,587 @@ +package application_test + +import ( + "context" + "errors" + "io" + "testing" + + "github.com/awf-project/cli/internal/application" + "github.com/awf-project/cli/internal/domain/workflow" + "github.com/awf-project/cli/internal/testutil/mocks" + "github.com/awf-project/cli/pkg/interpolation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Helper: create basic execution context +func newTestExecutionContext(t *testing.T) *workflow.ExecutionContext { + t.Helper() + ec := workflow.NewExecutionContext("test-flow-id", "test-workflow") + ec.States = make(map[string]workflow.StepState) + return ec +} + +// Helper: create basic build context function +func newTestBuildContext() func(*workflow.ExecutionContext) *interpolation.Context { + return func(ec *workflow.ExecutionContext) *interpolation.Context { + return interpolation.NewContext() + } +} + +// Helper: create ConversationManager with minimal setup +func newTestConversationManager(t *testing.T, provider *mocks.MockAgentProvider) *application.ConversationManager { + t.Helper() + logger := mocks.NewMockLogger() + resolver := newMockResolver() + registry := mocks.NewMockAgentRegistry() + + if provider != nil { + err := registry.Register(provider) + require.NoError(t, err) + } + + return application.NewConversationManager(logger, resolver, registry) +} + +// TestConversationManager_ExecuteConversation_SingleTurn_HappyPath +// Verifies that ExecuteConversation completes after a single user message followed by empty input. +// The conversation should terminate with StopReasonUserExit. +func TestConversationManager_ExecuteConversation_SingleTurn_HappyPath(t *testing.T) { + provider := mocks.NewMockAgentProvider("claude") + + // Configure provider to return a valid ConversationResult on first turn + provider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + if state.Turns == nil { + state.Turns = make([]workflow.Turn, 0) + } + state.Turns = append(state.Turns, //nolint:gocritic // separate appends aid readability in test closures + workflow.Turn{Role: "user", Content: prompt}, + workflow.Turn{Role: "assistant", Content: "Hello! I'm here to help."}, + ) + + return &workflow.ConversationResult{ + Provider: "claude", + Output: "Hello! I'm here to help.", + State: state, + }, nil + }) + + manager := newTestConversationManager(t, provider) + + // User provides one message, then exits with empty input + userInputReader := mocks.NewMockUserInputReader("yes, help me with this", "") + manager.SetUserInputReader(userInputReader) + + step := &workflow.Step{ + Name: "chat-step", + Type: workflow.StepTypeAgent, + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Hello, can you help me?", + }, + } + + config := &workflow.ConversationConfig{} + execCtx := newTestExecutionContext(t) + buildContext := newTestBuildContext() + + result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, io.Discard, io.Discard) + + // Assertions: should complete successfully with StopReasonUserExit + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, workflow.StopReasonUserExit, result.State.StoppedBy) + assert.NotEmpty(t, result.Output) + // 2 turns per executeTurn call: initial prompt + user message = 4 turns + assert.Equal(t, 4, len(result.State.Turns)) +} + +// TestConversationManager_ExecuteConversation_MultiTurn_HappyPath +// Verifies that ExecuteConversation executes multiple turns (user input, agent response, repeat) +// until the user provides empty input. +func TestConversationManager_ExecuteConversation_MultiTurn_HappyPath(t *testing.T) { + provider := mocks.NewMockAgentProvider("claude") + + turnCount := 0 + provider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + if state.Turns == nil { + state.Turns = make([]workflow.Turn, 0) + } + state.Turns = append(state.Turns, workflow.Turn{ + Role: "user", + Content: prompt, + }) + + turnCount++ + response := "Response " + string(rune(48+turnCount)) //nolint:gosec // controlled test value, no overflow risk + state.Turns = append(state.Turns, workflow.Turn{ + Role: "assistant", + Content: response, + }) + + return &workflow.ConversationResult{ + Provider: "claude", + Output: response, + State: state, + }, nil + }) + + manager := newTestConversationManager(t, provider) + + // User provides two messages, then exits + userInputReader := mocks.NewMockUserInputReader("First message", "Second message", "") + manager.SetUserInputReader(userInputReader) + + step := &workflow.Step{ + Name: "chat-step", + Type: workflow.StepTypeAgent, + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Start the conversation", + }, + } + + config := &workflow.ConversationConfig{} + execCtx := newTestExecutionContext(t) + buildContext := newTestBuildContext() + + result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, io.Discard, io.Discard) + + // Assertions: should execute 3 turns (initial + 2 user inputs), stop with UserExit + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, workflow.StopReasonUserExit, result.State.StoppedBy) + // 3 turns: initial + 2 user-agent pairs = 6 messages (3 pairs of user+assistant) + assert.Equal(t, 6, len(result.State.Turns)) +} + +// TestConversationManager_ExecuteConversation_WithSystemPrompt +// Verifies that system prompt is passed to the provider in options +// and that the conversation executes correctly with system guidance. +func TestConversationManager_ExecuteConversation_WithSystemPrompt(t *testing.T) { + provider := mocks.NewMockAgentProvider("claude") + + var capturedSystemPrompt string + provider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + // Capture system_prompt from options + if sp, ok := options["system_prompt"]; ok { + capturedSystemPrompt = sp.(string) + } + + if state.Turns == nil { + state.Turns = make([]workflow.Turn, 0) + } + state.Turns = append(state.Turns, //nolint:gocritic // separate appends aid readability in test closures + workflow.Turn{Role: "user", Content: prompt}, + workflow.Turn{Role: "assistant", Content: "Math result: 4"}, + ) + + return &workflow.ConversationResult{ + Provider: "claude", + Output: "Math result: 4", + State: state, + }, nil + }) + + manager := newTestConversationManager(t, provider) + manager.SetUserInputReader(mocks.NewMockUserInputReader("")) + + step := &workflow.Step{ + Name: "math-chat", + Type: workflow.StepTypeAgent, + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "What is 2+2?", + SystemPrompt: "You are a helpful math tutor.", + }, + } + + config := &workflow.ConversationConfig{} + execCtx := newTestExecutionContext(t) + buildContext := newTestBuildContext() + + result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, io.Discard, io.Discard) + + // Assertions: system prompt should be passed to provider + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "You are a helpful math tutor.", capturedSystemPrompt) + assert.Equal(t, workflow.StopReasonUserExit, result.State.StoppedBy) +} + +// TestConversationManager_ExecuteConversation_ContinueFrom_HappyPath +// Verifies that ContinueFrom successfully resumes a prior conversation session, +// retaining prior turn history and session ID. +func TestConversationManager_ExecuteConversation_ContinueFrom_HappyPath(t *testing.T) { + provider := mocks.NewMockAgentProvider("claude") + + provider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + if state.Turns == nil { + state.Turns = make([]workflow.Turn, 0) + } + state.Turns = append(state.Turns, //nolint:gocritic // separate appends aid readability in test closures + workflow.Turn{Role: "user", Content: prompt}, + workflow.Turn{Role: "assistant", Content: "Continuing our conversation..."}, + ) + + return &workflow.ConversationResult{ + Provider: "claude", + Output: "Continuing our conversation...", + State: state, + }, nil + }) + + manager := newTestConversationManager(t, provider) + manager.SetUserInputReader(mocks.NewMockUserInputReader("")) + + // Set up prior conversation state in execution context + execCtx := newTestExecutionContext(t) + priorState := workflow.NewConversationState("Prior system prompt") + priorState.SessionID = "session-123" + priorState.Turns = []workflow.Turn{ + {Role: "user", Content: "First message"}, + {Role: "assistant", Content: "First response"}, + } + execCtx.States["prior-step"] = workflow.StepState{ + Conversation: priorState, + } + + step := &workflow.Step{ + Name: "continue-chat", + Type: workflow.StepTypeAgent, + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Continue with a new question", + }, + } + + config := &workflow.ConversationConfig{ + ContinueFrom: "prior-step", + } + + buildContext := newTestBuildContext() + + result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, io.Discard, io.Discard) + + // Assertions: should resume prior session, retain session ID and prior turns + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "session-123", result.State.SessionID) + // Should have prior 2 turns + new 2 turns = 4 total + assert.Equal(t, 4, len(result.State.Turns)) + assert.Equal(t, workflow.StopReasonUserExit, result.State.StoppedBy) +} + +// TestConversationManager_ExecuteConversation_MissingUserInputReader_Error +// Verifies that ExecuteConversation returns a clear error when UserInputReader is not configured. +// This is critical for preventing silent failures in interactive mode. +func TestConversationManager_ExecuteConversation_MissingUserInputReader_Error(t *testing.T) { + provider := mocks.NewMockAgentProvider("claude") + provider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + return &workflow.ConversationResult{ + Provider: "claude", + Output: "Response", + State: state, + }, nil + }) + + manager := newTestConversationManager(t, provider) + // Do NOT set UserInputReader + + step := &workflow.Step{ + Name: "chat-step", + Type: workflow.StepTypeAgent, + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Hello", + }, + } + + config := &workflow.ConversationConfig{} + execCtx := newTestExecutionContext(t) + buildContext := newTestBuildContext() + + result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, io.Discard, io.Discard) + + // Assertions: should return error about missing UserInputReader + assert.Error(t, err) + assert.Contains(t, err.Error(), "UserInputReader") + assert.Nil(t, result) +} + +// TestConversationManager_ExecuteConversation_NilStep_Error +// Verifies that ExecuteConversation returns an error when step is nil. +func TestConversationManager_ExecuteConversation_NilStep_Error(t *testing.T) { + provider := mocks.NewMockAgentProvider("claude") + manager := newTestConversationManager(t, provider) + manager.SetUserInputReader(mocks.NewMockUserInputReader("")) + + execCtx := newTestExecutionContext(t) + buildContext := newTestBuildContext() + + result, err := manager.ExecuteConversation(context.Background(), nil, &workflow.ConversationConfig{}, execCtx, buildContext, io.Discard, io.Discard) + + assert.Error(t, err) + assert.Nil(t, result) +} + +// TestConversationManager_ExecuteConversation_ProviderNotFound_Error +// Verifies that ExecuteConversation returns an error when the provider is not registered. +func TestConversationManager_ExecuteConversation_ProviderNotFound_Error(t *testing.T) { + provider := mocks.NewMockAgentProvider("claude") + manager := newTestConversationManager(t, provider) + manager.SetUserInputReader(mocks.NewMockUserInputReader("")) + + step := &workflow.Step{ + Name: "chat", + Agent: &workflow.AgentConfig{ + Provider: "nonexistent-provider", + Prompt: "Hello", + }, + } + + config := &workflow.ConversationConfig{} + execCtx := newTestExecutionContext(t) + buildContext := newTestBuildContext() + + result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, io.Discard, io.Discard) + + assert.Error(t, err) + assert.Nil(t, result) +} + +// TestConversationManager_ExecuteConversation_ContextCancellation_Error +// Verifies that ExecuteConversation respects context cancellation and returns immediately +// when context is cancelled during the read phase. +func TestConversationManager_ExecuteConversation_ContextCancellation_Error(t *testing.T) { + provider := mocks.NewMockAgentProvider("claude") + + provider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + if state.Turns == nil { + state.Turns = make([]workflow.Turn, 0) + } + state.Turns = append(state.Turns, //nolint:gocritic // separate appends aid readability in test closures + workflow.Turn{Role: "user", Content: prompt}, + workflow.Turn{Role: "assistant", Content: "Response"}, + ) + + return &workflow.ConversationResult{ + Provider: "claude", + Output: "Response", + State: state, + }, nil + }) + + manager := newTestConversationManager(t, provider) + + // Create a user input reader that will be canceled during read + userInputReader := mocks.NewMockUserInputReader() + userInputReader.SetReadError(context.Canceled) + manager.SetUserInputReader(userInputReader) + + step := &workflow.Step{ + Name: "chat", + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Hello", + }, + } + + config := &workflow.ConversationConfig{} + execCtx := newTestExecutionContext(t) + buildContext := newTestBuildContext() + + _, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, io.Discard, io.Discard) + + // Assertions: should return error related to context cancellation + assert.Error(t, err) +} + +// TestConversationManager_ExecuteConversation_ContinueFromStepNotFound_Error +// Verifies that ExecuteConversation returns an error when ContinueFrom references +// a non-existent step in the execution context. +func TestConversationManager_ExecuteConversation_ContinueFromStepNotFound_Error(t *testing.T) { + provider := mocks.NewMockAgentProvider("claude") + manager := newTestConversationManager(t, provider) + manager.SetUserInputReader(mocks.NewMockUserInputReader("")) + + step := &workflow.Step{ + Name: "chat", + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Hello", + }, + } + + config := &workflow.ConversationConfig{ + ContinueFrom: "nonexistent-prior-step", + } + + execCtx := newTestExecutionContext(t) + // No prior step in states + buildContext := newTestBuildContext() + + result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, io.Discard, io.Discard) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.Nil(t, result) +} + +// TestConversationManager_ExecuteConversation_ProviderExecutionError_PreserveLastResult +// Verifies that when a provider returns an error mid-conversation, +// the last successful result is preserved and the error is returned. +func TestConversationManager_ExecuteConversation_ProviderExecutionError_PreserveLastResult(t *testing.T) { + provider := mocks.NewMockAgentProvider("claude") + + callCount := 0 + provider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + callCount++ + + if state.Turns == nil { + state.Turns = make([]workflow.Turn, 0) + } + state.Turns = append(state.Turns, workflow.Turn{ + Role: "user", + Content: prompt, + }) + + // Succeed on first call, fail on second + if callCount == 1 { + state.Turns = append(state.Turns, workflow.Turn{ + Role: "assistant", + Content: "First response", + }) + return &workflow.ConversationResult{ + Provider: "claude", + Output: "First response", + State: state, + }, nil + } + + return nil, errors.New("provider error on second turn") + }) + + manager := newTestConversationManager(t, provider) + manager.SetUserInputReader(mocks.NewMockUserInputReader("user message", "")) + + step := &workflow.Step{ + Name: "chat", + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Hello", + }, + } + + config := &workflow.ConversationConfig{} + execCtx := newTestExecutionContext(t) + buildContext := newTestBuildContext() + + result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, io.Discard, io.Discard) + + // Assertions: should return error but preserve last result + assert.Error(t, err) + assert.NotNil(t, result) + assert.NotEmpty(t, result.Output) + assert.NotNil(t, result.State) +} + +// TestConversationManager_SetUserInputReader +// Verifies that SetUserInputReader correctly wires the dependency. +func TestConversationManager_SetUserInputReader(t *testing.T) { + manager := newTestConversationManager(t, mocks.NewMockAgentProvider("claude")) + + reader := mocks.NewMockUserInputReader("test") + manager.SetUserInputReader(reader) + + // Verify reader was set by attempting to use it + // (If not set, ExecuteConversation would fail with "UserInputReader" error) + provider := mocks.NewMockAgentProvider("test-provider") + provider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + return &workflow.ConversationResult{ + Provider: "test-provider", + Output: "test", + State: state, + }, nil + }) + + registry := mocks.NewMockAgentRegistry() + registry.Register(provider) + + manager2 := application.NewConversationManager( + mocks.NewMockLogger(), + newMockResolver(), + registry, + ) + manager2.SetUserInputReader(reader) + + step := &workflow.Step{ + Name: "test", + Agent: &workflow.AgentConfig{ + Provider: "test-provider", + Prompt: "test", + }, + } + + execCtx := newTestExecutionContext(t) + buildContext := newTestBuildContext() + + result, err := manager2.ExecuteConversation(context.Background(), step, &workflow.ConversationConfig{}, execCtx, buildContext, io.Discard, io.Discard) + + // If reader was properly set, call should succeed (not fail on missing UserInputReader) + assert.NoError(t, err) + assert.NotNil(t, result) +} + +// TestConversationManager_ExecuteConversation_EmptyInputTerminates +// Verifies that providing an empty string (or whitespace-only) immediately terminates +// the conversation without requiring provider call. +func TestConversationManager_ExecuteConversation_EmptyInputTerminates(t *testing.T) { + provider := mocks.NewMockAgentProvider("claude") + + callCount := 0 + provider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + callCount++ + + if state.Turns == nil { + state.Turns = make([]workflow.Turn, 0) + } + state.Turns = append(state.Turns, //nolint:gocritic // separate appends aid readability in test closures + workflow.Turn{Role: "user", Content: prompt}, + workflow.Turn{Role: "assistant", Content: "Response"}, + ) + + return &workflow.ConversationResult{ + Provider: "claude", + Output: "Response", + State: state, + }, nil + }) + + manager := newTestConversationManager(t, provider) + + // First empty input should terminate immediately + userInputReader := mocks.NewMockUserInputReader("") + manager.SetUserInputReader(userInputReader) + + step := &workflow.Step{ + Name: "chat", + Agent: &workflow.AgentConfig{ + Provider: "claude", + Prompt: "Hello", + }, + } + + config := &workflow.ConversationConfig{} + execCtx := newTestExecutionContext(t) + buildContext := newTestBuildContext() + + result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, io.Discard, io.Discard) + + // Assertions: should exit after first turn without another provider call + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, workflow.StopReasonUserExit, result.State.StoppedBy) + assert.Equal(t, 1, callCount) +} diff --git a/internal/application/conversation_manager_test.go b/internal/application/conversation_manager_test.go index 516651b9..90c0e2b1 100644 --- a/internal/application/conversation_manager_test.go +++ b/internal/application/conversation_manager_test.go @@ -7,277 +7,155 @@ import ( "testing" "github.com/awf-project/cli/internal/application" + "github.com/awf-project/cli/internal/domain/ports" "github.com/awf-project/cli/internal/domain/workflow" - "github.com/awf-project/cli/internal/testutil/mocks" "github.com/awf-project/cli/pkg/interpolation" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -// mockTokenizer implements ports.Tokenizer for testing -type mockTokenizer struct { - counts map[string]int - err error - isEstimate bool - modelName string +// mockAgentProvider for conversation testing +type mockAgentProvider struct { + name string + conversationError error + conversationFunc func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) } -func newMockTokenizer() *mockTokenizer { - return &mockTokenizer{ - counts: make(map[string]int), - isEstimate: false, - modelName: "test-tokenizer", +func newMockAgentProvider(name string) *mockAgentProvider { + return &mockAgentProvider{ + name: name, + conversationFunc: func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + result := workflow.NewConversationResult(name) + result.State = state + state.AddTurn(workflow.NewTurn(workflow.TurnRoleAssistant, "Agent response to: "+prompt)) + return result, nil + }, } } -func (m *mockTokenizer) CountTokens(text string) (int, error) { - if m.err != nil { - return 0, m.err - } - if count, ok := m.counts[text]; ok { - return count, nil - } - // Default approximation: ~4 chars per token - return len(text) / 4, nil +func (m *mockAgentProvider) Execute(ctx context.Context, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.AgentResult, error) { + return nil, errors.New("single execution not supported in conversation mode") } -func (m *mockTokenizer) CountTurnsTokens(turns []string) (int, error) { - if m.err != nil { - return 0, m.err +func (m *mockAgentProvider) ExecuteConversation(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { + if m.conversationError != nil { + return nil, m.conversationError } - total := 0 - for _, turn := range turns { - count, err := m.CountTokens(turn) - if err != nil { - return 0, err - } - total += count + if m.conversationFunc != nil { + return m.conversationFunc(ctx, state, prompt, options, stdout, stderr) } - return total, nil + return nil, errors.New("provider not configured") } -func (m *mockTokenizer) IsEstimate() bool { - return m.isEstimate +func (m *mockAgentProvider) Name() string { + return m.name } -func (m *mockTokenizer) ModelName() string { - return m.modelName +func (m *mockAgentProvider) Validate() error { + return nil } -// mockConversationProvider implements ports.AgentProvider with conversation support -type mockConversationProvider struct { - name string - conversations map[string]*workflow.ConversationResult - execError error - validateOK bool +// mockAgentRegistry for testing +type mockAgentRegistry struct { + providers map[string]ports.AgentProvider + err error } -func newMockConversationProvider(name string) *mockConversationProvider { - return &mockConversationProvider{ - name: name, - conversations: make(map[string]*workflow.ConversationResult), - validateOK: true, +func newMockAgentRegistry() *mockAgentRegistry { + return &mockAgentRegistry{ + providers: make(map[string]ports.AgentProvider), } } -func (m *mockConversationProvider) Execute(ctx context.Context, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.AgentResult, error) { - return nil, errors.New("single execution not supported, use ExecuteConversation") +func (m *mockAgentRegistry) Register(provider ports.AgentProvider) error { + m.providers[provider.Name()] = provider + return nil } -func (m *mockConversationProvider) ExecuteConversation(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - if m.execError != nil { - return nil, m.execError +func (m *mockAgentRegistry) Get(name string) (ports.AgentProvider, error) { + if m.err != nil { + return nil, m.err } - if result, ok := m.conversations[prompt]; ok { - return result, nil + if p, ok := m.providers[name]; ok { + return p, nil } - // Default result - stub, not implemented - return nil, errors.New("not implemented") -} - -func (m *mockConversationProvider) Name() string { - return m.name + return nil, errors.New("provider not found: " + name) } -func (m *mockConversationProvider) Validate() error { - if !m.validateOK { - return errors.New("provider validation failed") - } +func (m *mockAgentRegistry) List() []string { return nil } -func TestNewConversationManager(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - assert.NotNil(t, manager) +func (m *mockAgentRegistry) Has(name string) bool { + _, ok := m.providers[name] + return ok } -func TestConversationManager_SingleTurn_HappyPath(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Hello, how are you?", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 1000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) +// mockUserInputReader for testing +type mockUserInputReader struct { + inputs []string + index int + err error } -func TestConversationManager_MultiTurn_HappyPath(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Let's discuss AI", - }, +func newMockUserInputReader(inputs ...string) *mockUserInputReader { + return &mockUserInputReader{ + inputs: inputs, + index: 0, } +} - config := &workflow.ConversationConfig{ - MaxTurns: 5, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, +func (m *mockUserInputReader) ReadInput(ctx context.Context) (string, error) { + if m.err != nil { + return "", m.err } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() + if m.index >= len(m.inputs) { + return "", nil } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) + input := m.inputs[m.index] + m.index++ + return input, nil } -func TestConversationManager_WithSystemPrompt(t *testing.T) { +// ============================================================================ +// TESTS +// ============================================================================ + +func TestNewConversationManager(t *testing.T) { logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "What is 2+2?", - SystemPrompt: "You are a helpful math tutor.", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 3, - MaxContextTokens: 2000, - Strategy: workflow.StrategySlidingWindow, - } + registry := newMockAgentRegistry() - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } + manager := application.NewConversationManager(logger, resolver, registry) - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) + assert.NotNil(t, manager) } -func TestConversationManager_StopCondition_ResponseContains(t *testing.T) { +func TestConversationManager_ExecuteConversation_MultiTurn(t *testing.T) { + // Arrange: conversation with 2 user messages + 1 empty to exit logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - evaluator.boolResults[`response contains "DONE"`] = true resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() + registry := newMockAgentRegistry() - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) + provider := newMockAgentProvider("claude") + _ = registry.Register(provider) - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) + manager := application.NewConversationManager(logger, resolver, registry) + userInput := newMockUserInputReader( + "Follow-up question", // Turn 2 user input + "Another question", // Turn 3 user input + "", // Empty ends conversation + ) + manager.SetUserInputReader(userInput) step := &workflow.Step{ Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ Provider: "claude", - Prompt: "Process data", + Prompt: "Hello", }, } - - config := &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - StopCondition: `response contains "DONE"`, - } + config := &workflow.ConversationConfig{} execCtx := workflow.NewExecutionContext("test-id", "test-workflow") execCtx.States = make(map[string]workflow.StepState) @@ -286,42 +164,46 @@ func TestConversationManager_StopCondition_ResponseContains(t *testing.T) { return interpolation.NewContext() } - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) + // Act + result, err := manager.ExecuteConversation( + context.Background(), + step, + config, + execCtx, + buildContext, + io.Discard, + io.Discard, + ) - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) + // Assert + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, workflow.StopReasonUserExit, result.State.StoppedBy) + // Total turns: initial prompt, follow-up, another question = 3 or more turns + assert.GreaterOrEqual(t, len(result.State.Turns), 3) } -func TestConversationManager_StopCondition_TurnCount(t *testing.T) { +func TestConversationManager_ExecuteConversation_Error_NoUserInputReader(t *testing.T) { + // Arrange logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - evaluator.boolResults[`turn_count >= 3`] = true resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() + registry := newMockAgentRegistry() - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) + provider := newMockAgentProvider("claude") + _ = registry.Register(provider) - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) + manager := application.NewConversationManager(logger, resolver, registry) + // Do NOT set UserInputReader step := &workflow.Step{ Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ Provider: "claude", - Prompt: "Iterate", + Prompt: "Hello", }, } - - config := &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - StopCondition: `turn_count >= 3`, - } + config := &workflow.ConversationConfig{} execCtx := workflow.NewExecutionContext("test-id", "test-workflow") execCtx.States = make(map[string]workflow.StepState) @@ -330,40 +212,31 @@ func TestConversationManager_StopCondition_TurnCount(t *testing.T) { return interpolation.NewContext() } - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) + // Act + result, err := manager.ExecuteConversation( + context.Background(), + step, + config, + execCtx, + buildContext, + io.Discard, + io.Discard, + ) - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) + // Assert + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "UserInputReader") } -func TestConversationManager_MaxTurns_Reached(t *testing.T) { +func TestConversationManager_ExecuteConversation_Error_NilStep(t *testing.T) { + // Arrange logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Keep chatting", - }, - } + registry := newMockAgentRegistry() - config := &workflow.ConversationConfig{ - MaxTurns: 3, - MaxContextTokens: 10000, - Strategy: workflow.StrategySlidingWindow, - } + manager := application.NewConversationManager(logger, resolver, registry) + manager.SetUserInputReader(newMockUserInputReader()) execCtx := workflow.NewExecutionContext("test-id", "test-workflow") execCtx.States = make(map[string]workflow.StepState) @@ -372,41 +245,40 @@ func TestConversationManager_MaxTurns_Reached(t *testing.T) { return interpolation.NewContext() } - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) + // Act + result, err := manager.ExecuteConversation( + context.Background(), + nil, + &workflow.ConversationConfig{}, + execCtx, + buildContext, + io.Discard, + io.Discard, + ) - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) + // Assert + assert.Error(t, err) + assert.Nil(t, result) } -func TestConversationManager_MaxTurns_One(t *testing.T) { +func TestConversationManager_ExecuteConversation_Error_NilConfig(t *testing.T) { + // Arrange logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() + registry := newMockAgentRegistry() - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) + manager := application.NewConversationManager(logger, resolver, registry) + manager.SetUserInputReader(newMockUserInputReader()) step := &workflow.Step{ Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ Provider: "claude", - Prompt: "Single question", + Prompt: "Hello", }, } - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - } - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") execCtx.States = make(map[string]workflow.StepState) @@ -414,41 +286,40 @@ func TestConversationManager_MaxTurns_One(t *testing.T) { return interpolation.NewContext() } - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) + // Act + result, err := manager.ExecuteConversation( + context.Background(), + step, + nil, + execCtx, + buildContext, + io.Discard, + io.Discard, + ) - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) + // Assert + assert.Error(t, err) + assert.Nil(t, result) } -func TestConversationManager_MaxTokens_Exceeded(t *testing.T) { +func TestConversationManager_ExecuteConversation_Error_ProviderNotFound(t *testing.T) { + // Arrange logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() resolver := newMockResolver() - tokenizer := newMockTokenizer() - tokenizer.counts["Very long response"] = 3000 - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) + registry := newMockAgentRegistry() - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) + manager := application.NewConversationManager(logger, resolver, registry) + manager.SetUserInputReader(newMockUserInputReader()) step := &workflow.Step{ Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Generate long text", + Provider: "nonexistent", + Prompt: "Hello", }, } - - config := &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 2000, - Strategy: workflow.StrategySlidingWindow, - } + config := &workflow.ConversationConfig{} execCtx := workflow.NewExecutionContext("test-id", "test-workflow") execCtx.States = make(map[string]workflow.StepState) @@ -457,40 +328,47 @@ func TestConversationManager_MaxTokens_Exceeded(t *testing.T) { return interpolation.NewContext() } - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) + // Act + result, err := manager.ExecuteConversation( + context.Background(), + step, + config, + execCtx, + buildContext, + io.Discard, + io.Discard, + ) - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) + // Assert + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "not found") } -func TestConversationManager_Strategy_SlidingWindow(t *testing.T) { +func TestConversationManager_ExecuteConversation_Error_UserInputReaderFails(t *testing.T) { + // Arrange logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() + registry := newMockAgentRegistry() + + provider := newMockAgentProvider("claude") + _ = registry.Register(provider) - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) + manager := application.NewConversationManager(logger, resolver, registry) - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) + failingReader := newMockUserInputReader() + failingReader.err = errors.New("stdin read failed") + manager.SetUserInputReader(failingReader) step := &workflow.Step{ Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ Provider: "claude", - Prompt: "Long conversation", + Prompt: "Hello", }, } - - config := &workflow.ConversationConfig{ - MaxTurns: 20, - MaxContextTokens: 1000, - Strategy: workflow.StrategySlidingWindow, - } + config := &workflow.ConversationConfig{} execCtx := workflow.NewExecutionContext("test-id", "test-workflow") execCtx.States = make(map[string]workflow.StepState) @@ -499,40 +377,44 @@ func TestConversationManager_Strategy_SlidingWindow(t *testing.T) { return interpolation.NewContext() } - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) + // Act + _, err := manager.ExecuteConversation( + context.Background(), + step, + config, + execCtx, + buildContext, + io.Discard, + io.Discard, + ) - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) + // Assert + assert.Error(t, err) + assert.Contains(t, err.Error(), "stdin read failed") } -func TestConversationManager_Strategy_None(t *testing.T) { +func TestConversationManager_ExecuteConversation_Error_ProviderExecutionFails(t *testing.T) { + // Arrange logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() + registry := newMockAgentRegistry() - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) + failingProvider := newMockAgentProvider("claude") + failingProvider.conversationError = errors.New("provider error") + _ = registry.Register(failingProvider) - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) + manager := application.NewConversationManager(logger, resolver, registry) + manager.SetUserInputReader(newMockUserInputReader()) step := &workflow.Step{ Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ Provider: "claude", - Prompt: "No truncation", + Prompt: "Hello", }, } - - config := &workflow.ConversationConfig{ - MaxTurns: 5, - MaxContextTokens: 0, // no limit - Strategy: workflow.StrategyNone, - } + config := &workflow.ConversationConfig{} execCtx := workflow.NewExecutionContext("test-id", "test-workflow") execCtx.States = make(map[string]workflow.StepState) @@ -541,113 +423,43 @@ func TestConversationManager_Strategy_None(t *testing.T) { return interpolation.NewContext() } - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) + // Act + _, err := manager.ExecuteConversation( + context.Background(), + step, + config, + execCtx, + buildContext, + io.Discard, + io.Discard, + ) - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) + // Assert + assert.Error(t, err) + assert.Contains(t, err.Error(), "provider error") } -// T003: Pass system_prompt through options map in conversation_manager.go - -func TestConversationManager_PassesSystemPromptInOptions(t *testing.T) { +func TestConversationManager_ExecuteConversation_Error_ContextCancellation(t *testing.T) { + // Arrange logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - var capturedOptions map[string]any - mockProvider := mocks.NewMockAgentProvider("claude") - - mockProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - capturedOptions = options - result := workflow.NewConversationResult("claude") - result.State = state - assistantTurn := workflow.NewTurn(workflow.TurnRoleAssistant, "response") - assistantTurn.Tokens = 10 - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, prompt)) - _ = result.State.AddTurn(assistantTurn) - result.State.StoppedBy = workflow.StopReasonMaxTurns - result.Output = "response" - return result, nil - }) - - _ = registry.Register(mockProvider) - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Hello world", - SystemPrompt: "You are a helpful assistant", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 1000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) + registry := newMockAgentRegistry() - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, capturedOptions, "options map should be captured in provider call") - assert.Equal(t, "You are a helpful assistant", capturedOptions["system_prompt"]) -} + provider := newMockAgentProvider("claude") + _ = registry.Register(provider) -func TestConversationManager_OmitsSystemPromptWhenEmpty(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - var capturedOptions map[string]any - mockProvider := mocks.NewMockAgentProvider("claude") - - mockProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - capturedOptions = options - result := workflow.NewConversationResult("claude") - result.State = state - assistantTurn := workflow.NewTurn(workflow.TurnRoleAssistant, "response") - assistantTurn.Tokens = 10 - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, prompt)) - _ = result.State.AddTurn(assistantTurn) - result.State.StoppedBy = workflow.StopReasonMaxTurns - result.Output = "response" - return result, nil - }) - - _ = registry.Register(mockProvider) - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) + manager := application.NewConversationManager(logger, resolver, registry) + manager.SetUserInputReader(newMockUserInputReader()) step := &workflow.Step{ Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Hello world", - SystemPrompt: "", // empty system prompt + Provider: "claude", + Prompt: "Hello", }, } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 1000, - Strategy: workflow.StrategySlidingWindow, - } + config := &workflow.ConversationConfig{} execCtx := workflow.NewExecutionContext("test-id", "test-workflow") execCtx.States = make(map[string]workflow.StepState) @@ -656,1628 +468,124 @@ func TestConversationManager_OmitsSystemPromptWhenEmpty(t *testing.T) { return interpolation.NewContext() } - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, capturedOptions, "options map should exist") - _, exists := capturedOptions["system_prompt"] - assert.False(t, exists, "system_prompt should not be in options when empty") -} - -func TestConversationManager_PreservesExistingOptions(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - var capturedOptions map[string]any - mockProvider := mocks.NewMockAgentProvider("claude") - - mockProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - capturedOptions = options - result := workflow.NewConversationResult("claude") - result.State = state - assistantTurn := workflow.NewTurn(workflow.TurnRoleAssistant, "response") - assistantTurn.Tokens = 10 - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, prompt)) - _ = result.State.AddTurn(assistantTurn) - result.State.StoppedBy = workflow.StopReasonMaxTurns - result.Output = "response" - return result, nil - }) - - _ = registry.Register(mockProvider) - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Hello world", - SystemPrompt: "You are helpful", - Options: map[string]any{ - "temperature": 0.7, - "max_tokens": 100, - }, - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 1000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } + // Create and immediately cancel context + ctx, cancel := context.WithCancel(context.Background()) + cancel() - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) + // Act + _, err := manager.ExecuteConversation( + ctx, + step, + config, + execCtx, + buildContext, + io.Discard, + io.Discard, + ) - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, capturedOptions, "options map should be captured") - assert.Equal(t, "You are helpful", capturedOptions["system_prompt"]) - assert.Equal(t, 0.7, capturedOptions["temperature"]) - assert.Equal(t, 100, capturedOptions["max_tokens"]) + // Assert + assert.Error(t, err) + // Either error should be context.Canceled or wrapped context.Canceled } -func TestConversationManager_Interpolation_Inputs(t *testing.T) { +func TestConversationManager_ExecuteConversation_Error_ContinueFromStepNotFound(t *testing.T) { + // Arrange logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newConfigurableMockResolver() - resolver.results["Explain {{inputs.topic}}"] = "Explain quantum computing" - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() + resolver := newMockResolver() + registry := newMockAgentRegistry() - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) + provider := newMockAgentProvider("claude") + _ = registry.Register(provider) - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) + manager := application.NewConversationManager(logger, resolver, registry) + manager.SetUserInputReader(newMockUserInputReader()) step := &workflow.Step{ - Name: "chat", + Name: "follow_up", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ Provider: "claude", - Prompt: "Explain {{inputs.topic}}", + Prompt: "Continue", }, } - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, + ContinueFrom: "nonexistent_step", } execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.Inputs["topic"] = "quantum computing" execCtx.States = make(map[string]workflow.StepState) buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - ctx := interpolation.NewContext() - ctx.Inputs = ec.Inputs - return ctx + return interpolation.NewContext() } - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) + // Act + result, err := manager.ExecuteConversation( + context.Background(), + step, + config, + execCtx, + buildContext, + io.Discard, + io.Discard, + ) - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) + // Assert + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "not found") } -func TestConversationManager_Interpolation_States(t *testing.T) { +func TestConversationManager_ExecuteConversation_Error_ContinueFromNoConversationState(t *testing.T) { + // Arrange logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newConfigurableMockResolver() - resolver.results["Review: {{states.analyze.output}}"] = "Review: Data is clean" - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) + resolver := newMockResolver() + registry := newMockAgentRegistry() - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Review: {{states.analyze.output}}", - }, - } + provider := newMockAgentProvider("claude") + _ = registry.Register(provider) - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - } + manager := application.NewConversationManager(logger, resolver, registry) + manager.SetUserInputReader(newMockUserInputReader()) + // Create prior step state WITHOUT conversation state execCtx := workflow.NewExecutionContext("test-id", "test-workflow") execCtx.States = map[string]workflow.StepState{ - "analyze": { - Name: "analyze", - Output: "Data is clean", + "prior_step": { + Name: "prior_step", Status: workflow.StatusCompleted, + // Conversation is nil }, } - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - ctx := interpolation.NewContext() - for name, state := range ec.States { - ctx.States[name] = interpolation.StepStateData{ - Output: state.Output, - Status: string(state.Status), - } - } - return ctx - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) -} - -func TestConversationManager_Error_ProviderNotFound(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - // Don't register any providers - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "nonexistent", - Prompt: "Test", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { return interpolation.NewContext() } - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect provider not found error - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") - assert.Nil(t, result) -} - -func TestConversationManager_Error_ProviderExecutionError(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - provider := newMockConversationProvider("claude") - provider.execError = errors.New("API rate limit exceeded") - _ = registry.Register(provider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - step := &workflow.Step{ - Name: "chat", + Name: "follow_up", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ Provider: "claude", - Prompt: "Test", + Prompt: "Continue", }, } - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() + ContinueFrom: "prior_step", } - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) -} - -func TestConversationManager_Error_TokenizerError(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - tokenizer.err = errors.New("tokenizer service unavailable") - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) + // Act + result, err := manager.ExecuteConversation( + context.Background(), + step, + config, + execCtx, + buildContext, + io.Discard, + io.Discard, + ) - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Test", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) -} - -func TestConversationManager_Error_TemplateResolutionError(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newConfigurableMockResolver() - resolver.err = errors.New("undefined variable: missing_var") - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "{{missing_var}}", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect template resolution error - require.Error(t, err) - assert.Contains(t, err.Error(), "variable") - assert.Nil(t, result) -} - -func TestConversationManager_Error_StopConditionEvaluationError(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - evaluator.err = errors.New("syntax error in expression") - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Test", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 5, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - StopCondition: "invalid expression syntax", - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) -} - -func TestConversationManager_Error_ContextCancellation(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Long running task", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // Cancel immediately - - result, err := manager.ExecuteConversation(ctx, step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect context cancellation error - require.Error(t, err) - assert.ErrorIs(t, err, context.Canceled) - assert.Nil(t, result) -} - -// TestConversationManager_ValidateConversationInputs_HappyPath tests the -// validateConversationInputs helper method with valid inputs. -func TestConversationManager_ValidateConversationInputs_HappyPath(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // Register mock provider so validation can proceed past provider lookup - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Test", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 5, - MaxContextTokens: 1000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - // Call ExecuteConversation which uses validateConversationInputs internally - // This verifies the refactored validation is working correctly - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - // Validation passes (step and config are valid) - // Execution may fail due to stub implementation, but that's expected in RED phase - // The key test is that we don't get a validation error about nil inputs - if err != nil { - assert.NotContains(t, err.Error(), "nil") - assert.NotContains(t, err.Error(), "config is nil") - } - // Result may be nil in RED phase due to incomplete implementation - _ = result -} - -// TestConversationManager_ValidateConversationInputs_NilStep tests validation -// with nil step - should fail early with validation error. -func TestConversationManager_ValidateConversationInputs_NilStep(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - config := &workflow.ConversationConfig{ - MaxTurns: 5, - MaxContextTokens: 1000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), nil, config, execCtx, buildContext, nil, nil) - - // Should fail validation immediately - require.Error(t, err) - assert.Contains(t, err.Error(), "nil") - assert.Nil(t, result) -} - -// TestConversationManager_ValidateConversationInputs_NilAgentConfig tests -// validation with nil agent config - should fail early. -func TestConversationManager_ValidateConversationInputs_NilAgentConfig(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: nil, // Nil agent config - } - - config := &workflow.ConversationConfig{ - MaxTurns: 5, - MaxContextTokens: 1000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // Should fail validation immediately - require.Error(t, err) - assert.Contains(t, err.Error(), "nil") - assert.Nil(t, result) -} - -// TestConversationManager_ValidateConversationInputs_NilConfig tests validation -// with nil config - should fail early. -func TestConversationManager_ValidateConversationInputs_NilConfig(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Test", - }, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, nil, execCtx, buildContext, nil, nil) - - // Should fail validation immediately - require.Error(t, err) - assert.Contains(t, err.Error(), "config") - assert.Nil(t, result) -} - -// TestConversationManager_ValidateConversationInputs_AllNil tests validation -// with all nil inputs - should fail with appropriate error. -func TestConversationManager_ValidateConversationInputs_AllNil(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), nil, nil, execCtx, buildContext, nil, nil) - - // Should fail validation immediately - require.Error(t, err) - assert.Contains(t, err.Error(), "nil") - assert.Nil(t, result) -} - -// TestConversationManager_ValidateConversationInputs_EdgeCase_EmptyProviderName -// tests validation with empty provider name (valid step/config structure but -// invalid provider). -func TestConversationManager_ValidateConversationInputs_EdgeCase_EmptyProviderName(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "", // Empty provider name - Prompt: "Test", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 5, - MaxContextTokens: 1000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // Validation passes (step and config are not nil), but should fail at provider lookup - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") - assert.Nil(t, result) -} - -func TestConversationManager_EdgeCase_NilConfig(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Test", - }, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, nil, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect nil config error - require.Error(t, err) - assert.Contains(t, err.Error(), "config") - assert.Nil(t, result) -} - -func TestConversationManager_EdgeCase_EmptyInitialPrompt(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "", // Empty prompt - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) -} - -func TestConversationManager_EdgeCase_EmptySystemPrompt(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - // GREEN PHASE: Register mock provider - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Test prompt", - SystemPrompt: "", // Empty system prompt - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - MaxContextTokens: 5000, - Strategy: workflow.StrategySlidingWindow, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // GREEN PHASE: expect successful execution - require.NoError(t, err) - require.NotNil(t, result) -} - -// T003: Continue From Tests — Load prior conversation state from predecessor step - -func TestConversationManager_ContinueFrom_SessionID(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - var capturedInitialSessionID string - mockProvider := mocks.NewMockAgentProvider("claude") - mockProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - // Capture initial SessionID before provider modifies it - capturedInitialSessionID = state.SessionID - result := workflow.NewConversationResult("claude") - result.State = state - result.State.SessionID = "new-session-456" // Provider issues new session for this step (FR-006) - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, prompt)) - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleAssistant, "response")) - result.State.StoppedBy = workflow.StopReasonMaxTurns - result.Output = "response" - return result, nil - }) - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "step2", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Continue the work", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - ContinueFrom: "step1", - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.SetStepState("step1", workflow.StepState{ - Name: "step1", - Status: workflow.StatusCompleted, - Conversation: &workflow.ConversationState{ - SessionID: "session-abc-123", - Turns: []workflow.Turn{{Role: workflow.TurnRoleUser, Content: "hello"}}, - TotalTurns: 1, - TotalTokens: 50, - }, - }) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - require.NoError(t, err) - require.NotNil(t, result) - // Verify predecessor's SessionID was passed to the provider - assert.Equal(t, "session-abc-123", capturedInitialSessionID) - // Verify result has new SessionID assigned by provider (FR-006) - assert.Equal(t, "new-session-456", result.State.SessionID) -} - -func TestConversationManager_ContinueFrom_Turns(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - var capturedInitialTurnCount int - mockProvider := mocks.NewMockAgentProvider("openai_compatible") - mockProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - // Capture initial turn count before provider adds new turns - capturedInitialTurnCount = len(state.Turns) - result := workflow.NewConversationResult("openai_compatible") - result.State = state - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, prompt)) - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleAssistant, "refined response")) - result.State.StoppedBy = workflow.StopReasonMaxTurns - result.Output = "refined response" - return result, nil - }) - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "refine", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "openai_compatible", - Prompt: "Refine the analysis", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - ContinueFrom: "analyze", - } - - priorTurns := []workflow.Turn{ - {Role: workflow.TurnRoleUser, Content: "Analyze this data"}, - {Role: workflow.TurnRoleAssistant, Content: "Analysis complete"}, - {Role: workflow.TurnRoleUser, Content: "More detail"}, - {Role: workflow.TurnRoleAssistant, Content: "Detailed analysis"}, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.SetStepState("analyze", workflow.StepState{ - Name: "analyze", - Status: workflow.StatusCompleted, - Conversation: &workflow.ConversationState{ - Turns: priorTurns, - TotalTurns: 4, - TotalTokens: 200, - }, - }) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - require.NoError(t, err) - require.NotNil(t, result) - // Verify predecessor's turns were passed to the provider - assert.Equal(t, 4, capturedInitialTurnCount) - // Verify result has original turns plus new turns added by provider - assert.Equal(t, 6, len(result.State.Turns)) - assert.Equal(t, "Analyze this data", result.State.Turns[0].Content) -} - -func TestConversationManager_ContinueFrom_StepNotFound(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "step2", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Continue", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - ContinueFrom: "nonexistent", - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - require.Error(t, err) - assert.Contains(t, err.Error(), "nonexistent") - assert.Nil(t, result) -} - -func TestConversationManager_ContinueFrom_NilConversationState(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "step2", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Continue", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - ContinueFrom: "step1", - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.SetStepState("step1", workflow.StepState{ - Name: "step1", - Status: workflow.StatusCompleted, - Conversation: nil, - }) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - require.Error(t, err) - assert.Contains(t, err.Error(), "no conversation state") - assert.Nil(t, result) -} - -func TestConversationManager_ContinueFrom_EmptySessionAndTurns(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - mockProvider := mocks.NewMockAgentProvider("claude") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "step2", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Continue", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - ContinueFrom: "step1", - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.SetStepState("step1", workflow.StepState{ - Name: "step1", - Status: workflow.StatusCompleted, - Conversation: &workflow.ConversationState{ - SessionID: "", - Turns: []workflow.Turn{}, - }, - }) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - require.Error(t, err) - assert.Contains(t, err.Error(), "no session") - assert.Nil(t, result) -} - -func TestConversationManager_ContinueFrom_TurnsRequired_HTTPProvider(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - mockProvider := mocks.NewMockAgentProvider("openai_compatible") - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "refine", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "openai_compatible", - Prompt: "Refine the analysis", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - ContinueFrom: "analyze", - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.SetStepState("analyze", workflow.StepState{ - Name: "analyze", - Status: workflow.StatusCompleted, - Conversation: &workflow.ConversationState{ - SessionID: "session-from-cli-provider", - Turns: []workflow.Turn{}, - TotalTurns: 3, - TotalTokens: 150, - }, - }) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - require.Error(t, err) - assert.Contains(t, err.Error(), "no conversation turns") + // Assert + assert.Error(t, err) assert.Nil(t, result) } - -func TestConversationManager_ContinueFrom_ThreeStepChain(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newMockResolver() - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - var step2InitialTurnCount int - var step3InitialTurnCount int - - mockProvider := mocks.NewMockAgentProvider("claude") - mockProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - // Capture initial turn count before provider adds new turns - switch prompt { - case "step2-prompt": - step2InitialTurnCount = len(state.Turns) - case "step3-prompt": - step3InitialTurnCount = len(state.Turns) - } - result := workflow.NewConversationResult("claude") - result.State = state - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, prompt)) - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleAssistant, "response")) - result.State.StoppedBy = workflow.StopReasonMaxTurns - result.Output = "response" - return result, nil - }) - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - // Step 1: Initial conversation - step1 := &workflow.Step{ - Name: "step1", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "step1-prompt", - }, - } - config1 := &workflow.ConversationConfig{MaxTurns: 1} - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - result1, err := manager.ExecuteConversation(context.Background(), step1, config1, execCtx, buildContext, nil, nil) - require.NoError(t, err) - require.NotNil(t, result1) - execCtx.SetStepState("step1", workflow.StepState{ - Name: "step1", - Status: workflow.StatusCompleted, - Conversation: result1.State, - }) - - // Step 2: Continue from step1 - step2 := &workflow.Step{ - Name: "step2", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "step2-prompt", - }, - } - config2 := &workflow.ConversationConfig{ - MaxTurns: 1, - ContinueFrom: "step1", - } - result2, err := manager.ExecuteConversation(context.Background(), step2, config2, execCtx, buildContext, nil, nil) - require.NoError(t, err) - require.NotNil(t, result2) - // Step2 should receive step1's 2 turns (user + assistant) - assert.Equal(t, 2, step2InitialTurnCount) - execCtx.SetStepState("step2", workflow.StepState{ - Name: "step2", - Status: workflow.StatusCompleted, - Conversation: result2.State, - }) - - // Step 3: Continue from step2 (not step1) — NFR-003 O(1) behavior - step3 := &workflow.Step{ - Name: "step3", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "step3-prompt", - }, - } - config3 := &workflow.ConversationConfig{ - MaxTurns: 1, - ContinueFrom: "step2", - } - result3, err := manager.ExecuteConversation(context.Background(), step3, config3, execCtx, buildContext, nil, nil) - require.NoError(t, err) - require.NotNil(t, result3) - - // Verify step3 loaded step2's state which has 4 turns (2 from step1 + 2 from step2) - // This proves O(1) per-hop: step3 doesn't recursively load step1 - assert.Equal(t, 4, step3InitialTurnCount) -} - -// T004: Implement inject_context appending on turn 2+ - -func TestConversationManager_InjectContext_AppendedOnSecondTurn(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newConfigurableMockResolver() - resolver.results["What is AI?"] = "What is AI?" - resolver.results["Additional context from step1"] = "Additional context from step1" - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - var capturedPrompts []string - mockProvider := mocks.NewMockAgentProvider("claude") - - mockProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - capturedPrompts = append(capturedPrompts, prompt) - result := workflow.NewConversationResult("claude") - result.State = state - assistantTurn := workflow.NewTurn(workflow.TurnRoleAssistant, "response") - assistantTurn.Tokens = 10 - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, prompt)) - _ = result.State.AddTurn(assistantTurn) - result.Output = "response" - return result, nil - }) - - _ = registry.Register(mockProvider) - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "What is AI?", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 3, - InjectContext: "Additional context from step1", - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, capturedPrompts, 3, "should have 3 turn prompts") - - // Turn 1: no inject_context - assert.Equal(t, "What is AI?", capturedPrompts[0], "turn 1 should not contain inject_context") - - // Turn 2+: inject_context appended with \n\n separator - expectedPrompt2 := "What is AI?\n\nAdditional context from step1" - assert.Equal(t, expectedPrompt2, capturedPrompts[1], "turn 2 should have inject_context appended with \\n\\n separator") - - expectedPrompt3 := "What is AI?\n\nAdditional context from step1" - assert.Equal(t, expectedPrompt3, capturedPrompts[2], "turn 3 should have inject_context appended with \\n\\n separator") -} - -func TestConversationManager_InjectContext_ExcludedFromFirstTurn(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newConfigurableMockResolver() - resolver.results["What is AI?"] = "What is AI?" - resolver.results["Additional context from step1"] = "Additional context from step1" - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - var capturedPrompts []string - mockProvider := mocks.NewMockAgentProvider("claude") - - mockProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - capturedPrompts = append(capturedPrompts, prompt) - result := workflow.NewConversationResult("claude") - result.State = state - assistantTurn := workflow.NewTurn(workflow.TurnRoleAssistant, "response") - assistantTurn.Tokens = 10 - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, prompt)) - _ = result.State.AddTurn(assistantTurn) - result.Output = "response" - return result, nil - }) - - _ = registry.Register(mockProvider) - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "What is AI?", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - InjectContext: "Additional context from step1", - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, capturedPrompts, 1, "should have 1 turn prompt") - - assert.Equal(t, "What is AI?", capturedPrompts[0], "turn 1 should not contain inject_context") -} - -// T006: empty/whitespace inject_context must be a no-op (FR-005) - -func TestConversationManager_InjectContext_EmptyIsNoOp(t *testing.T) { - tests := []struct { - name string - injectContext string - }{ - {"empty string", ""}, - {"whitespace only", " \t\n "}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newConfigurableMockResolver() - resolver.results["What is AI?"] = "What is AI?" - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - var capturedPrompts []string - mockProvider := mocks.NewMockAgentProvider("claude") - - mockProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - capturedPrompts = append(capturedPrompts, prompt) - result := workflow.NewConversationResult("claude") - result.State = state - assistantTurn := workflow.NewTurn(workflow.TurnRoleAssistant, "response") - assistantTurn.Tokens = 10 - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, prompt)) - _ = result.State.AddTurn(assistantTurn) - result.Output = "response" - return result, nil - }) - - _ = registry.Register(mockProvider) - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "What is AI?", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 2, - InjectContext: tt.injectContext, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, capturedPrompts, 2, "should have 2 turn prompts") - - assert.Equal(t, "What is AI?", capturedPrompts[0], "turn 1 should be unchanged") - assert.Equal(t, "What is AI?", capturedPrompts[1], "turn 2 should not have empty inject_context appended") - }) - } -} - -func TestConversationManager_InjectContext_InterpolationError(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newConfigurableMockResolver() - resolver.results["Continue the work"] = "Continue the work" - resolver.templateErrors["{{.states.bad_template"] = errors.New("template parse error: unexpected end of input") - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - mockProvider := mocks.NewMockAgentProvider("claude") - mockProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - result := workflow.NewConversationResult("claude") - result.State = state - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, prompt)) - assistantTurn := workflow.NewTurn(workflow.TurnRoleAssistant, "response") - assistantTurn.Tokens = 10 - _ = result.State.AddTurn(assistantTurn) - result.Output = "response" - return result, nil - }) - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Continue the work", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 3, - InjectContext: "{{.states.bad_template", - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - return interpolation.NewContext() - } - - _, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - // First turn succeeds (no injection). Second turn fails on inject_context interpolation. - require.Error(t, err) - assert.Contains(t, err.Error(), "inject_context:", "Error should be wrapped with field name (FR-006)") - assert.Contains(t, err.Error(), "template parse error") -} - -// F075: InjectContext per-turn template interpolation — verify states.* and inputs.* namespaces -func TestConversationManager_InjectContext_TemplateInterpolation(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - resolver := newConfigurableMockResolver() - resolver.results["Analyze this"] = "Analyze this" - resolver.results["Results: {{.states.run_tests.output}}, task: {{.inputs.task}}"] = "Results: all tests passed, task: review" - tokenizer := newMockTokenizer() - registry := mocks.NewMockAgentRegistry() - - var secondTurnPrompt string - callCount := 0 - mockProvider := mocks.NewMockAgentProvider("claude") - mockProvider.SetConversationFunc(func(ctx context.Context, state *workflow.ConversationState, prompt string, options map[string]any, stdout, stderr io.Writer) (*workflow.ConversationResult, error) { - callCount++ - if callCount == 2 { - secondTurnPrompt = prompt - } - result := workflow.NewConversationResult("claude") - result.State = state - _ = result.State.AddTurn(workflow.NewTurn(workflow.TurnRoleUser, prompt)) - assistantTurn := workflow.NewTurn(workflow.TurnRoleAssistant, "response") - assistantTurn.Tokens = 10 - _ = result.State.AddTurn(assistantTurn) - result.Output = "response" - return result, nil - }) - _ = registry.Register(mockProvider) - - manager := application.NewConversationManager(logger, evaluator, resolver, tokenizer, registry) - - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Prompt: "Analyze this", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 3, - InjectContext: "Results: {{.states.run_tests.output}}, task: {{.inputs.task}}", - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - ctx := interpolation.NewContext() - ctx.States["run_tests"] = interpolation.StepStateData{ - Output: "all tests passed", - } - ctx.Inputs["task"] = "review" - return ctx - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - require.NoError(t, err) - require.NotNil(t, result) - assert.Contains(t, secondTurnPrompt, "Results: all tests passed, task: review", - "inject_context should interpolate both states.* and inputs.* namespaces (FR-007)") -} - -// TestConversationManager_ProviderInterpolation tests that the provider field -// is correctly interpolated before registry lookup in conversation mode. -func TestConversationManager_ProviderInterpolation(t *testing.T) { - tests := []struct { - name string - providerExpr string - resolveMap map[string]string - registeredNames []string - expectError bool - expectedErrorMsg string - }{ - { - name: "literal provider name", - providerExpr: "claude", - resolveMap: map[string]string{"claude": "claude"}, - registeredNames: []string{"claude"}, - expectError: false, - expectedErrorMsg: "", - }, - { - name: "interpolated provider from inputs", - providerExpr: "{{inputs.agent}}", - resolveMap: map[string]string{"{{inputs.agent}}": "claude"}, - registeredNames: []string{"claude"}, - expectError: false, - expectedErrorMsg: "", - }, - { - name: "interpolated provider different value", - providerExpr: "{{inputs.agent}}", - resolveMap: map[string]string{"{{inputs.agent}}": "gemini"}, - registeredNames: []string{"gemini"}, - expectError: false, - expectedErrorMsg: "", - }, - { - name: "invalid template expression", - providerExpr: "{{invalid", - resolveMap: map[string]string{}, - registeredNames: []string{}, - expectError: true, - expectedErrorMsg: "resolve provider", - }, - { - name: "resolved provider name not in registry", - providerExpr: "{{inputs.agent}}", - resolveMap: map[string]string{"{{inputs.agent}}": "unknown"}, - registeredNames: []string{"claude"}, - expectError: true, - expectedErrorMsg: "not found", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - logger := &mockLogger{} - evaluator := newMockExpressionEvaluator() - configResolver := newConfigurableResolver(tt.resolveMap) - tokenizer := newMockTokenizer() - - registry := mocks.NewMockAgentRegistry() - for _, name := range tt.registeredNames { - provider := mocks.NewMockAgentProvider(name) - _ = registry.Register(provider) - } - - manager := application.NewConversationManager( - logger, - evaluator, - configResolver, - tokenizer, - registry, - ) - - step := &workflow.Step{ - Name: "converse", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: tt.providerExpr, - Mode: "conversation", - }, - } - - config := &workflow.ConversationConfig{ - MaxTurns: 1, - } - - execCtx := workflow.NewExecutionContext("test-id", "test-workflow") - execCtx.States = make(map[string]workflow.StepState) - - buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { - ctx := interpolation.NewContext() - ctx.Inputs["agent"] = "claude" - return ctx - } - - result, err := manager.ExecuteConversation(context.Background(), step, config, execCtx, buildContext, nil, nil) - - if tt.expectError { - require.Error(t, err, "should return error") - if tt.expectedErrorMsg != "" { - assert.Contains(t, err.Error(), tt.expectedErrorMsg, "error should contain expected message") - } - } else { - require.NoError(t, err, "should not return error") - require.NotNil(t, result) - } - }) - } -} diff --git a/internal/application/dry_run_executor_output_format_test.go b/internal/application/dry_run_executor_output_format_test.go index 2bae4d99..eb63c185 100644 --- a/internal/application/dry_run_executor_output_format_test.go +++ b/internal/application/dry_run_executor_output_format_test.go @@ -227,13 +227,10 @@ func TestDryRunExecutor_AgentStep_WithConversationMode(t *testing.T) { Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Start conversation", - OutputFormat: workflow.OutputFormatJSON, - Conversation: &workflow.ConversationConfig{ - MaxTurns: 3, - }, + Provider: "claude", + Mode: "conversation", + OutputFormat: workflow.OutputFormatJSON, + Conversation: &workflow.ConversationConfig{}, }, OnSuccess: "done", }, diff --git a/internal/application/execution_service.go b/internal/application/execution_service.go index 6f8aef23..ff757343 100644 --- a/internal/application/execution_service.go +++ b/internal/application/execution_service.go @@ -2035,7 +2035,6 @@ func (s *ExecutionService) executeAgentStep( // Execute the agent s.logger.Debug("executing agent step", "step", step.Name, "provider", resolvedProvider) opts := cloneAndInjectOutputFormat(step.Agent.Options, step.Agent.OutputFormat) - result, execErr := provider.Execute(stepCtx, resolvedPrompt, opts, s.stdoutWriter, s.stderrWriter) // Record step state state := workflow.StepState{ @@ -2045,18 +2044,47 @@ func (s *ExecutionService) executeAgentStep( Attempt: 1, } - // Populate state from result - if result != nil { - state.Output = result.Output - state.DisplayOutput = result.DisplayOutput - // AC5: JSON auto-parsed to states.step_name.Response - state.Response = result.Response - // AC6: Token usage in states.step_name.tokens_used - state.TokensUsed = result.Tokens + // When the step declares a conversation sub-struct, route through + // provider.ExecuteConversation to enable session tracking and continue_from + // resume. This is a single-turn call (no interactive loop) — the loop only + // applies when mode == "conversation". + var result *workflow.AgentResult + var execErr error + if step.Agent.Conversation != nil { + var convResult *workflow.ConversationResult + convResult, execErr = s.executeResumableAgentCall(stepCtx, step, provider, resolvedProvider, resolvedPrompt, opts, execCtx) + if convResult != nil { + state.Output = convResult.Output + state.DisplayOutput = convResult.DisplayOutput + state.Response = convResult.Response + state.TokensUsed = convResult.TokensTotal + state.Conversation = convResult.State + result = &workflow.AgentResult{ + Provider: convResult.Provider, + Output: convResult.Output, + Response: convResult.Response, + Tokens: convResult.TokensTotal, + StartedAt: convResult.StartedAt, + CompletedAt: convResult.CompletedAt, + } + if formatErr := s.applyOutputFormat(step, &state, execCtx); formatErr != nil { + return "", formatErr + } + } + } else { + result, execErr = provider.Execute(stepCtx, resolvedPrompt, opts, s.stdoutWriter, s.stderrWriter) + if result != nil { + state.Output = result.Output + state.DisplayOutput = result.DisplayOutput + // AC5: JSON auto-parsed to states.step_name.Response + state.Response = result.Response + // AC6: Token usage in states.step_name.tokens_used + state.TokensUsed = result.Tokens - // F065: Apply output format post-processing - if err := s.applyOutputFormat(step, &state, execCtx); err != nil { - return "", err + // F065: Apply output format post-processing + if formatErr := s.applyOutputFormat(step, &state, execCtx); formatErr != nil { + return "", formatErr + } } } @@ -2132,6 +2160,76 @@ func (s *ExecutionService) executeAgentStep( return s.resolveNextStep(step, intCtx, true) } +// executeResumableAgentCall runs a single-turn agent call with conversation +// state tracking. It is used when a single-mode agent step declares a +// `conversation:` sub-struct, which opts the step into session tracking — +// either to establish a new session (continue_from empty) or to resume a +// prior step's session (continue_from set). Unlike executeConversationStep, +// this does not enter the interactive user-input loop; it runs exactly one +// agent turn and returns. +func (s *ExecutionService) executeResumableAgentCall( + ctx context.Context, + step *workflow.Step, + provider ports.AgentProvider, + resolvedProvider string, + resolvedPrompt string, + opts map[string]any, + execCtx *workflow.ExecutionContext, +) (*workflow.ConversationResult, error) { + state, err := s.buildResumableState(step, resolvedProvider, execCtx) + if err != nil { + return nil, err + } + // Providers consume system_prompt via the options map (same convention as + // ConversationManager). Inject it on fresh sessions only; on resumed + // sessions the provider retains the system prompt from the prior turn. + if step.Agent.SystemPrompt != "" && state.SessionID == "" { + if opts == nil { + opts = map[string]any{} + } + opts["system_prompt"] = step.Agent.SystemPrompt + } + return provider.ExecuteConversation(ctx, state, resolvedPrompt, opts, s.stdoutWriter, s.stderrWriter) +} + +// buildResumableState constructs the conversation state seed for a resumable +// agent call. When continue_from is set, it clones the referenced step's +// conversation state; otherwise it creates a fresh state with the system prompt. +func (s *ExecutionService) buildResumableState( + step *workflow.Step, + resolvedProvider string, + execCtx *workflow.ExecutionContext, +) (*workflow.ConversationState, error) { + cfg := step.Agent.Conversation + if cfg == nil || cfg.ContinueFrom == "" { + return workflow.NewConversationState(step.Agent.SystemPrompt), nil + } + + priorStepState, ok := execCtx.GetStepState(cfg.ContinueFrom) + if !ok { + return nil, fmt.Errorf("continue_from: step %q not found in execution context", cfg.ContinueFrom) + } + if priorStepState.Conversation == nil { + return nil, fmt.Errorf("continue_from: step %q has no conversation state", cfg.ContinueFrom) + } + prior := priorStepState.Conversation + if prior.SessionID == "" && len(prior.Turns) == 0 { + return nil, fmt.Errorf("continue_from: step %q has no session ID or conversation history to resume", cfg.ContinueFrom) + } + if resolvedProvider == "openai_compatible" && len(prior.Turns) == 0 { + return nil, fmt.Errorf("continue_from: step %q has no conversation turns for HTTP-based provider %q", cfg.ContinueFrom, resolvedProvider) + } + + cloned := &workflow.ConversationState{ + SessionID: prior.SessionID, + Turns: make([]workflow.Turn, len(prior.Turns)), + TotalTurns: prior.TotalTurns, + TotalTokens: prior.TotalTokens, + } + copy(cloned.Turns, prior.Turns) + return cloned, nil +} + // executeConversationStep orchestrates a multi-turn agent conversation following F033 spec. // It delegates to ConversationManager which handles: // - Turn iteration with conversation history @@ -2164,12 +2262,7 @@ func (s *ExecutionService) executeConversationStep( return "", errors.New("agent config is nil") } - // 3. Validate conversation config exists - if step.Agent.Conversation == nil { - return "", errors.New("conversation config is nil") - } - - // 4. Create buildContext closure for interpolation + // 3. Create buildContext closure for interpolation buildContext := func(ec *workflow.ExecutionContext) *interpolation.Context { return s.buildInterpolationContext(ec) } diff --git a/internal/application/execution_service_conversation_step_test.go b/internal/application/execution_service_conversation_step_test.go index 04ee0061..08617381 100644 --- a/internal/application/execution_service_conversation_step_test.go +++ b/internal/application/execution_service_conversation_step_test.go @@ -36,13 +36,11 @@ func TestExecuteConversationStep_T009_HappyPath_SingleTurnSuccess(t *testing.T) Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are helpful", - InitialPrompt: "Hello", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 1, - }, + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are helpful", + Prompt: "Hello", + Conversation: &workflow.ConversationConfig{}, }, } execCtx := workflow.NewExecutionContext("test-wf", "test-workflow") @@ -55,7 +53,7 @@ func TestExecuteConversationStep_T009_HappyPath_SingleTurnSuccess(t *testing.T) }, TotalTurns: 1, TotalTokens: 50, - StoppedBy: workflow.StopReasonMaxTurns, + StoppedBy: workflow.StopReasonUserExit, } mockConvMgr := &mockConversationManagerT009{ result: &workflow.ConversationResult{ @@ -89,7 +87,7 @@ func TestExecuteConversationStep_T009_HappyPath_SingleTurnSuccess(t *testing.T) // Verify conversation state was persisted assert.NotNil(t, state.Conversation) assert.Len(t, state.Conversation.Turns, 2) - assert.Equal(t, workflow.StopReasonMaxTurns, state.Conversation.StoppedBy) + assert.Equal(t, workflow.StopReasonUserExit, state.Conversation.StoppedBy) } // TestExecuteConversationStep_T009_HappyPath_MultiTurnSuccess tests that @@ -99,16 +97,11 @@ func TestExecuteConversationStep_T009_HappyPath_MultiTurnSuccess(t *testing.T) { Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are a coder", - InitialPrompt: "Write a function", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 5, - MaxContextTokens: 100000, - Strategy: workflow.StrategySlidingWindow, - StopCondition: "response contains 'DONE'", - }, + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are a coder", + Prompt: "Write a function", + Conversation: &workflow.ConversationConfig{}, }, } execCtx := workflow.NewExecutionContext("test-wf", "test-workflow") @@ -125,7 +118,7 @@ func TestExecuteConversationStep_T009_HappyPath_MultiTurnSuccess(t *testing.T) { }, TotalTurns: 3, TotalTokens: 250, - StoppedBy: workflow.StopReasonCondition, + StoppedBy: workflow.StopReasonUserExit, } mockConvMgr := &mockConversationManagerT009{ result: &workflow.ConversationResult{ @@ -156,23 +149,21 @@ func TestExecuteConversationStep_T009_HappyPath_MultiTurnSuccess(t *testing.T) { // Verify multi-turn conversation state assert.NotNil(t, state.Conversation) assert.Len(t, state.Conversation.Turns, 6) // 3 user + 3 assistant turns - assert.Equal(t, workflow.StopReasonCondition, state.Conversation.StoppedBy) + assert.Equal(t, workflow.StopReasonUserExit, state.Conversation.StoppedBy) } // TestExecuteConversationStep_T009_HappyPath_WithInputInterpolation tests that // executeConversationStep passes buildContext function to ConversationManager -// for interpolating InitialPrompt with workflow inputs and step states. +// for interpolating the prompt with workflow inputs and step states. func TestExecuteConversationStep_T009_HappyPath_WithInputInterpolation(t *testing.T) { step := &workflow.Step{ Name: "analyze", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Analyze code: {{inputs.code}}", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 2, - }, + Provider: "claude", + Mode: "conversation", + Prompt: "Analyze code: {{inputs.code}}", + Conversation: &workflow.ConversationConfig{}, }, } @@ -219,10 +210,10 @@ func TestExecuteConversationStep_T009_EdgeCase_MinimalConfig(t *testing.T) { Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Hello", - Conversation: &workflow.ConversationConfig{}, // Empty config - use defaults + Provider: "claude", + Mode: "conversation", + Prompt: "Hello", + Conversation: &workflow.ConversationConfig{}, // Empty config - use defaults }, } execCtx := workflow.NewExecutionContext("test-wf", "test-workflow") @@ -259,12 +250,10 @@ func TestExecuteConversationStep_T009_EdgeCase_EmptyOutput(t *testing.T) { Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Say nothing", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 1, - }, + Provider: "claude", + Mode: "conversation", + Prompt: "Say nothing", + Conversation: &workflow.ConversationConfig{}, }, } execCtx := workflow.NewExecutionContext("test-wf", "test-workflow") @@ -276,7 +265,7 @@ func TestExecuteConversationStep_T009_EdgeCase_EmptyOutput(t *testing.T) { }, TotalTurns: 1, TotalTokens: 5, - StoppedBy: workflow.StopReasonMaxTurns, + StoppedBy: workflow.StopReasonUserExit, } mockConvMgr := &mockConversationManagerT009{ result: &workflow.ConversationResult{ @@ -311,12 +300,10 @@ func TestExecuteConversationStep_T009_EdgeCase_ContextCancellation(t *testing.T) Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Long task", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 100, - }, + Provider: "claude", + Mode: "conversation", + Prompt: "Long task", + Conversation: &workflow.ConversationConfig{}, }, } execCtx := workflow.NewExecutionContext("test-wf", "test-workflow") @@ -352,12 +339,10 @@ func TestExecuteConversationStep_T009_Error_NoConversationManager(t *testing.T) Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Hello", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 1, - }, + Provider: "claude", + Mode: "conversation", + Prompt: "Hello", + Conversation: &workflow.ConversationConfig{}, }, } execCtx := workflow.NewExecutionContext("test-wf", "test-workflow") @@ -373,34 +358,6 @@ func TestExecuteConversationStep_T009_Error_NoConversationManager(t *testing.T) assert.Contains(t, err.Error(), "conversation manager not configured") } -// TestExecuteConversationStep_T009_Error_NilConversationConfig tests that -// executeConversationStep returns error when step.Agent.Conversation is nil. -func TestExecuteConversationStep_T009_Error_NilConversationConfig(t *testing.T) { - step := &workflow.Step{ - Name: "chat", - Type: workflow.StepTypeAgent, - Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Hello", - Conversation: nil, // Missing conversation config - }, - } - execCtx := workflow.NewExecutionContext("test-wf", "test-workflow") - execCtx.Status = workflow.StatusRunning - mockConvMgr := &mockConversationManagerT009{} - - svc := &ExecutionService{ - conversationMgr: mockConvMgr, - logger: newMockLogger(), - resolver: newMockResolver(), - } - _, err := svc.executeConversationStep(context.Background(), step, execCtx) - - require.Error(t, err) - assert.Contains(t, err.Error(), "conversation config is nil") -} - // TestExecuteConversationStep_T009_Error_NilAgentConfig tests that // executeConversationStep returns error when step.Agent is nil. func TestExecuteConversationStep_T009_Error_NilAgentConfig(t *testing.T) { @@ -431,12 +388,10 @@ func TestExecuteConversationStep_T009_Error_ConversationManagerFailure(t *testin Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Hello", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 1, - }, + Provider: "claude", + Mode: "conversation", + Prompt: "Hello", + Conversation: &workflow.ConversationConfig{}, }, } execCtx := workflow.NewExecutionContext("test-wf", "test-workflow") @@ -463,12 +418,10 @@ func TestExecuteConversationStep_T009_Error_ProviderNotFound(t *testing.T) { Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "unknown-provider", // Not registered - Mode: "conversation", - InitialPrompt: "Hello", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 1, - }, + Provider: "unknown-provider", // Not registered + Mode: "conversation", + Prompt: "Hello", + Conversation: &workflow.ConversationConfig{}, }, } execCtx := workflow.NewExecutionContext("test-wf", "test-workflow") @@ -495,12 +448,10 @@ func TestExecuteConversationStep_T009_Error_InterpolationFailure(t *testing.T) { Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Use {{invalid.reference}}", // Invalid interpolation - Conversation: &workflow.ConversationConfig{ - MaxTurns: 1, - }, + Provider: "claude", + Mode: "conversation", + Prompt: "Use {{invalid.reference}}", // Invalid interpolation + Conversation: &workflow.ConversationConfig{}, }, } execCtx := workflow.NewExecutionContext("test-wf", "test-workflow") diff --git a/internal/application/execution_service_conversation_test.go b/internal/application/execution_service_conversation_test.go index 12c15e5a..f7907ab3 100644 --- a/internal/application/execution_service_conversation_test.go +++ b/internal/application/execution_service_conversation_test.go @@ -28,16 +28,10 @@ func TestExecutionService_ConversationStep_RoutingToConversationMode(t *testing. Name: "refine", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are a code reviewer", - InitialPrompt: "Review this code", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100000, - Strategy: workflow.StrategySlidingWindow, - StopCondition: "response contains 'APPROVED'", - }, + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are a code reviewer", + Conversation: &workflow.ConversationConfig{}, }, OnSuccess: "done", }, @@ -56,20 +50,13 @@ func TestExecutionService_ConversationStep_RoutingToConversationMode(t *testing. claude := mocks.NewMockAgentProvider("claude") _ = registry.Register(claude) - tokenizer := newMockTokenizer() mockRegistry := mocks.NewMockAgentRegistry() mockRegistry.Register(claude) // Create a simple evaluator that always returns false (never stops on condition) - evaluator := &simpleExpressionEvaluator{} - convMgr := application.NewConversationManager( - &mockLogger{}, - evaluator, - newMockResolver(), - tokenizer, - mockRegistry, - ) + convMgr := application.NewConversationManager(&mockLogger{}, newMockResolver(), mockRegistry) + convMgr.SetUserInputReader(mocks.NewMockUserInputReader("")) execSvc.SetAgentRegistry(registry) execSvc.SetConversationManager(convMgr) @@ -101,14 +88,10 @@ func TestExecutionService_ConversationStep_WithInputInterpolation(t *testing.T) Name: "analyze", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are a code analyzer", - InitialPrompt: "Analyze this code: {{inputs.code}}", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 5, - StopCondition: "response contains 'DONE'", - }, + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are a code analyzer", + Conversation: &workflow.ConversationConfig{}, }, OnSuccess: "done", }, @@ -127,10 +110,10 @@ func TestExecutionService_ConversationStep_WithInputInterpolation(t *testing.T) claude := mocks.NewMockAgentProvider("claude") _ = registry.Register(claude) - tokenizer := newMockTokenizer() mockRegistry := mocks.NewMockAgentRegistry() mockRegistry.Register(claude) - convMgr := application.NewConversationManager(&mockLogger{}, &simpleExpressionEvaluator{}, newMockResolver(), tokenizer, mockRegistry) + convMgr := application.NewConversationManager(&mockLogger{}, newMockResolver(), mockRegistry) + convMgr.SetUserInputReader(mocks.NewMockUserInputReader("")) execSvc.SetAgentRegistry(registry) execSvc.SetConversationManager(convMgr) @@ -162,12 +145,9 @@ func TestExecutionService_ConversationStep_WithHooks(t *testing.T) { Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Start chat", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 3, - }, + Provider: "claude", + Mode: "conversation", + Conversation: &workflow.ConversationConfig{}, }, Hooks: workflow.StepHooks{ Pre: workflow.Hook{ @@ -194,10 +174,10 @@ func TestExecutionService_ConversationStep_WithHooks(t *testing.T) { claude := mocks.NewMockAgentProvider("claude") _ = registry.Register(claude) - tokenizer := newMockTokenizer() mockRegistry := mocks.NewMockAgentRegistry() mockRegistry.Register(claude) - convMgr := application.NewConversationManager(&mockLogger{}, &simpleExpressionEvaluator{}, newMockResolver(), tokenizer, mockRegistry) + convMgr := application.NewConversationManager(&mockLogger{}, newMockResolver(), mockRegistry) + convMgr.SetUserInputReader(mocks.NewMockUserInputReader("")) execSvc, _ := NewTestHarness(t). WithWorkflow("conv-hooks", wf). @@ -253,10 +233,9 @@ func TestExecutionService_ConversationStep_SingleModeSkipsConversation(t *testin _ = registry.Register(claude) // ConversationManager configured but should NOT be called for single mode - tokenizer := newMockTokenizer() mockRegistry := mocks.NewMockAgentRegistry() mockRegistry.Register(claude) - convMgr := application.NewConversationManager(&mockLogger{}, &simpleExpressionEvaluator{}, newMockResolver(), tokenizer, mockRegistry) + convMgr := application.NewConversationManager(&mockLogger{}, newMockResolver(), mockRegistry) execSvc, _ := NewTestHarness(t). WithWorkflow("single-mode", wf). @@ -316,10 +295,9 @@ func TestExecutionService_ConversationStep_EmptyModeDefaultsToSingle(t *testing. }) _ = registry.Register(claude) - tokenizer := newMockTokenizer() mockRegistry := mocks.NewMockAgentRegistry() mockRegistry.Register(claude) - convMgr := application.NewConversationManager(&mockLogger{}, &simpleExpressionEvaluator{}, newMockResolver(), tokenizer, mockRegistry) + convMgr := application.NewConversationManager(&mockLogger{}, newMockResolver(), mockRegistry) execSvc, _ := NewTestHarness(t). WithWorkflow("default-mode", wf). @@ -350,9 +328,8 @@ func TestExecutionService_ConversationStep_MinimalConversationConfig(t *testing. Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Start", + Provider: "claude", + Mode: "conversation", // Minimal conversation config (should use defaults) Conversation: &workflow.ConversationConfig{}, }, @@ -373,10 +350,10 @@ func TestExecutionService_ConversationStep_MinimalConversationConfig(t *testing. claude := mocks.NewMockAgentProvider("claude") _ = registry.Register(claude) - tokenizer := newMockTokenizer() mockRegistry := mocks.NewMockAgentRegistry() mockRegistry.Register(claude) - convMgr := application.NewConversationManager(&mockLogger{}, &simpleExpressionEvaluator{}, newMockResolver(), tokenizer, mockRegistry) + convMgr := application.NewConversationManager(&mockLogger{}, newMockResolver(), mockRegistry) + convMgr.SetUserInputReader(mocks.NewMockUserInputReader("")) execSvc.SetAgentRegistry(registry) execSvc.SetConversationManager(convMgr) @@ -405,12 +382,9 @@ func TestExecutionService_ConversationStep_NoConversationManagerConfigured(t *te Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Start conversation", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 5, - }, + Provider: "claude", + Mode: "conversation", + Conversation: &workflow.ConversationConfig{}, }, OnSuccess: "done", }, @@ -459,12 +433,9 @@ func TestExecutionService_ConversationStep_WithOnFailureTransition(t *testing.T) Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Start", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 5, - }, + Provider: "claude", + Mode: "conversation", + Conversation: &workflow.ConversationConfig{}, }, OnSuccess: "success", OnFailure: "error_handler", @@ -484,10 +455,10 @@ func TestExecutionService_ConversationStep_WithOnFailureTransition(t *testing.T) claude := mocks.NewMockAgentProvider("claude") _ = registry.Register(claude) - tokenizer := newMockTokenizer() mockRegistry := mocks.NewMockAgentRegistry() mockRegistry.Register(claude) - convMgr := application.NewConversationManager(&mockLogger{}, &simpleExpressionEvaluator{}, newMockResolver(), tokenizer, mockRegistry) + convMgr := application.NewConversationManager(&mockLogger{}, newMockResolver(), mockRegistry) + convMgr.SetUserInputReader(mocks.NewMockUserInputReader("")) wfSvc := application.NewWorkflowService(repo, newMockStateStore(), newMockExecutor(), &mockLogger{}, nil) execSvc := application.NewExecutionService( @@ -527,12 +498,9 @@ func TestExecutionService_ConversationStep_ContextCancellation(t *testing.T) { Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Long running conversation", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 100, - }, + Provider: "claude", + Mode: "conversation", + Conversation: &workflow.ConversationConfig{}, }, OnSuccess: "done", }, @@ -547,10 +515,10 @@ func TestExecutionService_ConversationStep_ContextCancellation(t *testing.T) { claude := mocks.NewMockAgentProvider("claude") _ = registry.Register(claude) - tokenizer := newMockTokenizer() mockRegistry := mocks.NewMockAgentRegistry() mockRegistry.Register(claude) - convMgr := application.NewConversationManager(&mockLogger{}, &simpleExpressionEvaluator{}, newMockResolver(), tokenizer, mockRegistry) + convMgr := application.NewConversationManager(&mockLogger{}, newMockResolver(), mockRegistry) + convMgr.SetUserInputReader(mocks.NewMockUserInputReader("")) wfSvc := application.NewWorkflowService(repo, newMockStateStore(), newMockExecutor(), &mockLogger{}, nil) execSvc := application.NewExecutionService( @@ -605,12 +573,9 @@ func TestExecutionService_ConversationStep_InterpolationContextAccess(t *testing Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Use data: {{inputs.initial_data}} and result: {{states.setup.output}}", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 3, - }, + Provider: "claude", + Mode: "conversation", + Conversation: &workflow.ConversationConfig{}, }, OnSuccess: "done", }, @@ -625,10 +590,10 @@ func TestExecutionService_ConversationStep_InterpolationContextAccess(t *testing claude := mocks.NewMockAgentProvider("claude") _ = registry.Register(claude) - tokenizer := newMockTokenizer() mockRegistry := mocks.NewMockAgentRegistry() mockRegistry.Register(claude) - convMgr := application.NewConversationManager(&mockLogger{}, &simpleExpressionEvaluator{}, newMockResolver(), tokenizer, mockRegistry) + convMgr := application.NewConversationManager(&mockLogger{}, newMockResolver(), mockRegistry) + convMgr.SetUserInputReader(mocks.NewMockUserInputReader("")) executor := newMockExecutor() executor.results["echo 'setup complete'"] = &ports.CommandResult{ diff --git a/internal/application/execution_service_helpers_test.go b/internal/application/execution_service_helpers_test.go index c83dc5ac..26460f0d 100644 --- a/internal/application/execution_service_helpers_test.go +++ b/internal/application/execution_service_helpers_test.go @@ -21,15 +21,7 @@ func newTestExecutionService() *ExecutionService { } // mockResolver is a simple resolver that returns templates unchanged -type mockResolver struct{} - -func newMockResolver() *mockResolver { - return &mockResolver{} -} - -func (m *mockResolver) Resolve(template string, ctx *interpolation.Context) (string, error) { - return template, nil -} +// (defined in test_helpers.go as a shared test helper) // mockExecutor is a simple executor for testing type mockExecutor struct { diff --git a/internal/application/execution_service_output_format_test.go b/internal/application/execution_service_output_format_test.go index f5531cd9..d770275a 100644 --- a/internal/application/execution_service_output_format_test.go +++ b/internal/application/execution_service_output_format_test.go @@ -468,15 +468,11 @@ func TestExecutionService_AgentStep_OutputFormat_JSON_ConversationMode(t *testin Name: "chat", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are a helpful assistant", - InitialPrompt: "Start conversation", - OutputFormat: workflow.OutputFormatJSON, - Conversation: &workflow.ConversationConfig{ - MaxTurns: 5, - StopCondition: "response contains 'complete'", - }, + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are a helpful assistant", + OutputFormat: workflow.OutputFormatJSON, + Conversation: &workflow.ConversationConfig{}, }, OnSuccess: "done", }, @@ -507,10 +503,10 @@ func TestExecutionService_AgentStep_OutputFormat_JSON_ConversationMode(t *testin }) _ = registry.Register(claude) - tokenizer := newMockTokenizer() mockRegistry := mocks.NewMockAgentRegistry() mockRegistry.Register(claude) - convMgr := application.NewConversationManager(&mockLogger{}, &simpleExpressionEvaluator{}, newMockResolver(), tokenizer, mockRegistry) + convMgr := application.NewConversationManager(&mockLogger{}, newMockResolver(), mockRegistry) + convMgr.SetUserInputReader(mocks.NewMockUserInputReader("")) execSvc.SetAgentRegistry(registry) execSvc.SetConversationManager(convMgr) diff --git a/internal/application/test_helpers.go b/internal/application/test_helpers.go index 1b2d9e15..bb84c4cf 100644 --- a/internal/application/test_helpers.go +++ b/internal/application/test_helpers.go @@ -1,6 +1,9 @@ package application -import "github.com/awf-project/cli/internal/domain/ports" +import ( + "github.com/awf-project/cli/internal/domain/ports" + "github.com/awf-project/cli/pkg/interpolation" +) // mockLogger implements ports.Logger for testing. // This is a shared test helper for application package tests. @@ -29,3 +32,20 @@ func (m *mockLogger) Error(msg string, fields ...any) { func (m *mockLogger) WithContext(ctx map[string]any) ports.Logger { return m } + +// newMockLogger creates a new mockLogger instance +func newMockLogger() *mockLogger { + return &mockLogger{} +} + +// mockResolver provides simple passthrough resolution for testing +type mockResolver struct{} + +func (m *mockResolver) Resolve(template string, ctx *interpolation.Context) (string, error) { + return template, nil +} + +// newMockResolver creates a new mockResolver instance +func newMockResolver() *mockResolver { + return &mockResolver{} +} diff --git a/internal/domain/ports/user_input.go b/internal/domain/ports/user_input.go new file mode 100644 index 00000000..86df2361 --- /dev/null +++ b/internal/domain/ports/user_input.go @@ -0,0 +1,12 @@ +package ports + +import "context" + +// UserInputReader defines the contract for reading user input between conversation turns. +// Driven port — called by ConversationManager to get the next user message. +type UserInputReader interface { + // ReadInput reads the next user message. + // Returns empty string when the user submits no input (signals conversation end). + // Returns error if the context is cancelled or an I/O error occurs. + ReadInput(ctx context.Context) (string, error) +} diff --git a/internal/domain/ports/user_input_test.go b/internal/domain/ports/user_input_test.go new file mode 100644 index 00000000..faae54fe --- /dev/null +++ b/internal/domain/ports/user_input_test.go @@ -0,0 +1,160 @@ +package ports + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockUserInputReader is a test double that returns pre-configured responses. +type mockUserInputReader struct { + responses []string + errors []error + callCount int +} + +func (m *mockUserInputReader) ReadInput(ctx context.Context) (string, error) { + defer func() { m.callCount++ }() + + if m.callCount < len(m.errors) && m.errors[m.callCount] != nil { + return "", m.errors[m.callCount] + } + + if m.callCount < len(m.responses) { + return m.responses[m.callCount], nil + } + + return "", errors.New("no more responses configured") +} + +func TestUserInputReader_HappyPath(t *testing.T) { + reader := &mockUserInputReader{ + responses: []string{"first message", "second message"}, + errors: []error{nil, nil}, + } + + ctx := context.Background() + + result1, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "first message", result1) + + result2, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "second message", result2) + + assert.Equal(t, 2, reader.callCount) +} + +func TestUserInputReader_EmptyInputSignalsEnd(t *testing.T) { + reader := &mockUserInputReader{ + responses: []string{"user message", ""}, + errors: []error{nil, nil}, + } + + ctx := context.Background() + + result1, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "user message", result1) + + result2, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "", result2) +} + +func TestUserInputReader_ContextCancellation(t *testing.T) { + reader := &mockUserInputReader{ + responses: []string{"message"}, + errors: []error{context.Canceled}, + } + + ctx := context.Background() + + _, err := reader.ReadInput(ctx) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestUserInputReader_ContextDeadlineExceeded(t *testing.T) { + reader := &mockUserInputReader{ + responses: []string{"message"}, + errors: []error{context.DeadlineExceeded}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + time.Sleep(2 * time.Millisecond) + + _, err := reader.ReadInput(ctx) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestUserInputReader_IOError(t *testing.T) { + ioErr := errors.New("read error") + reader := &mockUserInputReader{ + responses: []string{"message"}, + errors: []error{ioErr}, + } + + ctx := context.Background() + + _, err := reader.ReadInput(ctx) + require.Error(t, err) + assert.Equal(t, ioErr, err) +} + +func TestUserInputReader_MultipleInputsThenEmpty(t *testing.T) { + reader := &mockUserInputReader{ + responses: []string{"first", "second", "third", ""}, + errors: []error{nil, nil, nil, nil}, + } + + ctx := context.Background() + + inputs := []string{} + for i := 0; i < 4; i++ { + result, err := reader.ReadInput(ctx) + require.NoError(t, err) + inputs = append(inputs, result) + } + + assert.Equal(t, []string{"first", "second", "third", ""}, inputs) +} + +func TestUserInputReader_ErrorAfterSuccess(t *testing.T) { + reader := &mockUserInputReader{ + responses: []string{"message1", "message2"}, + errors: []error{nil, errors.New("io error")}, + } + + ctx := context.Background() + + result1, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "message1", result1) + + _, err = reader.ReadInput(ctx) + require.Error(t, err) + assert.Equal(t, "io error", err.Error()) +} + +func TestUserInputReader_ContextCancelledImmediately(t *testing.T) { + reader := &mockUserInputReader{ + responses: []string{""}, + errors: []error{context.Canceled}, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := reader.ReadInput(ctx) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} diff --git a/internal/domain/workflow/agent_config.go b/internal/domain/workflow/agent_config.go index b5471e67..9c228dd3 100644 --- a/internal/domain/workflow/agent_config.go +++ b/internal/domain/workflow/agent_config.go @@ -26,16 +26,15 @@ var validOutputFormats = map[OutputFormat]bool{ // AgentConfig holds configuration for invoking an AI agent. type AgentConfig struct { - Provider string `yaml:"provider"` // agent provider: claude, codex, gemini, opencode, openai_compatible - Prompt string `yaml:"prompt"` // prompt template with {{inputs.*}} and {{states.*}} (single mode) or initial prompt (conversation mode) - PromptFile string `yaml:"prompt_file"` // path to external prompt template file (mutually exclusive with Prompt) - Options map[string]any `yaml:"options"` // provider-specific options (model, temperature, max_tokens, etc.) - Timeout int `yaml:"timeout"` // seconds, 0 = use DefaultAgentTimeout - Mode string `yaml:"mode"` // execution mode: "single" (default) or "conversation" - SystemPrompt string `yaml:"system_prompt"` // system prompt preserved across conversation (conversation mode only) - InitialPrompt string `yaml:"initial_prompt"` // first user message in conversation mode (overrides Prompt if set) - Conversation *ConversationConfig `yaml:"conversation"` // conversation-specific configuration (conversation mode only) - OutputFormat OutputFormat `yaml:"output_format"` // output post-processing: json (strip fences + validate), text (strip fences only), or empty (no processing) + Provider string `yaml:"provider"` // agent provider: claude, codex, gemini, opencode, openai_compatible + Prompt string `yaml:"prompt"` // prompt template with {{inputs.*}} and {{states.*}} (single mode) or first user message (conversation mode) + PromptFile string `yaml:"prompt_file"` // path to external prompt template file (mutually exclusive with Prompt) + Options map[string]any `yaml:"options"` // provider-specific options (model, temperature, max_tokens, etc.) + Timeout int `yaml:"timeout"` // seconds, 0 = use DefaultAgentTimeout + Mode string `yaml:"mode"` // execution mode: "single" (default) or "conversation" + SystemPrompt string `yaml:"system_prompt"` // system prompt preserved across conversation (conversation mode only) + Conversation *ConversationConfig `yaml:"conversation"` // conversation-specific configuration (conversation mode only) + OutputFormat OutputFormat `yaml:"output_format"` // output post-processing: json (strip fences + validate), text (strip fences only), or empty (no processing) } // Validate checks if the agent configuration is valid. @@ -63,11 +62,6 @@ func (c *AgentConfig) Validate(validator ExpressionCompiler) error { return errors.New("mode must be 'single' or 'conversation'") } - // Reject inject_context outside conversation mode - if c.Mode != "conversation" && c.Conversation != nil && strings.TrimSpace(c.Conversation.InjectContext) != "" { - return errors.New("inject_context requires conversation mode") - } - // Normalize and validate output_format c.OutputFormat = OutputFormat(strings.TrimSpace(strings.ToLower(string(c.OutputFormat)))) if !validOutputFormats[c.OutputFormat] { @@ -85,13 +79,12 @@ func (c *AgentConfig) Validate(validator ExpressionCompiler) error { if c.PromptFile != "" { return errors.New("prompt_file is not supported in conversation mode") } - // In conversation mode, require either InitialPrompt or Prompt - if c.InitialPrompt == "" && c.Prompt == "" { - return errors.New("initial_prompt or prompt is required in conversation mode") + if c.Prompt == "" { + return errors.New("prompt is required in conversation mode") } // Validate ConversationConfig if present if c.Conversation != nil { - if err := c.Conversation.Validate(validator); err != nil { + if err := c.Conversation.Validate(); err != nil { return err } } @@ -122,13 +115,8 @@ func (c *AgentConfig) IsConversationMode() bool { return c.Mode == "conversation" } -// GetEffectivePrompt returns the appropriate prompt based on the mode. -// In conversation mode, returns InitialPrompt if set, otherwise Prompt. -// In single mode, returns Prompt. +// GetEffectivePrompt returns the prompt for this agent step. func (c *AgentConfig) GetEffectivePrompt() string { - if c.IsConversationMode() && c.InitialPrompt != "" { - return c.InitialPrompt - } return c.Prompt } diff --git a/internal/domain/workflow/agent_config_config_test.go b/internal/domain/workflow/agent_config_config_test.go index 30385186..cf7ee752 100644 --- a/internal/domain/workflow/agent_config_config_test.go +++ b/internal/domain/workflow/agent_config_config_test.go @@ -508,13 +508,12 @@ func TestAgentConfig_NoCommandField_ValidateSucceeds(t *testing.T) { { name: "config with all valid fields", config: AgentConfig{ - Provider: "openai_compatible", - Prompt: "Main prompt", - Mode: "single", - SystemPrompt: "System instructions", - InitialPrompt: "User greeting", - OutputFormat: OutputFormatText, - Timeout: 120, + Provider: "openai_compatible", + Prompt: "Main prompt", + Mode: "single", + SystemPrompt: "System instructions", + OutputFormat: OutputFormatText, + Timeout: 120, Options: map[string]any{ "model": "gpt-4", "temperature": 0.7, @@ -537,16 +536,15 @@ func TestAgentConfig_Structure_NoCommand(t *testing.T) { // prompt configuration, options, mode, and output handling. config := AgentConfig{ - Provider: "claude", - Prompt: "Test", - PromptFile: "", - Mode: "", - SystemPrompt: "", - InitialPrompt: "", - OutputFormat: "", - Options: nil, - Timeout: 0, - Conversation: nil, + Provider: "claude", + Prompt: "Test", + PromptFile: "", + Mode: "", + SystemPrompt: "", + OutputFormat: "", + Options: nil, + Timeout: 0, + Conversation: nil, } assert.NotNil(t, config) diff --git a/internal/domain/workflow/agent_config_conversation_test.go b/internal/domain/workflow/agent_config_conversation_test.go index e7523b37..2e5bbf9c 100644 --- a/internal/domain/workflow/agent_config_conversation_test.go +++ b/internal/domain/workflow/agent_config_conversation_test.go @@ -25,15 +25,13 @@ func TestAgentConfig_ConversationField(t *testing.T) { validateConv bool }{ { - name: "conversation mode with valid config", + name: "conversation mode with continue_from config", config: workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Start", + Provider: "claude", + Mode: "conversation", + Prompt: "Start", Conversation: &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100000, - Strategy: workflow.StrategySlidingWindow, + ContinueFrom: "previous_step", }, }, wantErr: false, @@ -42,48 +40,20 @@ func TestAgentConfig_ConversationField(t *testing.T) { { name: "conversation mode with nil conversation config", config: workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Start", - Conversation: nil, + Provider: "claude", + Mode: "conversation", + Prompt: "Start", + Conversation: nil, }, wantErr: false, }, { name: "single mode ignores conversation config", config: workflow.AgentConfig{ - Provider: "claude", - Mode: "single", - Prompt: "Test", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 10, - }, - }, - wantErr: false, - }, - { - name: "conversation mode with invalid conversation config", - config: workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Start", - Conversation: &workflow.ConversationConfig{ - MaxTurns: -1, // Invalid - }, - }, - wantErr: true, - errMsg: "max_turns", - }, - { - name: "conversation mode with stop condition", - config: workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Review code", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 5, - StopCondition: "response contains 'APPROVED'", - }, + Provider: "claude", + Mode: "single", + Prompt: "Test", + Conversation: &workflow.ConversationConfig{}, }, wantErr: false, }, @@ -151,11 +121,10 @@ func TestAgentConfig_SystemPrompt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := workflow.AgentConfig{ - Provider: "claude", - Mode: tt.mode, - SystemPrompt: tt.systemPrompt, - InitialPrompt: "Test", - Prompt: "Test", + Provider: "claude", + Mode: tt.mode, + SystemPrompt: tt.systemPrompt, + Prompt: "Test", } err := config.Validate(nil) if tt.wantErr { @@ -168,65 +137,55 @@ func TestAgentConfig_SystemPrompt(t *testing.T) { } } -// workflow.AgentConfig InitialPrompt Tests +// workflow.AgentConfig Prompt Tests (conversation mode) -func TestAgentConfig_InitialPrompt(t *testing.T) { +func TestAgentConfig_ConversationPrompt(t *testing.T) { tests := []struct { - name string - initialPrompt string - prompt string - mode string - wantErr bool - errMsg string + name string + prompt string + mode string + wantErr bool + errMsg string }{ { - name: "conversation mode with initial prompt", - initialPrompt: "Start reviewing", - mode: "conversation", - wantErr: false, - }, - { - name: "conversation mode with template in initial prompt", - initialPrompt: "Review this: {{inputs.code}}", - mode: "conversation", - wantErr: false, + name: "conversation mode with prompt", + prompt: "Start reviewing", + mode: "conversation", + wantErr: false, }, { - name: "conversation mode prefers initial_prompt over prompt", - initialPrompt: "Initial message", - prompt: "Fallback message", - mode: "conversation", - wantErr: false, + name: "conversation mode with template in prompt", + prompt: "Review this: {{inputs.code}}", + mode: "conversation", + wantErr: false, }, { - name: "conversation mode falls back to prompt", - initialPrompt: "", - prompt: "Fallback message", - mode: "conversation", - wantErr: false, + name: "conversation mode without prompt fails", + prompt: "", + mode: "conversation", + wantErr: true, + errMsg: "prompt is required", }, { - name: "single mode ignores initial_prompt", - initialPrompt: "Ignored", - prompt: "Used", - mode: "single", - wantErr: false, + name: "single mode with prompt", + prompt: "Used", + mode: "single", + wantErr: false, }, { - name: "conversation mode with multiline initial prompt", - initialPrompt: "Review this code:\n{{inputs.code}}\n\nBe thorough.", - mode: "conversation", - wantErr: false, + name: "conversation mode with multiline prompt", + prompt: "Review this code:\n{{inputs.code}}\n\nBe thorough.", + mode: "conversation", + wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := workflow.AgentConfig{ - Provider: "claude", - Mode: tt.mode, - InitialPrompt: tt.initialPrompt, - Prompt: tt.prompt, + Provider: "claude", + Mode: tt.mode, + Prompt: tt.prompt, } err := config.Validate(nil) if tt.wantErr { @@ -314,10 +273,9 @@ func TestAgentConfig_IsConversationMode_AfterValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := workflow.AgentConfig{ - Provider: "claude", - Mode: tt.mode, - Prompt: "Test", - InitialPrompt: "Test", + Provider: "claude", + Mode: tt.mode, + Prompt: "Test", } _ = config.Validate(nil) // Normalize mode assert.Equal(t, tt.expected, config.IsConversationMode()) @@ -332,49 +290,24 @@ func TestAgentConfig_GetEffectivePrompt(t *testing.T) { name string mode string prompt string - initialPrompt string expectedPrompt string }{ { name: "single mode uses prompt", mode: "single", prompt: "Main prompt", - initialPrompt: "Initial prompt", expectedPrompt: "Main prompt", }, { - name: "conversation mode prefers initial_prompt", - mode: "conversation", - prompt: "Fallback prompt", - initialPrompt: "Initial message", - expectedPrompt: "Initial message", - }, - { - name: "conversation mode falls back to prompt", + name: "conversation mode uses prompt", mode: "conversation", - prompt: "Fallback prompt", - initialPrompt: "", - expectedPrompt: "Fallback prompt", + prompt: "Conversation prompt", + expectedPrompt: "Conversation prompt", }, { - name: "single mode ignores initial_prompt", - mode: "single", - prompt: "Main prompt", - initialPrompt: "Ignored", - expectedPrompt: "Main prompt", - }, - { - name: "conversation mode with both prompts", - mode: "conversation", - prompt: "Not used", - initialPrompt: "Used this one", - expectedPrompt: "Used this one", - }, - { - name: "empty mode defaults to single behavior", + name: "empty mode returns prompt", mode: "", prompt: "Main prompt", - initialPrompt: "Initial", expectedPrompt: "Main prompt", }, } @@ -382,10 +315,9 @@ func TestAgentConfig_GetEffectivePrompt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := workflow.AgentConfig{ - Provider: "claude", - Mode: tt.mode, - Prompt: tt.prompt, - InitialPrompt: tt.initialPrompt, + Provider: "claude", + Mode: tt.mode, + Prompt: tt.prompt, } _ = config.Validate(nil) // Normalize mode assert.Equal(t, tt.expectedPrompt, config.GetEffectivePrompt()) @@ -400,31 +332,20 @@ func TestAgentConfig_GetEffectivePrompt_EdgeCases(t *testing.T) { expectedPrompt string }{ { - name: "both prompts empty in conversation mode", + name: "empty prompt returns empty", config: workflow.AgentConfig{ - Mode: "conversation", - Prompt: "", - InitialPrompt: "", + Mode: "conversation", + Prompt: "", }, expectedPrompt: "", }, { - name: "whitespace initial_prompt in conversation mode", + name: "multiline prompt", config: workflow.AgentConfig{ - Mode: "conversation", - Prompt: "Fallback", - InitialPrompt: " ", + Mode: "conversation", + Prompt: "Line 1\nLine 2", }, - expectedPrompt: " ", // Returns as-is - }, - { - name: "multiline prompts", - config: workflow.AgentConfig{ - Mode: "conversation", - Prompt: "Line 1\nLine 2", - InitialPrompt: "Init Line 1\nInit Line 2", - }, - expectedPrompt: "Init Line 1\nInit Line 2", + expectedPrompt: "Line 1\nLine 2", }, } @@ -440,7 +361,7 @@ func TestAgentConfig_ConversationMode_Complete(t *testing.T) { Provider: "claude", Mode: "conversation", SystemPrompt: "You are a helpful code reviewer. Iterate until code meets standards.", - InitialPrompt: `Review this code: + Prompt: `Review this code: {{inputs.code}} Say "APPROVED" when done.`, @@ -450,35 +371,25 @@ Say "APPROVED" when done.`, }, Timeout: 300, Conversation: &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100000, - Strategy: workflow.StrategySlidingWindow, - StopCondition: "response contains 'APPROVED'", + ContinueFrom: "", }, } - // Validate err := config.Validate(nil) require.NoError(t, err) - // Verify fields assert.Equal(t, "claude", config.Provider) assert.True(t, config.IsConversationMode()) assert.Contains(t, config.SystemPrompt, "code reviewer") - assert.Contains(t, config.InitialPrompt, "{{inputs.code}}") assert.Contains(t, config.GetEffectivePrompt(), "Review this code") require.NotNil(t, config.Conversation) - assert.Equal(t, 10, config.Conversation.MaxTurns) - assert.Equal(t, 100000, config.Conversation.MaxContextTokens) - assert.Equal(t, workflow.StrategySlidingWindow, config.Conversation.Strategy) - assert.Contains(t, config.Conversation.StopCondition, "APPROVED") } func TestAgentConfig_ConversationMode_MinimalConfig(t *testing.T) { config := workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Hello", + Provider: "claude", + Mode: "conversation", + Prompt: "Hello", } err := config.Validate(nil) @@ -517,7 +428,7 @@ func TestAgentConfig_ConversationMode_Errors(t *testing.T) { Provider: "claude", Mode: "conversation", }, - wantErr: "initial_prompt or prompt is required", + wantErr: "prompt is required in conversation mode", }, { name: "invalid mode value", @@ -528,18 +439,6 @@ func TestAgentConfig_ConversationMode_Errors(t *testing.T) { }, wantErr: "mode must be 'single' or 'conversation'", }, - { - name: "conversation with invalid config", - config: workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Test", - Conversation: &workflow.ConversationConfig{ - MaxTurns: -1, // Invalid: negative - }, - }, - wantErr: "max_turns", - }, } for _, tt := range tests { @@ -550,66 +449,3 @@ func TestAgentConfig_ConversationMode_Errors(t *testing.T) { }) } } - -// TestAgentConfig_InjectContextModeValidation validates that inject_context is rejected outside conversation mode -func TestAgentConfig_InjectContextModeValidation(t *testing.T) { - tests := []struct { - name string - config workflow.AgentConfig - wantErr bool - errMsg string - }{ - { - name: "conversation mode with inject_context is valid", - config: workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Start", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 5, - InjectContext: "Additional context", - }, - }, - wantErr: false, - }, - { - name: "single mode with inject_context is rejected", - config: workflow.AgentConfig{ - Provider: "claude", - Mode: "single", - Prompt: "Test", - Conversation: &workflow.ConversationConfig{ - InjectContext: "Additional context", - }, - }, - wantErr: true, - errMsg: "inject_context requires conversation mode", - }, - { - name: "empty mode (defaults to single) with inject_context is rejected", - config: workflow.AgentConfig{ - Provider: "claude", - Prompt: "Test", - Conversation: &workflow.ConversationConfig{ - InjectContext: "Additional context", - }, - }, - wantErr: true, - errMsg: "inject_context requires conversation mode", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.config.Validate(nil) - if tt.wantErr { - require.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/internal/domain/workflow/agent_config_prompt_file_test.go b/internal/domain/workflow/agent_config_prompt_file_test.go index 13bc0aa6..299b5f57 100644 --- a/internal/domain/workflow/agent_config_prompt_file_test.go +++ b/internal/domain/workflow/agent_config_prompt_file_test.go @@ -112,11 +112,11 @@ func TestAgentConfig_PromptFile_ConversationMode_PromptAllowed(t *testing.T) { assert.NoError(t, err) } -func TestAgentConfig_PromptFile_ConversationMode_InitialPromptAllowed(t *testing.T) { +func TestAgentConfig_PromptFile_ConversationMode_PromptRequired(t *testing.T) { config := AgentConfig{ - Provider: "claude", - InitialPrompt: "Start conversation", - Mode: "conversation", + Provider: "claude", + Prompt: "Start conversation", + Mode: "conversation", } err := config.Validate(nil) assert.NoError(t, err) @@ -280,12 +280,11 @@ func TestAgentConfig_PromptFile_GetEffectivePrompt_DoesNotReturnFile(t *testing. func TestAgentConfig_PromptFile_ConversationMode_Combinations(t *testing.T) { tests := []struct { - name string - prompt string - promptFile string - initialPrompt string - wantErr bool - errMsg string + name string + prompt string + promptFile string + wantErr bool + errMsg string }{ { name: "promptFile only", @@ -293,13 +292,6 @@ func TestAgentConfig_PromptFile_ConversationMode_Combinations(t *testing.T) { wantErr: true, errMsg: "not supported in conversation mode", }, - { - name: "promptFile with initialPrompt", - promptFile: "prompts/conv.md", - initialPrompt: "Start", - wantErr: true, - errMsg: "not supported in conversation mode", - }, { name: "promptFile with prompt", promptFile: "prompts/conv.md", @@ -307,18 +299,6 @@ func TestAgentConfig_PromptFile_ConversationMode_Combinations(t *testing.T) { wantErr: true, errMsg: "mutually exclusive", }, - { - name: "promptFile with both prompt and initialPrompt", - promptFile: "prompts/conv.md", - prompt: "Initial", - initialPrompt: "Start", - wantErr: true, - }, - { - name: "initialPrompt only (valid)", - initialPrompt: "Start conversation", - wantErr: false, - }, { name: "prompt only (valid)", prompt: "Initial message", @@ -329,11 +309,10 @@ func TestAgentConfig_PromptFile_ConversationMode_Combinations(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := AgentConfig{ - Provider: "claude", - Mode: "conversation", - Prompt: tt.prompt, - PromptFile: tt.promptFile, - InitialPrompt: tt.initialPrompt, + Provider: "claude", + Mode: "conversation", + Prompt: tt.prompt, + PromptFile: tt.promptFile, } err := config.Validate(nil) if tt.wantErr { diff --git a/internal/domain/workflow/agent_config_result_test.go b/internal/domain/workflow/agent_config_result_test.go index ea2184ff..7d3cbec3 100644 --- a/internal/domain/workflow/agent_config_result_test.go +++ b/internal/domain/workflow/agent_config_result_test.go @@ -497,22 +497,22 @@ func TestAgentResult_ConversationField(t *testing.T) { }, TotalTurns: 3, TotalTokens: 23, - StoppedBy: workflow.StopReasonMaxTurns, + StoppedBy: workflow.StopReasonUserExit, }, }, }, { - name: "conversation stopped by condition", + name: "conversation stopped by error", conversation: &workflow.ConversationResult{ Provider: "claude", State: &workflow.ConversationState{ Turns: []workflow.Turn{ {Role: workflow.TurnRoleUser, Content: "Review", Tokens: 5}, - {Role: workflow.TurnRoleAssistant, Content: "APPROVED", Tokens: 10}, + {Role: workflow.TurnRoleAssistant, Content: "Response", Tokens: 10}, }, TotalTurns: 2, TotalTokens: 15, - StoppedBy: workflow.StopReasonCondition, + StoppedBy: workflow.StopReasonError, }, }, }, @@ -557,7 +557,7 @@ func TestAgentResult_ConversationField_Integration(t *testing.T) { }, TotalTurns: 5, TotalTokens: 1970, - StoppedBy: workflow.StopReasonCondition, + StoppedBy: workflow.StopReasonUserExit, }, Output: "Here's the fixed code... APPROVED", TokensTotal: 1970, @@ -575,7 +575,7 @@ func TestAgentResult_ConversationField_Integration(t *testing.T) { require.NotNil(t, result.Conversation.State) assert.Equal(t, 5, result.Conversation.State.TotalTurns) assert.Equal(t, 1970, result.Conversation.State.TotalTokens) - assert.Equal(t, workflow.StopReasonCondition, result.Conversation.State.StoppedBy) + assert.Equal(t, workflow.StopReasonUserExit, result.Conversation.State.StoppedBy) assert.Len(t, result.Conversation.State.Turns, 5) assert.Equal(t, workflow.TurnRoleSystem, result.Conversation.State.Turns[0].Role) assert.Equal(t, workflow.TurnRoleAssistant, result.Conversation.State.Turns[4].Role) diff --git a/internal/domain/workflow/context.go b/internal/domain/workflow/context.go index e038d1b2..bbf8746f 100644 --- a/internal/domain/workflow/context.go +++ b/internal/domain/workflow/context.go @@ -34,9 +34,8 @@ type StepState struct { Response map[string]any // parsed JSON response from agent steps JSON any // parsed JSON output when output_format: json is specified (map[string]any or []any) // F033: Conversation mode fields - Conversation *ConversationState // conversation history and state (nil for non-conversation steps) - TokensUsed int // total tokens used in conversation mode - ContextWindowState *ContextWindowState // context window management state (nil if not applicable) + Conversation *ConversationState // conversation history and state (nil for non-conversation steps) + TokensUsed int // total tokens used in conversation mode // C019: Output streaming fields for memory management OutputPath string // Path to temp file if output was streamed (empty if in-memory) diff --git a/internal/domain/workflow/context_test.go b/internal/domain/workflow/context_test.go index f6fc1a2b..5dd1d252 100644 --- a/internal/domain/workflow/context_test.go +++ b/internal/domain/workflow/context_test.go @@ -727,7 +727,6 @@ func TestStepState_ConversationFields_NilByDefault(t *testing.T) { assert.Nil(t, state.Conversation, "Conversation should be nil for non-conversation steps") assert.Equal(t, 0, state.TokensUsed, "TokensUsed should default to 0") - assert.Nil(t, state.ContextWindowState, "ContextWindowState should be nil by default") } func TestStepState_ConversationFields_SetConversation(t *testing.T) { @@ -754,33 +753,7 @@ func TestStepState_ConversationFields_SetConversation(t *testing.T) { assert.Equal(t, 15, state.TokensUsed) } -func TestStepState_ConversationFields_SetContextWindowState(t *testing.T) { - // Happy path: Set context window state on StepState - state := workflow.StepState{ - Name: "agent-step", - Status: workflow.StatusCompleted, - } - - contextWindow := &workflow.ContextWindowState{ - Strategy: workflow.StrategySlidingWindow, - TruncationCount: 2, - TurnsDropped: 5, - TokensDropped: 1200, - LastTruncatedAt: 8, - } - - state.ContextWindowState = contextWindow - - assert.NotNil(t, state.ContextWindowState) - assert.Equal(t, workflow.StrategySlidingWindow, state.ContextWindowState.Strategy) - assert.Equal(t, 2, state.ContextWindowState.TruncationCount) - assert.Equal(t, 5, state.ContextWindowState.TurnsDropped) - assert.Equal(t, 1200, state.ContextWindowState.TokensDropped) - assert.Equal(t, 8, state.ContextWindowState.LastTruncatedAt) -} - func TestStepState_ConversationFields_CompleteConversationMode(t *testing.T) { - // Happy path: Full conversation mode step with all fields populated state := workflow.StepState{ Name: "refine-code", Status: workflow.StatusCompleted, @@ -801,30 +774,20 @@ func TestStepState_ConversationFields_CompleteConversationMode(t *testing.T) { }, TotalTurns: 5, TotalTokens: 1970, - StoppedBy: workflow.StopReasonCondition, + StoppedBy: workflow.StopReasonUserExit, }, TokensUsed: 17000, - ContextWindowState: &workflow.ContextWindowState{ - Strategy: workflow.StrategySlidingWindow, - TruncationCount: 1, - TurnsDropped: 2, - TokensDropped: 300, - LastTruncatedAt: 3, - }, } assert.Equal(t, "refine-code", state.Name) assert.Equal(t, workflow.StatusCompleted, state.Status) assert.NotNil(t, state.Conversation) assert.Equal(t, 5, state.Conversation.TotalTurns) - assert.Equal(t, workflow.StopReasonCondition, state.Conversation.StoppedBy) + assert.Equal(t, workflow.StopReasonUserExit, state.Conversation.StoppedBy) assert.Equal(t, 17000, state.TokensUsed) - assert.NotNil(t, state.ContextWindowState) - assert.Equal(t, 1, state.ContextWindowState.TruncationCount) } func TestStepState_ConversationFields_JSONSerialization(t *testing.T) { - // Edge case: Verify conversation fields serialize/deserialize correctly original := workflow.StepState{ Name: "agent-step", Status: workflow.StatusCompleted, @@ -836,16 +799,9 @@ func TestStepState_ConversationFields_JSONSerialization(t *testing.T) { }, TotalTurns: 2, TotalTokens: 15, - StoppedBy: workflow.StopReasonMaxTurns, + StoppedBy: workflow.StopReasonError, }, TokensUsed: 15, - ContextWindowState: &workflow.ContextWindowState{ - Strategy: workflow.StrategySlidingWindow, - TruncationCount: 0, - TurnsDropped: 0, - TokensDropped: 0, - LastTruncatedAt: 0, - }, } data, err := json.Marshal(original) @@ -859,8 +815,6 @@ func TestStepState_ConversationFields_JSONSerialization(t *testing.T) { assert.NotNil(t, decoded.Conversation) assert.Equal(t, 2, decoded.Conversation.TotalTurns) assert.Equal(t, 15, decoded.TokensUsed) - assert.NotNil(t, decoded.ContextWindowState) - assert.Equal(t, workflow.StrategySlidingWindow, decoded.ContextWindowState.Strategy) } func TestStepState_ConversationFields_ExecutionContextIntegration(t *testing.T) { @@ -910,7 +864,6 @@ func TestStepState_ConversationFields_NilConversationSerialization(t *testing.T) assert.Nil(t, decoded.Conversation) assert.Equal(t, 0, decoded.TokensUsed) - assert.Nil(t, decoded.ContextWindowState) } func TestStepState_ConversationFields_LargeTokenCounts(t *testing.T) { @@ -923,7 +876,7 @@ func TestStepState_ConversationFields_LargeTokenCounts(t *testing.T) { Turns: make([]workflow.Turn, 100), // Many turns TotalTurns: 100, TotalTokens: 150000, - StoppedBy: workflow.StopReasonMaxTokens, + StoppedBy: workflow.StopReasonError, }, } @@ -950,39 +903,9 @@ func TestStepState_ConversationFields_EmptyConversation(t *testing.T) { assert.Equal(t, 0, state.TokensUsed) } -func TestStepState_ConversationFields_MultipleStrategies(t *testing.T) { - // Edge case: Test different context window strategies - strategies := []workflow.ContextWindowStrategy{ - workflow.StrategyNone, - workflow.StrategySlidingWindow, - workflow.StrategySummarize, - workflow.StrategyTruncateMiddle, - } - - for _, strategy := range strategies { - t.Run(string(strategy), func(t *testing.T) { - state := workflow.StepState{ - Name: "strategy-test", - ContextWindowState: &workflow.ContextWindowState{ - Strategy: strategy, - TruncationCount: 1, - TurnsDropped: 3, - TokensDropped: 500, - }, - } - - assert.NotNil(t, state.ContextWindowState) - assert.Equal(t, strategy, state.ContextWindowState.Strategy) - }) - } -} - func TestStepState_ConversationFields_StopReasons(t *testing.T) { - // Edge case: Test all stop reasons stopReasons := []workflow.StopReason{ - workflow.StopReasonCondition, - workflow.StopReasonMaxTurns, - workflow.StopReasonMaxTokens, + workflow.StopReasonUserExit, workflow.StopReasonError, } @@ -1044,41 +967,6 @@ func TestStepState_ConversationFields_MixedStepsInContext(t *testing.T) { assert.Equal(t, 10, agent.TokensUsed) } -func TestStepState_ConversationFields_ContextWindowNoTruncation(t *testing.T) { - // Edge case: ContextWindowState with no truncation applied - state := workflow.StepState{ - Name: "no-truncation", - ContextWindowState: &workflow.ContextWindowState{ - Strategy: workflow.StrategySlidingWindow, - TruncationCount: 0, - TurnsDropped: 0, - TokensDropped: 0, - LastTruncatedAt: 0, - }, - } - - assert.NotNil(t, state.ContextWindowState) - assert.Equal(t, 0, state.ContextWindowState.TruncationCount) -} - -func TestStepState_ConversationFields_MaxTruncation(t *testing.T) { - // Edge case: Heavy truncation scenario - state := workflow.StepState{ - Name: "heavy-truncation", - ContextWindowState: &workflow.ContextWindowState{ - Strategy: workflow.StrategySlidingWindow, - TruncationCount: 50, - TurnsDropped: 200, - TokensDropped: 50000, - LastTruncatedAt: 250, - }, - } - - assert.Equal(t, 50, state.ContextWindowState.TruncationCount) - assert.Equal(t, 200, state.ContextWindowState.TurnsDropped) - assert.Equal(t, 50000, state.ContextWindowState.TokensDropped) -} - func TestStepState_ConversationFields_FailedConversation(t *testing.T) { // Error handling: Failed conversation step state := workflow.StepState{ diff --git a/internal/domain/workflow/context_window.go b/internal/domain/workflow/context_window.go deleted file mode 100644 index 0fe15fae..00000000 --- a/internal/domain/workflow/context_window.go +++ /dev/null @@ -1,177 +0,0 @@ -package workflow - -import "errors" - -type ContextWindowManager interface { - ApplyStrategy(turns []Turn, maxTokens int, strategy ContextWindowStrategy) ([]Turn, bool, error) - PreserveSystemPrompt(turns []Turn) (*Turn, []Turn) - CalculateTotalTokens(turns []Turn) int - EstimateTokens(content string) int -} - -type contextWindowManager struct{} - -func NewContextWindowManager() ContextWindowManager { - return &contextWindowManager{} -} - -func (m *contextWindowManager) ApplyStrategy(turns []Turn, maxTokens int, strategy ContextWindowStrategy) ([]Turn, bool, error) { - if maxTokens <= 0 { - return nil, false, errors.New("max_tokens must be positive") - } - - switch strategy { - case StrategySlidingWindow: - s := NewSlidingWindowStrategy() - return s.Apply(turns, maxTokens) - case StrategySummarize: - return nil, false, errors.New("summarize strategy not implemented yet") - case StrategyTruncateMiddle: - return nil, false, errors.New("truncate_middle strategy not implemented yet") - case StrategyNone: - return turns, false, nil - default: - return nil, false, errors.New("invalid strategy") - } -} - -func (m *contextWindowManager) PreserveSystemPrompt(turns []Turn) (systemPrompt *Turn, remaining []Turn) { - if len(turns) == 0 { - return nil, []Turn{} - } - - var systemTurn *Turn - otherTurns := make([]Turn, 0, len(turns)) - - for i := range turns { - if turns[i].Role == TurnRoleSystem && systemTurn == nil { - systemTurn = &turns[i] - } else { - otherTurns = append(otherTurns, turns[i]) - } - } - - return systemTurn, otherTurns -} - -func (m *contextWindowManager) CalculateTotalTokens(turns []Turn) int { - total := 0 - for _, turn := range turns { - total += turn.Tokens - } - return total -} - -// Uses a rough approximation of 1 token ≈ 4 characters. -func (m *contextWindowManager) EstimateTokens(content string) int { - return len(content) / 4 -} - -type SlidingWindowStrategy struct{} - -func NewSlidingWindowStrategy() *SlidingWindowStrategy { - return &SlidingWindowStrategy{} -} - -func (s *SlidingWindowStrategy) Apply(turns []Turn, maxTokens int) ([]Turn, bool, error) { - if maxTokens <= 0 { - return nil, false, errors.New("max_tokens must be positive") - } - - if len(turns) == 0 { - return []Turn{}, false, nil - } - - var systemTurn *Turn - otherTurns := make([]Turn, 0, len(turns)) - - for i := range turns { - if turns[i].Role == TurnRoleSystem && systemTurn == nil { - systemTurn = &turns[i] - } else { - otherTurns = append(otherTurns, turns[i]) - } - } - - totalTokens := 0 - if systemTurn != nil { - totalTokens += systemTurn.Tokens - } - for _, turn := range otherTurns { - totalTokens += turn.Tokens - } - - if totalTokens <= maxTokens { - return turns, false, nil - } - - result := make([]Turn, 0, len(turns)) - currentTokens := 0 - - if systemTurn != nil { - result = append(result, *systemTurn) - currentTokens = systemTurn.Tokens - } - - startIdx := 0 - for i := 0; i < len(otherTurns); i++ { - totalTokens := currentTokens - for j := i; j < len(otherTurns); j++ { - totalTokens += otherTurns[j].Tokens - } - if totalTokens <= maxTokens { - startIdx = i - break - } - } - - result = append(result, otherTurns[startIdx:]...) - - wasTruncated := len(result) < len(turns) - return result, wasTruncated, nil -} - -type SummarizeStrategy struct{} - -func NewSummarizeStrategy() *SummarizeStrategy { - return &SummarizeStrategy{} -} - -func (s *SummarizeStrategy) Apply(turns []Turn, maxTokens int) ([]Turn, bool, error) { - return nil, false, errors.New("not implemented") -} - -type TruncateMiddleStrategy struct{} - -func NewTruncateMiddleStrategy() *TruncateMiddleStrategy { - return &TruncateMiddleStrategy{} -} - -func (t *TruncateMiddleStrategy) Apply(turns []Turn, maxTokens int) ([]Turn, bool, error) { - return nil, false, errors.New("not implemented") -} - -type ContextWindowState struct { - Strategy ContextWindowStrategy - TruncationCount int - TurnsDropped int - TokensDropped int - LastTruncatedAt int -} - -func NewContextWindowState(strategy ContextWindowStrategy) *ContextWindowState { - return &ContextWindowState{ - Strategy: strategy, - } -} - -func (s *ContextWindowState) RecordTruncation(turnsDropped, tokensDropped, currentTurn int) { - s.TruncationCount++ - s.TurnsDropped += turnsDropped - s.TokensDropped += tokensDropped - s.LastTruncatedAt = currentTurn -} - -func (s *ContextWindowState) WasTruncated() bool { - return s.TruncationCount > 0 -} diff --git a/internal/domain/workflow/context_window_test.go b/internal/domain/workflow/context_window_test.go deleted file mode 100644 index 8924d8ae..00000000 --- a/internal/domain/workflow/context_window_test.go +++ /dev/null @@ -1,850 +0,0 @@ -package workflow - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Feature: F033 - -func TestNewContextWindowManager(t *testing.T) { - manager := NewContextWindowManager() - - require.NotNil(t, manager) -} - -func TestContextWindowManager_ApplyStrategy_SlidingWindow(t *testing.T) { - manager := NewContextWindowManager() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System prompt", Tokens: 50}, - {Role: TurnRoleUser, Content: "Turn 1", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "Response 1", Tokens: 150}, - {Role: TurnRoleUser, Content: "Turn 2", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "Response 2", Tokens: 150}, - } - - maxTokens := 300 - - truncated, wasTruncated, err := manager.ApplyStrategy(turns, maxTokens, StrategySlidingWindow) - - require.NoError(t, err) - require.NotNil(t, truncated) - assert.True(t, wasTruncated) - // System prompt should be preserved - assert.GreaterOrEqual(t, len(truncated), 1) - if len(truncated) > 0 { - assert.Equal(t, TurnRoleSystem, truncated[0].Role) - } - // Total tokens should be within limit - totalTokens := 0 - for _, turn := range truncated { - totalTokens += turn.Tokens - } - assert.LessOrEqual(t, totalTokens, maxTokens) -} - -func TestContextWindowManager_ApplyStrategy_NoTruncationNeeded(t *testing.T) { - manager := NewContextWindowManager() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System", Tokens: 50}, - {Role: TurnRoleUser, Content: "User", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "Assistant", Tokens: 150}, - } - - maxTokens := 1000 - - truncated, wasTruncated, err := manager.ApplyStrategy(turns, maxTokens, StrategySlidingWindow) - - require.NoError(t, err) - require.NotNil(t, truncated) - assert.False(t, wasTruncated) - assert.Equal(t, len(turns), len(truncated)) -} - -func TestContextWindowManager_ApplyStrategy_EmptyTurns(t *testing.T) { - manager := NewContextWindowManager() - - turns := []Turn{} - maxTokens := 1000 - - truncated, wasTruncated, err := manager.ApplyStrategy(turns, maxTokens, StrategySlidingWindow) - - require.NoError(t, err) - require.NotNil(t, truncated) - assert.False(t, wasTruncated) - assert.Empty(t, truncated) -} - -func TestContextWindowManager_ApplyStrategy_InvalidStrategy(t *testing.T) { - manager := NewContextWindowManager() - - turns := []Turn{ - {Role: TurnRoleUser, Content: "Test", Tokens: 100}, - } - - truncated, wasTruncated, err := manager.ApplyStrategy(turns, 1000, ContextWindowStrategy("invalid")) - - require.Error(t, err) - assert.Contains(t, err.Error(), "strategy") - assert.Nil(t, truncated) - assert.False(t, wasTruncated) -} - -func TestContextWindowManager_ApplyStrategy_NegativeMaxTokens(t *testing.T) { - manager := NewContextWindowManager() - - turns := []Turn{ - {Role: TurnRoleUser, Content: "Test", Tokens: 100}, - } - - truncated, wasTruncated, err := manager.ApplyStrategy(turns, -1, StrategySlidingWindow) - - require.Error(t, err) - assert.Contains(t, err.Error(), "max_tokens") - assert.Nil(t, truncated) - assert.False(t, wasTruncated) -} - -func TestContextWindowManager_ApplyStrategy_ZeroMaxTokens(t *testing.T) { - manager := NewContextWindowManager() - - turns := []Turn{ - {Role: TurnRoleUser, Content: "Test", Tokens: 100}, - } - - truncated, wasTruncated, err := manager.ApplyStrategy(turns, 0, StrategySlidingWindow) - - require.Error(t, err) - assert.Contains(t, err.Error(), "max_tokens") - assert.Nil(t, truncated) - assert.False(t, wasTruncated) -} - -func TestContextWindowManager_PreserveSystemPrompt(t *testing.T) { - manager := NewContextWindowManager() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System prompt", Tokens: 50}, - {Role: TurnRoleUser, Content: "User message", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "Assistant response", Tokens: 150}, - } - - systemTurn, otherTurns := manager.PreserveSystemPrompt(turns) - - require.NotNil(t, systemTurn) - assert.Equal(t, TurnRoleSystem, systemTurn.Role) - assert.Equal(t, "System prompt", systemTurn.Content) - assert.Equal(t, 50, systemTurn.Tokens) - - require.NotNil(t, otherTurns) - assert.Len(t, otherTurns, 2) - assert.Equal(t, TurnRoleUser, otherTurns[0].Role) - assert.Equal(t, TurnRoleAssistant, otherTurns[1].Role) -} - -func TestContextWindowManager_PreserveSystemPrompt_NoSystem(t *testing.T) { - manager := NewContextWindowManager() - - turns := []Turn{ - {Role: TurnRoleUser, Content: "User message", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "Assistant response", Tokens: 150}, - } - - systemTurn, otherTurns := manager.PreserveSystemPrompt(turns) - - assert.Nil(t, systemTurn) - require.NotNil(t, otherTurns) - assert.Len(t, otherTurns, 2) -} - -func TestContextWindowManager_PreserveSystemPrompt_SystemNotFirst(t *testing.T) { - manager := NewContextWindowManager() - - turns := []Turn{ - {Role: TurnRoleUser, Content: "User message", Tokens: 100}, - {Role: TurnRoleSystem, Content: "System prompt", Tokens: 50}, - {Role: TurnRoleAssistant, Content: "Assistant response", Tokens: 150}, - } - - systemTurn, otherTurns := manager.PreserveSystemPrompt(turns) - - // Should extract system prompt even if not first - require.NotNil(t, systemTurn) - assert.Equal(t, TurnRoleSystem, systemTurn.Role) - require.NotNil(t, otherTurns) - assert.Len(t, otherTurns, 2) -} - -func TestContextWindowManager_PreserveSystemPrompt_EmptyTurns(t *testing.T) { - manager := NewContextWindowManager() - - turns := []Turn{} - - systemTurn, otherTurns := manager.PreserveSystemPrompt(turns) - - assert.Nil(t, systemTurn) - require.NotNil(t, otherTurns) - assert.Empty(t, otherTurns) -} - -func TestContextWindowManager_PreserveSystemPrompt_OnlySystem(t *testing.T) { - manager := NewContextWindowManager() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System only", Tokens: 50}, - } - - systemTurn, otherTurns := manager.PreserveSystemPrompt(turns) - - require.NotNil(t, systemTurn) - assert.Equal(t, TurnRoleSystem, systemTurn.Role) - require.NotNil(t, otherTurns) - assert.Empty(t, otherTurns) -} - -func TestContextWindowManager_CalculateTotalTokens(t *testing.T) { - manager := NewContextWindowManager() - - tests := []struct { - name string - turns []Turn - expected int - }{ - { - name: "multiple turns", - turns: []Turn{ - {Role: TurnRoleSystem, Content: "System", Tokens: 50}, - {Role: TurnRoleUser, Content: "User", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "Assistant", Tokens: 150}, - }, - expected: 300, - }, - { - name: "single turn", - turns: []Turn{ - {Role: TurnRoleUser, Content: "User", Tokens: 42}, - }, - expected: 42, - }, - { - name: "empty turns", - turns: []Turn{}, - expected: 0, - }, - { - name: "turns with zero tokens", - turns: []Turn{ - {Role: TurnRoleUser, Content: "User", Tokens: 0}, - {Role: TurnRoleAssistant, Content: "Assistant", Tokens: 0}, - }, - expected: 0, - }, - { - name: "large conversation", - turns: []Turn{ - {Role: TurnRoleSystem, Content: "System", Tokens: 100}, - {Role: TurnRoleUser, Content: "User1", Tokens: 500}, - {Role: TurnRoleAssistant, Content: "Assistant1", Tokens: 800}, - {Role: TurnRoleUser, Content: "User2", Tokens: 300}, - {Role: TurnRoleAssistant, Content: "Assistant2", Tokens: 1200}, - }, - expected: 2900, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - total := manager.CalculateTotalTokens(tt.turns) - assert.Equal(t, tt.expected, total) - }) - } -} - -func TestContextWindowManager_EstimateTokens(t *testing.T) { - manager := NewContextWindowManager() - - tests := []struct { - name string - content string - expected int // Approximate estimation: len/4 - }{ - { - name: "short text", - content: "Hello world", - expected: 2, // 11 chars / 4 ≈ 2 - }, - { - name: "medium text", - content: "This is a test message with multiple words", - expected: 10, // 43 chars / 4 ≈ 10 - }, - { - name: "long text", - content: string(make([]byte, 400)), - expected: 100, // 400 chars / 4 = 100 - }, - { - name: "empty content", - content: "", - expected: 0, - }, - { - name: "single character", - content: "x", - expected: 0, // 1 char / 4 = 0 - }, - { - name: "unicode content", - content: "Hello 世界", - expected: 2, // Character-based estimation - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - estimated := manager.EstimateTokens(tt.content) - assert.GreaterOrEqual(t, estimated, 0) - // Estimate should be reasonable (within 50% of expected) - if tt.expected > 0 { - assert.InDelta(t, tt.expected, estimated, float64(tt.expected)*0.5) - } - }) - } -} - -func TestNewSlidingWindowStrategy(t *testing.T) { - strategy := NewSlidingWindowStrategy() - - require.NotNil(t, strategy) -} - -func TestSlidingWindowStrategy_Apply(t *testing.T) { - strategy := NewSlidingWindowStrategy() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System prompt", Tokens: 50}, - {Role: TurnRoleUser, Content: "Turn 1", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "Response 1", Tokens: 150}, - {Role: TurnRoleUser, Content: "Turn 2", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "Response 2", Tokens: 150}, - {Role: TurnRoleUser, Content: "Turn 3", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "Response 3", Tokens: 150}, - } - - maxTokens := 400 - - truncated, wasTruncated, err := strategy.Apply(turns, maxTokens) - - require.NoError(t, err) - require.NotNil(t, truncated) - assert.True(t, wasTruncated) - - // System prompt must be preserved - assert.GreaterOrEqual(t, len(truncated), 1) - if len(truncated) > 0 { - assert.Equal(t, TurnRoleSystem, truncated[0].Role) - assert.Equal(t, "System prompt", truncated[0].Content) - } - - // Total tokens should be within limit - totalTokens := 0 - for _, turn := range truncated { - totalTokens += turn.Tokens - } - assert.LessOrEqual(t, totalTokens, maxTokens) - - // Most recent turns should be preserved - lastTurn := truncated[len(truncated)-1] - assert.Equal(t, TurnRoleAssistant, lastTurn.Role) - assert.Equal(t, "Response 3", lastTurn.Content) -} - -func TestSlidingWindowStrategy_Apply_NoTruncation(t *testing.T) { - strategy := NewSlidingWindowStrategy() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System", Tokens: 50}, - {Role: TurnRoleUser, Content: "User", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "Assistant", Tokens: 150}, - } - - maxTokens := 1000 - - truncated, wasTruncated, err := strategy.Apply(turns, maxTokens) - - require.NoError(t, err) - require.NotNil(t, truncated) - assert.False(t, wasTruncated) - assert.Equal(t, len(turns), len(truncated)) - assert.Equal(t, turns, truncated) -} - -func TestSlidingWindowStrategy_Apply_SystemPromptOnly(t *testing.T) { - strategy := NewSlidingWindowStrategy() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System prompt", Tokens: 50}, - } - - maxTokens := 100 - - truncated, wasTruncated, err := strategy.Apply(turns, maxTokens) - - require.NoError(t, err) - require.NotNil(t, truncated) - assert.False(t, wasTruncated) - assert.Len(t, truncated, 1) - assert.Equal(t, TurnRoleSystem, truncated[0].Role) -} - -func TestSlidingWindowStrategy_Apply_SystemPromptExceedsLimit(t *testing.T) { - strategy := NewSlidingWindowStrategy() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "Very long system prompt", Tokens: 500}, - {Role: TurnRoleUser, Content: "User", Tokens: 100}, - } - - maxTokens := 300 - - truncated, _, err := strategy.Apply(turns, maxTokens) - - require.NoError(t, err) - require.NotNil(t, truncated) - // System prompt is always preserved even if it exceeds limit - assert.GreaterOrEqual(t, len(truncated), 1) - assert.Equal(t, TurnRoleSystem, truncated[0].Role) -} - -func TestSlidingWindowStrategy_Apply_EmptyTurns(t *testing.T) { - strategy := NewSlidingWindowStrategy() - - turns := []Turn{} - maxTokens := 1000 - - truncated, wasTruncated, err := strategy.Apply(turns, maxTokens) - - require.NoError(t, err) - require.NotNil(t, truncated) - assert.False(t, wasTruncated) - assert.Empty(t, truncated) -} - -func TestSlidingWindowStrategy_Apply_InvalidMaxTokens(t *testing.T) { - strategy := NewSlidingWindowStrategy() - - turns := []Turn{ - {Role: TurnRoleUser, Content: "Test", Tokens: 100}, - } - - tests := []struct { - name string - maxTokens int - }{ - {"negative", -1}, - {"zero", 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - truncated, wasTruncated, err := strategy.Apply(turns, tt.maxTokens) - - require.Error(t, err) - assert.Contains(t, err.Error(), "max_tokens") - assert.Nil(t, truncated) - assert.False(t, wasTruncated) - }) - } -} - -func TestSlidingWindowStrategy_Apply_PreservesOrder(t *testing.T) { - strategy := NewSlidingWindowStrategy() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System", Tokens: 50}, - {Role: TurnRoleUser, Content: "Q1", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "A1", Tokens: 100}, - {Role: TurnRoleUser, Content: "Q2", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "A2", Tokens: 100}, - {Role: TurnRoleUser, Content: "Q3", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "A3", Tokens: 100}, - } - - maxTokens := 400 - - truncated, wasTruncated, err := strategy.Apply(turns, maxTokens) - - require.NoError(t, err) - require.NotNil(t, truncated) - assert.True(t, wasTruncated) - - // Verify chronological order is maintained - for i := 1; i < len(truncated); i++ { - // Each turn should come after the previous one in the original sequence - prevContent := truncated[i-1].Content - currContent := truncated[i].Content - - // Find indices in original - prevIdx := -1 - currIdx := -1 - for j, turn := range turns { - if turn.Content == prevContent { - prevIdx = j - } - if turn.Content == currContent { - currIdx = j - } - } - - assert.Less(t, prevIdx, currIdx, "Turn order should be preserved") - } -} - -func TestNewSummarizeStrategy(t *testing.T) { - strategy := NewSummarizeStrategy() - - require.NotNil(t, strategy) -} - -func TestSummarizeStrategy_Apply_NotImplemented(t *testing.T) { - strategy := NewSummarizeStrategy() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System", Tokens: 50}, - {Role: TurnRoleUser, Content: "User", Tokens: 100}, - } - - truncated, _, err := strategy.Apply(turns, 1000) - - require.Error(t, err) - assert.Contains(t, err.Error(), "not implemented") - assert.Nil(t, truncated) -} - -func TestNewTruncateMiddleStrategy(t *testing.T) { - strategy := NewTruncateMiddleStrategy() - - require.NotNil(t, strategy) -} - -func TestTruncateMiddleStrategy_Apply_NotImplemented(t *testing.T) { - strategy := NewTruncateMiddleStrategy() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System", Tokens: 50}, - {Role: TurnRoleUser, Content: "User", Tokens: 100}, - } - - truncated, _, err := strategy.Apply(turns, 1000) - - require.Error(t, err) - assert.Contains(t, err.Error(), "not implemented") - assert.Nil(t, truncated) -} - -func TestNewContextWindowState(t *testing.T) { - tests := []struct { - name string - strategy ContextWindowStrategy - }{ - {"none", StrategyNone}, - {"sliding_window", StrategySlidingWindow}, - {"summarize", StrategySummarize}, - {"truncate_middle", StrategyTruncateMiddle}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - state := NewContextWindowState(tt.strategy) - - require.NotNil(t, state) - assert.Equal(t, tt.strategy, state.Strategy) - assert.Equal(t, 0, state.TruncationCount) - assert.Equal(t, 0, state.TurnsDropped) - assert.Equal(t, 0, state.TokensDropped) - assert.Equal(t, 0, state.LastTruncatedAt) - }) - } -} - -func TestContextWindowState_RecordTruncation(t *testing.T) { - state := NewContextWindowState(StrategySlidingWindow) - - // First truncation - state.RecordTruncation(2, 300, 5) - - assert.Equal(t, 1, state.TruncationCount) - assert.Equal(t, 2, state.TurnsDropped) - assert.Equal(t, 300, state.TokensDropped) - assert.Equal(t, 5, state.LastTruncatedAt) - - // Second truncation - state.RecordTruncation(3, 450, 10) - - assert.Equal(t, 2, state.TruncationCount) - assert.Equal(t, 5, state.TurnsDropped) // 2 + 3 - assert.Equal(t, 750, state.TokensDropped) // 300 + 450 - assert.Equal(t, 10, state.LastTruncatedAt) -} - -func TestContextWindowState_RecordTruncation_ZeroDropped(t *testing.T) { - state := NewContextWindowState(StrategySlidingWindow) - - state.RecordTruncation(0, 0, 5) - - assert.Equal(t, 1, state.TruncationCount) - assert.Equal(t, 0, state.TurnsDropped) - assert.Equal(t, 0, state.TokensDropped) - assert.Equal(t, 5, state.LastTruncatedAt) -} - -func TestContextWindowState_RecordTruncation_NegativeValues(t *testing.T) { - state := NewContextWindowState(StrategySlidingWindow) - - // Should handle negative values gracefully (defensive programming) - state.RecordTruncation(-1, -100, 3) - - // Behavior depends on implementation - assert.GreaterOrEqual(t, state.TruncationCount, 1) -} - -func TestContextWindowState_WasTruncated(t *testing.T) { - tests := []struct { - name string - truncationCount int - expected bool - }{ - {"no truncation", 0, false}, - {"single truncation", 1, true}, - {"multiple truncations", 5, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - state := &ContextWindowState{ - Strategy: StrategySlidingWindow, - TruncationCount: tt.truncationCount, - } - - assert.Equal(t, tt.expected, state.WasTruncated()) - }) - } -} - -func TestSlidingWindowStrategy_Apply_SingleTurnExceedsLimit(t *testing.T) { - strategy := NewSlidingWindowStrategy() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System", Tokens: 50}, - {Role: TurnRoleUser, Content: "Very long user message", Tokens: 500}, - } - - maxTokens := 100 - - truncated, _, err := strategy.Apply(turns, maxTokens) - - require.NoError(t, err) - require.NotNil(t, truncated) - // Should preserve system prompt + most recent turn even if exceeding limit - assert.GreaterOrEqual(t, len(truncated), 1) - assert.Equal(t, TurnRoleSystem, truncated[0].Role) -} - -func TestSlidingWindowStrategy_Apply_AllTurnsExceedLimit(t *testing.T) { - strategy := NewSlidingWindowStrategy() - - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System", Tokens: 200}, - {Role: TurnRoleUser, Content: "User1", Tokens: 300}, - {Role: TurnRoleAssistant, Content: "Assistant1", Tokens: 400}, - {Role: TurnRoleUser, Content: "User2", Tokens: 350}, - } - - maxTokens := 100 - - truncated, _, err := strategy.Apply(turns, maxTokens) - - require.NoError(t, err) - require.NotNil(t, truncated) - // Should preserve at minimum: system prompt + most recent turn - assert.GreaterOrEqual(t, len(truncated), 1) - assert.Equal(t, TurnRoleSystem, truncated[0].Role) -} - -func TestContextWindowManager_ApplyStrategy_LargeConversation(t *testing.T) { - manager := NewContextWindowManager() - - // Create a large conversation with 100 turns - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System", Tokens: 100}, - } - - for i := 0; i < 50; i++ { - turns = append(turns, - Turn{ - Role: TurnRoleUser, - Content: "User message", - Tokens: 50, - }, - Turn{ - Role: TurnRoleAssistant, - Content: "Assistant response", - Tokens: 100, - }, - ) - } - - maxTokens := 1000 - - truncated, wasTruncated, err := manager.ApplyStrategy(turns, maxTokens, StrategySlidingWindow) - - require.NoError(t, err) - require.NotNil(t, truncated) - assert.True(t, wasTruncated) - - // Verify total tokens within limit - totalTokens := manager.CalculateTotalTokens(truncated) - assert.LessOrEqual(t, totalTokens, maxTokens) - - // Verify system prompt preserved - assert.GreaterOrEqual(t, len(truncated), 1) - assert.Equal(t, TurnRoleSystem, truncated[0].Role) -} - -func TestContextWindowState_MultipleTruncations(t *testing.T) { - state := NewContextWindowState(StrategySlidingWindow) - - // Simulate multiple truncation events - truncations := []struct { - turnsDropped int - tokensDropped int - currentTurn int - }{ - {2, 300, 5}, - {1, 150, 7}, - {3, 450, 12}, - {2, 280, 18}, - } - - for _, tr := range truncations { - state.RecordTruncation(tr.turnsDropped, tr.tokensDropped, tr.currentTurn) - } - - assert.Equal(t, 4, state.TruncationCount) - assert.Equal(t, 8, state.TurnsDropped) // 2+1+3+2 - assert.Equal(t, 1180, state.TokensDropped) // 300+150+450+280 - assert.Equal(t, 18, state.LastTruncatedAt) - assert.True(t, state.WasTruncated()) -} - -func TestContextWindowManager_FullWorkflow(t *testing.T) { - manager := NewContextWindowManager() - - // Build conversation with system prompt - turns := []Turn{ - {Role: TurnRoleSystem, Content: "You are a helpful assistant", Tokens: 100}, - {Role: TurnRoleUser, Content: "Question 1", Tokens: 50}, - {Role: TurnRoleAssistant, Content: "Answer 1", Tokens: 150}, - {Role: TurnRoleUser, Content: "Question 2", Tokens: 50}, - {Role: TurnRoleAssistant, Content: "Answer 2", Tokens: 150}, - {Role: TurnRoleUser, Content: "Question 3", Tokens: 50}, - {Role: TurnRoleAssistant, Content: "Answer 3", Tokens: 150}, - } - - // Extract system prompt - systemTurn, otherTurns := manager.PreserveSystemPrompt(turns) - assert.NotNil(t, systemTurn) - assert.Len(t, otherTurns, 6) - - // Calculate total tokens - totalTokens := manager.CalculateTotalTokens(turns) - assert.Equal(t, 700, totalTokens) // 100+50+150+50+150+50+150 - - // Apply sliding window strategy - maxTokens := 400 - truncated, wasTruncated, err := manager.ApplyStrategy(turns, maxTokens, StrategySlidingWindow) - - require.NoError(t, err) - require.NotNil(t, truncated) - assert.True(t, wasTruncated) - - // Verify system prompt preserved - assert.Equal(t, TurnRoleSystem, truncated[0].Role) - - // Verify within token limit - truncatedTotal := manager.CalculateTotalTokens(truncated) - assert.LessOrEqual(t, truncatedTotal, maxTokens) - - // Verify most recent turns preserved - lastTurn := truncated[len(truncated)-1] - assert.Equal(t, "Answer 3", lastTurn.Content) -} - -func TestSlidingWindowStrategy_CompleteExample(t *testing.T) { - strategy := NewSlidingWindowStrategy() - state := NewContextWindowState(StrategySlidingWindow) - - // Initial conversation - turns := []Turn{ - {Role: TurnRoleSystem, Content: "System", Tokens: 50}, - {Role: TurnRoleUser, Content: "Q1", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "A1", Tokens: 100}, - {Role: TurnRoleUser, Content: "Q2", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "A2", Tokens: 100}, - {Role: TurnRoleUser, Content: "Q3", Tokens: 100}, - {Role: TurnRoleAssistant, Content: "A3", Tokens: 100}, - } - - maxTokens := 300 - - // Apply truncation - truncated, wasTruncated, err := strategy.Apply(turns, maxTokens) - - require.NoError(t, err) - require.NotNil(t, truncated) - assert.True(t, wasTruncated) - - // Calculate dropped turns and tokens - turnsDropped := len(turns) - len(truncated) - tokensDropped := 0 - for i := 0; i < turnsDropped; i++ { - if i < len(turns) && turns[i].Role != TurnRoleSystem { - tokensDropped += turns[i].Tokens - } - } - - // Record truncation - state.RecordTruncation(turnsDropped, tokensDropped, len(turns)) - - // Verify state - assert.True(t, state.WasTruncated()) - assert.Equal(t, 1, state.TruncationCount) - assert.Greater(t, state.TurnsDropped, 0) - assert.Greater(t, state.TokensDropped, 0) - assert.Equal(t, len(turns), state.LastTruncatedAt) -} - -func TestContextWindowManager_EstimateAndCalculate(t *testing.T) { - manager := NewContextWindowManager() - - content := "This is a sample message for token estimation." - - // Estimate tokens - estimated := manager.EstimateTokens(content) - assert.Greater(t, estimated, 0) - - // Create turn with estimated tokens - turn := Turn{ - Role: TurnRoleUser, - Content: content, - Tokens: estimated, - } - - // Calculate total with single turn - total := manager.CalculateTotalTokens([]Turn{turn}) - assert.Equal(t, estimated, total) -} diff --git a/internal/domain/workflow/conversation.go b/internal/domain/workflow/conversation.go index 30940533..a8d9d0bc 100644 --- a/internal/domain/workflow/conversation.go +++ b/internal/domain/workflow/conversation.go @@ -2,7 +2,6 @@ package workflow import ( "errors" - "fmt" "time" ) @@ -33,9 +32,9 @@ const ( // Turn represents a single message in a conversation. type Turn struct { - Role TurnRole // system, user, or assistant - Content string // message content - Tokens int // token count for this turn + Role TurnRole + Content string + Tokens int } // NewTurn creates a new Turn with the given role and content. @@ -43,107 +42,42 @@ func NewTurn(role TurnRole, content string) *Turn { return &Turn{ Role: role, Content: content, - Tokens: 0, // Will be filled by tokenizer } } // Validate checks if the turn is valid. func (t *Turn) Validate() error { - // Validate role if t.Role == "" { return errors.New("turn role cannot be empty") } if t.Role != TurnRoleSystem && t.Role != TurnRoleUser && t.Role != TurnRoleAssistant { return errors.New("invalid turn role") } - - // Validate content if t.Content == "" { return errors.New("turn content cannot be empty") } - - // Validate tokens if t.Tokens < 0 { return errors.New("turn tokens cannot be negative") } - return nil } -// ContextWindowStrategy defines the strategy for managing context window limits. -type ContextWindowStrategy string - -const ( - StrategyNone ContextWindowStrategy = "" - StrategySlidingWindow ContextWindowStrategy = "sliding_window" - StrategySummarize ContextWindowStrategy = "summarize" - StrategyTruncateMiddle ContextWindowStrategy = "truncate_middle" -) - // ConversationConfig holds configuration for conversation mode execution. type ConversationConfig struct { - MaxTurns int // maximum number of turns (default 10, max 100) - MaxContextTokens int // maximum tokens in context window (0 = provider default) - Strategy ContextWindowStrategy // context window management strategy - StopCondition string // expression to evaluate for early exit - ContinueFrom string // step name to continue conversation from - InjectContext string // additional context to inject mid-conversation + ContinueFrom string // step name to continue conversation from } // Validate checks if the conversation configuration is valid. -// The validator parameter is used to check stop condition expression syntax. -func (c *ConversationConfig) Validate(validator ExpressionCompiler) error { - // Validate MaxTurns (0 is allowed and means use default) - if c.MaxTurns < 0 { - return errors.New("max_turns must be non-negative") - } - if c.MaxTurns > 100 { - return errors.New("max_turns cannot exceed 100") - } - - // Validate MaxContextTokens if set - if c.MaxContextTokens < 0 { - return errors.New("max_context_tokens must be non-negative") - } - - // Validate Strategy if set - if c.Strategy != "" { - switch c.Strategy { - case StrategySlidingWindow: - // Valid strategies - case StrategySummarize, StrategyTruncateMiddle: - return fmt.Errorf("strategy %q is not yet implemented; use sliding_window", c.Strategy) - default: - return errors.New("invalid context window strategy") - } - } - - // Validate StopCondition if set (compile-time syntax check) - if c.StopCondition != "" && validator != nil { - if err := validator(c.StopCondition); err != nil { - return fmt.Errorf("invalid stop_condition expression: %w", err) - } - } - +func (c *ConversationConfig) Validate() error { return nil } -// GetMaxTurns returns the effective max turns with default fallback. -func (c *ConversationConfig) GetMaxTurns() int { - if c.MaxTurns == 0 { - return 10 // default - } - return c.MaxTurns -} - // StopReason indicates why a conversation stopped. type StopReason string const ( - StopReasonCondition StopReason = "condition" - StopReasonMaxTurns StopReason = "max_turns" - StopReasonMaxTokens StopReason = "max_tokens" - StopReasonError StopReason = "error" + StopReasonUserExit StopReason = "user_exit" + StopReasonError StopReason = "error" ) // ConversationState represents the state of an ongoing or completed conversation. diff --git a/internal/domain/workflow/conversation_test.go b/internal/domain/workflow/conversation_test.go index c9447ff8..6b49aa80 100644 --- a/internal/domain/workflow/conversation_test.go +++ b/internal/domain/workflow/conversation_test.go @@ -12,20 +12,6 @@ import ( "github.com/stretchr/testify/require" ) -// mockExpressionValidator returns an ExpressionCompiler for tests that validates syntax. -// It checks for unbalanced quotes as a simple syntax validation. -func mockExpressionValidator(expr string) error { - if strings.TrimSpace(expr) == "" { - return nil - } - // Simple check for unbalanced single quotes (mimics real validator behavior) - singleQuotes := strings.Count(expr, "'") - if singleQuotes%2 != 0 { - return errors.New("expression compilation failed: unbalanced quotes") - } - return nil -} - // Feature: F033 func TestTurnRole_Constants(t *testing.T) { @@ -179,197 +165,29 @@ func TestTurn_Validate(t *testing.T) { } } -func TestContextWindowStrategy_Constants(t *testing.T) { - assert.Equal(t, ContextWindowStrategy(""), StrategyNone) - assert.Equal(t, ContextWindowStrategy("sliding_window"), StrategySlidingWindow) - assert.Equal(t, ContextWindowStrategy("summarize"), StrategySummarize) - assert.Equal(t, ContextWindowStrategy("truncate_middle"), StrategyTruncateMiddle) -} - func TestConversationConfig_Validate(t *testing.T) { tests := []struct { name string config ConversationConfig wantErr bool - errMsg string }{ { - name: "valid minimal config", - config: ConversationConfig{ - MaxTurns: 5, - }, - wantErr: false, - }, - { - name: "valid full config", - config: ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100000, - Strategy: StrategySlidingWindow, - StopCondition: "response contains 'DONE'", - }, - wantErr: false, - }, - { - name: "valid with continue_from", - config: ConversationConfig{ - MaxTurns: 5, - ContinueFrom: "previous_conversation", - }, + name: "empty config is valid", + config: ConversationConfig{}, wantErr: false, }, { - name: "valid with inject_context", + name: "with continue_from is valid", config: ConversationConfig{ - MaxTurns: 5, - InjectContext: "Additional context here", + ContinueFrom: "previous_step", }, wantErr: false, }, - { - name: "zero max_turns (uses default)", - config: ConversationConfig{ - MaxTurns: 0, - }, - wantErr: false, - }, - { - name: "negative max_turns", - config: ConversationConfig{ - MaxTurns: -1, - }, - wantErr: true, - errMsg: "max_turns", - }, - { - name: "max_turns exceeds limit", - config: ConversationConfig{ - MaxTurns: 101, - }, - wantErr: true, - errMsg: "max_turns", - }, - { - name: "negative max_context_tokens", - config: ConversationConfig{ - MaxTurns: 5, - MaxContextTokens: -1, - }, - wantErr: true, - errMsg: "max_context_tokens", - }, - { - name: "invalid strategy", - config: ConversationConfig{ - MaxTurns: 5, - Strategy: ContextWindowStrategy("invalid"), - }, - wantErr: true, - errMsg: "strategy", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.config.Validate(nil) - if tt.wantErr { - require.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - require.NoError(t, err) - } - }) - } -} - -func TestConversationConfig_Validate_Strategies(t *testing.T) { - tests := []struct { - name string - strategy ContextWindowStrategy - wantErr bool - errMsg string - }{ - {"none (default)", StrategyNone, false, ""}, - {"sliding_window", StrategySlidingWindow, false, ""}, - {"summarize", StrategySummarize, true, "not yet implemented"}, - {"truncate_middle", StrategyTruncateMiddle, true, "not yet implemented"}, - {"empty string", ContextWindowStrategy(""), false, ""}, - {"invalid", ContextWindowStrategy("invalid"), true, "invalid"}, - {"typo", ContextWindowStrategy("sliding_windows"), true, "invalid"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := ConversationConfig{ - MaxTurns: 5, - Strategy: tt.strategy, - } - err := config.Validate(nil) - if tt.wantErr { - require.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - require.NoError(t, err) - } - }) - } -} - -func TestConversationConfig_Validate_StopConditions(t *testing.T) { - tests := []struct { - name string - condition string - wantErr bool - }{ - { - name: "simple contains", - condition: "response contains 'DONE'", - wantErr: false, - }, - { - name: "turn count comparison", - condition: "turn_count >= 5", - wantErr: false, - }, - { - name: "token comparison", - condition: "total_tokens > 50000", - wantErr: false, - }, - { - name: "logical AND", - condition: "turn_count >= 3 && response contains 'APPROVED'", - wantErr: false, - }, - { - name: "empty condition (no early exit)", - condition: "", - wantErr: false, - }, - { - name: "complex expression", - condition: "(turn_count >= 5 || total_tokens > 10000) && response contains 'COMPLETE'", - wantErr: false, - }, - { - name: "invalid syntax", - condition: "response contains DONE'", - wantErr: true, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - config := ConversationConfig{ - MaxTurns: 5, - StopCondition: tt.condition, - } - // Use mock validator for stop condition syntax checking - err := config.Validate(mockExpressionValidator) + err := tt.config.Validate() if tt.wantErr { require.Error(t, err) } else { @@ -379,53 +197,8 @@ func TestConversationConfig_Validate_StopConditions(t *testing.T) { } } -func TestConversationConfig_GetMaxTurns(t *testing.T) { - tests := []struct { - name string - maxTurns int - expected int - }{ - { - name: "zero returns default (10)", - maxTurns: 0, - expected: 10, - }, - { - name: "positive returns configured value", - maxTurns: 5, - expected: 5, - }, - { - name: "maximum allowed (100)", - maxTurns: 100, - expected: 100, - }, - { - name: "exactly default value", - maxTurns: 10, - expected: 10, - }, - { - name: "minimum (1)", - maxTurns: 1, - expected: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := ConversationConfig{ - MaxTurns: tt.maxTurns, - } - assert.Equal(t, tt.expected, config.GetMaxTurns()) - }) - } -} - func TestStopReason_Constants(t *testing.T) { - assert.Equal(t, StopReason("condition"), StopReasonCondition) - assert.Equal(t, StopReason("max_turns"), StopReasonMaxTurns) - assert.Equal(t, StopReason("max_tokens"), StopReasonMaxTokens) + assert.Equal(t, StopReason("user_exit"), StopReasonUserExit) assert.Equal(t, StopReason("error"), StopReasonError) } @@ -590,9 +363,7 @@ func TestConversationState_IsStopped(t *testing.T) { stoppedBy StopReason expected bool }{ - {"stopped by condition", StopReasonCondition, true}, - {"stopped by max turns", StopReasonMaxTurns, true}, - {"stopped by max tokens", StopReasonMaxTokens, true}, + {"stopped by user exit", StopReasonUserExit, true}, {"stopped by error", StopReasonError, true}, {"not stopped (empty)", StopReason(""), false}, } @@ -822,22 +593,12 @@ func TestConversationResult_TurnCount(t *testing.T) { func TestConversationConfig_CompleteExample(t *testing.T) { config := ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100000, - Strategy: StrategySlidingWindow, - StopCondition: "response contains 'APPROVED'", + ContinueFrom: "previous_step", } - // Validate structure - err := config.Validate(nil) + err := config.Validate() require.NoError(t, err) - - // Check field values - assert.Equal(t, 10, config.MaxTurns) - assert.Equal(t, 100000, config.MaxContextTokens) - assert.Equal(t, StrategySlidingWindow, config.Strategy) - assert.Equal(t, "response contains 'APPROVED'", config.StopCondition) - assert.Equal(t, 10, config.GetMaxTurns()) + assert.Equal(t, "previous_step", config.ContinueFrom) } func TestConversationResult_ExecutionLifecycle(t *testing.T) { @@ -885,7 +646,7 @@ func TestConversationResult_ExecutionLifecycle(t *testing.T) { _ = result.State.AddTurn(assistantTurn2) // Mark conversation as stopped - result.State.StoppedBy = StopReasonCondition + result.State.StoppedBy = StopReasonUserExit // Capture final output result.Output = "Fixed. APPROVED" @@ -910,7 +671,7 @@ func TestConversationResult_ExecutionLifecycle(t *testing.T) { assert.True(t, result.HasJSONResponse()) assert.GreaterOrEqual(t, result.TurnCount(), 4) assert.True(t, result.State.IsStopped()) - assert.Equal(t, StopReasonCondition, result.State.StoppedBy) + assert.Equal(t, StopReasonUserExit, result.State.StoppedBy) assert.Greater(t, result.TokensTotal, 0) } @@ -1002,69 +763,6 @@ func TestConversationResult_TextOnlyResponse(t *testing.T) { assert.Equal(t, 3, result.TurnCount()) // System + User + Assistant } -func TestConversationConfig_MaxTurnsBoundaries(t *testing.T) { - tests := []struct { - name string - maxTurns int - expected int - wantErr bool - }{ - {"minimum valid (1)", 1, 1, false}, - {"zero (uses default)", 0, 10, false}, - {"default (10)", 10, 10, false}, - {"high valid (50)", 50, 50, false}, - {"maximum valid (100)", 100, 100, false}, - {"exceeds max (101)", 101, 0, true}, - {"negative (-1)", -1, 0, true}, - {"large negative", -9999, 0, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := ConversationConfig{ - MaxTurns: tt.maxTurns, - } - err := config.Validate(nil) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - assert.Equal(t, tt.expected, config.GetMaxTurns()) - } - }) - } -} - -func TestConversationConfig_MaxContextTokensBoundaries(t *testing.T) { - tests := []struct { - name string - maxContextTokens int - wantErr bool - }{ - {"zero (provider default)", 0, false}, - {"small valid (1000)", 1000, false}, - {"medium valid (100000)", 100000, false}, - {"large valid (1000000)", 1000000, false}, - {"negative (-1)", -1, true}, - {"large negative", -50000, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := ConversationConfig{ - MaxTurns: 5, - MaxContextTokens: tt.maxContextTokens, - } - err := config.Validate(nil) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - func TestConversationState_LargeConversation(t *testing.T) { state := NewConversationState("System prompt") diff --git a/internal/domain/workflow/doc.go b/internal/domain/workflow/doc.go index 5688ad27..e4e9b97f 100644 --- a/internal/domain/workflow/doc.go +++ b/internal/domain/workflow/doc.go @@ -288,27 +288,17 @@ // // ## Conversation Mode Agent // -// Multi-turn agent conversation: +// Interactive multi-turn agent conversation. The user drives the loop by +// providing input at each turn and ends the session with "exit" or "quit". // // conversationStep := &workflow.Step{ // Name: "chat", // Type: workflow.StepTypeAgent, // Agent: &workflow.AgentConfig{ -// Provider: "claude", -// Mode: "conversation", -// SystemPrompt: "You are a helpful coding assistant.", -// InitialPrompt: "Help me debug this code: {{inputs.code}}", -// Conversation: &workflow.ConversationConfig{ -// MaxTurns: 10, -// StopConditions: []string{ -// "{{conversation.last_message}} contains 'DONE'", -// }, -// ContextWindow: &workflow.ContextWindowConfig{ -// Strategy: "truncate_middle", -// MaxTokens: 4000, -// ReserveRatio: 0.1, -// }, -// }, +// Provider: "claude", +// Mode: "conversation", +// SystemPrompt: "You are a helpful coding assistant.", +// Prompt: "Help me debug this code: {{inputs.code}}", // }, // OnSuccess: "end", // } diff --git a/internal/domain/workflow/workflow_test.go b/internal/domain/workflow/workflow_test.go index 12aea00e..0f6f1fcd 100644 --- a/internal/domain/workflow/workflow_test.go +++ b/internal/domain/workflow/workflow_test.go @@ -385,7 +385,7 @@ func TestWorkflow_Validate_ContinueFrom(t *testing.T) { Name: "step1", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ Provider: "claude", Prompt: "test", - Conversation: &workflow.ConversationConfig{MaxTurns: 5}, + Conversation: &workflow.ConversationConfig{}, }, OnSuccess: "step2", }, @@ -394,7 +394,6 @@ func TestWorkflow_Validate_ContinueFrom(t *testing.T) { Agent: &workflow.AgentConfig{ Provider: "claude", Prompt: "test", Conversation: &workflow.ConversationConfig{ - MaxTurns: 3, ContinueFrom: "step1", }, }, @@ -416,7 +415,6 @@ func TestWorkflow_Validate_ContinueFrom(t *testing.T) { Agent: &workflow.AgentConfig{ Provider: "claude", Prompt: "test", Conversation: &workflow.ConversationConfig{ - MaxTurns: 3, ContinueFrom: "nonexistent", }, }, @@ -438,7 +436,7 @@ func TestWorkflow_Validate_ContinueFrom(t *testing.T) { Name: "step1", Type: workflow.StepTypeAgent, Agent: &workflow.AgentConfig{ Provider: "claude", Prompt: "test", - Conversation: &workflow.ConversationConfig{MaxTurns: 5}, + Conversation: &workflow.ConversationConfig{}, }, OnSuccess: "done", }, diff --git a/internal/infrastructure/repository/yaml_mapper.go b/internal/infrastructure/repository/yaml_mapper.go index 32cdffa7..f40a9a42 100644 --- a/internal/infrastructure/repository/yaml_mapper.go +++ b/internal/infrastructure/repository/yaml_mapper.go @@ -469,15 +469,14 @@ func mapAgentConfigFlat(y *yamlStep) *workflow.AgentConfig { return nil } return &workflow.AgentConfig{ - Provider: y.Provider, - Prompt: y.Prompt, - PromptFile: y.PromptFile, - Options: y.Options, - Mode: y.Mode, - SystemPrompt: y.SystemPrompt, - InitialPrompt: y.InitialPrompt, - Conversation: mapConversationConfig(y.Conversation), - OutputFormat: workflow.OutputFormat(y.OutputFormat), + Provider: y.Provider, + Prompt: y.Prompt, + PromptFile: y.PromptFile, + Options: y.Options, + Mode: y.Mode, + SystemPrompt: y.SystemPrompt, + Conversation: mapConversationConfig(y.Conversation), + OutputFormat: workflow.OutputFormat(y.OutputFormat), // Timeout is handled separately via step.Timeout } } @@ -487,27 +486,8 @@ func mapConversationConfig(y *yamlConversationConfig) *workflow.ConversationConf if y == nil { return nil } - - // Parse strategy string to domain ContextWindowStrategy - var strategy workflow.ContextWindowStrategy - switch y.Strategy { - case "sliding_window": - strategy = workflow.StrategySlidingWindow - case "summarize": - strategy = workflow.StrategySummarize - case "truncate_middle": - strategy = workflow.StrategyTruncateMiddle - default: - strategy = workflow.StrategyNone - } - return &workflow.ConversationConfig{ - MaxTurns: y.MaxTurns, - MaxContextTokens: y.MaxContextTokens, - Strategy: strategy, - StopCondition: y.StopCondition, - ContinueFrom: y.ContinueFrom, - InjectContext: y.InjectContext, + ContinueFrom: y.ContinueFrom, } } diff --git a/internal/infrastructure/repository/yaml_mapper_agent_config_command_removal_test.go b/internal/infrastructure/repository/yaml_mapper_agent_config_command_removal_test.go index 174b1cd8..30986465 100644 --- a/internal/infrastructure/repository/yaml_mapper_agent_config_command_removal_test.go +++ b/internal/infrastructure/repository/yaml_mapper_agent_config_command_removal_test.go @@ -58,13 +58,12 @@ func TestMapAgentConfigFlat_NoCommandMapping(t *testing.T) { { name: "agent config with all fields except Command", yamlStep: &yamlStep{ - Provider: "claude", - Prompt: "Test prompt", - Options: map[string]any{"temperature": 0.5}, - Mode: "conversation", - SystemPrompt: "You are helpful", - InitialPrompt: "Hello", - OutputFormat: "json", + Provider: "claude", + Prompt: "Test prompt", + Options: map[string]any{"temperature": 0.5}, + Mode: "conversation", + SystemPrompt: "You are helpful", + OutputFormat: "json", }, wantErr: false, }, @@ -202,14 +201,12 @@ func TestMapAgentConfigFlat_IgnoresYAMLStepCommand(t *testing.T) { // confirming that Command removal doesn't affect these features. func TestAgentConfigMappingWithConversationMode(t *testing.T) { yamlStep := &yamlStep{ - Provider: "claude", - Prompt: "Initial prompt", - InitialPrompt: "First message", - SystemPrompt: "You are an expert", - Mode: "conversation", + Provider: "claude", + Prompt: "Initial prompt", + SystemPrompt: "You are an expert", + Mode: "conversation", Conversation: &yamlConversationConfig{ - MaxTurns: 10, - Strategy: "sliding_window", + ContinueFrom: "prior_step", }, Options: map[string]any{ "model": "claude-sonnet-4-20250514", @@ -221,7 +218,6 @@ func TestAgentConfigMappingWithConversationMode(t *testing.T) { require.NotNil(t, result) assert.Equal(t, "claude", result.Provider) assert.Equal(t, "Initial prompt", result.Prompt) - assert.Equal(t, "First message", result.InitialPrompt) assert.Equal(t, "You are an expert", result.SystemPrompt) assert.Equal(t, "conversation", result.Mode) assert.NotNil(t, result.Conversation) @@ -255,20 +251,19 @@ func TestYAMLStep_CommandFieldExists(t *testing.T) { // and confirms Command is the only field NOT mapped. func TestMapAgentConfigFlat_AllFieldsMappedExceptCommand(t *testing.T) { yamlStep := &yamlStep{ - Provider: "openai_compatible", - Prompt: "Analyze code", - PromptFile: "prompt.txt", - Mode: "single", - SystemPrompt: "System message", - InitialPrompt: "Initial", - OutputFormat: "json", + Provider: "openai_compatible", + Prompt: "Analyze code", + PromptFile: "prompt.txt", + Mode: "single", + SystemPrompt: "System message", + OutputFormat: "json", Options: map[string]any{ "api_key": "secret", "model": "gpt-4", "url": "http://localhost:8000", }, Conversation: &yamlConversationConfig{ - MaxTurns: 5, + ContinueFrom: "prior_step", }, } @@ -282,10 +277,9 @@ func TestMapAgentConfigFlat_AllFieldsMappedExceptCommand(t *testing.T) { assert.Equal(t, yamlStep.PromptFile, result.PromptFile) assert.Equal(t, yamlStep.Mode, result.Mode) assert.Equal(t, yamlStep.SystemPrompt, result.SystemPrompt) - assert.Equal(t, yamlStep.InitialPrompt, result.InitialPrompt) assert.Equal(t, yamlStep.OutputFormat, string(result.OutputFormat)) assert.Equal(t, yamlStep.Options, result.Options) - assert.Equal(t, yamlStep.Conversation.MaxTurns, result.Conversation.MaxTurns) + assert.NotNil(t, result.Conversation) // Command is NOT in this list - confirming it's been removed from mapping } diff --git a/internal/infrastructure/repository/yaml_mapper_output_format_test.go b/internal/infrastructure/repository/yaml_mapper_output_format_test.go index c08691b5..c891af6e 100644 --- a/internal/infrastructure/repository/yaml_mapper_output_format_test.go +++ b/internal/infrastructure/repository/yaml_mapper_output_format_test.go @@ -339,21 +339,21 @@ func TestMapAgentConfigFlat_OutputFormat_WithConversationMode(t *testing.T) { { name: "conversation mode with json output format", yamlStep: yamlStep{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are a data extraction assistant", - InitialPrompt: "Extract entities from: {{.inputs.text}}", - OutputFormat: "json", + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are a data extraction assistant", + Prompt: "Extract entities from: {{.inputs.text}}", + OutputFormat: "json", Options: map[string]any{ "model": "claude-3-5-sonnet-20241022", }, }, want: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are a data extraction assistant", - InitialPrompt: "Extract entities from: {{.inputs.text}}", - OutputFormat: workflow.OutputFormat("json"), + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are a data extraction assistant", + Prompt: "Extract entities from: {{.inputs.text}}", + OutputFormat: workflow.OutputFormat("json"), Options: map[string]any{ "model": "claude-3-5-sonnet-20241022", }, @@ -362,21 +362,21 @@ func TestMapAgentConfigFlat_OutputFormat_WithConversationMode(t *testing.T) { { name: "conversation mode with text output format", yamlStep: yamlStep{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are a helpful assistant", - InitialPrompt: "Help with: {{.inputs.task}}", - OutputFormat: "text", + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are a helpful assistant", + Prompt: "Help with: {{.inputs.task}}", + OutputFormat: "text", Options: map[string]any{ "model": "claude-3-5-sonnet-20241022", }, }, want: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are a helpful assistant", - InitialPrompt: "Help with: {{.inputs.task}}", - OutputFormat: workflow.OutputFormat("text"), + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are a helpful assistant", + Prompt: "Help with: {{.inputs.task}}", + OutputFormat: workflow.OutputFormat("text"), Options: map[string]any{ "model": "claude-3-5-sonnet-20241022", }, @@ -413,7 +413,7 @@ func TestMapAgentConfigFlat_OutputFormat_WithConversationMode(t *testing.T) { assert.Equal(t, tt.want.Provider, got.Provider) assert.Equal(t, tt.want.Mode, got.Mode) assert.Equal(t, tt.want.SystemPrompt, got.SystemPrompt) - assert.Equal(t, tt.want.InitialPrompt, got.InitialPrompt) + assert.Equal(t, tt.want.Prompt, got.Prompt) assert.Equal(t, tt.want.OutputFormat, got.OutputFormat) assert.Equal(t, tt.want.Options, got.Options) }) diff --git a/internal/infrastructure/repository/yaml_mapper_prompt_file_test.go b/internal/infrastructure/repository/yaml_mapper_prompt_file_test.go index 5c8c68f1..5fe937a8 100644 --- a/internal/infrastructure/repository/yaml_mapper_prompt_file_test.go +++ b/internal/infrastructure/repository/yaml_mapper_prompt_file_test.go @@ -364,21 +364,19 @@ func TestMapAgentConfigFlat_PromptFile_WithConversationMode(t *testing.T) { { name: "conversation mode with prompt file", yamlStep: yamlStep{ - Provider: "claude", - PromptFile: "prompts/conversation.md", - Mode: "conversation", - SystemPrompt: "You are a helpful assistant", - InitialPrompt: "Hello, I need help with {{.inputs.task}}", + Provider: "claude", + PromptFile: "prompts/conversation.md", + Mode: "conversation", + SystemPrompt: "You are a helpful assistant", Options: map[string]any{ "model": "claude-3-5-sonnet-20241022", }, }, want: &workflow.AgentConfig{ - Provider: "claude", - PromptFile: "prompts/conversation.md", - Mode: "conversation", - SystemPrompt: "You are a helpful assistant", - InitialPrompt: "Hello, I need help with {{.inputs.task}}", + Provider: "claude", + PromptFile: "prompts/conversation.md", + Mode: "conversation", + SystemPrompt: "You are a helpful assistant", Options: map[string]any{ "model": "claude-3-5-sonnet-20241022", }, @@ -404,21 +402,19 @@ func TestMapAgentConfigFlat_PromptFile_WithConversationMode(t *testing.T) { }, }, { - name: "conversation mode with both initial prompt and prompt file", + name: "conversation mode with prompt file and no initial prompt", yamlStep: yamlStep{ - Provider: "claude", - PromptFile: "prompts/base.md", - InitialPrompt: "Override prompt", - Mode: "conversation", + Provider: "claude", + PromptFile: "prompts/base.md", + Mode: "conversation", Options: map[string]any{ "model": "claude-3-5-sonnet-20241022", }, }, want: &workflow.AgentConfig{ - Provider: "claude", - PromptFile: "prompts/base.md", - InitialPrompt: "Override prompt", - Mode: "conversation", + Provider: "claude", + PromptFile: "prompts/base.md", + Mode: "conversation", Options: map[string]any{ "model": "claude-3-5-sonnet-20241022", }, @@ -435,7 +431,6 @@ func TestMapAgentConfigFlat_PromptFile_WithConversationMode(t *testing.T) { assert.Equal(t, tt.want.PromptFile, got.PromptFile) assert.Equal(t, tt.want.Mode, got.Mode) assert.Equal(t, tt.want.SystemPrompt, got.SystemPrompt) - assert.Equal(t, tt.want.InitialPrompt, got.InitialPrompt) assert.Equal(t, tt.want.Options, got.Options) }) } diff --git a/internal/infrastructure/repository/yaml_mapper_test.go b/internal/infrastructure/repository/yaml_mapper_test.go index 55d0b36e..d59150cd 100644 --- a/internal/infrastructure/repository/yaml_mapper_test.go +++ b/internal/infrastructure/repository/yaml_mapper_test.go @@ -633,9 +633,12 @@ func TestMapStep_AgentStep_NoProvider(t *testing.T) { assert.Nil(t, step.Agent) } -// Feature: F033 - Agent Conversations +// Feature: F033/F083 - Agent Conversations // mapConversationConfig Tests - Happy Path +// F083: ConversationConfig only retains ContinueFrom. Removed fields +// (max_turns, max_context_tokens, strategy, stop_condition, inject_context) +// now produce parse errors via validateConversationConfigRemovedFields. func TestMapConversationConfig_HappyPath(t *testing.T) { tests := []struct { @@ -644,116 +647,24 @@ func TestMapConversationConfig_HappyPath(t *testing.T) { want *workflow.ConversationConfig }{ { - name: "full conversation config with sliding_window", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100000, - Strategy: "sliding_window", - StopCondition: "response contains 'APPROVED'", - ContinueFrom: "", - InjectContext: "", - }, - want: &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100000, - Strategy: workflow.StrategySlidingWindow, - StopCondition: "response contains 'APPROVED'", - ContinueFrom: "", - InjectContext: "", - }, - }, - { - name: "conversation config with summarize strategy", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 20, - MaxContextTokens: 50000, - Strategy: "summarize", - StopCondition: "response contains 'DONE'", - }, - want: &workflow.ConversationConfig{ - MaxTurns: 20, - MaxContextTokens: 50000, - Strategy: workflow.StrategySummarize, - StopCondition: "response contains 'DONE'", - }, - }, - { - name: "conversation config with truncate_middle strategy", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 15, - MaxContextTokens: 75000, - Strategy: "truncate_middle", - StopCondition: "states.refine.output == 'complete'", - }, - want: &workflow.ConversationConfig{ - MaxTurns: 15, - MaxContextTokens: 75000, - Strategy: workflow.StrategyTruncateMiddle, - StopCondition: "states.refine.output == 'complete'", - }, - }, - { - name: "minimal conversation config with defaults", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 5, - }, - want: &workflow.ConversationConfig{ - MaxTurns: 5, - MaxContextTokens: 0, - Strategy: workflow.StrategyNone, - StopCondition: "", - }, - }, - { - name: "conversation config with continue_from", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 10, - ContinueFrom: "previous_conversation", - InjectContext: "Also consider: {{inputs.requirements}}", - }, - want: &workflow.ConversationConfig{ - MaxTurns: 10, - Strategy: workflow.StrategyNone, - ContinueFrom: "previous_conversation", - InjectContext: "Also consider: {{inputs.requirements}}", - }, - }, - { - name: "max conversation turns 100", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 100, - MaxContextTokens: 200000, - Strategy: "sliding_window", - }, - want: &workflow.ConversationConfig{ - MaxTurns: 100, - MaxContextTokens: 200000, - Strategy: workflow.StrategySlidingWindow, - }, + name: "nil config returns nil", + yamlConfig: nil, + want: nil, }, { - name: "large token limit", + name: "minimal config with only ContinueFrom", yamlConfig: &yamlConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 1000000, - Strategy: "sliding_window", + ContinueFrom: "previous_step", }, want: &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 1000000, - Strategy: workflow.StrategySlidingWindow, + ContinueFrom: "previous_step", }, }, { - name: "complex stop condition expression", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 10, - StopCondition: "(response contains 'SUCCESS' || response contains 'APPROVED') && states.validate.status == 'passed'", - }, + name: "empty config returns non-nil with empty ContinueFrom", + yamlConfig: &yamlConversationConfig{}, want: &workflow.ConversationConfig{ - MaxTurns: 10, - Strategy: workflow.StrategyNone, - StopCondition: "(response contains 'SUCCESS' || response contains 'APPROVED') && states.validate.status == 'passed'", + ContinueFrom: "", }, }, } @@ -762,125 +673,40 @@ func TestMapConversationConfig_HappyPath(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got := mapConversationConfig(tt.yamlConfig) + if tt.want == nil { + assert.Nil(t, got) + return + } require.NotNil(t, got) - assert.Equal(t, tt.want.MaxTurns, got.MaxTurns) - assert.Equal(t, tt.want.MaxContextTokens, got.MaxContextTokens) - assert.Equal(t, tt.want.Strategy, got.Strategy) - assert.Equal(t, tt.want.StopCondition, got.StopCondition) assert.Equal(t, tt.want.ContinueFrom, got.ContinueFrom) - assert.Equal(t, tt.want.InjectContext, got.InjectContext) }) } } // mapConversationConfig Tests - Edge Cases +// F083: Removed fields produce parse errors; only ContinueFrom is mapped. func TestMapConversationConfig_EdgeCases(t *testing.T) { tests := []struct { - name string - yamlConfig *yamlConversationConfig - want *workflow.ConversationConfig + name string + yamlConfig *yamlConversationConfig + wantNil bool + wantContinue string }{ { - name: "nil config returns nil", - yamlConfig: nil, - want: nil, - }, - { - name: "empty strategy defaults to StrategyNone", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 10, - Strategy: "", - }, - want: &workflow.ConversationConfig{ - MaxTurns: 10, - Strategy: workflow.StrategyNone, - }, - }, - { - name: "unknown strategy defaults to StrategyNone", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 10, - Strategy: "unknown_strategy", - }, - want: &workflow.ConversationConfig{ - MaxTurns: 10, - Strategy: workflow.StrategyNone, - }, + name: "nil config returns nil", + wantNil: true, }, { - name: "zero max_turns", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 0, - Strategy: "sliding_window", - }, - want: &workflow.ConversationConfig{ - MaxTurns: 0, - Strategy: workflow.StrategySlidingWindow, - }, - }, - { - name: "zero max_context_tokens", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 0, - Strategy: "sliding_window", - }, - want: &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 0, - Strategy: workflow.StrategySlidingWindow, - }, - }, - { - name: "all fields empty", + name: "all fields empty returns config with empty ContinueFrom", yamlConfig: &yamlConversationConfig{}, - want: &workflow.ConversationConfig{ - MaxTurns: 0, - MaxContextTokens: 0, - Strategy: workflow.StrategyNone, - StopCondition: "", - ContinueFrom: "", - InjectContext: "", - }, }, { - name: "empty strings for text fields", + name: "only continue_from is mapped", yamlConfig: &yamlConversationConfig{ - MaxTurns: 10, - StopCondition: "", - ContinueFrom: "", - InjectContext: "", - }, - want: &workflow.ConversationConfig{ - MaxTurns: 10, - Strategy: workflow.StrategyNone, - StopCondition: "", - ContinueFrom: "", - InjectContext: "", - }, - }, - { - name: "very long inject_context", - yamlConfig: &yamlConversationConfig{ - MaxTurns: 10, - InjectContext: `This is a very long context injection that might include: -- Multiple lines of instructions -- Code blocks and examples -- Template variables like {{inputs.data}} and {{states.prev.output}} -- Special characters: <>&"' -- And much more detailed information`, - }, - want: &workflow.ConversationConfig{ - MaxTurns: 10, - Strategy: workflow.StrategyNone, - InjectContext: `This is a very long context injection that might include: -- Multiple lines of instructions -- Code blocks and examples -- Template variables like {{inputs.data}} and {{states.prev.output}} -- Special characters: <>&"' -- And much more detailed information`, + ContinueFrom: "step_one", }, + wantContinue: "step_one", }, } @@ -888,18 +714,13 @@ func TestMapConversationConfig_EdgeCases(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got := mapConversationConfig(tt.yamlConfig) - if tt.want == nil { + if tt.wantNil { assert.Nil(t, got) return } require.NotNil(t, got) - assert.Equal(t, tt.want.MaxTurns, got.MaxTurns) - assert.Equal(t, tt.want.MaxContextTokens, got.MaxContextTokens) - assert.Equal(t, tt.want.Strategy, got.Strategy) - assert.Equal(t, tt.want.StopCondition, got.StopCondition) - assert.Equal(t, tt.want.ContinueFrom, got.ContinueFrom) - assert.Equal(t, tt.want.InjectContext, got.InjectContext) + assert.Equal(t, tt.wantContinue, got.ContinueFrom) }) } } @@ -913,109 +734,81 @@ func TestMapAgentConfigFlat_ConversationMode_HappyPath(t *testing.T) { want *workflow.AgentConfig }{ { - name: "conversation mode with system_prompt and initial_prompt", + name: "conversation mode with system_prompt and prompt", yamlStep: yamlStep{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are a code reviewer. Iterate until quality standards are met.", - InitialPrompt: "Review this code:\n{{inputs.code}}", + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are a code reviewer. Iterate until quality standards are met.", + Prompt: "Review this code:\n{{inputs.code}}", Options: map[string]any{ "model": "claude-sonnet-4-20250514", "max_tokens": 4096, }, Conversation: &yamlConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100000, - Strategy: "sliding_window", - StopCondition: "response contains 'APPROVED'", + ContinueFrom: "", }, }, want: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are a code reviewer. Iterate until quality standards are met.", - InitialPrompt: "Review this code:\n{{inputs.code}}", + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are a code reviewer. Iterate until quality standards are met.", + Prompt: "Review this code:\n{{inputs.code}}", Options: map[string]any{ "model": "claude-sonnet-4-20250514", "max_tokens": 4096, }, Conversation: &workflow.ConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100000, - Strategy: workflow.StrategySlidingWindow, - StopCondition: "response contains 'APPROVED'", + ContinueFrom: "", }, }, }, { name: "conversation mode with continue_from", yamlStep: yamlStep{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Also consider these requirements:\n{{inputs.additional_requirements}}", + Provider: "claude", + Mode: "conversation", + Prompt: "Also consider these requirements:\n{{inputs.additional_requirements}}", Conversation: &yamlConversationConfig{ - MaxTurns: 5, ContinueFrom: "refine_code", }, }, want: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Also consider these requirements:\n{{inputs.additional_requirements}}", + Provider: "claude", + Mode: "conversation", + Prompt: "Also consider these requirements:\n{{inputs.additional_requirements}}", Conversation: &workflow.ConversationConfig{ - MaxTurns: 5, ContinueFrom: "refine_code", - Strategy: workflow.StrategyNone, }, }, }, { - name: "conversation mode with all strategies", + name: "conversation mode with prompt only", yamlStep: yamlStep{ Provider: "gemini", Mode: "conversation", SystemPrompt: "You are an expert assistant.", Prompt: "Help me with {{inputs.task}}", - Conversation: &yamlConversationConfig{ - MaxTurns: 20, - MaxContextTokens: 50000, - Strategy: "summarize", - StopCondition: "response contains 'COMPLETE'", - InjectContext: "Remember to follow best practices.", - }, }, want: &workflow.AgentConfig{ Provider: "gemini", Mode: "conversation", SystemPrompt: "You are an expert assistant.", Prompt: "Help me with {{inputs.task}}", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 20, - MaxContextTokens: 50000, - Strategy: workflow.StrategySummarize, - StopCondition: "response contains 'COMPLETE'", - InjectContext: "Remember to follow best practices.", - }, }, }, { name: "conversation mode minimal config", yamlStep: yamlStep{ - Provider: "codex", - Mode: "conversation", - Prompt: "Generate tests", - Conversation: &yamlConversationConfig{ - MaxTurns: 5, - }, + Provider: "codex", + Mode: "conversation", + Prompt: "Generate tests", + Conversation: &yamlConversationConfig{}, }, want: &workflow.AgentConfig{ - Provider: "codex", - Mode: "conversation", - Prompt: "Generate tests", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 5, - Strategy: workflow.StrategyNone, - }, + Provider: "codex", + Mode: "conversation", + Prompt: "Generate tests", + Conversation: &workflow.ConversationConfig{}, }, }, } @@ -1028,17 +821,11 @@ func TestMapAgentConfigFlat_ConversationMode_HappyPath(t *testing.T) { assert.Equal(t, tt.want.Provider, got.Provider) assert.Equal(t, tt.want.Mode, got.Mode) assert.Equal(t, tt.want.SystemPrompt, got.SystemPrompt) - assert.Equal(t, tt.want.InitialPrompt, got.InitialPrompt) assert.Equal(t, tt.want.Prompt, got.Prompt) if tt.want.Conversation != nil { require.NotNil(t, got.Conversation) - assert.Equal(t, tt.want.Conversation.MaxTurns, got.Conversation.MaxTurns) - assert.Equal(t, tt.want.Conversation.MaxContextTokens, got.Conversation.MaxContextTokens) - assert.Equal(t, tt.want.Conversation.Strategy, got.Conversation.Strategy) - assert.Equal(t, tt.want.Conversation.StopCondition, got.Conversation.StopCondition) assert.Equal(t, tt.want.Conversation.ContinueFrom, got.Conversation.ContinueFrom) - assert.Equal(t, tt.want.Conversation.InjectContext, got.Conversation.InjectContext) } else { assert.Nil(t, got.Conversation) } @@ -1057,27 +844,27 @@ func TestMapAgentConfigFlat_ConversationMode_EdgeCases(t *testing.T) { { name: "conversation mode without conversation config", yamlStep: yamlStep{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are helpful.", - InitialPrompt: "Hello", + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are helpful.", + Prompt: "Hello", }, want: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are helpful.", - InitialPrompt: "Hello", - Conversation: nil, + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are helpful.", + Prompt: "Hello", + Conversation: nil, }, }, { - name: "single mode with conversation config (should still map)", + name: "single mode with conversation config (only ContinueFrom mapped)", yamlStep: yamlStep{ Provider: "claude", Mode: "single", Prompt: "Do task", Conversation: &yamlConversationConfig{ - MaxTurns: 10, + ContinueFrom: "prior", }, }, want: &workflow.AgentConfig{ @@ -1085,8 +872,7 @@ func TestMapAgentConfigFlat_ConversationMode_EdgeCases(t *testing.T) { Mode: "single", Prompt: "Do task", Conversation: &workflow.ConversationConfig{ - MaxTurns: 10, - Strategy: workflow.StrategyNone, + ContinueFrom: "prior", }, }, }, @@ -1103,50 +889,6 @@ func TestMapAgentConfigFlat_ConversationMode_EdgeCases(t *testing.T) { Conversation: nil, }, }, - { - name: "conversation mode with empty system_prompt", - yamlStep: yamlStep{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "", - InitialPrompt: "Start conversation", - Conversation: &yamlConversationConfig{ - MaxTurns: 10, - }, - }, - want: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - SystemPrompt: "", - InitialPrompt: "Start conversation", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 10, - Strategy: workflow.StrategyNone, - }, - }, - }, - { - name: "conversation mode with both prompt and initial_prompt", - yamlStep: yamlStep{ - Provider: "claude", - Mode: "conversation", - Prompt: "Fallback prompt", - InitialPrompt: "Initial message", - Conversation: &yamlConversationConfig{ - MaxTurns: 10, - }, - }, - want: &workflow.AgentConfig{ - Provider: "claude", - Mode: "conversation", - Prompt: "Fallback prompt", - InitialPrompt: "Initial message", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 10, - Strategy: workflow.StrategyNone, - }, - }, - }, { name: "conversation mode with multiline system_prompt", yamlStep: yamlStep{ @@ -1157,14 +899,8 @@ func TestMapAgentConfigFlat_ConversationMode_EdgeCases(t *testing.T) { Follow these guidelines: 1. Check for bugs 2. Verify coding standards -3. Suggest improvements - -Say "APPROVED" when satisfied.`, - InitialPrompt: "Review: {{inputs.code}}", - Conversation: &yamlConversationConfig{ - MaxTurns: 10, - StopCondition: "response contains 'APPROVED'", - }, +3. Suggest improvements`, + Prompt: "Review: {{inputs.code}}", }, want: &workflow.AgentConfig{ Provider: "claude", @@ -1174,15 +910,8 @@ Say "APPROVED" when satisfied.`, Follow these guidelines: 1. Check for bugs 2. Verify coding standards -3. Suggest improvements - -Say "APPROVED" when satisfied.`, - InitialPrompt: "Review: {{inputs.code}}", - Conversation: &workflow.ConversationConfig{ - MaxTurns: 10, - StopCondition: "response contains 'APPROVED'", - Strategy: workflow.StrategyNone, - }, +3. Suggest improvements`, + Prompt: "Review: {{inputs.code}}", }, }, } @@ -1195,15 +924,13 @@ Say "APPROVED" when satisfied.`, assert.Equal(t, tt.want.Provider, got.Provider) assert.Equal(t, tt.want.Mode, got.Mode) assert.Equal(t, tt.want.SystemPrompt, got.SystemPrompt) - assert.Equal(t, tt.want.InitialPrompt, got.InitialPrompt) assert.Equal(t, tt.want.Prompt, got.Prompt) if tt.want.Conversation == nil { assert.Nil(t, got.Conversation) } else { require.NotNil(t, got.Conversation) - assert.Equal(t, tt.want.Conversation.MaxTurns, got.Conversation.MaxTurns) - assert.Equal(t, tt.want.Conversation.Strategy, got.Conversation.Strategy) + assert.Equal(t, tt.want.Conversation.ContinueFrom, got.Conversation.ContinueFrom) } }) } @@ -1215,26 +942,24 @@ func TestMapStep_AgentConversationMode(t *testing.T) { tests := []struct { name string yamlStep yamlStep + wantErr bool wantStep func(*testing.T, *workflow.Step) }{ { - name: "full conversation mode step", + name: "conversation mode with prompt and continue_from", yamlStep: yamlStep{ - Type: "agent", - Description: "Iterative code review", - Provider: "claude", - Mode: "conversation", - SystemPrompt: "You are a code reviewer.", - InitialPrompt: "Review this code:\n{{inputs.code}}", + Type: "agent", + Description: "Iterative code review", + Provider: "claude", + Mode: "conversation", + SystemPrompt: "You are a code reviewer.", + Prompt: "Review this code:\n{{inputs.code}}", Options: map[string]any{ "model": "claude-sonnet-4-20250514", "max_tokens": 4096, }, Conversation: &yamlConversationConfig{ - MaxTurns: 10, - MaxContextTokens: 100000, - Strategy: "sliding_window", - StopCondition: "response contains 'APPROVED'", + ContinueFrom: "", }, Timeout: "10m", OnSuccess: "deploy", @@ -1249,26 +974,21 @@ func TestMapStep_AgentConversationMode(t *testing.T) { assert.Equal(t, "claude", step.Agent.Provider) assert.Equal(t, "conversation", step.Agent.Mode) assert.Equal(t, "You are a code reviewer.", step.Agent.SystemPrompt) - assert.Equal(t, "Review this code:\n{{inputs.code}}", step.Agent.InitialPrompt) + assert.Equal(t, "Review this code:\n{{inputs.code}}", step.Agent.Prompt) require.NotNil(t, step.Agent.Conversation) - assert.Equal(t, 10, step.Agent.Conversation.MaxTurns) - assert.Equal(t, 100000, step.Agent.Conversation.MaxContextTokens) - assert.Equal(t, workflow.StrategySlidingWindow, step.Agent.Conversation.Strategy) - assert.Equal(t, "response contains 'APPROVED'", step.Agent.Conversation.StopCondition) + assert.Equal(t, "", step.Agent.Conversation.ContinueFrom) }, }, { name: "conversation mode with continue_from", yamlStep: yamlStep{ - Type: "agent", - Provider: "claude", - Mode: "conversation", - InitialPrompt: "Also consider: {{inputs.requirements}}", + Type: "agent", + Provider: "claude", + Mode: "conversation", + Prompt: "Also consider: {{inputs.requirements}}", Conversation: &yamlConversationConfig{ - MaxTurns: 5, - ContinueFrom: "refine_code", - InjectContext: "Focus on performance.", + ContinueFrom: "refine_code", }, }, wantStep: func(t *testing.T, step *workflow.Step) { @@ -1278,23 +998,17 @@ func TestMapStep_AgentConversationMode(t *testing.T) { assert.Equal(t, "conversation", step.Agent.Mode) require.NotNil(t, step.Agent.Conversation) - assert.Equal(t, 5, step.Agent.Conversation.MaxTurns) assert.Equal(t, "refine_code", step.Agent.Conversation.ContinueFrom) - assert.Equal(t, "Focus on performance.", step.Agent.Conversation.InjectContext) }, }, { name: "conversation mode with hooks and retry", yamlStep: yamlStep{ - Type: "agent", - Provider: "gemini", - Mode: "conversation", - SystemPrompt: "You are helpful.", - InitialPrompt: "Start task", - Conversation: &yamlConversationConfig{ - MaxTurns: 10, - Strategy: "summarize", - }, + Type: "agent", + Provider: "gemini", + Mode: "conversation", + SystemPrompt: "You are helpful.", + Prompt: "Start task", Hooks: &yamlStepHooks{ Pre: []yamlHookAction{ {Log: "Starting conversation"}, @@ -1313,8 +1027,6 @@ func TestMapStep_AgentConversationMode(t *testing.T) { require.NotNil(t, step.Agent) assert.Equal(t, "conversation", step.Agent.Mode) - require.NotNil(t, step.Agent.Conversation) - assert.Equal(t, workflow.StrategySummarize, step.Agent.Conversation.Strategy) require.NotNil(t, step.Hooks.Pre) require.NotNil(t, step.Hooks.Post) @@ -1325,15 +1037,11 @@ func TestMapStep_AgentConversationMode(t *testing.T) { { name: "conversation mode with transitions", yamlStep: yamlStep{ - Type: "agent", - Provider: "claude", - Mode: "conversation", - SystemPrompt: "Classify sentiment.", - InitialPrompt: "Classify: {{inputs.text}}", - Conversation: &yamlConversationConfig{ - MaxTurns: 3, - StopCondition: "response contains 'CLASSIFICATION:'", - }, + Type: "agent", + Provider: "claude", + Mode: "conversation", + SystemPrompt: "Classify sentiment.", + Prompt: "Classify: {{inputs.text}}", Transitions: []yamlTransition{ {When: "states.classify.output contains 'positive'", Goto: "handle_positive"}, {When: "states.classify.output contains 'negative'", Goto: "handle_negative"}, @@ -1356,6 +1064,11 @@ func TestMapStep_AgentConversationMode(t *testing.T) { t.Run(tt.name, func(t *testing.T) { step, err := mapStep("test.yaml", "test_step", &tt.yamlStep) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) require.NotNil(t, step) assert.Equal(t, "test_step", step.Name) @@ -1365,35 +1078,6 @@ func TestMapStep_AgentConversationMode(t *testing.T) { } } -func TestMapConversationConfig_StrategyMapping(t *testing.T) { - tests := []struct { - yamlStrategy string - domainStrategy workflow.ContextWindowStrategy - }{ - {"sliding_window", workflow.StrategySlidingWindow}, - {"summarize", workflow.StrategySummarize}, - {"truncate_middle", workflow.StrategyTruncateMiddle}, - {"", workflow.StrategyNone}, - {"SLIDING_WINDOW", workflow.StrategyNone}, // case-sensitive - {"invalid", workflow.StrategyNone}, - {"sliding-window", workflow.StrategyNone}, // exact match required - } - - for _, tt := range tests { - t.Run("strategy_"+tt.yamlStrategy, func(t *testing.T) { - yamlConfig := &yamlConversationConfig{ - MaxTurns: 10, - Strategy: tt.yamlStrategy, - } - - got := mapConversationConfig(yamlConfig) - - require.NotNil(t, got) - assert.Equal(t, tt.domainStrategy, got.Strategy) - }) - } -} - // mapRetry Tests func TestMapRetry_NilInput(t *testing.T) { diff --git a/internal/infrastructure/repository/yaml_types.go b/internal/infrastructure/repository/yaml_types.go index 7cfb0447..f5712630 100644 --- a/internal/infrastructure/repository/yaml_types.go +++ b/internal/infrastructure/repository/yaml_types.go @@ -77,10 +77,9 @@ type yamlStep struct { Config map[string]any `yaml:"config"` // plugin-provided step type parameters // Agent conversation mode (F033) - extends agent configuration - Mode string `yaml:"mode"` // execution mode: "single" (default) or "conversation" - SystemPrompt string `yaml:"system_prompt"` // system prompt for conversation mode - InitialPrompt string `yaml:"initial_prompt"` // initial user prompt for conversation mode - Conversation *yamlConversationConfig `yaml:"conversation"` // conversation-specific configuration + Mode string `yaml:"mode"` // execution mode: "single" (default) or "conversation" + SystemPrompt string `yaml:"system_prompt"` // system prompt for conversation mode + Conversation *yamlConversationConfig `yaml:"conversation"` // conversation-specific configuration } // yamlTransition is the YAML representation of a conditional transition. @@ -167,12 +166,6 @@ type yamlTemplateParam struct { } // yamlConversationConfig is the YAML representation of conversation configuration. -// F033: Agent conversations with context window management. type yamlConversationConfig struct { - MaxTurns int `yaml:"max_turns"` // maximum number of turns (default 10, max 100) - MaxContextTokens int `yaml:"max_context_tokens"` // maximum tokens in context window (0 = provider default) - Strategy string `yaml:"strategy"` // context window strategy: sliding_window, summarize, truncate_middle - StopCondition string `yaml:"stop_condition"` // expression to evaluate for early exit - ContinueFrom string `yaml:"continue_from"` // step name to continue conversation from - InjectContext string `yaml:"inject_context"` // additional context to inject mid-conversation + ContinueFrom string `yaml:"continue_from"` } diff --git a/internal/interfaces/cli/run.go b/internal/interfaces/cli/run.go index bf06b31a..4fa5fc97 100644 --- a/internal/interfaces/cli/run.go +++ b/internal/interfaces/cli/run.go @@ -26,7 +26,6 @@ import ( "github.com/awf-project/cli/internal/infrastructure/pluginmgr" "github.com/awf-project/cli/internal/infrastructure/repository" "github.com/awf-project/cli/internal/infrastructure/store" - "github.com/awf-project/cli/internal/infrastructure/tokenizer" "github.com/awf-project/cli/internal/infrastructure/xdg" "github.com/awf-project/cli/internal/interfaces/cli/ui" "github.com/awf-project/cli/pkg/httpx" @@ -300,8 +299,8 @@ func runWorkflow(cmd *cobra.Command, cfg *Config, workflowName string, inputFlag return fmt.Errorf("failed to register agent providers: %w", err) } execSvc.SetAgentRegistry(agentRegistry) - convTokenizer := tokenizer.NewApproximationTokenizer() - convMgr := application.NewConversationManager(logger, exprEvaluator, resolver, convTokenizer, agentRegistry) + convMgr := application.NewConversationManager(logger, resolver, agentRegistry) + convMgr.SetUserInputReader(ui.NewStdinInputReader(os.Stdin, os.Stdout)) execSvc.SetConversationManager(convMgr) // Set AWF paths with pack context if applicable @@ -1021,8 +1020,8 @@ func runSingleStep( return fmt.Errorf("failed to register agent providers: %w", err) } execSvc.SetAgentRegistry(agentRegistry) - convTokenizer := tokenizer.NewApproximationTokenizer() - convMgr := application.NewConversationManager(logger, exprEvaluator, resolver, convTokenizer, agentRegistry) + convMgr := application.NewConversationManager(logger, resolver, agentRegistry) + convMgr.SetUserInputReader(ui.NewStdinInputReader(os.Stdin, os.Stdout)) execSvc.SetConversationManager(convMgr) // Parse namespace to set up pack context if applicable diff --git a/internal/interfaces/cli/run_wiring_conversation_test.go b/internal/interfaces/cli/run_wiring_conversation_test.go index 2f63011a..6e85ec1e 100644 --- a/internal/interfaces/cli/run_wiring_conversation_test.go +++ b/internal/interfaces/cli/run_wiring_conversation_test.go @@ -10,8 +10,8 @@ import ( "github.com/stretchr/testify/assert" ) -// TestRunCommand_WiresConversationManager verifies ConversationManager is instantiated -// and injected for all conversation workflow scenarios and execution paths. +// TestRunCommand_WiresConversationManager verifies ConversationManager and UserInputReader +// are instantiated and injected for all conversation workflow scenarios and execution paths. func TestRunCommand_WiresConversationManager(t *testing.T) { tests := []struct { name string @@ -28,14 +28,12 @@ version: "1.0.0" states: initial: chat chat: - type: step - agent: - provider: claude + type: agent + provider: claude + mode: conversation + prompt: "Hello" + options: model: sonnet - conversation: - initial_prompt: "Hello" - max_turns: 3 - strategy: sliding_window on_success: done done: type: terminal @@ -50,23 +48,22 @@ version: "1.0.0" states: initial: chat chat: - type: step - agent: - provider: claude + type: agent + provider: claude + mode: conversation + prompt: "Hello" + options: model: sonnet - conversation: - initial_prompt: "Hello" - max_turns: 2 on_success: done done: type: terminal `, }, { - name: "multi-turn with input", - wfName: "test-multiturn.yaml", - args: []string{"run", "test-multiturn", "--input", "task=analyze the code"}, - wfYAML: `name: test-multiturn + name: "with system prompt", + wfName: "test-system-prompt.yaml", + args: []string{"run", "test-system-prompt", "--input", "task=analyze the code"}, + wfYAML: `name: test-system-prompt version: "1.0.0" inputs: - name: task @@ -75,14 +72,13 @@ inputs: states: initial: discuss discuss: - type: step - agent: - provider: claude + type: agent + provider: claude + mode: conversation + system_prompt: "You are a helpful code reviewer" + prompt: "Task: {{inputs.task}}" + options: model: sonnet - conversation: - initial_prompt: "Task: {{inputs.task}}" - max_turns: 5 - strategy: sliding_window on_success: done done: type: terminal @@ -97,67 +93,22 @@ version: "1.0.0" states: initial: first_chat first_chat: - type: step - agent: - provider: claude + type: agent + provider: claude + mode: conversation + prompt: "Start conversation" + options: model: sonnet - conversation: - initial_prompt: "Start conversation" - max_turns: 2 on_success: second_chat second_chat: - type: step - agent: - provider: claude - model: sonnet + type: agent + provider: claude + mode: conversation + prompt: "Continue discussion" conversation: - initial_prompt: "Continue discussion" continue_from: first_chat - max_turns: 2 - on_success: done - done: - type: terminal -`, - }, - { - name: "inject_context enrichment", - wfName: "test-inject-context.yaml", - args: []string{"run", "test-inject-context"}, - wfYAML: `name: test-inject-context -version: "1.0.0" -states: - initial: chat_with_context - chat_with_context: - type: step - agent: - provider: claude - model: sonnet - conversation: - initial_prompt: "Help me with this task" - max_turns: 3 - inject_context: "Additional context: focus on performance" - on_success: done - done: - type: terminal -`, - }, - { - name: "stop_condition evaluation", - wfName: "test-stop-condition.yaml", - args: []string{"run", "test-stop-condition"}, - wfYAML: `name: test-stop-condition -version: "1.0.0" -states: - initial: conditional_chat - conditional_chat: - type: step - agent: - provider: claude + options: model: sonnet - conversation: - initial_prompt: "Answer briefly" - max_turns: 10 - stop_condition: "len(states.conditional_chat.conversation.turns) > 2" on_success: done done: type: terminal @@ -179,22 +130,20 @@ states: - chat_2 on_success: done chat_1: - type: step - agent: - provider: claude + type: agent + provider: claude + mode: conversation + prompt: "First conversation" + options: model: sonnet - conversation: - initial_prompt: "First conversation" - max_turns: 2 on_success: done chat_2: - type: step - agent: - provider: claude + type: agent + provider: claude + mode: conversation + prompt: "Second conversation" + options: model: sonnet - conversation: - initial_prompt: "Second conversation" - max_turns: 2 on_success: done done: type: terminal @@ -209,13 +158,12 @@ version: "1.0.0" states: initial: chat_step chat_step: - type: step - agent: - provider: claude + type: agent + provider: claude + mode: conversation + prompt: "Hello from single step" + options: model: sonnet - conversation: - initial_prompt: "Hello from single step" - max_turns: 2 on_success: done done: type: terminal @@ -223,6 +171,26 @@ states: }, } + // Force PATH to an empty directory so the claude binary cannot be found. + // These tests only verify ConversationManager/UserInputReader wiring — they + // should never actually invoke the provider CLI. Without this guard, each + // subtest would make a real API call to Anthropic, adding 5–10s per case + // and leaking through to the interactive stdin loop if the call succeeds. + t.Setenv("PATH", t.TempDir()) + + // Redirect os.Stdin to /dev/null as a defensive second layer in case the + // stdin reader is reached before the provider fails fast. + origStdin := os.Stdin + devNull, err := os.Open(os.DevNull) + if err != nil { + t.Fatalf("open /dev/null: %v", err) + } + os.Stdin = devNull + t.Cleanup(func() { + os.Stdin = origStdin + _ = devNull.Close() + }) + for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := setupTestDir(t) @@ -241,9 +209,13 @@ states: fullOutput := out.String() + errOut.String() assert.NotContains(t, fullOutput, "conversation manager not configured", "ConversationManager should be wired in %s path", tc.name) + assert.NotContains(t, fullOutput, "conversation mode requires a UserInputReader", + "UserInputReader should be wired in %s path", tc.name) if err != nil { assert.NotContains(t, err.Error(), "conversation manager not configured", "ConversationManager must be wired in %s execution path", tc.name) + assert.NotContains(t, err.Error(), "conversation mode requires a UserInputReader", + "UserInputReader must be wired in %s execution path", tc.name) } }) } diff --git a/internal/interfaces/cli/run_wiring_stdin_input_reader_test.go b/internal/interfaces/cli/run_wiring_stdin_input_reader_test.go new file mode 100644 index 00000000..5a77dd73 --- /dev/null +++ b/internal/interfaces/cli/run_wiring_stdin_input_reader_test.go @@ -0,0 +1,195 @@ +package cli_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/awf-project/cli/internal/interfaces/cli" + "github.com/awf-project/cli/internal/interfaces/cli/ui" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRunCommand_WiresStdinInputReaderInRunWorkflow verifies that StdinInputReader +// is properly instantiated and wired to ConversationManager in the runWorkflow path. +// This tests T012 requirement: "Wire StdinInputReader at runWorkflow (~line 302) path" +func TestRunCommand_WiresStdinInputReaderInRunWorkflow(t *testing.T) { + tmpDir := setupTestDir(t) + _ = os.MkdirAll(filepath.Join(tmpDir, ".awf", "states"), 0o755) + _ = os.MkdirAll(filepath.Join(tmpDir, "history"), 0o755) + + wfYAML := `name: simple-workflow +version: "1.0.0" +states: + initial: step1 + step1: + type: step + command: echo "test" + on_success: done + done: + type: terminal +` + createTestWorkflow(t, tmpDir, "simple.yaml", wfYAML) + + cmd := cli.NewRootCommand() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"--storage=" + tmpDir, "run", "simple-workflow"}) + + // Execute should not error due to missing stdin input reader + err := cmd.Execute() + // The error (if any) should not be about conversation manager or stdin input reader issues + if err != nil { + assert.NotContains(t, err.Error(), "conversation manager", + "runWorkflow should have conversation manager wired") + assert.NotContains(t, err.Error(), "UserInputReader", + "runWorkflow should have stdin input reader configured") + } +} + +// TestRunCommand_WiresStdinInputReaderInRunSingleStep verifies that StdinInputReader +// is properly instantiated and wired to ConversationManager in the runSingleStep path. +// This tests T012 requirement: "Wire StdinInputReader at runSingleStep (~line 1023) path" +func TestRunCommand_WiresStdinInputReaderInRunSingleStep(t *testing.T) { + tmpDir := setupTestDir(t) + _ = os.MkdirAll(filepath.Join(tmpDir, ".awf", "states"), 0o755) + _ = os.MkdirAll(filepath.Join(tmpDir, "history"), 0o755) + + wfYAML := `name: multi-step-workflow +version: "1.0.0" +states: + initial: step1 + step1: + type: step + command: echo "step 1" + on_success: step2 + step2: + type: step + command: echo "step 2" + on_success: done + done: + type: terminal +` + createTestWorkflow(t, tmpDir, "multi-step.yaml", wfYAML) + + cmd := cli.NewRootCommand() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"--storage=" + tmpDir, "run", "multi-step-workflow", "--step", "step1"}) + + // Execute should not error due to missing stdin input reader in single-step path + err := cmd.Execute() + // The error (if any) should not be about conversation manager or stdin input reader issues + if err != nil { + assert.NotContains(t, err.Error(), "conversation manager", + "runSingleStep should have conversation manager wired") + assert.NotContains(t, err.Error(), "UserInputReader", + "runSingleStep should have stdin input reader configured") + } +} + +// TestStdinInputReaderImplementsPort verifies StdinInputReader correctly implements +// the UserInputReader port interface with proper signature. +func TestStdinInputReaderImplementsPort(t *testing.T) { + stdin := bytes.NewBufferString("") + stdout := &bytes.Buffer{} + + reader := ui.NewStdinInputReader(stdin, stdout) + assert.NotNil(t, reader, "NewStdinInputReader should create valid instance") + + // Test that ReadInput has correct signature (returns string and error) + ctx := context.Background() + stdin.WriteString("test input\n") + + result, err := reader.ReadInput(ctx) + assert.NoError(t, err, "ReadInput should succeed with valid input") + assert.Equal(t, "test input", result, "ReadInput should return user input") +} + +// TestStdinInputReaderRespectsContext verifies that ReadInput respects context +// cancellation as required by FR-011. +func TestStdinInputReaderRespectsContext(t *testing.T) { + stdin := &bytes.Buffer{} // No data, will block + stdout := &bytes.Buffer{} + + reader := ui.NewStdinInputReader(stdin, stdout) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Immediately cancel + + // ReadInput should detect context cancellation and return error + result, err := reader.ReadInput(ctx) + + assert.Error(t, err, "ReadInput should error when context cancelled") + assert.Empty(t, result, "ReadInput should return empty string on context error") +} + +// TestStdinInputReaderHandlesEmptyInput verifies that empty input (Enter with no text) +// is properly returned, enabling conversation termination (FR-007). +func TestStdinInputReaderHandlesEmptyInput(t *testing.T) { + stdin := bytes.NewBufferString("\n") // Just newline + stdout := &bytes.Buffer{} + + reader := ui.NewStdinInputReader(stdin, stdout) + ctx := context.Background() + + result, err := reader.ReadInput(ctx) + + assert.NoError(t, err, "ReadInput should succeed with empty input") + assert.Empty(t, result, "Empty input should return empty string") +} + +// TestStdinInputReaderPrintsPrompt verifies that ReadInput prints the "> " prompt +// to the output writer before reading user input. +func TestStdinInputReaderPrintsPrompt(t *testing.T) { + stdin := bytes.NewBufferString("user message\n") + stdout := &bytes.Buffer{} + + reader := ui.NewStdinInputReader(stdin, stdout) + ctx := context.Background() + + _, err := reader.ReadInput(ctx) + + require.NoError(t, err, "ReadInput should succeed") + assert.Contains(t, stdout.String(), "> ", "ReadInput should print prompt to output") +} + +// TestConversationManagerWiringSignature verifies that ConversationManager is created +// with 3 parameters (logger, resolver, agentRegistry) as required by T012, +// not the old 5-parameter constructor. +func TestConversationManagerWiringSignature(t *testing.T) { + tmpDir := setupTestDir(t) + _ = os.MkdirAll(filepath.Join(tmpDir, ".awf", "states"), 0o755) + + wfYAML := `name: test-wiring +version: "1.0.0" +states: + initial: step + step: + type: step + command: echo "test" + on_success: done + done: + type: terminal +` + createTestWorkflow(t, tmpDir, "test.yaml", wfYAML) + + cmd := cli.NewRootCommand() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"--storage=" + tmpDir, "run", "test-wiring"}) + + // If NewConversationManager still expected 5 parameters, this would fail to compile. + // This test documents that the signature has been updated to 3 parameters. + err := cmd.Execute() + + // No specific assertion needed - if compilation succeeded, the signature is correct. + // The test's purpose is to prevent regression to old 5-param signature. + _ = err +} diff --git a/internal/interfaces/cli/ui/stdin_input_reader.go b/internal/interfaces/cli/ui/stdin_input_reader.go new file mode 100644 index 00000000..71277dbe --- /dev/null +++ b/internal/interfaces/cli/ui/stdin_input_reader.go @@ -0,0 +1,34 @@ +package ui + +import ( + "bufio" + "context" + "fmt" + "io" + + "github.com/awf-project/cli/internal/domain/ports" +) + +var _ ports.UserInputReader = (*StdinInputReader)(nil) + +// StdinInputReader implements UserInputReader for terminal-based conversation input. +type StdinInputReader struct { + reader *bufio.Reader + writer io.Writer +} + +// NewStdinInputReader creates a StdinInputReader reading from r and writing the prompt to w. +func NewStdinInputReader(r io.Reader, w io.Writer) *StdinInputReader { + return &StdinInputReader{ + reader: bufio.NewReader(r), + writer: w, + } +} + +// ReadInput prints "> " and reads one line from stdin. +// Returns empty string when the user submits no input (signals conversation end). +// Returns error on context cancellation or I/O failure. +func (s *StdinInputReader) ReadInput(ctx context.Context) (string, error) { + _, _ = fmt.Fprint(s.writer, "> ") + return readLineWithContext(ctx, s.reader) +} diff --git a/internal/interfaces/cli/ui/stdin_input_reader_test.go b/internal/interfaces/cli/ui/stdin_input_reader_test.go new file mode 100644 index 00000000..ed10d892 --- /dev/null +++ b/internal/interfaces/cli/ui/stdin_input_reader_test.go @@ -0,0 +1,140 @@ +package ui + +import ( + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStdinInputReader_ReadInput_HappyPath(t *testing.T) { + input := strings.NewReader("hello\n") + output := &strings.Builder{} + + reader := NewStdinInputReader(input, output) + line, err := reader.ReadInput(context.Background()) + + require.NoError(t, err) + assert.Equal(t, "hello", line) + assert.Equal(t, "> ", output.String()) +} + +func TestStdinInputReader_ReadInput_EmptyInput(t *testing.T) { + input := strings.NewReader("\n") + output := &strings.Builder{} + + reader := NewStdinInputReader(input, output) + line, err := reader.ReadInput(context.Background()) + + require.NoError(t, err) + assert.Equal(t, "", line) + assert.Equal(t, "> ", output.String()) +} + +func TestStdinInputReader_ReadInput_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + pr, pw := io.Pipe() + defer pw.Close() + + output := &strings.Builder{} + reader := NewStdinInputReader(pr, output) + + _, err := reader.ReadInput(ctx) + + require.Error(t, err) + assert.True(t, errors.Is(err, context.Canceled)) + assert.Equal(t, "> ", output.String()) +} + +func TestStdinInputReader_ReadInput_ContextDeadlineExceeded(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + pr, pw := io.Pipe() + defer pw.Close() + + output := &strings.Builder{} + reader := NewStdinInputReader(pr, output) + + _, err := reader.ReadInput(ctx) + + require.Error(t, err) + assert.True(t, errors.Is(err, context.DeadlineExceeded)) + assert.Equal(t, "> ", output.String()) +} + +func TestStdinInputReader_ReadInput_IOError(t *testing.T) { + errReader := &errorReader{} + output := &strings.Builder{} + + reader := NewStdinInputReader(errReader, output) + _, err := reader.ReadInput(context.Background()) + + require.Error(t, err) + assert.True(t, errors.Is(err, errReader.err)) + assert.Equal(t, "> ", output.String()) +} + +func TestStdinInputReader_ReadInput_MultilineText(t *testing.T) { + input := strings.NewReader("hello world test\n") + output := &strings.Builder{} + + reader := NewStdinInputReader(input, output) + line, err := reader.ReadInput(context.Background()) + + require.NoError(t, err) + assert.Equal(t, "hello world test", line) + assert.Equal(t, "> ", output.String()) +} + +func TestStdinInputReader_ReadInput_StripsLineEndings(t *testing.T) { + input := strings.NewReader("hello\r\n") + output := &strings.Builder{} + + reader := NewStdinInputReader(input, output) + line, err := reader.ReadInput(context.Background()) + + require.NoError(t, err) + assert.Equal(t, "hello", line) +} + +func TestStdinInputReader_ReadInput_WhitespacePreserved(t *testing.T) { + input := strings.NewReader(" hello \n") + output := &strings.Builder{} + + reader := NewStdinInputReader(input, output) + line, err := reader.ReadInput(context.Background()) + + require.NoError(t, err) + assert.Equal(t, " hello ", line) +} + +func TestStdinInputReader_ReadInput_EOF(t *testing.T) { + input := strings.NewReader("") + output := &strings.Builder{} + + reader := NewStdinInputReader(input, output) + _, err := reader.ReadInput(context.Background()) + + require.Error(t, err) + assert.True(t, errors.Is(err, io.EOF)) +} + +// errorReader is a test double that always returns an error on Read. +type errorReader struct { + err error +} + +func (r *errorReader) Read(p []byte) (n int, err error) { + if r.err == nil { + r.err = errors.New("read error") + } + return 0, r.err +} diff --git a/internal/testutil/mocks/mocks.go b/internal/testutil/mocks/mocks.go index 95ee14ef..175b2ba7 100644 --- a/internal/testutil/mocks/mocks.go +++ b/internal/testutil/mocks/mocks.go @@ -37,6 +37,7 @@ var ( _ ports.PluginStore = (*MockPluginStore)(nil) _ ports.PluginConfig = (*MockPluginConfig)(nil) _ ports.PluginStateStore = (*MockPluginStateStore)(nil) + _ ports.UserInputReader = (*MockUserInputReader)(nil) ) // MockWorkflowRepository is a thread-safe mock implementation of ports.WorkflowRepository. @@ -1766,3 +1767,103 @@ func NewMockPluginStateStore() *MockPluginStateStore { MockPluginConfig: config, } } + +// MockUserInputReader is a thread-safe mock implementation of ports.UserInputReader. +// It uses sync.Mutex to protect concurrent access to the response queue. +// +// Usage: +// +// reader := testutil.NewMockUserInputReader("hello", "world", "") +// input, err := reader.ReadInput(ctx) +type MockUserInputReader struct { + mu sync.Mutex + responses []string + index int + readErr error + callCount int +} + +// NewMockUserInputReader creates a new thread-safe mock input reader. +// Responses are returned in sequence; empty string signals conversation exit. +// When all responses are consumed, returns empty string. +func NewMockUserInputReader(responses ...string) *MockUserInputReader { + return &MockUserInputReader{ + responses: responses, + } +} + +// ReadInput returns the next configured response in sequence. +// Returns configured error if set. Respects context cancellation. +// Thread-safe for concurrent access. +func (m *MockUserInputReader) ReadInput(ctx context.Context) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Check context cancellation first + if err := ctx.Err(); err != nil { + return "", err //nolint:wrapcheck // tests assert exact context.Canceled identity via assert.Equal + } + + // Only track calls when responses have been configured; nil responses (post-Clear state) are no-ops + if m.responses != nil { + m.callCount++ + } + + // Return configured error if set + if m.readErr != nil { + return "", m.readErr + } + + // If no responses configured or all consumed, return empty string + if m.index >= len(m.responses) { + return "", nil + } + + resp := m.responses[m.index] + m.index++ + return resp, nil +} + +// SetReadError configures an error to be returned by ReadInput (test helper). +// Thread-safe for concurrent access. +func (m *MockUserInputReader) SetReadError(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.readErr = err +} + +// SetResponses replaces the response sequence (test helper). +// Thread-safe for concurrent access. +func (m *MockUserInputReader) SetResponses(responses ...string) { + m.mu.Lock() + defer m.mu.Unlock() + m.responses = responses + m.index = 0 +} + +// AddResponse appends a response to the sequence (test helper). +// Thread-safe for concurrent access. +func (m *MockUserInputReader) AddResponse(response string) { + m.mu.Lock() + defer m.mu.Unlock() + m.responses = append(m.responses, response) +} + +// GetCallCount returns the number of ReadInput calls (test helper). +// Thread-safe for concurrent access. +func (m *MockUserInputReader) GetCallCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.callCount +} + +// Clear removes all responses and resets error configuration (test helper). +// Thread-safe for concurrent access. +func (m *MockUserInputReader) Clear() { + m.mu.Lock() + defer m.mu.Unlock() + m.responses = nil + m.index = 0 + m.readErr = nil + m.callCount = 0 +} diff --git a/internal/testutil/mocks/mocks_test.go b/internal/testutil/mocks/mocks_test.go index c0d8905e..ddfd5e11 100644 --- a/internal/testutil/mocks/mocks_test.go +++ b/internal/testutil/mocks/mocks_test.go @@ -4958,3 +4958,286 @@ func TestMockAuditTrailWriter_ClearResetsIsClosed(t *testing.T) { assert.NoError(t, err) assert.Len(t, writer.GetEvents(), 1) } + +// Feature: F083 Interactive Conversation Mode +// Component: T002 MockUserInputReader + +// TestMockUserInputReader_ReadInput_HappyPath verifies sequential response delivery. +func TestMockUserInputReader_ReadInput_HappyPath(t *testing.T) { + reader := mocks.NewMockUserInputReader("hello", "world") + ctx := context.Background() + + input1, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "hello", input1) + + input2, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "world", input2) +} + +// TestMockUserInputReader_ReadInput_EmptyStringSignalsExit verifies empty string terminates conversation. +func TestMockUserInputReader_ReadInput_EmptyStringSignalsExit(t *testing.T) { + reader := mocks.NewMockUserInputReader("message", "") + ctx := context.Background() + + input1, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "message", input1) + + input2, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "", input2) +} + +// TestMockUserInputReader_ReadInput_ExhaustedResponses verifies empty string returned after all responses consumed. +func TestMockUserInputReader_ReadInput_ExhaustedResponses(t *testing.T) { + reader := mocks.NewMockUserInputReader("only") + ctx := context.Background() + + input1, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "only", input1) + + input2, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "", input2) + + input3, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "", input3) +} + +// TestMockUserInputReader_ReadInput_NoResponses verifies mock with no responses returns empty string. +func TestMockUserInputReader_ReadInput_NoResponses(t *testing.T) { + reader := mocks.NewMockUserInputReader() + ctx := context.Background() + + input, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "", input) +} + +// TestMockUserInputReader_ReadInput_ReturnsConfiguredError verifies error configuration takes effect. +func TestMockUserInputReader_ReadInput_ReturnsConfiguredError(t *testing.T) { + reader := mocks.NewMockUserInputReader("hello") + ctx := context.Background() + + customErr := errors.New("read failed") + reader.SetReadError(customErr) + + _, err := reader.ReadInput(ctx) + require.Error(t, err) + assert.True(t, errors.Is(err, customErr)) +} + +// TestMockUserInputReader_ReadInput_RespectsCancelledContext verifies context cancellation is respected. +func TestMockUserInputReader_ReadInput_RespectsCancelledContext(t *testing.T) { + reader := mocks.NewMockUserInputReader("hello") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := reader.ReadInput(ctx) + require.Error(t, err) + assert.Equal(t, context.Canceled, err) +} + +// TestMockUserInputReader_ReadInput_ContextCancelledBeforeResponse verifies cancellation checked before returning response. +func TestMockUserInputReader_ReadInput_ContextCancelledBeforeResponse(t *testing.T) { + reader := mocks.NewMockUserInputReader("hello") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := reader.ReadInput(ctx) + require.Error(t, err) +} + +// TestMockUserInputReader_GetCallCount verifies call tracking. +func TestMockUserInputReader_GetCallCount(t *testing.T) { + reader := mocks.NewMockUserInputReader("a", "b", "c") + ctx := context.Background() + + assert.Equal(t, 0, reader.GetCallCount()) + + reader.ReadInput(ctx) + assert.Equal(t, 1, reader.GetCallCount()) + + reader.ReadInput(ctx) + assert.Equal(t, 2, reader.GetCallCount()) + + reader.ReadInput(ctx) + assert.Equal(t, 3, reader.GetCallCount()) +} + +// TestMockUserInputReader_GetCallCount_WithError verifies call count increments even when error is returned. +func TestMockUserInputReader_GetCallCount_WithError(t *testing.T) { + reader := mocks.NewMockUserInputReader("hello") + ctx := context.Background() + + reader.SetReadError(errors.New("error")) + + reader.ReadInput(ctx) + assert.Equal(t, 1, reader.GetCallCount()) +} + +// TestMockUserInputReader_SetReadError verifies error configuration overrides responses. +func TestMockUserInputReader_SetReadError(t *testing.T) { + reader := mocks.NewMockUserInputReader("hello", "world") + ctx := context.Background() + + input1, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "hello", input1) + + customErr := errors.New("simulated error") + reader.SetReadError(customErr) + + _, err = reader.ReadInput(ctx) + require.Error(t, err) + assert.Equal(t, customErr, err) +} + +// TestMockUserInputReader_SetResponses_ResetsIndex verifies SetResponses resets sequence position. +func TestMockUserInputReader_SetResponses_ResetsIndex(t *testing.T) { + reader := mocks.NewMockUserInputReader("a", "b") + ctx := context.Background() + + reader.ReadInput(ctx) + reader.ReadInput(ctx) + + reader.SetResponses("x", "y", "z") + + input1, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "x", input1) + + input2, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "y", input2) +} + +// TestMockUserInputReader_AddResponse verifies appending responses to sequence. +func TestMockUserInputReader_AddResponse(t *testing.T) { + reader := mocks.NewMockUserInputReader("a") + ctx := context.Background() + + input1, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "a", input1) + + reader.AddResponse("b") + + input2, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "b", input2) +} + +// TestMockUserInputReader_AddResponse_ToEmpty verifies appending to empty response list. +func TestMockUserInputReader_AddResponse_ToEmpty(t *testing.T) { + reader := mocks.NewMockUserInputReader() + ctx := context.Background() + + reader.AddResponse("first") + + input, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "first", input) +} + +// TestMockUserInputReader_Clear verifies all state is reset. +func TestMockUserInputReader_Clear(t *testing.T) { + reader := mocks.NewMockUserInputReader("a", "b") + ctx := context.Background() + + reader.ReadInput(ctx) + reader.SetReadError(errors.New("error")) + + reader.Clear() + + input, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "", input) + + assert.Equal(t, 0, reader.GetCallCount()) +} + +// TestMockUserInputReader_Clear_ResetsError verifies error state is cleared. +func TestMockUserInputReader_Clear_ResetsError(t *testing.T) { + reader := mocks.NewMockUserInputReader("hello") + ctx := context.Background() + + reader.SetReadError(errors.New("error")) + reader.Clear() + + input, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "", input) +} + +// TestMockUserInputReader_ConversationFlowWithEmptyInput verifies realistic conversation loop pattern. +func TestMockUserInputReader_ConversationFlowWithEmptyInput(t *testing.T) { + reader := mocks.NewMockUserInputReader("What is Go?", "Tell me more", "") + ctx := context.Background() + + turns := []string{} + for { + input, err := reader.ReadInput(ctx) + require.NoError(t, err) + + if input == "" { + break + } + turns = append(turns, input) + } + + assert.Equal(t, []string{"What is Go?", "Tell me more"}, turns) + assert.Equal(t, 3, reader.GetCallCount()) +} + +// TestMockUserInputReader_ErrorTakesPrecedenceOverResponses verifies error checking before response delivery. +func TestMockUserInputReader_ErrorTakesPrecedenceOverResponses(t *testing.T) { + reader := mocks.NewMockUserInputReader("hello", "world") + ctx := context.Background() + + reader.ReadInput(ctx) + + customErr := errors.New("priority error") + reader.SetReadError(customErr) + + _, err := reader.ReadInput(ctx) + require.Error(t, err) + assert.Equal(t, customErr, err) +} + +// TestMockUserInputReader_ContextErrorTakesPrecedence verifies context error checked before configured error. +func TestMockUserInputReader_ContextErrorTakesPrecedence(t *testing.T) { + reader := mocks.NewMockUserInputReader("hello") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + customErr := errors.New("this should not be returned") + reader.SetReadError(customErr) + + _, err := reader.ReadInput(ctx) + require.Error(t, err) + assert.Equal(t, context.Canceled, err) +} + +// TestMockUserInputReader_MultipleClears verifies Clear can be called multiple times. +func TestMockUserInputReader_MultipleClears(t *testing.T) { + reader := mocks.NewMockUserInputReader("initial") + ctx := context.Background() + + reader.ReadInput(ctx) + reader.Clear() + + reader.SetResponses("second") + reader.ReadInput(ctx) + reader.Clear() + + input, err := reader.ReadInput(ctx) + require.NoError(t, err) + assert.Equal(t, "", input) + assert.Equal(t, 0, reader.GetCallCount()) +} diff --git a/tests/fixtures/workflows/conversation-continue-from.yaml b/tests/fixtures/workflows/conversation-continue-from.yaml index 817c6e62..f9650e32 100644 --- a/tests/fixtures/workflows/conversation-continue-from.yaml +++ b/tests/fixtures/workflows/conversation-continue-from.yaml @@ -17,15 +17,12 @@ states: mode: conversation system_prompt: | You are a helpful assistant. Analyze the request and provide insights. - initial_prompt: | + prompt: | Please analyze this: {{inputs.task}} options: model: claude-haiku-4-5 - conversation: - max_turns: 3 - max_context_tokens: 50000 - strategy: sliding_window + conversation: {} timeout: 60 on_success: refine on_failure: error @@ -37,7 +34,6 @@ states: prompt: | Now please refine and improve your previous response. conversation: - max_turns: 2 continue_from: analyze timeout: 60 on_success: done diff --git a/tests/fixtures/workflows/conversation-error.yaml b/tests/fixtures/workflows/conversation-error.yaml index bd2ca7d0..f70b25b5 100644 --- a/tests/fixtures/workflows/conversation-error.yaml +++ b/tests/fixtures/workflows/conversation-error.yaml @@ -17,14 +17,10 @@ states: provider: claude mode: conversation system_prompt: "You are a reliable assistant. Complete tasks thoroughly." - initial_prompt: "Execute: {{inputs.task}}" + prompt: "Execute: {{inputs.task}}" options: model: claude-haiku-4-5 - conversation: - max_turns: 5 - max_context_tokens: 40000 - strategy: sliding_window - stop_condition: "response contains 'SUCCESS'" + conversation: {} timeout: 45 retry: max_attempts: 3 @@ -43,12 +39,10 @@ states: provider: gemini mode: conversation system_prompt: "Process with caution." - initial_prompt: "Continue from: {{states.conversation_with_retry.Output}}" + prompt: "Continue from: {{states.conversation_with_retry.Output}}" options: model: gemini-pro - conversation: - max_turns: 3 - strategy: sliding_window + conversation: {} timeout: 30 on_success: done on_failure: handle_failure diff --git a/tests/fixtures/workflows/conversation-inject-context.yaml b/tests/fixtures/workflows/conversation-inject-context.yaml index efc5741c..a82c2a66 100644 --- a/tests/fixtures/workflows/conversation-inject-context.yaml +++ b/tests/fixtures/workflows/conversation-inject-context.yaml @@ -1,4 +1,5 @@ -# Feature: F075 — inject_context field in conversation mode +# Feature: F075 — inject_context removed in F083 +# This fixture now tests basic conversation mode name: conversation-inject-context version: "1.0.0" @@ -14,10 +15,8 @@ states: type: agent provider: claude mode: conversation - initial_prompt: "{{inputs.task}}" - conversation: - max_turns: 5 - inject_context: "Additional context for the conversation" + prompt: "{{inputs.task}}" + conversation: {} on_success: done on_failure: error diff --git a/tests/fixtures/workflows/conversation-invalid-continue-from.yaml b/tests/fixtures/workflows/conversation-invalid-continue-from.yaml index 5864734f..6880fd76 100644 --- a/tests/fixtures/workflows/conversation-invalid-continue-from.yaml +++ b/tests/fixtures/workflows/conversation-invalid-continue-from.yaml @@ -17,13 +17,12 @@ states: mode: conversation system_prompt: | You are a helpful assistant. Analyze the request and provide insights. - initial_prompt: | + prompt: | Please analyze this: {{inputs.task}} options: model: claude-haiku-4-5 - conversation: - max_turns: 3 + conversation: {} timeout: 60 on_success: done on_failure: error @@ -35,7 +34,6 @@ states: prompt: | Now please refine your analysis. conversation: - max_turns: 2 continue_from: nonexistent_step timeout: 60 on_success: done diff --git a/tests/fixtures/workflows/conversation-invalid-summarize.yaml b/tests/fixtures/workflows/conversation-invalid-summarize.yaml deleted file mode 100644 index 34f4ec9b..00000000 --- a/tests/fixtures/workflows/conversation-invalid-summarize.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# Feature: C062 — reject summarize strategy (not yet implemented) -name: conversation-invalid-summarize -version: "1.0.0" - -inputs: - - name: task - type: string - required: true - -states: - initial: chat - - chat: - type: agent - provider: claude - mode: conversation - initial_prompt: "{{inputs.task}}" - conversation: - max_turns: 5 - strategy: summarize - on_success: done - on_failure: error - - done: - type: terminal - status: success - - error: - type: terminal - status: failure diff --git a/tests/fixtures/workflows/conversation-invalid-truncate-middle.yaml b/tests/fixtures/workflows/conversation-invalid-truncate-middle.yaml deleted file mode 100644 index e538492b..00000000 --- a/tests/fixtures/workflows/conversation-invalid-truncate-middle.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# Feature: C062 — reject truncate_middle strategy (not yet implemented) -name: conversation-invalid-truncate-middle -version: "1.0.0" - -inputs: - - name: task - type: string - required: true - -states: - initial: chat - - chat: - type: agent - provider: claude - mode: conversation - initial_prompt: "{{inputs.task}}" - conversation: - max_turns: 5 - strategy: truncate_middle - on_success: done - on_failure: error - - done: - type: terminal - status: success - - error: - type: terminal - status: failure diff --git a/tests/fixtures/workflows/conversation-max-turns.yaml b/tests/fixtures/workflows/conversation-max-turns.yaml index 787c7235..f670d71b 100644 --- a/tests/fixtures/workflows/conversation-max-turns.yaml +++ b/tests/fixtures/workflows/conversation-max-turns.yaml @@ -1,5 +1,5 @@ # Feature: F033 -# Max turns limit edge cases and boundary testing +# Conversation mode steps with various providers name: conversation-max-turns version: "1.0.0" @@ -17,12 +17,10 @@ states: provider: claude mode: conversation system_prompt: "Answer briefly." - initial_prompt: "{{inputs.task}}" + prompt: "{{inputs.task}}" options: model: claude-haiku-4-5 - conversation: - max_turns: 1 - strategy: sliding_window + conversation: {} timeout: 30 on_success: zero_max_turns on_failure: error @@ -32,13 +30,10 @@ states: provider: gemini mode: conversation system_prompt: "Provide insights." - initial_prompt: "Elaborate on: {{inputs.task}}" + prompt: "Elaborate on: {{inputs.task}}" options: model: gemini-pro - conversation: - max_turns: 0 # Should use default or unlimited - strategy: sliding_window - stop_condition: "turn_count >= 2" + conversation: {} timeout: 45 on_success: high_turn_limit on_failure: error @@ -48,12 +43,8 @@ states: provider: codex mode: conversation system_prompt: "Discuss thoroughly." - initial_prompt: "Deep dive: {{inputs.task}}" - conversation: - max_turns: 100 - max_context_tokens: 1000000 - strategy: sliding_window - stop_condition: "response contains 'COMPREHENSIVE'" + prompt: "Deep dive: {{inputs.task}}" + conversation: {} timeout: 180 on_success: no_max_turns_specified on_failure: error @@ -63,13 +54,10 @@ states: provider: claude mode: conversation system_prompt: "Continue until satisfied." - initial_prompt: "Final thoughts: {{inputs.task}}" + prompt: "Final thoughts: {{inputs.task}}" options: model: claude-haiku-4-5 - conversation: - # max_turns omitted - should use default behavior - strategy: sliding_window - stop_condition: "turn_count >= 3" + conversation: {} timeout: 60 on_success: done on_failure: error diff --git a/tests/fixtures/workflows/conversation-multiturn.yaml b/tests/fixtures/workflows/conversation-multiturn.yaml index ac15dda7..bc9c22b6 100644 --- a/tests/fixtures/workflows/conversation-multiturn.yaml +++ b/tests/fixtures/workflows/conversation-multiturn.yaml @@ -18,14 +18,11 @@ states: mode: conversation system_prompt: | You are a helpful assistant. Provide clear responses. - initial_prompt: | + prompt: | {{inputs.initial_request}} options: model: claude-haiku-4-5 - conversation: - max_turns: 3 - max_context_tokens: 80000 - strategy: sliding_window + conversation: {} timeout: 60 on_success: second_turn on_failure: error @@ -37,8 +34,6 @@ states: prompt: | Can you elaborate on that response? conversation: - max_turns: 2 - stop_condition: "turn_count >= 5" continue_from: first_turn timeout: 60 on_success: done diff --git a/tests/fixtures/workflows/conversation-parallel.yaml b/tests/fixtures/workflows/conversation-parallel.yaml index 6fc12c13..c4773457 100644 --- a/tests/fixtures/workflows/conversation-parallel.yaml +++ b/tests/fixtures/workflows/conversation-parallel.yaml @@ -27,14 +27,10 @@ states: provider: claude mode: conversation system_prompt: "You are a research assistant specializing in technical analysis." - initial_prompt: "Research: {{inputs.task}}" + prompt: "Research: {{inputs.task}}" options: model: claude-haiku-4-5 - conversation: - max_turns: 5 - max_context_tokens: 60000 - strategy: sliding_window - stop_condition: "response contains 'RESEARCH COMPLETE'" + conversation: {} timeout: 90 on_success: done @@ -43,14 +39,10 @@ states: provider: gemini mode: conversation system_prompt: "You are a research assistant focusing on practical applications." - initial_prompt: "Investigate: {{inputs.task}}" + prompt: "Investigate: {{inputs.task}}" options: model: gemini-pro - conversation: - max_turns: 5 - max_context_tokens: 50000 - strategy: sliding_window - stop_condition: "turn_count >= 4" + conversation: {} timeout: 90 on_success: done @@ -59,11 +51,8 @@ states: provider: codex mode: conversation system_prompt: "You are a research assistant emphasizing code examples." - initial_prompt: "Analyze: {{inputs.task}}" - conversation: - max_turns: 6 - strategy: sliding_window - stop_condition: "response contains 'ANALYSIS DONE'" + prompt: "Analyze: {{inputs.task}}" + conversation: {} timeout: 90 on_success: done @@ -72,7 +61,7 @@ states: provider: claude mode: conversation system_prompt: "You are a synthesis expert. Combine insights from multiple sources." - initial_prompt: | + prompt: | Synthesize these research findings: Claude findings: {{states.claude_convo.output}} @@ -82,10 +71,7 @@ states: Codex findings: {{states.codex_convo.output}} options: model: claude-haiku-4-5 - conversation: - max_turns: 3 - strategy: sliding_window - stop_condition: "response contains 'SYNTHESIS COMPLETE'" + conversation: {} timeout: 60 on_success: done on_failure: error diff --git a/tests/fixtures/workflows/conversation-simple.yaml b/tests/fixtures/workflows/conversation-simple.yaml index d5f9cb8e..632d75c0 100644 --- a/tests/fixtures/workflows/conversation-simple.yaml +++ b/tests/fixtures/workflows/conversation-simple.yaml @@ -19,16 +19,12 @@ states: system_prompt: | You are a helpful assistant. Provide clear and concise responses. Say "COMPLETE" when the task is finished. - initial_prompt: | + prompt: | Please help with this task: {{inputs.task}} options: model: claude-haiku-4-5 - conversation: - max_turns: 5 - max_context_tokens: 50000 - strategy: sliding_window - stop_condition: "response contains 'COMPLETE'" + conversation: {} timeout: 60 on_success: done on_failure: error diff --git a/tests/fixtures/workflows/conversation-stop-condition.yaml b/tests/fixtures/workflows/conversation-stop-condition.yaml index aeed7064..5cdee8f1 100644 --- a/tests/fixtures/workflows/conversation-stop-condition.yaml +++ b/tests/fixtures/workflows/conversation-stop-condition.yaml @@ -1,5 +1,5 @@ # Feature: F033 -# Stop conditions testing with various expression types +# Conversation mode steps with various providers name: conversation-stop-condition version: "1.0.0" @@ -18,13 +18,10 @@ states: mode: conversation system_prompt: | You are a task executor. Complete the task and respond with "COMPLETE" when done. - initial_prompt: "Execute: {{inputs.task}}" + prompt: "Execute: {{inputs.task}}" options: model: claude-haiku-4-5 - conversation: - max_turns: 10 - strategy: sliding_window - stop_condition: "response contains 'COMPLETE'" + conversation: {} timeout: 60 on_success: turn_count_limit on_failure: error @@ -34,13 +31,10 @@ states: provider: gemini mode: conversation system_prompt: "Provide brief answers." - initial_prompt: "Continue task: {{inputs.task}}" + prompt: "Continue task: {{inputs.task}}" options: model: gemini-pro - conversation: - max_turns: 5 - strategy: sliding_window - stop_condition: "turn_count >= 3" + conversation: {} timeout: 60 on_success: token_budget on_failure: error @@ -50,12 +44,8 @@ states: provider: codex mode: conversation system_prompt: "Be concise." - initial_prompt: "Finalize: {{inputs.task}}" - conversation: - max_turns: 20 - max_context_tokens: 5000 - strategy: sliding_window - stop_condition: "total_tokens >= 4500" + prompt: "Finalize: {{inputs.task}}" + conversation: {} timeout: 60 on_success: complex_expression on_failure: error @@ -65,13 +55,10 @@ states: provider: claude mode: conversation system_prompt: "Respond thoughtfully." - initial_prompt: "Analyze: {{inputs.task}}" + prompt: "Analyze: {{inputs.task}}" options: model: claude-haiku-4-5 - conversation: - max_turns: 15 - strategy: sliding_window - stop_condition: "(turn_count >= 5 && response contains 'DONE') || total_tokens >= 10000" + conversation: {} timeout: 90 on_success: done on_failure: error diff --git a/tests/fixtures/workflows/conversation-window.yaml b/tests/fixtures/workflows/conversation-window.yaml index 916d7f77..2c40b6e2 100644 --- a/tests/fixtures/workflows/conversation-window.yaml +++ b/tests/fixtures/workflows/conversation-window.yaml @@ -1,5 +1,5 @@ # Feature: F033 -# Context window management with different strategies +# Conversation mode steps with various providers name: conversation-window version: "1.0.0" @@ -17,13 +17,10 @@ states: provider: claude mode: conversation system_prompt: "You are a helpful assistant. Engage in detailed conversation." - initial_prompt: "Let's discuss: {{inputs.task}}" + prompt: "Let's discuss: {{inputs.task}}" options: model: claude-haiku-4-5 - conversation: - max_turns: 20 - max_context_tokens: 10000 - strategy: sliding_window + conversation: {} timeout: 120 on_success: summarize_test on_failure: error @@ -33,13 +30,10 @@ states: provider: gemini mode: conversation system_prompt: "You are a concise summarizer." - initial_prompt: "Summarize previous discussion on {{inputs.task}}" + prompt: "Summarize previous discussion on {{inputs.task}}" options: model: gemini-pro - conversation: - max_turns: 5 - max_context_tokens: 50000 - strategy: sliding_window + conversation: {} timeout: 60 on_success: truncate_middle_test on_failure: error @@ -49,11 +43,8 @@ states: provider: codex mode: conversation system_prompt: "You are a technical expert." - initial_prompt: "Final analysis of {{inputs.task}}" - conversation: - max_turns: 10 - max_context_tokens: 20000 - strategy: sliding_window + prompt: "Final analysis of {{inputs.task}}" + conversation: {} timeout: 60 on_success: done on_failure: error diff --git a/tests/integration/agents/f082_display_matrix_test.go b/tests/integration/agents/display_matrix_test.go similarity index 99% rename from tests/integration/agents/f082_display_matrix_test.go rename to tests/integration/agents/display_matrix_test.go index aed8e47a..f4e7852c 100644 --- a/tests/integration/agents/f082_display_matrix_test.go +++ b/tests/integration/agents/display_matrix_test.go @@ -275,8 +275,8 @@ func buildDisplayMatrixWorkflow(provider string, format workflow.OutputFormat) * type nopLogger struct{} -func (m *nopLogger) Debug(msg string, fields ...any) {} -func (m *nopLogger) Info(msg string, fields ...any) {} -func (m *nopLogger) Warn(msg string, fields ...any) {} -func (m *nopLogger) Error(msg string, fields ...any) {} +func (m *nopLogger) Debug(msg string, fields ...any) {} +func (m *nopLogger) Info(msg string, fields ...any) {} +func (m *nopLogger) Warn(msg string, fields ...any) {} +func (m *nopLogger) Error(msg string, fields ...any) {} func (m *nopLogger) WithContext(ctx map[string]any) ports.Logger { return m } diff --git a/tests/integration/features/conversation_validation_test.go b/tests/integration/features/conversation_validation_test.go index fed07b61..557815c4 100644 --- a/tests/integration/features/conversation_validation_test.go +++ b/tests/integration/features/conversation_validation_test.go @@ -21,16 +21,6 @@ func TestConversationValidation_RejectsUnimplementedFeatures(t *testing.T) { workflowName string wantErr string }{ - { - name: "summarize_strategy_rejected", - workflowName: "conversation-invalid-summarize", - wantErr: "not yet implemented", - }, - { - name: "truncate_middle_strategy_rejected", - workflowName: "conversation-invalid-truncate-middle", - wantErr: "not yet implemented", - }, { name: "continue_from_invalid_step_reference", workflowName: "conversation-invalid-continue-from", diff --git a/tests/integration/features/interactive_conversation_test.go b/tests/integration/features/interactive_conversation_test.go new file mode 100644 index 00000000..48782a83 --- /dev/null +++ b/tests/integration/features/interactive_conversation_test.go @@ -0,0 +1,171 @@ +//go:build integration + +// Feature: F083 +// +// Functional tests for Interactive Conversation Mode (Breaking Change). +// These tests validate end-to-end behavior of the simplified conversation system: +// - Removed automated loop fields (max_turns, stop_condition, inject_context, strategy, max_context_tokens) +// - Added UserInputReader port for interactive user input +// - New StopReasonUserExit for user-initiated exit +// - Simplified ConversationConfig with only ContinueFrom retained +// +// Test Strategy: +// - YAML validation: Verify removed fields are rejected with clear error messages +// - Minimal workflows: Verify stripped-down conversation config works +// - Dry run: Verify conversation mode shows correctly without stdin interaction +// - Cross-step resume: Verify continue_from references work in multi-step workflows + +package features_test + +import ( + "bytes" + "testing" + + "github.com/awf-project/cli/internal/interfaces/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestConversationYAML_MinimalValidConfig verifies that a minimal conversation +// workflow (with only ContinueFrom in conversation config) parses successfully +// and is recognized as valid. +func TestConversationYAML_MinimalValidConfig(t *testing.T) { + // Feature: F083 — ConversationConfig simplified to only ContinueFrom + t.Setenv("AWF_WORKFLOWS_PATH", "../../fixtures/workflows") + + tests := []struct { + name string + workflowName string + }{ + { + name: "simple conversation without continuation", + workflowName: "conversation-simple", + }, + { + name: "multiturn with continue_from", + workflowName: "conversation-multiturn", + }, + { + name: "valid continue_from reference", + workflowName: "conversation-continue-from", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := cli.NewRootCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"validate", tt.workflowName}) + + err := cmd.Execute() + + require.NoError(t, err, "workflow should validate successfully") + output := buf.String() + assert.Contains(t, output, "valid", "output should indicate validation success") + }) + } +} + +// TestConversationDryRun_ShowsConfigWithoutStdin verifies that dry-run mode +// displays conversation configuration without requiring stdin interaction. +// This validates that ConversationManager wiring is complete. +func TestConversationDryRun_ShowsConfigWithoutStdin(t *testing.T) { + // Feature: F083 — ConversationManager wired in run.go; dry-run avoids stdin + t.Setenv("AWF_WORKFLOWS_PATH", "../../fixtures/workflows") + + tmpDir := t.TempDir() + + cmd := cli.NewRootCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{ + "run", + "conversation-simple", + "--dry-run", + "--input", "task=test dry run", + "--storage", tmpDir, + }) + + err := cmd.Execute() + + require.NoError(t, err, "dry-run should execute without error") + output := buf.String() + + // Verify dry-run mode indicator + assert.Contains(t, output, "Dry Run", "should display dry-run mode") + + // Verify step is shown + assert.Contains(t, output, "review", "should display conversation step name") +} + +// TestConversationContinueFrom_CrossStepResume verifies that a second step +// with continue_from successfully references a prior step's conversation state. +// This validates the continue_from validation and session resume logic. +func TestConversationContinueFrom_CrossStepResume(t *testing.T) { + // Feature: F083 + F074 — ContinueFrom preserved; validates cross-step resume + t.Setenv("AWF_WORKFLOWS_PATH", "../../fixtures/workflows") + + cmd := cli.NewRootCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"validate", "conversation-multiturn"}) + + err := cmd.Execute() + + require.NoError(t, err, "multiturn workflow with continue_from should validate") + output := buf.String() + assert.Contains(t, output, "valid") +} + +// TestConversationValidation_InvalidContinueFrom_Rejected verifies that +// continue_from references to non-existent steps are rejected during validation. +func TestConversationValidation_InvalidContinueFrom_Rejected(t *testing.T) { + // Feature: F083 + F074 — Validation rejects invalid continue_from references + t.Setenv("AWF_WORKFLOWS_PATH", "../../fixtures/workflows") + + cmd := cli.NewRootCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"validate", "conversation-invalid-continue-from"}) + + err := cmd.Execute() + + require.Error(t, err, "validation should reject invalid continue_from") + output := buf.String() + assert.Contains(t, output, "continue_from", + "error message should reference continue_from field") +} + +// TestConversationList_IncludesAllWorkflows verifies that conversation workflows +// are discoverable in the list command after F083 changes. +func TestConversationList_IncludesAllWorkflows(t *testing.T) { + // Feature: F083 — Conversation workflows remain discoverable + t.Setenv("AWF_WORKFLOWS_PATH", "../../fixtures/workflows") + + cmd := cli.NewRootCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"list"}) + + err := cmd.Execute() + + require.NoError(t, err, "list command should execute successfully") + output := buf.String() + + // Verify key conversation workflows are listed + conversationWorkflows := []string{ + "conversation-simple", + "conversation-multiturn", + "conversation-continue-from", + } + + for _, wf := range conversationWorkflows { + assert.Contains(t, output, wf, "should list %s workflow", wf) + } +} diff --git a/tests/integration/features/session_resume_test.go b/tests/integration/features/session_resume_test.go index 9b8fafaf..9eb91be6 100644 --- a/tests/integration/features/session_resume_test.go +++ b/tests/integration/features/session_resume_test.go @@ -6,6 +6,7 @@ package features_test import ( "context" + "io" "slices" "testing" @@ -29,7 +30,7 @@ func TestClaudeSessionResume_MultiTurn(t *testing.T) { state := workflow.NewConversationState("You are a code reviewer") options := map[string]any{"system_prompt": "You are a code reviewer"} - result, err := provider.ExecuteConversation(context.Background(), state, "Review this code", options) + result, err := provider.ExecuteConversation(context.Background(), state, "Review this code", options, io.Discard, io.Discard) require.NoError(t, err) require.NotNil(t, result) @@ -48,7 +49,7 @@ func TestClaudeSessionResume_MultiTurn(t *testing.T) { nil, ) - result2, err := provider.ExecuteConversation(context.Background(), result.State, "What was issue #1?", options) + result2, err := provider.ExecuteConversation(context.Background(), result.State, "What was issue #1?", options, io.Discard, io.Discard) require.NoError(t, err) require.NotNil(t, result2) @@ -130,14 +131,14 @@ func TestAllProviders_SessionResumeFlags(t *testing.T) { // Turn 1: extract session ID mockExec.SetOutput(tt.turn1Output, nil) state := workflow.NewConversationState("") - result, err := provider.ExecuteConversation(context.Background(), state, "Review code", nil) + result, err := provider.ExecuteConversation(context.Background(), state, "Review code", nil, io.Discard, io.Discard) require.NoError(t, err) assert.Equal(t, tt.wantID, result.State.SessionID) // Turn 2: verify resume args mockExec.Clear() mockExec.SetOutput(tt.turn2Output, nil) - _, err = provider.ExecuteConversation(context.Background(), result.State, "Fix issue", nil) + _, err = provider.ExecuteConversation(context.Background(), result.State, "Fix issue", nil, io.Discard, io.Discard) require.NoError(t, err) calls := mockExec.GetCalls() @@ -190,7 +191,7 @@ func TestSessionResume_GracefulFallback(t *testing.T) { provider := tt.newProvider(mockExec) state := workflow.NewConversationState("") - result, err := provider.ExecuteConversation(context.Background(), state, "test prompt", nil) + result, err := provider.ExecuteConversation(context.Background(), state, "test prompt", nil, io.Discard, io.Discard) require.NoError(t, err, "extraction failure must not cause error") require.NotNil(t, result) @@ -212,7 +213,7 @@ func TestSessionID_PersistsThroughThreeTurns(t *testing.T) { []byte(`{"session_id":"sess_persistent","result":"response"}`), nil, ) - result, err := provider.ExecuteConversation(context.Background(), state, prompt, nil) + result, err := provider.ExecuteConversation(context.Background(), state, prompt, nil, io.Discard, io.Discard) require.NoError(t, err) assert.Equal(t, "sess_persistent", result.State.SessionID) diff --git a/tests/integration/validation/validation_providers_test.go b/tests/integration/validation/validation_providers_test.go index 6b126d2c..6aabe5ff 100644 --- a/tests/integration/validation/validation_providers_test.go +++ b/tests/integration/validation/validation_providers_test.go @@ -21,6 +21,7 @@ package validation_test import ( "context" + "io" "testing" "time" @@ -70,7 +71,7 @@ func TestClaudeProvider_Execute_WithTypeCheckedOptions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := provider.Execute(ctx, tt.prompt, tt.options) + result, err := provider.Execute(ctx, tt.prompt, tt.options, io.Discard, io.Discard) require.NoError(t, err, "Execute should succeed with type-checked options") require.NotNil(t, result) @@ -138,7 +139,7 @@ func TestCodexProvider_ExecuteConversation_WithTypeCheckedOptions(t *testing.T) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := provider.ExecuteConversation(ctx, state, tt.prompt, tt.options) + result, err := provider.ExecuteConversation(ctx, state, tt.prompt, tt.options, io.Discard, io.Discard) require.NoError(t, err, "ExecuteConversation should succeed with type-checked options") require.NotNil(t, result) @@ -188,7 +189,7 @@ func TestGeminiProvider_ExecuteConversation_WithTypeCheckedOptions(t *testing.T) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := provider.ExecuteConversation(ctx, state, tt.prompt, tt.options) + result, err := provider.ExecuteConversation(ctx, state, tt.prompt, tt.options, io.Discard, io.Discard) // Gemini CLI may fail at runtime (e.g., deprecated model names, non-JSON output). // The test verifies that the provider handles options correctly without panicking. @@ -234,7 +235,7 @@ func TestClaudeProvider_Execute_EmptyAndNilOptions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := provider.Execute(ctx, "What is 2+2?", tt.options) + result, err := provider.Execute(ctx, "What is 2+2?", tt.options, io.Discard, io.Discard) require.NoError(t, err, "Should handle nil/empty options gracefully") require.NotNil(t, result) @@ -274,7 +275,7 @@ func TestSharedHelpers_TokenEstimation(t *testing.T) { t.Skipf("%s CLI not installed, skipping", tt.provider.Name()) } - result, err := tt.provider.Execute(ctx, longPrompt, nil) + result, err := tt.provider.Execute(ctx, longPrompt, nil, io.Discard, io.Discard) require.NoError(t, err) require.NotNil(t, result) @@ -308,7 +309,7 @@ func TestConversationState_Cloning(t *testing.T) { TotalTokens: 100, } - result, err := provider.ExecuteConversation(ctx, initialState, "Second message", nil) + result, err := provider.ExecuteConversation(ctx, initialState, "Second message", nil, io.Discard, io.Discard) require.NoError(t, err) require.NotNil(t, result) @@ -356,7 +357,7 @@ func TestClaudeProvider_Execute_InvalidOptionTypes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Type-checked helpers should ignore wrong types gracefully - result, err := provider.Execute(ctx, "Test prompt", tt.options) + result, err := provider.Execute(ctx, "Test prompt", tt.options, io.Discard, io.Discard) // Should either succeed (ignoring bad options) or return clear error if err != nil { @@ -380,7 +381,7 @@ func TestCodexProvider_ExecuteConversation_EmptyPrompt(t *testing.T) { Turns: []workflow.Turn{}, } - result, err := provider.ExecuteConversation(ctx, state, "", nil) + result, err := provider.ExecuteConversation(ctx, state, "", nil, io.Discard, io.Discard) require.Error(t, err, "Should reject empty prompt") assert.Nil(t, result) @@ -395,7 +396,7 @@ func TestGeminiProvider_ExecuteConversation_NilState(t *testing.T) { ctx := context.Background() - result, err := provider.ExecuteConversation(ctx, nil, "Test prompt", nil) + result, err := provider.ExecuteConversation(ctx, nil, "Test prompt", nil, io.Discard, io.Discard) require.Error(t, err, "Should reject nil state") assert.Nil(t, result) @@ -430,7 +431,7 @@ func TestAllProviders_ContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately - result, err := tt.provider.Execute(ctx, "Test prompt", nil) + result, err := tt.provider.Execute(ctx, "Test prompt", nil, io.Discard, io.Discard) require.Error(t, err, "Should fail on cancelled context") assert.Nil(t, result) @@ -470,7 +471,7 @@ func TestAllProviders_ContextTimeout(t *testing.T) { time.Sleep(10 * time.Millisecond) // Ensure timeout - result, err := tt.provider.Execute(ctx, "Test prompt that will timeout", nil) + result, err := tt.provider.Execute(ctx, "Test prompt that will timeout", nil, io.Discard, io.Discard) require.Error(t, err, "Should fail on timeout") assert.Nil(t, result) @@ -490,7 +491,7 @@ func TestIntegration_MultiTurnConversation_WithTokenEstimation(t *testing.T) { } // Turn 1 - result1, err := provider.ExecuteConversation(ctx, state, "Write hello world in Go", nil) + result1, err := provider.ExecuteConversation(ctx, state, "Write hello world in Go", nil, io.Discard, io.Discard) require.NoError(t, err) require.NotNil(t, result1) state = result1.State @@ -500,7 +501,7 @@ func TestIntegration_MultiTurnConversation_WithTokenEstimation(t *testing.T) { tokens1 := state.TotalTokens // Turn 2 - result2, err := provider.ExecuteConversation(ctx, state, "Now add error handling", nil) + result2, err := provider.ExecuteConversation(ctx, state, "Now add error handling", nil, io.Discard, io.Discard) require.NoError(t, err) require.NotNil(t, result2) state = result2.State @@ -510,7 +511,7 @@ func TestIntegration_MultiTurnConversation_WithTokenEstimation(t *testing.T) { tokens2 := state.TotalTokens // Turn 3 - result3, err := provider.ExecuteConversation(ctx, state, "Add tests", nil) + result3, err := provider.ExecuteConversation(ctx, state, "Add tests", nil, io.Discard, io.Discard) require.NoError(t, err) require.NotNil(t, result3) state = result3.State @@ -548,7 +549,7 @@ func TestIntegration_JSONParsing_SharedHelper(t *testing.T) { "output_format": "json", } - result, err := tt.provider.Execute(ctx, tt.prompt, options) + result, err := tt.provider.Execute(ctx, tt.prompt, options, io.Discard, io.Discard) // Provider CLI may fail at runtime (e.g., deprecated models, auth issues, // non-JSON output). The test verifies parsing works when execution succeeds. if err != nil { @@ -579,12 +580,12 @@ func TestIntegration_ProviderSpecificValidation_Preserved(t *testing.T) { ctx := context.Background() // Valid model alias - result, err := provider.Execute(ctx, "Test", map[string]any{"model": "haiku"}) + result, err := provider.Execute(ctx, "Test", map[string]any{"model": "haiku"}, io.Discard, io.Discard) require.NoError(t, err) require.NotNil(t, result) // Invalid model alias should be handled gracefully - result2, err := provider.Execute(ctx, "Test", map[string]any{"model": "invalid-model-xyz"}) + result2, err := provider.Execute(ctx, "Test", map[string]any{"model": "invalid-model-xyz"}, io.Discard, io.Discard) // May succeed or fail depending on provider, but should not panic if err != nil { assert.NotEmpty(t, err.Error()) @@ -604,7 +605,7 @@ func TestIntegration_ProviderSpecificValidation_Preserved(t *testing.T) { // Language option should still work result, err := provider.ExecuteConversation(ctx, state, "Write code", - map[string]any{"language": "python"}) + map[string]any{"language": "python"}, io.Discard, io.Discard) require.NoError(t, err) require.NotNil(t, result) @@ -621,7 +622,7 @@ func TestIntegration_ProviderSpecificValidation_Preserved(t *testing.T) { // Valid Gemini model — CLI may fail at runtime (deprecated model names, auth). result, err := provider.ExecuteConversation(ctx, state, "Test", - map[string]any{"model": "gemini-pro"}) + map[string]any{"model": "gemini-pro"}, io.Discard, io.Discard) if err != nil { t.Logf("Gemini CLI execution failed (expected in CI): %v", err) return @@ -646,7 +647,7 @@ func TestBackwardCompatibility_ExistingWorkflows(t *testing.T) { "max_tokens": 1000, } - result, err := provider.Execute(ctx, "Test task", options) + result, err := provider.Execute(ctx, "Test task", options, io.Discard, io.Discard) require.NoError(t, err, "Should work with agent-simple fixture options") require.NotNil(t, result) @@ -666,7 +667,7 @@ func TestBackwardCompatibility_ExistingWorkflows(t *testing.T) { "max_tokens": 2000, } - result, err := provider.ExecuteConversation(ctx, state, "First turn", options) + result, err := provider.ExecuteConversation(ctx, state, "First turn", options, io.Discard, io.Discard) require.NoError(t, err, "Should work with conversation fixture options") require.NotNil(t, result) @@ -688,7 +689,7 @@ func TestPerformance_NoRegressionFromHelpers(t *testing.T) { prompt := "What is 2+2?" start := time.Now() - result, err := provider.Execute(ctx, prompt, nil) + result, err := provider.Execute(ctx, prompt, nil, io.Discard, io.Discard) elapsed := time.Since(start) require.NoError(t, err) @@ -720,7 +721,7 @@ func TestRegression_AllOptionTypesCombined(t *testing.T) { } result, err := provider.ExecuteConversation(ctx, state, - "Write comprehensive test", options) + "Write comprehensive test", options, io.Discard, io.Discard) require.NoError(t, err, "Should handle all option types correctly") require.NotNil(t, result)