diff --git a/AGENTS.md b/AGENTS.md index 213fdc504..102d9aa0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -505,14 +505,14 @@ AgentPool maintains two queue systems because native and non-native agents use d Native agents drive execution through `RunExecutor`, which calls `agent_run.next(node)` in a loop. The bare `async for node in agent_run:` pattern does not fire `after_node_run` hooks, so `"when_idle"` messages would never drain. `RunExecutor` avoids this by using explicit `next()` calls. -**Non-native agents** (ACP, ClaudeCode, AGUI) do not use PydanticAI's agent loop. They communicate through subprocess JSON-RPC, Claude SDK, or HTTP/SSE. These agents use `TurnRunner`, which preserves the manual queue system: +**Non-native agents** (ACP, ClaudeCode, AGUI) do not use PydanticAI's agent loop. They communicate through subprocess JSON-RPC, Claude SDK, or HTTP/SSE. These agents continue using `LegacyTurnRunner`, which preserves the manual queue system: - `_post_turn_injections` for immediate injections. - `_post_turn_prompts` for follow-up prompts. - `_process_queued_work()` and `_trigger_auto_resume()` for the auto-resume loop. - `SessionState.turn_lock` for turn serialization. -`TurnRunner` creates `RunHandle` instances and registers them in `SessionController._runs` just like native runs. This gives the pool a unified view of all active execution. +`LegacyTurnRunner` creates `RunHandle` instances and registers them in `SessionController._runs` just like native runs. This gives the pool a unified view of all active execution. #### RunHandle Lifecycle @@ -560,7 +560,7 @@ The `RunExecutor` runs PydanticAI iteration in a background task and pushes even **For all agents**, `inject()` and `consume()` handle tool result augmentation. When a tool finishes, `after_tool_execute` hooks call `consume()` to inject additional context into the conversation. If no tool runs, `flush_pending_to_queue()` moves unconsumed injections into the queued prompts. -**For non-native agents**, `queue()` and `pop_queued()` also handle follow-up prompts after a turn ends. `TurnRunner` drains these queues through `_process_queued_work()`. +**For non-native agents**, `queue()` and `pop_queued()` also handle follow-up prompts after a turn ends. `LegacyTurnRunner` drains these queues through `_process_queued_work()`. **For native agents**, the follow-up prompt queue (`queue()` / `pop_queued()`) is replaced by PydanticAI's `PendingMessageDrainCapability`. `inject()` / `consume()` remain in use for tool augmentation. diff --git a/docs/rfcs/RFC-0001-unified-run-tracking.md b/docs/rfcs/RFC-0001-unified-run-tracking.md index 6115b94fc..b4ab3e4e2 100644 --- a/docs/rfcs/RFC-0001-unified-run-tracking.md +++ b/docs/rfcs/RFC-0001-unified-run-tracking.md @@ -91,7 +91,7 @@ AgentPool's session orchestration lives in `src/agentpool/orchestrator/core.py`, **Description**: - Phase 1: Build `RunHandle` managed by `SessionPool._runs` for all agent types, keeping existing manual queues -- Phase 2: Migrate native agents to PydanticAI `enqueue()`, unify non-native agents into `TurnRunner` +- Phase 2: Migrate native agents to PydanticAI `enqueue()`, extract `LegacyTurnRunner` for non-native agents **Advantages**: - **Lower risk**: Phase 1 validates run tracking without changing execution semantics @@ -104,7 +104,7 @@ AgentPool's session orchestration lives in `src/agentpool/orchestrator/core.py`, **Disadvantages**: - **Two-phase complexity**: Requires maintaining both old and new paths during transition - **Timeline**: Takes longer than a single-phase approach -- **Legacy code**: Non-native agent queue logic persists in `TurnRunner` (necessary; non-native agents cannot use PydanticAI) +- **Legacy code**: `LegacyTurnRunner` persists indefinitely for non-native agents **Evaluation Against Criteria**: @@ -217,7 +217,7 @@ AgentPool's session orchestration lives in `src/agentpool/orchestrator/core.py`, - Sets foundation for future pool-level orchestration features (load balancing, circuit breakers, monitoring) **Acknowledged trade-offs**: -- Non-native agent queue logic persists in `TurnRunner` (necessary; non-native agents cannot use PydanticAI) +- `LegacyTurnRunner` persists indefinitely for non-native agents (necessary; non-native agents cannot use PydanticAI) - Two queue systems create some cognitive overhead (mitigated by clear documentation and agent-type-aware routing) - Phase 2 requires a successful event mapping prototype before proceeding @@ -243,7 +243,8 @@ AgentPool's session orchestration lives in `src/agentpool/orchestrator/core.py`, │ │ │ │ │ │ │ │ │ receive_request(session_id, content, priority) │ │ │ │ │ │ ├─ Native agent? ──► _create_run() or enqueue() │ │ │ - │ │ │ └─ Non-native? ───► TurnRunner.inject_prompt() │ │ │ +│ │ │ └─ Non-native? ───► TurnRunner.inject_prompt() │ │ │ +│ │ │ (LegacyTurnRunner in P2) │ │ │ │ │ │ │ │ │ │ │ │ _create_run() → RunHandle → add to _runs │ │ │ │ │ │ _cleanup_run() → remove from _runs │ │ │ @@ -443,9 +444,10 @@ run task finally block: - [ ] Map PydanticAI events to AgentPool EventBus - [ ] Preserve isolated `agent_iteration_task` pattern -2. **TurnRunner Unification** - - [x] Non-native queue logic already in `TurnRunner`; verified integration with `SessionPool._runs` - - [x] `turn_lock` preserved for turn serialization +2. **LegacyTurnRunner Extraction** + - [ ] Extract non-native queue logic into `LegacyTurnRunner` + - [ ] Ensure `LegacyTurnRunner` integrates with `SessionPool._runs` + - [ ] Keep `turn_lock` for turn serialization 3. **Native Agent Queue Migration** - [ ] Remove manual follow-up prompt queue for native agents diff --git a/openspec/changes/archive/2026-06-05-acp-elicitation/tasks.md b/openspec/changes/archive/2026-06-05-acp-elicitation/tasks.md deleted file mode 100644 index f2c3d2b58..000000000 --- a/openspec/changes/archive/2026-06-05-acp-elicitation/tasks.md +++ /dev/null @@ -1,40 +0,0 @@ -## 1. ACP Schema - Elicitation Types - -- [x] 1.1 Create `src/acp/schema/elicitation.py` with `ElicitationCreateRequest`, `ElicitationCreateResponse`, `ElicitationCompleteNotification`, `URLElicitationRequiredError` -- [x] 1.2 Add `ElicitationCapabilities(form: bool, url: bool)` and `ClientCapabilities.elicitation` field to `src/acp/schema/capabilities.py` -- [x] 1.3 Add `ElicitationCreateRequest` to `AgentRequest` union in `src/acp/schema/agent_requests.py` -- [x] 1.4 Add `ElicitationCreateResponse` to `ClientResponse` union in `src/acp/schema/client_responses.py` -- [x] 1.5 Add `ElicitationCompleteNotification` to `AgentNotification` union in `src/acp/schema/notifications.py` -- [x] 1.6 Add `"elicitation/create"` to `ClientMethod` literal in `src/acp/schema/messages.py` -- [x] 1.7 Update `src/acp/schema/__init__.py` exports with all new types - -## 2. ACP Protocol - Client Methods & Routing - -- [x] 2.1 Add `elicitation_create()` method to `Client` protocol in `src/acp/client/protocol.py` -- [x] 2.2 Add `elicitation_create()` convenience method to `ACPRequests` in `src/acp/agent/acp_requests.py` -- [x] 2.3 Add `"elicitation/create"` routing in `ClientSideConnection._handle_client_method()` in `src/acp/client/connection.py` - -## 3. ACP Protocol - Client Implementations - -- [x] 3.1 Implement `elicitation_create()` in `DefaultACPClient` (auto-accept form, decline URL) -- [x] 3.2 Implement `elicitation_create()` in `HeadlessACPClient` (auto-accept form, decline URL) -- [x] 3.3 Implement `elicitation_create()` in `NoOpClient` (decline all) - -## 4. ACP Server - Input Provider Rewrite - -- [x] 4.1 Add capability check to `ACPInputProvider.get_elicitation()` — detect `client_capabilities.elicitation` -- [x] 4.2 Implement form-mode elicitation path using `elicitation_create` with `requested_schema` from `to_mcp_schema()` -- [x] 4.3 Implement URL-mode elicitation path using `elicitation_create` with `url` + `elicitation_id` -- [x] 4.4 Implement response mapping: `ElicitationCreateResponse` → internal `ElicitResult` -- [x] 4.5 Preserve existing `request_permission` fallback path for clients without elicitation capability - -## 5. Notification Routing - -- [x] 5.1 Add `ElicitationCompleteNotification` routing in `ClientSideConnection` notification handling -- [x] 5.2 Add `send_elicitation_complete()` convenience method to `ACPNotifications` - -## 6. Verification - -- [x] 6.1 Run `uv run ruff check src/acp/ src/agentpool_server/acp_server/` — no errors -- [x] 6.2 Run `uv run mypy src/acp/ src/agentpool_server/acp_server/` — no type errors -- [x] 6.3 Run `uv run pytest tests/ -k "acp"` — existing tests pass diff --git a/openspec/changes/archive/2026-06-05-acp-turn-complete-compat/tasks.md b/openspec/changes/archive/2026-06-05-acp-turn-complete-compat/tasks.md index 0818bd844..b2c7d9620 100644 --- a/openspec/changes/archive/2026-06-05-acp-turn-complete-compat/tasks.md +++ b/openspec/changes/archive/2026-06-05-acp-turn-complete-compat/tasks.md @@ -1,42 +1,42 @@ ## 1. Schema Changes -- [x] 1.1 Add `turn_complete: bool | None = False` field to `ClientCapabilities` in `src/acp/schema/capabilities.py` -- [x] 1.2 Update `ClientCapabilities.create()` factory method to accept `turn_complete` parameter -- [x] 1.3 Verify `ClientCapabilities` serialization/deserialization handles the new field correctly +- [ ] 1.1 Add `turn_complete: bool | None = False` field to `ClientCapabilities` in `src/acp/schema/capabilities.py` +- [ ] 1.2 Update `ClientCapabilities.create()` factory method to accept `turn_complete` parameter +- [ ] 1.3 Verify `ClientCapabilities` serialization/deserialization handles the new field correctly ## 2. Capability Negotiation -- [x] 2.1 Update `AgentPoolACPAgent.initialize()` to only advertise `turn_complete=True` when `client_capabilities.turn_complete` is truthy -- [x] 2.2 Ensure `AgentPoolACPAgent.initialize()` stores `client_capabilities` for later use (if not already stored) +- [ ] 2.1 Update `AgentPoolACPAgent.initialize()` to only advertise `turn_complete=True` when `client_capabilities.turn_complete` is truthy +- [ ] 2.2 Ensure `AgentPoolACPAgent.initialize()` stores `client_capabilities` for later use (if not already stored) ## 3. ACPEventConverter Changes -- [x] 3.1 Add `client_supports_turn_complete: bool = False` parameter to `ACPEventConverter.__init__` in `src/agentpool_server/acp_server/event_converter.py` -- [x] 3.2 Update `StreamCompleteEvent` branch in `convert()` to only yield `TurnCompleteUpdate` when `self.client_supports_turn_complete` is True -- [x] 3.3 Update `reset()` to preserve the `client_supports_turn_complete` flag across resets +- [ ] 3.1 Add `client_supports_turn_complete: bool = False` parameter to `ACPEventConverter.__init__` in `src/agentpool_server/acp_server/event_converter.py` +- [ ] 3.2 Update `StreamCompleteEvent` branch in `convert()` to only yield `TurnCompleteUpdate` when `self.client_supports_turn_complete` is True +- [ ] 3.3 Update `reset()` to preserve the `client_supports_turn_complete` flag across resets ## 4. Legacy Session Path Updates -- [x] 4.1 Update `ACPSession.process_prompt()` in `src/agentpool_server/acp_server/session.py` to pass `client_supports_turn_complete` when creating `ACPEventConverter` -- [x] 4.2 Derive the flag from `self.client_capabilities.turn_complete` +- [ ] 4.1 Update `ACPSession.process_prompt()` in `src/agentpool_server/acp_server/session.py` to pass `client_supports_turn_complete` when creating `ACPEventConverter` +- [ ] 4.2 Derive the flag from `self.client_capabilities.turn_complete` ## 5. SessionPool Path Updates -- [x] 5.1 Update `ACPProtocolHandler.__init__` to store `client_capabilities` (or derive a boolean flag) -- [x] 5.2 Update `ACPProtocolHandler.handle_prompt()` to block `PromptResponse` until run completion when client does NOT support `turn_complete` -- [x] 5.3 Use `asyncio.wait_for()` with a timeout (e.g., 60s) when awaiting run completion to prevent deadlocks -- [x] 5.4 Update `ACPProtocolHandler._event_consumer_loop()` to pass `client_supports_turn_complete` when creating per-session `ACPEventConverter` +- [ ] 5.1 Update `ACPProtocolHandler.__init__` to store `client_capabilities` (or derive a boolean flag) +- [ ] 5.2 Update `ACPProtocolHandler.handle_prompt()` to block `PromptResponse` until run completion when client does NOT support `turn_complete` +- [ ] 5.3 Use `asyncio.wait_for()` with a timeout (e.g., 60s) when awaiting run completion to prevent deadlocks +- [ ] 5.4 Update `ACPProtocolHandler._event_consumer_loop()` to pass `client_supports_turn_complete` when creating per-session `ACPEventConverter` ## 6. Testing -- [x] 6.1 Add test for `ClientCapabilities` with `turn_complete=True` and `turn_complete=False` -- [x] 6.2 Add test for `AgentPoolACPAgent.initialize()` advertising `turn_complete` only when client supports it -- [x] 6.3 Add test for `ACPEventConverter` emitting `TurnCompleteUpdate` only when flag is True -- [x] 6.4 Add test for `ACPProtocolHandler.handle_prompt()` blocking behavior for legacy clients -- [x] 6.5 Add test for `ACPProtocolHandler.handle_prompt()` non-blocking behavior for modern clients -- [x] 6.6 Run existing ACP server tests to ensure no regressions +- [ ] 6.1 Add test for `ClientCapabilities` with `turn_complete=True` and `turn_complete=False` +- [ ] 6.2 Add test for `AgentPoolACPAgent.initialize()` advertising `turn_complete` only when client supports it +- [ ] 6.3 Add test for `ACPEventConverter` emitting `TurnCompleteUpdate` only when flag is True +- [ ] 6.4 Add test for `ACPProtocolHandler.handle_prompt()` blocking behavior for legacy clients +- [ ] 6.5 Add test for `ACPProtocolHandler.handle_prompt()` non-blocking behavior for modern clients +- [ ] 6.6 Run existing ACP server tests to ensure no regressions ## 7. Documentation & Cleanup -- [x] 7.1 Update any inline comments or docstrings referencing unconditional `turn_complete` behavior -- [x] 7.2 Verify all modified files pass `ruff check` and type checking +- [ ] 7.1 Update any inline comments or docstrings referencing unconditional `turn_complete` behavior +- [ ] 7.2 Verify all modified files pass `ruff check` and type checking diff --git a/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/.openspec.yaml b/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/.openspec.yaml new file mode 100644 index 000000000..c53ef21aa --- /dev/null +++ b/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/design.md b/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/design.md new file mode 100644 index 000000000..b5f34fdf7 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/design.md @@ -0,0 +1,63 @@ +## Context + +`develop/agentic` (2 commits ahead of base) and `feat/0042` (11 commits ahead of base) diverged from `bcd8ac876`. Both modify the ACP server core files but for orthogonal reasons: + +- **develop/agentic** replaces per-session agent creation with `SessionPool` orchestration, adds `ACPProtocolHandler` as an event consumer bridge, and gates everything behind a `use_session_pool` canary flag. +- **feat/0042** adds subagent delegation (`PromptDelegation` policies), subagent catalog advertisement, foreground child session cancellation, and `ToolCallStart(kind="subagent")` event conversion. + +The rebase must produce a single branch where SessionPool mode and subagent delegation coexist. The key insight is that `ACPProtocolHandler.handle_prompt()` (SessionPool path) currently bypasses `ACPSession.process_prompt()` entirely, so subagent delegation logic must be extracted and made available to both paths. + +## Goals / Non-Goals + +**Goals:** +- Rebase `develop/agentic` onto `feat/0042` with clean history (2 commits replayed on top of 11) +- Resolve all merge conflicts in ACP server files without losing functionality from either branch +- Preserve backward compatibility: legacy path (`use_session_pool=false`) continues to work exactly as in feat/0042 +- Ensure all existing tests pass after rebase; fix or delete tests referencing removed APIs + +**Non-Goals:** +- Port subagent delegation into `ACPProtocolHandler` in this change (tracked separately by `subagent-delegation-session-pool-compat` spec) +- Migrate opencode_server or other protocols to SessionPool +- Add new subagent features beyond what already exists in feat/0042 +- Preserve the `_session_agents` registry — it is intentionally removed by develop/agentic + +## Decisions + +### Decision 1: `acp_agent.py` — keep both `_protocol_handler` and `_catalog_provider` +- **Rationale**: `_protocol_handler` (SessionPool bridge) and `_catalog_provider` (subagent catalog) are orthogonal responsibilities. The class is a dataclass with no `__slots__`, so adding both fields is safe. +- **Alternative considered**: Merge them into a single provider. **Rejected** — they have different lifecycles (protocol handler is optional/config-gated; catalog is always present). + +### Decision 2: Accept develop/agentic's removal of per-session agent APIs +- **Rationale**: `SessionPool` replaces the per-session agent lifecycle. Keeping both systems would create duplicate state and confusion. +- **Migration**: Any code calling `get_or_create_session_agent()` must use `pool.all_agents[agent_name]` directly, or rely on `SessionPool.create_session()`. + +### Decision 3: `event_converter.py` — merge both event handling paths +- **Rationale**: The two branches touch different event types (`StreamCompleteEvent` vs `SpawnSessionStart`) but both modify the converter's state management (`reset()`). We need both `TurnCompleteUpdate` emission AND subagent `ToolCallStart` conversion. +- **Conflict resolution order**: Apply feat/0042's subagent changes first, then layer develop/agentic's `TurnCompleteUpdate` and `UsageUpdate` always-yield logic on top. + +### Decision 4: `session_manager.py` — adopt develop's SessionPool child session path, keep feat/0042's `cancel_session()` +- **Rationale**: `cancel_session()` is a simple delegation method used by foreground child cancellation in `ACPSession`. It does not conflict with SessionPool's child session creation logic. + +### Decision 5: Delete `test_acp_per_session_agent_red_flags.py` +- **Rationale**: The entire file tests `get_or_create_session_agent()`, which is removed. No replacement tests are needed — SessionPool has its own test coverage. + +## Risks / Trade-offs + +- **[Risk]** `ACPSession.process_prompt()` subagent delegation does not work when `use_session_pool=true` because `ACPProtocolHandler.handle_prompt()` bypasses `ACPSession` entirely. + - **Mitigation**: Documented as a known gap. The spec `subagent-delegation-session-pool-compat` tracks the follow-up work. +- **[Risk]** Test coverage for subagent catalog + SessionPool interaction is missing. + - **Mitigation**: Add integration tests in `tests/servers/acp_server/` that verify catalog updates are still sent when SessionPool is active. +- **[Risk]** The rebase may introduce subtle bugs in event ordering (converter state is now mutated by both branches' logic). + - **Mitigation**: Run the snapshot test suite (`test_acp_event_converter_snapshots.py`) before and after; any diff indicates an ordering regression. + +## Migration Plan + +1. **Pre-rebase**: Ensure `feat/0042` branch is clean (all tests pass on current branch). +2. **Rebase**: `git rebase develop/agentic` onto `feat/0042` — resolve 4-file conflicts. +3. **Post-rebase fixes**: Delete `test_acp_per_session_agent_red_flags.py`, fix `test_acp_session_manager_child_session.py` for SessionPool, run full test suite. +4. **Validation**: Run `pytest tests/servers/acp_server/ tests/acp_server/ -v` and confirm green. + +## Open Questions + +- Should `ACPProtocolHandler.handle_prompt()` be extended to support `PromptDelegation` in this change, or deferred? +- Do any opencode_server tests break due to `_session_agents` removal, and if so, should they be fixed here or in a separate change? diff --git a/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/proposal.md b/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/proposal.md new file mode 100644 index 000000000..aba35695d --- /dev/null +++ b/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/proposal.md @@ -0,0 +1,31 @@ +## Why + +The `develop/agentic` branch introduces a major architectural evolution: unified session orchestration via `SessionPool`, `EventBus`-based event routing, capability-based extensions, and pydantic-graph team execution. Meanwhile, `feat/0042` (the current working branch) adds ACP subagent delegation, catalog advertisement, foreground child cancellation, and `ToolCallStart(kind="subagent")` event conversion. + +These two branches diverged from a common base (`bcd8ac876`) and both touch the same core ACP server files (`acp_agent.py`, `session.py`, `event_converter.py`, `session_manager.py`). We need to rebase `develop/agentic` onto `feat/0042` so that the SessionPool orchestration coexists with subagent delegation capabilities. Without this, the project cannot move forward with both features in a single branch. + +## What Changes + +- **Rebase `develop/agentic` onto `feat/0042`**: Replay the 2 commits (orchestrator + race fix) on top of the 11 subagent/delegation commits. +- **Resolve merge conflicts** in 4 ACP server files where both branches made incompatible modifications: + - `acp_agent.py`: Remove per-session agent registry (`_session_agents`, `get_or_create_session_agent`) while keeping subagent catalog provider (`_catalog_provider`). + - `session.py`: Keep subagent delegation policies and foreground child cancellation; adapt to direct pool agent assignment (SessionPool replaces per-session agents). + - `event_converter.py`: Merge `TurnCompleteUpdate` emission (from develop) with subagent `ToolCallStart` conversion (from feat/0042). + - `session_manager.py`: Accept SessionPool-based child session creation while preserving `cancel_session()` method. +- **Update tests**: Rewrite or delete tests referencing removed APIs (`get_or_create_session_agent`, `_session_agents`), fix session manager tests for SessionPool, and add new tests for subagent delegation under SessionPool. +- **BREAKING**: `get_or_create_session_agent()` and `remove_session_agent()` are permanently removed from `AgentPoolACPAgent`. Per-session agents are now managed exclusively by `SessionPool`. + +## Capabilities + +### New Capabilities +- `subagent-delegation-session-pool-compat`: Ensures subagent delegation policies (`auto`/`disable`/`prefer`/`require`) work correctly when the ACP server is running in SessionPool mode (`use_session_pool=true`). Currently, delegation only works in the legacy `ACPSession.process_prompt()` path. + +### Modified Capabilities +- *(none — this is a rebase/integration task with no new spec-level requirements)* + +## Impact + +- **Files affected**: `src/agentpool_server/acp_server/{acp_agent.py,session.py,event_converter.py,session_manager.py,handler.py}`, tests in `tests/servers/acp_server/`, `tests/acp_server/` +- **APIs removed**: `AgentPoolACPAgent.get_or_create_session_agent()`, `AgentPoolACPAgent.remove_session_agent()`, `AgentPoolACPAgent._session_agents` +- **APIs preserved**: `AgentPoolACPAgent.get_subagent_catalog()`, `ACPSession.cancel_session()`, subagent `ToolCallStart` events, `PromptDelegation` handling +- **Test breakage expected**: `tests/servers/acp_server/test_acp_per_session_agent_red_flags.py` (tests removed API), `tests/servers/acp_server/test_acp_session_manager_child_session.py` (SessionPool rewrite), some `tests/servers/opencode_server/` tests (`_session_agents` removal) diff --git a/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/specs/subagent-delegation-session-pool-compat/spec.md b/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/specs/subagent-delegation-session-pool-compat/spec.md new file mode 100644 index 000000000..d74aa2c47 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/specs/subagent-delegation-session-pool-compat/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Subagent delegation works in SessionPool mode +When the ACP server operates with `use_session_pool=true` (SessionPool-backed prompt handling via `ACPProtocolHandler`), subagent delegation policies configured via `PromptDelegation` SHALL still be honored. + +#### Scenario: Auto policy with SessionPool +- **WHEN** a prompt request includes `delegation.policy="auto"` and `use_session_pool=true` +- **THEN** the SessionPool processes the prompt normally through the main agent without blocking subagent tools + +#### Scenario: Disable policy with SessionPool +- **WHEN** a prompt request includes `delegation.policy="disable"` and `use_session_pool=true` +- **THEN** subagent tools are disabled for that turn before the prompt is submitted to the SessionPool + +#### Scenario: Prefer policy with SessionPool +- **WHEN** a prompt request includes `delegation.policy="prefer"` and `use_session_pool=true` and the specified subagent exists in the pool +- **THEN** the prompt is routed directly to the subagent via the SessionPool instead of the main agent + +#### Scenario: Require policy with SessionPool +- **WHEN** a prompt request includes `delegation.policy="require"` and `use_session_pool=true` and the specified subagent does not exist +- **THEN** the system returns a `RequestError` with code `-32602` before invoking the SessionPool diff --git a/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/tasks.md b/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/tasks.md new file mode 100644 index 000000000..34e00da2e --- /dev/null +++ b/openspec/changes/archive/2026-06-05-rebase-agentic-onto-subagent-delegation/tasks.md @@ -0,0 +1,58 @@ +## 1. Pre-Rebase Validation + +- [ ] 1.1 Run `pytest tests/acp_server/ tests/servers/acp_server/` on `feat/0042` and record baseline pass/fail +- [ ] 1.2 Run `pytest tests/acp_server/ tests/servers/acp_server/` on `develop/agentic` and record baseline pass/fail +- [ ] 1.3 Create backup branch: `git branch backup/feat-0042-before-rebase` + +## 2. Rebase Execution + +- [ ] 2.1 Start rebase: `git checkout develop/agentic && git rebase feat/0042` +- [ ] 2.2 Resolve conflict in `src/agentpool_server/acp_server/acp_agent.py` + - Keep `_protocol_handler` field and SessionPool init logic + - Keep `_catalog_provider` field and subagent catalog methods + - Remove `_session_agents`, `_session_agent_locks`, `get_or_create_session_agent()`, `remove_session_agent()`, `cleanup_all_session_agents()` +- [ ] 2.3 Resolve conflict in `src/agentpool_server/acp_server/session.py` + - Keep subagent delegation logic (`delegation` param, `_run_subagent_directly`, `_foreground_children`) + - Keep develop's direct pool agent assignment (no per-session agent creation) + - Keep develop's `client_supports_turn_complete` wiring +- [ ] 2.4 Resolve conflict in `src/agentpool_server/acp_server/event_converter.py` + - Keep feat/0042's `SpawnSessionStart` → `ToolCallStart(kind="subagent")` conversion + - Keep develop's `TurnCompleteUpdate` emission on `StreamCompleteEvent` + - Keep develop's always-yield `UsageUpdate` behavior + - Merge `reset()` changes from both branches +- [ ] 2.5 Resolve conflict in `src/agentpool_server/acp_server/session_manager.py` + - Accept develop's `SessionPool`-based child session creation path + - Keep feat/0042's `cancel_session()` method +- [ ] 2.6 Verify `src/agentpool_server/acp_server/handler.py` applies cleanly (new file, no conflict expected) + +## 3. Post-Rebase Cleanup + +- [ ] 3.1 Delete `tests/servers/acp_server/test_acp_per_session_agent_red_flags.py` (tests removed API) +- [ ] 3.2 Fix `tests/servers/acp_server/test_acp_session_manager_child_session.py` for SessionPool API + - Replace `SessionManager` with `SessionPool` + - Update `pool.sessions` references to `pool._session_pool` +- [ ] 3.3 Fix any opencode_server tests referencing `_session_agents` if they fail +- [ ] 3.4 Run `ruff check src/agentpool_server/acp_server/` and fix any lint errors +- [ ] 3.5 Run `ruff format src/agentpool_server/acp_server/` to normalize formatting + +## 4. Test Validation + +- [ ] 4.1 Run `pytest tests/acp_server/ -v` and fix failures +- [ ] 4.2 Run `pytest tests/servers/acp_server/ -v` and fix failures +- [ ] 4.3 Run snapshot tests: `pytest tests/test_acp_event_converter_snapshots.py -v` + - Update `.ambr` snapshots if changes are intentional +- [ ] 4.4 Run full test suite: `pytest` (or `pytest -m unit` for quick check) +- [ ] 4.5 Verify no type errors: `mypy src/agentpool_server/acp_server/` + +## 5. Functional Verification + +- [ ] 5.1 Verify subagent catalog is still advertised after rebase +- [ ] 5.2 Verify foreground child cancellation still works in legacy mode (`use_session_pool=false`) +- [ ] 5.3 Verify `TurnCompleteUpdate` is emitted when `client_capabilities.turn_complete=True` +- [ ] 5.4 Verify subagent `ToolCallStart` events are still emitted in inline/tool_box mode + +## 6. Finalization + +- [ ] 6.1 Review rebase commit history: `git log --oneline --graph feat/0042..HEAD` +- [ ] 6.2 Ensure commit messages are preserved and meaningful +- [ ] 6.3 Update this change's status to complete in OpenSpec diff --git a/schema/config-schema.json b/schema/config-schema.json index 2950b61a2..3bce4da1c 100644 --- a/schema/config-schema.json +++ b/schema/config-schema.json @@ -53,6 +53,12 @@ "null" ] }, + "metadata": { + "additionalProperties": true, + "description": "Arbitrary metadata for the agent.\n\nCan be used for feature flags, annotations, and other per-agent\nconfiguration that doesn't fit into standard fields.\n\nExample:\n ```yaml\n metadata:\n use_session_pool: true\n ```", + "title": "Agent metadata", + "type": "object" + }, "triggers": { "description": "Event sources that activate this agent / team", "examples": [ @@ -214,6 +220,7 @@ { "discriminator": { "mapping": { + "acp": "#/$defs/AcpMCPServerConfig", "sse": "#/$defs/SSEMCPServerConfig", "stdio": "#/$defs/StdioMCPServerConfig", "streamable-http": "#/$defs/StreamableHTTPMCPServerConfig" @@ -229,6 +236,9 @@ }, { "$ref": "#/$defs/StreamableHTTPMCPServerConfig" + }, + { + "$ref": "#/$defs/AcpMCPServerConfig" } ] } @@ -886,6 +896,20 @@ "title": "ACPAgentWorkerConfig", "type": "object" }, + "ACPConfig": { + "additionalProperties": false, + "description": "ACP protocol-specific configuration.", + "properties": { + "use_session_pool": { + "default": false, + "description": "Whether to use the SessionPool for ACP protocol session management.", + "title": "Use session pool", + "type": "boolean" + } + }, + "title": "ACPConfig", + "type": "object" + }, "ACPPoolServerConfig": { "additionalProperties": false, "description": "Configuration for ACP (Agent Client Protocol) server.", @@ -921,15 +945,41 @@ "title": "Raise exceptions", "type": "boolean" }, - "subagent_display_mode": { - "default": "tool_box", - "description": "How to display nested agent output in ACP clients:\n- \"tool_box\": Displays subagent output in a tool box (current default)\n- \"inline\": Displays subagent output inline with the main agent's text", + "transport": { + "default": "stdio", + "description": "Transport type to use.", "enum": [ - "inline", - "tool_box" + "stdio", + "streamable-http" + ], + "examples": [ + "stdio", + "streamable-http" ], - "title": "Subagent display mode", + "title": "Transport type", "type": "string" + }, + "host": { + "default": "localhost", + "description": "Host to bind server to (streamable-http only).", + "examples": [ + "localhost", + "0.0.0.0", + "127.0.0.1" + ], + "title": "Server host", + "type": "string" + }, + "port": { + "default": 8080, + "description": "Port to listen on (streamable-http only).", + "examples": [ + 8080, + 9000 + ], + "exclusiveMinimum": 0, + "title": "Server port", + "type": "integer" } }, "title": "ACPPoolServerConfig", @@ -1039,6 +1089,12 @@ "null" ] }, + "metadata": { + "additionalProperties": true, + "description": "Arbitrary metadata for the agent.\n\nCan be used for feature flags, annotations, and other per-agent\nconfiguration that doesn't fit into standard fields.\n\nExample:\n ```yaml\n metadata:\n use_session_pool: true\n ```", + "title": "Agent metadata", + "type": "object" + }, "triggers": { "description": "Event sources that activate this agent / team", "examples": [ @@ -1200,6 +1256,7 @@ { "discriminator": { "mapping": { + "acp": "#/$defs/AcpMCPServerConfig", "sse": "#/$defs/SSEMCPServerConfig", "stdio": "#/$defs/StdioMCPServerConfig", "streamable-http": "#/$defs/StreamableHTTPMCPServerConfig" @@ -1215,6 +1272,9 @@ }, { "$ref": "#/$defs/StreamableHTTPMCPServerConfig" + }, + { + "$ref": "#/$defs/AcpMCPServerConfig" } ] } @@ -1552,6 +1612,133 @@ "title": "AGUIAgentWorkerConfig", "type": "object" }, + "AcpMCPServerConfig": { + "additionalProperties": false, + "description": "MCP server using ACP channel transport.\n\nConnects to a server over the existing ACP connection.", + "properties": { + "type": { + "const": "acp", + "default": "acp", + "description": "ACP server configuration.", + "title": "Type", + "type": "string" + }, + "name": { + "default": null, + "description": "Optional name for referencing the server.", + "examples": [ + "my_server", + "api_connector", + "file_handler" + ], + "title": "Server name", + "type": [ + "string", + "null" + ] + }, + "enabled": { + "default": true, + "description": "Whether this server is currently enabled.", + "title": "Server enabled", + "type": "boolean" + }, + "env": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Environment variables to pass to the server process.", + "title": "Environment variables" + }, + "timeout": { + "default": 60.0, + "description": "Timeout for the server process in seconds.", + "examples": [ + 30.0, + 60.0, + 120.0 + ], + "exclusiveMinimum": 0, + "title": "Server timeout", + "type": "number" + }, + "enabled_tools": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "If set, only these tools will be available (whitelist).\nMutually exclusive with disabled_tools.", + "examples": [ + [ + "read_file", + "list_directory" + ], + [ + "search", + "fetch" + ] + ], + "title": "Enabled tools" + }, + "disabled_tools": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Tools to exclude from this server (blacklist). Mutually exclusive with enabled_tools.", + "examples": [ + [ + "delete_file", + "write_file" + ], + [ + "dangerous_tool" + ] + ], + "title": "Disabled tools" + }, + "acp_id": { + "description": "Unique identifier for the ACP-transport MCP server.", + "examples": [ + "uuid-xxx", + "server-123" + ], + "title": "ACP server ID", + "type": "string" + } + }, + "required": [ + "acp_id" + ], + "title": "AcpMCPServerConfig", + "type": "object", + "x-doc-title": "ACP MCP Server" + }, "AgentCliToolConfig": { "additionalProperties": false, "description": "Configuration for agent CLI tool.\n\nExample:\n ```yaml\n tools:\n - type: agent_cli\n ```", @@ -1771,6 +1958,7 @@ "additionalProperties": { "discriminator": { "mapping": { + "acp": "#/$defs/AcpMCPServerConfig", "sse": "#/$defs/SSEMCPServerConfig", "stdio": "#/$defs/StdioMCPServerConfig", "streamable-http": "#/$defs/StreamableHTTPMCPServerConfig" @@ -1786,6 +1974,9 @@ }, { "$ref": "#/$defs/StreamableHTTPMCPServerConfig" + }, + { + "$ref": "#/$defs/AcpMCPServerConfig" } ] }, @@ -5334,6 +5525,12 @@ "null" ] }, + "metadata": { + "additionalProperties": true, + "description": "Arbitrary metadata for the agent.\n\nCan be used for feature flags, annotations, and other per-agent\nconfiguration that doesn't fit into standard fields.\n\nExample:\n ```yaml\n metadata:\n use_session_pool: true\n ```", + "title": "Agent metadata", + "type": "object" + }, "triggers": { "description": "Event sources that activate this agent / team", "examples": [ @@ -5495,6 +5692,7 @@ { "discriminator": { "mapping": { + "acp": "#/$defs/AcpMCPServerConfig", "sse": "#/$defs/SSEMCPServerConfig", "stdio": "#/$defs/StdioMCPServerConfig", "streamable-http": "#/$defs/StreamableHTTPMCPServerConfig" @@ -5510,6 +5708,9 @@ }, { "$ref": "#/$defs/StreamableHTTPMCPServerConfig" + }, + { + "$ref": "#/$defs/AcpMCPServerConfig" } ] } @@ -6189,6 +6390,12 @@ "title": "Use Claude Subscription", "type": "boolean" }, + "dangerously_skip_permissions": { + "default": false, + "description": "Skip all permission checks without prompting.\n\nWhen True, bypasses all tool use confirmation dialogs. Use with caution\nas this allows the agent to execute arbitrary code and make changes\nwithout user approval.", + "title": "Dangerously Skip Permissions", + "type": "boolean" + }, "tools": { "description": "Tools and toolsets to expose to this Claude Code agent via MCP bridge.\n\nSupports both single tools and toolsets. These will be started as an\nin-process MCP server and made available to Claude Code.\n\nDocs: https://phil65.github.io/agentpool/YAML%20Configuration/tool_configuration/", "examples": [ @@ -7288,6 +7495,12 @@ "null" ] }, + "metadata": { + "additionalProperties": true, + "description": "Arbitrary metadata for the agent.\n\nCan be used for feature flags, annotations, and other per-agent\nconfiguration that doesn't fit into standard fields.\n\nExample:\n ```yaml\n metadata:\n use_session_pool: true\n ```", + "title": "Agent metadata", + "type": "object" + }, "triggers": { "description": "Event sources that activate this agent / team", "examples": [ @@ -7449,6 +7662,7 @@ { "discriminator": { "mapping": { + "acp": "#/$defs/AcpMCPServerConfig", "sse": "#/$defs/SSEMCPServerConfig", "stdio": "#/$defs/StdioMCPServerConfig", "streamable-http": "#/$defs/StreamableHTTPMCPServerConfig" @@ -7464,6 +7678,9 @@ }, { "$ref": "#/$defs/StreamableHTTPMCPServerConfig" + }, + { + "$ref": "#/$defs/AcpMCPServerConfig" } ] } @@ -22686,6 +22903,12 @@ "null" ] }, + "metadata": { + "additionalProperties": true, + "description": "Arbitrary metadata for the agent.\n\nCan be used for feature flags, annotations, and other per-agent\nconfiguration that doesn't fit into standard fields.\n\nExample:\n ```yaml\n metadata:\n use_session_pool: true\n ```", + "title": "Agent metadata", + "type": "object" + }, "triggers": { "description": "Event sources that activate this agent / team", "examples": [ @@ -22847,6 +23070,7 @@ { "discriminator": { "mapping": { + "acp": "#/$defs/AcpMCPServerConfig", "sse": "#/$defs/SSEMCPServerConfig", "stdio": "#/$defs/StdioMCPServerConfig", "streamable-http": "#/$defs/StreamableHTTPMCPServerConfig" @@ -22862,6 +23086,9 @@ }, { "$ref": "#/$defs/StreamableHTTPMCPServerConfig" + }, + { + "$ref": "#/$defs/AcpMCPServerConfig" } ] } @@ -24428,6 +24655,12 @@ "codemode" ], "title": "Tool execution mode" + }, + "capabilities": { + "description": "Additional pydantic-ai capabilities to attach to the agent.\n\nCan contain either CapabilityConfig objects (for YAML-loaded capabilities)\nor pre-instantiated AbstractCapability objects (for Python API usage).", + "items": {}, + "title": "Capabilities", + "type": "array" } }, "required": [ @@ -25648,6 +25881,20 @@ "title": "OpenAPI Configuration", "type": "object" }, + "OpenCodeConfig": { + "additionalProperties": false, + "description": "OpenCode protocol-specific configuration.", + "properties": { + "use_session_pool": { + "default": false, + "description": "Whether to use the SessionPool for OpenCode protocol session management.", + "title": "Use session pool", + "type": "boolean" + } + }, + "title": "OpenCodeConfig", + "type": "object" + }, "OpenCodeFileAgentConfig": { "additionalProperties": false, "description": "Configuration for an OpenCode format agent file.\n\nOpenCode agents use markdown files with YAML frontmatter containing\nfields like `model`, `tools`, `description`, etc.\n\nExample:\n ```yaml\n file_agents:\n debugger:\n type: opencode\n path: ./agents/debugger.md\n ```", @@ -28226,6 +28473,12 @@ "null" ] }, + "metadata": { + "additionalProperties": true, + "description": "Arbitrary metadata for the agent.\n\nCan be used for feature flags, annotations, and other per-agent\nconfiguration that doesn't fit into standard fields.\n\nExample:\n ```yaml\n metadata:\n use_session_pool: true\n ```", + "title": "Agent metadata", + "type": "object" + }, "triggers": { "description": "Event sources that activate this agent / team", "examples": [ @@ -28387,6 +28640,7 @@ { "discriminator": { "mapping": { + "acp": "#/$defs/AcpMCPServerConfig", "sse": "#/$defs/SSEMCPServerConfig", "stdio": "#/$defs/StdioMCPServerConfig", "streamable-http": "#/$defs/StreamableHTTPMCPServerConfig" @@ -28402,6 +28656,9 @@ }, { "$ref": "#/$defs/StreamableHTTPMCPServerConfig" + }, + { + "$ref": "#/$defs/AcpMCPServerConfig" } ] } @@ -31003,6 +31260,54 @@ "title": "SerperConfig", "type": "object" }, + "SessionPoolConfig": { + "additionalProperties": false, + "description": "Configuration for the SessionPool orchestration layer.\n\nControls session lifecycle management, turn execution, event routing,\nand auto-resume capabilities for agent sessions.", + "properties": { + "enable_auto_resume": { + "default": true, + "description": "Whether to enable the auto-resume loop for post-turn work.", + "title": "Enable auto-resume", + "type": "boolean" + }, + "enable_event_bus": { + "default": true, + "description": "Whether to enable cross-turn event routing via the event bus.", + "title": "Enable event bus", + "type": "boolean" + }, + "session_ttl_seconds": { + "default": 3600.0, + "description": "Time-to-live for sessions in seconds. Expired sessions are cleaned up.", + "exclusiveMinimum": 0, + "title": "Session TTL seconds", + "type": "number" + }, + "max_auto_resume": { + "default": 10, + "description": "Maximum number of auto-resume iterations per turn loop.", + "minimum": 0, + "title": "Max auto-resume", + "type": "integer" + }, + "max_queue_size": { + "default": 1000, + "description": "Maximum size for event bus subscriber queues.", + "minimum": 1, + "title": "Max queue size", + "type": "integer" + }, + "mcp_max_processes": { + "default": 100, + "description": "Maximum number of MCP processes for per-session agents.", + "minimum": 1, + "title": "MCP max processes", + "type": "integer" + } + }, + "title": "SessionPoolConfig", + "type": "object" + }, "SessionQuery": { "additionalProperties": false, "description": "Query configuration for session recovery.", @@ -31124,7 +31429,7 @@ "description": "Configuration for custom skill discovery paths.\n\nSkills are discovered from configured directories, allowing\nusers to add custom skills from local paths. The discovery\nfollows \"first path wins\" semantics - earlier paths in the list\ntake precedence over later ones.\n\nDefault paths (when include_default=True):\n- ~/.claude/skills/ (user home directory)\n- .claude/skills/ (relative to current directory)", "properties": { "paths": { - "description": "List of custom paths to search for skills.\n\nPaths can be:\n- Absolute: /home/user/skills\n- Relative: ./my-skills (resolved against config file location or CWD)\n- Remote: s3://bucket/skills, github://org/repo/skills\n\nEarlier paths take precedence over later ones (\"first path wins\").", + "description": "List of custom paths to search for skills.\n\nPaths can be:\n- Absolute: /home/user/skills\n- Relative: ./my-skills (resolved against config file location or CWD)\n- Remote: s3://bucket/skills, github://org/repo/skills\n\nEarlier paths take precedence over later ones (\"first path wins\").\n\nPaths are automatically resolved relative to the config file location\nvia ConfigPath validation.", "examples": [ [ "/path/to/skills", @@ -34862,6 +35167,17 @@ "null" ] }, + "metadata": { + "additionalProperties": true, + "description": "Arbitrary metadata for the node.\n\nCan be used for feature flags, annotations, or other protocol-specific\nconfiguration that does not fit into structured fields.", + "examples": [ + { + "use_session_pool": true + } + ], + "title": "Node metadata", + "type": "object" + }, "triggers": { "description": "Event sources that activate this agent / team", "examples": [ @@ -35023,6 +35339,7 @@ { "discriminator": { "mapping": { + "acp": "#/$defs/AcpMCPServerConfig", "sse": "#/$defs/SSEMCPServerConfig", "stdio": "#/$defs/StdioMCPServerConfig", "streamable-http": "#/$defs/StreamableHTTPMCPServerConfig" @@ -35038,6 +35355,9 @@ }, { "$ref": "#/$defs/StreamableHTTPMCPServerConfig" + }, + { + "$ref": "#/$defs/AcpMCPServerConfig" } ] } @@ -35846,6 +36166,7 @@ "type": "object" }, "UsageLimits": { + "description": "Limits on model usage.\n\nThe request count is tracked by pydantic_ai, and the request limit is checked before each request to the model.\nToken counts are provided in responses from the model, and the token limits are checked after each response.\n\nEach of the limits can be set to `None` to disable that limit.", "properties": { "request_limit": { "default": 50, @@ -35894,7 +36215,7 @@ }, "count_tokens_before_request": { "default": false, - "description": "If True, perform a token counting pass before sending the request to the model,\nto enforce `request_tokens_limit` ahead of time.\n\nThis may incur additional overhead (from calling the model's `count_tokens` API before making the actual request) and is disabled by default.\n\nSupported by:\n\n- Anthropic\n- Google\n- Bedrock Converse\n\nSupport for OpenAI is in development: https://github.com/pydantic/pydantic-ai/issues/3430", + "description": "If True, perform a token counting pass before sending the request to the model,\nto enforce `input_tokens_limit` ahead of time.\n\nThis may incur additional overhead (from calling the model's `count_tokens` API before making the actual request)\nand is disabled by default.\n\nSupported by:\n\n- Anthropic\n- Google\n- Bedrock Converse\n- OpenAI Responses", "title": "Count Tokens Before Request", "type": "boolean" } @@ -38959,6 +39280,7 @@ { "discriminator": { "mapping": { + "acp": "#/$defs/AcpMCPServerConfig", "sse": "#/$defs/SSEMCPServerConfig", "stdio": "#/$defs/StdioMCPServerConfig", "streamable-http": "#/$defs/StreamableHTTPMCPServerConfig" @@ -38974,6 +39296,9 @@ }, { "$ref": "#/$defs/StreamableHTTPMCPServerConfig" + }, + { + "$ref": "#/$defs/AcpMCPServerConfig" } ] } @@ -39003,6 +39328,18 @@ "$ref": "#/$defs/SkillsConfig", "description": "Custom skill discovery paths configuration.\n\nDefines where to search for custom skills. Skills are discovered from\nconfigured directories following \"first path wins\" semantics.\n\nExample:\n ```yaml\n skills:\n paths:\n - ./my-skills\n - s3://bucket/skills\n include_default: true\n ```" }, + "session_pool": { + "$ref": "#/$defs/SessionPoolConfig", + "description": "Session pool configuration for session lifecycle management.\n\nControls session TTL, auto-resume, event bus, and queue sizing.\n\nExample:\n ```yaml\n session_pool:\n enable_auto_resume: true\n enable_event_bus: true\n session_ttl_seconds: 3600.0\n max_auto_resume: 10\n max_queue_size: 1000\n mcp_max_processes: 100\n ```" + }, + "acp": { + "$ref": "#/$defs/ACPConfig", + "description": "ACP protocol-specific configuration.\n\nExample:\n ```yaml\n acp:\n use_session_pool: true\n ```" + }, + "opencode": { + "$ref": "#/$defs/OpenCodeConfig", + "description": "OpenCode protocol-specific configuration.\n\nExample:\n ```yaml\n opencode:\n use_session_pool: true\n ```" + }, "commands": { "additionalProperties": { "anyOf": [ diff --git a/src/acp/schema/__init__.py b/src/acp/schema/__init__.py index 19a1347a9..54f433297 100644 --- a/src/acp/schema/__init__.py +++ b/src/acp/schema/__init__.py @@ -118,6 +118,7 @@ ProvidersCapabilities, ProviderStatus, ) +from acp.schema.requests import PromptDelegation from acp.schema.messages import AgentMethod, ClientMethod from acp.schema.notifications import ( AgentNotification, @@ -138,6 +139,8 @@ SessionMode, SessionModeState, SessionModelState, + SubagentCapabilities, + SubagentInfo, ) from acp.schema.slash_commands import ( AvailableCommand, @@ -152,6 +155,7 @@ FileEditToolCallContent, PermissionKind, PermissionOption, + SubagentRunInfo, TerminalToolCallContent, ToolCall, ToolCallContent, @@ -164,6 +168,7 @@ AgentPlanUpdate, AgentThoughtChunk, AvailableCommandsUpdate, + AvailableSubagentsUpdate, ConfigOptionUpdate, Cost, CurrentModeUpdate, @@ -205,6 +210,7 @@ "AvailableCommand", "AvailableCommandInput", "AvailableCommandsUpdate", + "AvailableSubagentsUpdate", "BlobResourceContents", "CancelNotification", "ClientCapabilities", @@ -264,6 +270,7 @@ "PlanEntryPriority", "PlanEntryStatus", "PromptCapabilities", + "PromptDelegation", "PromptRequest", "PromptResponse", "ProviderCurrentConfig", @@ -310,6 +317,9 @@ "CloseSessionRequest", "CloseSessionResponse", "StopReason", + "SubagentCapabilities", + "SubagentInfo", + "SubagentRunInfo", "TerminalExitStatus", "TerminalOutputRequest", "TerminalOutputResponse", diff --git a/src/acp/schema/agent_responses.py b/src/acp/schema/agent_responses.py index b34ab5c0a..28d1e55b6 100644 --- a/src/acp/schema/agent_responses.py +++ b/src/acp/schema/agent_responses.py @@ -8,12 +8,14 @@ from acp.schema.base import Response from acp.schema.capabilities import AgentCapabilities from acp.schema.common import AuthMethod, Implementation +from acp.schema.session_state import SubagentCapabilities from acp.schema.providers import ProviderInfo # noqa: TC001 from acp.schema.session_state import ( # noqa: TC001 SessionConfigOption, SessionInfo, SessionModelState, SessionModeState, + SubagentInfo, ) from acp.schema.session_updates import Usage # noqa: TC001 @@ -73,6 +75,9 @@ class NewSessionResponse(Response): See RFD: Session Config Options """ + available_subagents: Sequence[SubagentInfo] | None = None + """Subagents available for delegation in this session.""" + session_id: str """Unique identifier for the created session. @@ -100,6 +105,9 @@ class LoadSessionResponse(Response): config_options: Sequence[SessionConfigOption] = [] """The full list of config options with updated values.""" + available_subagents: Sequence[SubagentInfo] | None = None + """Subagents available for delegation in this session.""" + class ForkSessionResponse(Response): """**UNSTABLE**: This capability is not part of the spec yet. @@ -127,6 +135,9 @@ class ForkSessionResponse(Response): config_options: Sequence[SessionConfigOption] = [] """The full list of config options with updated values.""" + available_subagents: Sequence[SubagentInfo] | None = None + """Subagents available for delegation in this session.""" + @classmethod def create( cls, @@ -181,6 +192,9 @@ class ResumeSessionResponse(Response): config_options: Sequence[SessionConfigOption] = [] """The full list of config options with updated values.""" + available_subagents: Sequence[SubagentInfo] | None = None + """Subagents available for delegation in this session.""" + @classmethod def create( cls, @@ -205,6 +219,7 @@ def create( ) return cls( config_options=config_options or [], + available_subagents=None, models=model_state, modes=mode_state, ) @@ -297,6 +312,7 @@ def create( close_session: bool = False, fork_session: bool = False, providers: bool = False, + subagents: SubagentCapabilities | None = None, turn_complete: bool = False, auth_methods: Sequence[AuthMethod] | None = None, ) -> Self: @@ -319,6 +335,7 @@ def create( close_session: Whether the agent supports `session/close` (unstable). fork_session: Whether the agent supports `session/fork` (unstable). providers: Whether the agent supports `providers/*` methods. + subagents: Subagent capabilities supported by the agent. turn_complete: Whether the agent emits `turn_complete` updates (unstable). auth_methods: The authentication methods supported by the agent. """ @@ -335,6 +352,7 @@ def create( close_session=close_session, fork_session=fork_session, providers=providers, + subagents=subagents, turn_complete=turn_complete, ) return cls( diff --git a/src/acp/schema/capabilities.py b/src/acp/schema/capabilities.py index 661f5a524..c6feb7fa2 100644 --- a/src/acp/schema/capabilities.py +++ b/src/acp/schema/capabilities.py @@ -8,6 +8,7 @@ from acp.schema.base import AnnotatedObject from acp.schema.providers import ProvidersCapabilities +from acp.schema.session_state import SubagentCapabilities class FileSystemCapability(AnnotatedObject): @@ -332,6 +333,9 @@ class AgentCapabilities(AnnotatedObject): via the providers/list, providers/set, and providers/disable methods. """ + subagents: SubagentCapabilities | None = None + """Subagent capabilities supported by the agent.""" + @classmethod def create( cls, @@ -347,6 +351,7 @@ def create( close_session: bool = False, fork_session: bool = False, providers: bool = False, + subagents: SubagentCapabilities | None = None, turn_complete: bool = False, ) -> Self: """Create an instance of AgentCapabilities. @@ -364,6 +369,7 @@ def create( close_session: Whether the agent supports `session/close` (unstable). fork_session: Whether the agent supports `session/fork` (unstable). providers: Whether the agent supports `providers/*` methods. + subagents: Subagent capabilities supported by the agent. turn_complete: Whether the agent emits `turn_complete` updates (unstable). """ session_caps = SessionCapabilities( @@ -377,6 +383,7 @@ def create( return cls( load_session=load_session, providers=providers_caps, + subagents=subagents, mcp_capabilities=McpCapabilities( http=http_mcp_servers, sse=sse_mcp_servers, acp=acp_mcp_servers ), diff --git a/src/acp/schema/client_requests.py b/src/acp/schema/client_requests.py index 15ca0a10f..291514d4a 100644 --- a/src/acp/schema/client_requests.py +++ b/src/acp/schema/client_requests.py @@ -12,6 +12,7 @@ from acp.schema.common import Implementation from acp.schema.content_blocks import ContentBlock # noqa: TC001 from acp.schema.mcp import McpServer # noqa: TC001 +from acp.schema.requests import PromptDelegation # noqa: TC001 if TYPE_CHECKING: @@ -162,6 +163,13 @@ class PromptRequest(Request): session_id: str """The ID of the session to send this user message to.""" + delegation: PromptDelegation | None = None + """Optional delegation configuration for this prompt. + + When provided, controls whether and how the agent may delegate this + prompt to a subagent. See [`PromptDelegation`] for policy details. + """ + class SetSessionModelRequest(Request): """**UNSTABLE**: This capability is not part of the spec yet. diff --git a/src/acp/schema/requests.py b/src/acp/schema/requests.py new file mode 100644 index 000000000..8f68e1b0c --- /dev/null +++ b/src/acp/schema/requests.py @@ -0,0 +1,37 @@ +"""Shared request schema definitions.""" + +from __future__ import annotations + +from typing import Literal + +from acp.schema.base import AnnotatedObject + + +class PromptDelegation(AnnotatedObject): + """Delegation configuration for a prompt request. + + Controls whether and how the agent may delegate this prompt to a subagent. + """ + + policy: Literal["auto", "disable", "prefer", "require"] + """Delegation policy. + + - ``auto``: Agent decides whether to delegate (default behavior). + - ``disable``: Do not delegate; process locally. + - ``prefer``: Prefer delegation if a suitable subagent is available. + - ``require``: Must delegate to a subagent. + """ + + subagent_id: str | None = None + """Optional specific subagent to delegate to. + + When ``policy`` is ``prefer`` or ``require``, this may be set to target + a specific subagent. If ``None``, the agent selects an appropriate subagent. + """ + + run_mode: Literal["foreground", "background"] | None = None + """Optional execution mode for the delegated subagent. + + - ``foreground``: Synchronous execution; parent waits for completion. + - ``background``: Asynchronous execution; parent receives a handle. + """ diff --git a/src/acp/schema/session_state.py b/src/acp/schema/session_state.py index b28c931ec..aee3796fc 100644 --- a/src/acp/schema/session_state.py +++ b/src/acp/schema/session_state.py @@ -115,6 +115,54 @@ class SessionInfo(AnnotatedObject): meta: dict[str, Any] | None = None """Arbitrary session metadata.""" + parent_session_id: str | None = None + """ID of the parent session if this session was spawned as a subagent.""" + + child_session_ids: Sequence[str] | None = None + """IDs of child sessions spawned from this session.""" + + depth: int | None = Field(default=None, ge=0) + """Nesting depth in the session hierarchy (0 for root sessions).""" + + +class SubagentCapabilities(AnnotatedObject): + """Capabilities of an available subagent.""" + + streaming: bool | None = False + """Whether the subagent supports streaming updates.""" + + tools: bool | None = False + """Whether the subagent can use tools.""" + + delegation: bool | None = False + """Whether the subagent can delegate to other subagents.""" + + prompt_delegation: bool | None = False + """Whether the subagent supports prompt delegation (Phase 2).""" + + background: bool | None = False + """Whether the subagent supports background execution (Phase 2).""" + + +class SubagentInfo(AnnotatedObject): + """Information about an available subagent for delegation. + + Advertised during session lifecycle so clients know which subagents + can be invoked. + """ + + subagent_id: str + """Unique identifier for the subagent.""" + + name: str + """Human-readable name of the subagent.""" + + description: str | None = None + """Optional description of the subagent.""" + + capabilities: SubagentCapabilities | None = None + """Capabilities of the subagent.""" + class SessionConfigSelectOption(AnnotatedObject): """A possible value for a configuration selector.""" diff --git a/src/acp/schema/session_updates.py b/src/acp/schema/session_updates.py index 7cd93a979..daace918a 100644 --- a/src/acp/schema/session_updates.py +++ b/src/acp/schema/session_updates.py @@ -22,7 +22,7 @@ TextContentBlock, TextResourceContents, ) -from acp.schema.session_state import SessionConfigOption # noqa: TC001 +from acp.schema.session_state import SessionConfigOption, SubagentInfo # noqa: TC001 from acp.schema.slash_commands import AvailableCommand # noqa: TC001 from acp.schema.tool_call import ( # noqa: TC001 SubagentRunInfo, @@ -317,7 +317,7 @@ class ToolCallProgress(AnnotatedObject): """Update the execution status.""" subagent: SubagentRunInfo | None = None - """Subagent run information, if this is a subagent tool call.""" + """Information about the subagent being invoked.""" title: str | None = None """Update the human-readable title.""" @@ -432,7 +432,7 @@ class ToolCallStart(AnnotatedObject): """Current execution status of the tool call.""" subagent: SubagentRunInfo | None = None - """Subagent run information, if this is a subagent tool call.""" + """Information about the subagent being invoked.""" title: str """Human-readable title describing what the tool is doing.""" @@ -514,6 +514,17 @@ class SessionInfoUpdate(AnnotatedObject): """Additional metadata to merge, or None to leave unchanged.""" +class AvailableSubagentsUpdate(AnnotatedObject): + """Available subagents for delegation have changed.""" + + session_update: Literal["available_subagents_update"] = Field( + default="available_subagents_update", init=False + ) + + available_subagents: Sequence[SubagentInfo] + """The current list of available subagents for this session.""" + + class TurnCompleteUpdate(AnnotatedObject): """Signal that all updates for the current prompt turn have been delivered. @@ -540,6 +551,7 @@ class TurnCompleteUpdate(AnnotatedObject): | ToolCallStart | ToolCallProgress | AvailableCommandsUpdate + | AvailableSubagentsUpdate | AgentPlanUpdate | CurrentModeUpdate | CurrentModelUpdate diff --git a/src/acp/schema/tool_call.py b/src/acp/schema/tool_call.py index 57fd47350..111383359 100644 --- a/src/acp/schema/tool_call.py +++ b/src/acp/schema/tool_call.py @@ -367,4 +367,33 @@ class PermissionOption(AnnotatedObject): """Unique identifier for this permission option.""" +class SubagentRunInfo(AnnotatedObject): + """Information about a subagent invocation within a tool call. + + Provides metadata for clients to identify and track subagent executions, + enabling subagent-specific UI treatment such as expandable cards. + """ + + subagent_id: str + """Unique identifier for the subagent being invoked.""" + + name: str + """Human-readable name of the subagent.""" + + description: str | None = None + """Optional description of what the subagent does.""" + + status: Literal["pending", "running", "completed", "failed"] | None = None + """Current execution status of the subagent run.""" + + depth: int | None = Field(default=None, ge=0) + """Nesting depth of the subagent invocation in the hierarchy.""" + + child_session_id: str | None = None + """ID of the child session created for this subagent run.""" + + run_mode: Literal["foreground", "background"] | None = None + """Lifecycle mode: 'foreground' (blocking) or 'background' (async).""" + + ToolCallContent = ContentToolCallContent | FileEditToolCallContent | TerminalToolCallContent diff --git a/src/acp/utils.py b/src/acp/utils.py index c600e409f..5397507ea 100644 --- a/src/acp/utils.py +++ b/src/acp/utils.py @@ -253,4 +253,6 @@ def infer_tool_kind(tool_name: str) -> ToolCallKind: # noqa: PLR0911 return "think" if any(i in name_lower for i in ["fetch", "download", "request"]): return "fetch" + if any(i in name_lower for i in ["subagent", "delegate", "spawn"]): + return "subagent" return "other" # Default to other diff --git a/src/agentpool/agents/base_agent.py b/src/agentpool/agents/base_agent.py index 54eca12b9..1a24e0f69 100644 --- a/src/agentpool/agents/base_agent.py +++ b/src/agentpool/agents/base_agent.py @@ -283,8 +283,8 @@ def __init__( self.event_handler: MultiEventHandler[IndividualEventHandler] = MultiEventHandler(handlers) self.hooks = hooks self._cancelled = False - # _background_run_ctx is used only for the background task's internal state. - # It is intentionally NOT used as a fallback in get_active_run_context(). + self._active_run_ctx: AgentRunContext | None = None + """Foreground run context for cross-task access (legacy path without SessionPool).""" self._background_run_ctx: AgentRunContext | None = None # Deferred initialization support - subclasses set True in __aenter__, # override ensure_initialized() to do actual connection @@ -600,25 +600,21 @@ def _get_session_run_ctx(self, session_id: str | None = None) -> AgentRunContext """Get active run context from SessionPool for cross-task access. Args: - session_id: Optional session ID to look up. Falls back to - self._events.session_id if not provided. + session_id: Optional session ID to look up. Uses the provided + value directly instead of instance state. Returns: The session's active run context, or None if not found. """ - if self.agent_pool is not None: + if self.agent_pool is not None and session_id is not None: session_pool = self.agent_pool.session_pool if session_pool is None: return None - effective_session_id = session_id or getattr(self._events, "session_id", None) - if effective_session_id is not None: - session = session_pool.sessions.get_session(effective_session_id) - if session is not None and session.current_run_id is not None: - run_handle = session_pool.get_run(session.current_run_id) - if run_handle is not None: - run_ctx = run_handle.run_ctx - if run_ctx is not None and not run_ctx.completed: - return run_ctx + session = session_pool.sessions.get_session(session_id) + if session is not None and session.current_run_id is not None: + run_handle = session_pool.get_run(session.current_run_id) + if run_handle is not None and not run_handle.run_ctx.completed: + return run_handle.run_ctx return None def get_active_run_context(self, session_id: str | None = None) -> AgentRunContext | None: @@ -628,9 +624,9 @@ def get_active_run_context(self, session_id: str | None = None) -> AgentRunConte turn is active and access the run context without relying on private attributes. - Uses two-level fallback: - 1. SessionPool lookup when pooled (via session_id or agent_pool) - 2. ContextVar (_current_run_ctx_var) for standalone execution + Tries _current_run_ctx_var (ContextVar) first, then falls back to + SessionPool's session.current_run_id + get_run() for cross-task access, then + _background_run_ctx. Args: session_id: Optional session ID for SessionPool lookup. @@ -640,22 +636,17 @@ def get_active_run_context(self, session_id: str | None = None) -> AgentRunConte Returns: The active run context, or None if no turn is running. """ - # Level 1: SessionPool lookup when pooled and session has active run - if self.agent_pool is not None: - session_pool = self.agent_pool.session_pool - if session_pool is not None: - effective_session_id = session_id or getattr(self._events, "session_id", None) - if effective_session_id is not None: - session = session_pool.sessions.get_session(effective_session_id) - if session is not None and session.current_run_id is not None: - run_handle = session_pool.get_run(session.current_run_id) - if run_handle is not None and not run_handle.run_ctx.completed: - return run_handle.run_ctx - # No active run in SessionPool for this session — fall through to ContextVar - # Level 2: ContextVar for standalone execution run_ctx = _current_run_ctx_var.get() if run_ctx is not None and not run_ctx.completed: return run_ctx + # Instance-level fallback for cross-task access (legacy path without SessionPool) + if self._active_run_ctx is not None and not self._active_run_ctx.completed: + return self._active_run_ctx + run_ctx = self._get_session_run_ctx(session_id=session_id) + if run_ctx is not None: + return run_ctx + if self._background_run_ctx is not None and not self._background_run_ctx.completed: + return self._background_run_ctx return None def is_turn_active(self) -> bool: @@ -794,13 +785,12 @@ async def my_tool(ctx: AgentContext) -> str: # No active run context — delegate to SessionPool for auto-resume effective_session_id = session_id or (run_ctx.session_id if run_ctx else None) if self.agent_pool is not None and effective_session_id is not None: - _session_pool = self.agent_pool.session_pool - if _session_pool is None: - return + session_pool = self.agent_pool.session_pool + assert session_pool is not None # Fire-and-forget: delegate to SessionPool for auto-resume. # Use task_manager to prevent GC of the task mid-execution. self.task_manager.fire_and_forget( - _session_pool.inject_prompt(effective_session_id, message) + session_pool.inject_prompt(effective_session_id, message) ) return @@ -912,11 +902,7 @@ async def run_stream( ) final_message: ChatMessage[TResult] | None = None - async for event in session_pool.run_stream( - effective_session_id, - *prompts, # type: ignore[arg-type] - input_provider=input_provider, - ): + async for event in session_pool.run_stream(effective_session_id, *prompts): # type: ignore[arg-type] yield event if isinstance(event, StreamCompleteEvent): final_message = event.message @@ -951,6 +937,7 @@ async def run_stream( run_ctx.cancelled = False self._cancelled = False run_ctx.current_task = asyncio.current_task() + self._active_run_ctx = run_ctx # RFC-0021: reset only via the token from set(); never set(None) (breaks nesting). # token is initialized so finally always has a bound name; reset only if set() succeeded. @@ -1019,6 +1006,7 @@ async def run_stream( _current_run_ctx_var.reset(token) if run_ctx.injection_manager is not None: run_ctx.injection_manager.clear() + self._active_run_ctx = None async def _run_stream_once( self, @@ -1410,6 +1398,10 @@ async def interrupt(self, run_ctx: AgentRunContext | None = None, session_id: st effective_run_ctx = _current_run_ctx_var.get() if effective_run_ctx is None: effective_run_ctx = self._get_session_run_ctx(session_id=session_id) + # Fallback to instance-level active run context for cross-task access + # when SessionPool is not available (e.g. standalone agent usage). + if effective_run_ctx is None: + effective_run_ctx = self._active_run_ctx if effective_run_ctx: effective_run_ctx.cancelled = True if self._background_run_ctx: diff --git a/src/agentpool/agents/context.py b/src/agentpool/agents/context.py index 9ab6cbb5c..ce095e313 100644 --- a/src/agentpool/agents/context.py +++ b/src/agentpool/agents/context.py @@ -202,6 +202,9 @@ async def create_child_session( agent_name: str, agent_type: str, parent_session_id: str | None = None, + *, + parent_tool_call_id: str | None = None, + subagent_id: str | None = None, ) -> str: """Create a child session for a subagent delegation. @@ -217,6 +220,9 @@ async def create_child_session( agent_type: Type of the child agent (``"native"``, ``"claude"``, etc.). parent_session_id: Explicit parent session ID. When *None* the current node's ``session_id`` is used as the parent. + parent_tool_call_id: Optional ID of the tool call that triggered + this subagent delegation. + subagent_id: Optional identifier of the subagent being delegated to. Returns: The child session ID string. @@ -234,7 +240,6 @@ async def create_child_session( agent_type=agent_type, ) return child_session.session_id - # Fallback: no pool, no session_pool, or no parent — generate ephemeral ID. from agentpool.utils.identifiers import generate_session_id return generate_session_id() diff --git a/src/agentpool/agents/events/events.py b/src/agentpool/agents/events/events.py index 5cdd1b579..0d246a248 100644 --- a/src/agentpool/agents/events/events.py +++ b/src/agentpool/agents/events/events.py @@ -706,6 +706,8 @@ class SpawnSessionStart: """Model identifier for the subagent (e.g., 'openai:gpt-4o'). Propagated to UI for display.""" mode: str | None = None """Mode identifier for the subagent (e.g., 'code', 'ask'). Maps to OpenCode mode display.""" + run_mode: Literal["foreground", "background"] | None = None + """Lifecycle mode for ACP protocol: 'foreground' (blocking) or 'background' (async).""" event_kind: Literal["spawn_session_start"] = "spawn_session_start" """Event type identifier.""" diff --git a/src/agentpool/agents/native_agent/agent.py b/src/agentpool/agents/native_agent/agent.py index 757fa8bcd..bb5c458e4 100644 --- a/src/agentpool/agents/native_agent/agent.py +++ b/src/agentpool/agents/native_agent/agent.py @@ -786,7 +786,7 @@ async def get_agentlet[AgentOutputType]( create_approval_bridge_capability, ) - tool_capabilities.append(create_approval_bridge_capability(self, input_provider)) + tool_capabilities.append(create_approval_bridge_capability(self)) # 4. MCP servers mcp_capabilities = self.mcp.as_capability() tool_capabilities.extend(mcp_capabilities) diff --git a/src/agentpool/agents/native_agent/approval_bridge.py b/src/agentpool/agents/native_agent/approval_bridge.py index 1a09d81c2..674aef366 100644 --- a/src/agentpool/agents/native_agent/approval_bridge.py +++ b/src/agentpool/agents/native_agent/approval_bridge.py @@ -58,7 +58,6 @@ def _map_confirmation_result( async def _resolve_deferred_approvals( ctx: RunContext[AgentContext], requests: DeferredToolRequests, - input_provider: Any | None = None, ) -> DeferredToolResults | None: """Resolve deferred approval requests via InputProvider. @@ -69,8 +68,6 @@ async def _resolve_deferred_approvals( Args: ctx: pydantic-ai RunContext with AgentContext as deps requests: Deferred tool requests from pydantic-ai - input_provider: Optional InputProvider to use directly instead of - resolving via ctx.deps.get_input_provider() Returns: DeferredToolResults with approval/denial for each request, @@ -80,10 +77,7 @@ async def _resolve_deferred_approvals( return None agent_ctx = ctx.deps - # Use passed provider directly, fall back to ctx.deps resolution - provider = input_provider - if provider is None: - provider = agent_ctx.get_input_provider() + provider = agent_ctx.get_input_provider() # Access tool_confirmation_mode directly from node to avoid agent property assertion mode = getattr(agent_ctx.node, "tool_confirmation_mode", "per_tool") @@ -126,10 +120,7 @@ async def _resolve_deferred_approvals( return DeferredToolResults(approvals=approvals) -def create_approval_bridge_capability( - agent: Agent[Any, Any], - input_provider: Any | None = None, -) -> HandleDeferredToolCalls[AgentContext[Any]]: +def create_approval_bridge_capability(agent: Agent[Any, Any]) -> HandleDeferredToolCalls[AgentContext[Any]]: """Create a HandleDeferredToolCalls capability bridged to InputProvider. This capability intercepts pydantic-ai deferred tool approval requests @@ -137,8 +128,6 @@ def create_approval_bridge_capability( Args: agent: The Agent instance (used to access tool_confirmation_mode) - input_provider: Optional InputProvider to use directly. If None, - resolves via ctx.deps.get_input_provider() at runtime. Returns: HandleDeferredToolCalls capability configured with the bridge handler @@ -150,7 +139,7 @@ async def handler( ) -> DeferredToolResults | None: # Only handle approval requests (not external execution calls) if requests.approvals: - return await _resolve_deferred_approvals(ctx, requests, input_provider) + return await _resolve_deferred_approvals(ctx, requests) return None return HandleDeferredToolCalls(handler=handler) diff --git a/src/agentpool/delegation/team.py b/src/agentpool/delegation/team.py index afd7dd99d..dcf35f602 100644 --- a/src/agentpool/delegation/team.py +++ b/src/agentpool/delegation/team.py @@ -233,6 +233,7 @@ async def wrap_stream( depth=child_depth, description=f"Spawning {node.name} as team member", spawn_mechanism="spawn", + run_mode="foreground", ) if not isinstance(node, SupportsRunStream): diff --git a/src/agentpool/delegation/teamrun.py b/src/agentpool/delegation/teamrun.py index 3e761261e..4c9ebeaf8 100644 --- a/src/agentpool/delegation/teamrun.py +++ b/src/agentpool/delegation/teamrun.py @@ -413,6 +413,7 @@ async def run_stream( source_name=node.name, depth=child_depth, description=f"Sequential team member {node.name!r}", + run_mode="foreground", ) # Extract model_id from BaseAgent nodes diff --git a/src/agentpool/messaging/messagenode.py b/src/agentpool/messaging/messagenode.py index 37a1dbc5e..726860597 100644 --- a/src/agentpool/messaging/messagenode.py +++ b/src/agentpool/messaging/messagenode.py @@ -2,9 +2,10 @@ from __future__ import annotations -from abc import ABC, abstractmethod import asyncio -from collections.abc import Sequence +import warnings +from abc import ABC, abstractmethod +from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Any, Literal, Self, overload import warnings @@ -134,6 +135,7 @@ async def log_session( initial_prompt: str | None = None, model: str | None = None, parent_session_id: str | None = None, + session_title_setter: Callable[[str], None] | None = None, ) -> None: """Log conversation to storage if enabled. @@ -146,6 +148,7 @@ async def log_session( initial_prompt: Optional initial prompt to trigger title generation. model: Requested model identifier for this session. parent_session_id: Optional parent session ID. + session_title_setter: Optional callback for setting conversation title. """ if self.enable_db_logging and self.storage and session_id: await self.storage.log_session( @@ -154,6 +157,7 @@ async def log_session( model=model, initial_prompt=initial_prompt, parent_session_id=parent_session_id, + on_title_generated=session_title_setter, ) async def emit_agent_event( diff --git a/src/agentpool/orchestrator/core.py b/src/agentpool/orchestrator/core.py index bc7d53aff..6201dcf3c 100644 --- a/src/agentpool/orchestrator/core.py +++ b/src/agentpool/orchestrator/core.py @@ -25,7 +25,6 @@ if TYPE_CHECKING: from agentpool.agents.base_agent import BaseAgent - from agentpool.agents.native_agent import Agent from agentpool.delegation import AgentPool from agentpool.sessions.store import SessionStore @@ -83,7 +82,6 @@ class SessionState: lifecycle_policy: str = field(default_factory=SessionLifecyclePolicy.default) current_run_id: str | None = None _request_lock: asyncio.Lock = field(default_factory=asyncio.Lock) - input_provider: Any | None = None @property def closing(self) -> bool: @@ -475,9 +473,6 @@ async def get_or_create_session_agent( session_id=session_id, limit=self._mcp_max_processes, ) - # Store input_provider on session, NOT on shared agent - if input_provider is not None: - session.input_provider = input_provider self._session_agents[session_id] = base_agent session.agent = base_agent return base_agent @@ -487,7 +482,7 @@ async def get_or_create_session_agent( from agentpool_config.context import ConfigContextManager with ConfigContextManager(self.pool._config_file_path): - agent: Agent[Any, Any] = cfg.get_agent( + agent = cfg.get_agent( input_provider=input_provider, pool=self.pool, ) @@ -510,9 +505,6 @@ async def get_or_create_session_agent( agent_name=agent_name, agent_type=type(base_agent).__name__, ) - # Store input_provider on session, NOT on shared agent - if input_provider is not None: - session.input_provider = input_provider self._session_agents[session_id] = base_agent session.agent = base_agent return base_agent @@ -689,10 +681,6 @@ async def receive_request( if len(self._runs) >= self._max_concurrent_runs: return None - # Store input_provider on session for auto-resume - if "input_provider" in kwargs: - session.input_provider = kwargs["input_provider"] - if session.current_run_id is None: run_handle = self._create_run(session_id, content) self._runs[run_handle.run_id] = run_handle @@ -703,21 +691,17 @@ async def receive_request( self._turn_runner.run_loop(session_id, content, **kwargs), ) run_handle.start(task) - - def _cleanup_on_done( - _t: asyncio.Task[None], rid: str = run_handle.run_id - ) -> None: - self._cleanup_run(rid) - - task.add_done_callback(_cleanup_on_done) + task.add_done_callback( + lambda _t, rid=run_handle.run_id: self._cleanup_run(rid), + ) return run_handle # Session has an active run - delegate after releasing the request lock if self._turn_runner is not None: if priority == "asap": - await self._turn_runner.inject_prompt(session_id, content, **kwargs) + await self._turn_runner.inject_prompt(session_id, content) else: - await self._turn_runner.queue_prompt(session_id, content, **kwargs) + await self._turn_runner.queue_prompt(session_id, content) return None def cancel_run_for_session(self, session_id: str) -> None: @@ -936,37 +920,13 @@ async def _run_turn_unlocked( _session = self.sessions.get_session(session_id) from agentpool.agents.base_agent import _current_run_ctx_var - from agentpool.orchestrator.run import RunHandle, RunStatus + from agentpool.agents.context import AgentRunContext run_id_override = self.sessions._pending_run_ids.pop(session_id, None) - # If no pending run_id, check if session already has a current_run_id - # (e.g., manually created RunHandle in tests) - if run_id_override is None and _session is not None and _session.current_run_id is not None: - run_id_override = _session.current_run_id - run_id = run_id_override or uuid.uuid4().hex - - # Get or create RunHandle (create if called directly, not via receive_request) - run_handle = self.sessions._runs.get(run_id) - created_run_handle = False - if run_handle is None: - agent_type = ( - _session.metadata.get("agent_type", "unknown") - if _session is not None - else "unknown" - ) - run_handle = RunHandle( - run_id=run_id, - session_id=session_id, - agent_type=agent_type, - ) - self.sessions._runs[run_id] = run_handle - created_run_handle = True - run_handle.start(asyncio.current_task()) - - # Use RunHandle's run_ctx as the authoritative context - run_ctx = run_handle.run_ctx - run_ctx.deps = kwargs.get("deps") - run_ctx.run_id = run_id + run_ctx = AgentRunContext( + deps=kwargs.get("deps"), + run_id=run_id_override or uuid.uuid4().hex, + ) run_ctx.cancelled = False run_ctx.current_task = asyncio.current_task() run_ctx.event_bus = self.event_bus @@ -974,7 +934,7 @@ async def _run_turn_unlocked( _current_run_ctx_var.set(run_ctx) if _session is not None and _session.current_run_id is None: - _session.current_run_id = run_id + _session.current_run_id = run_ctx.run_id self._runs[run_ctx.run_id] = run_ctx # Consume events from run_ctx.event_queue and publish to EventBus. @@ -1031,11 +991,10 @@ async def _consume_event_queue() -> None: await self.event_bus.publish(session_id, event) run_ctx.injection_manager.flush_pending_to_queue() except Exception as exc: - if run_handle is not None and run_handle.status not in ( - RunStatus.completed, - RunStatus.failed, - ): - run_handle.fail(exception=exc, event_bus=self.event_bus) + if _session is not None and _session.current_run_id is not None: + run_handle = self.sessions._runs.get(_session.current_run_id) + if run_handle is not None: + run_handle.fail(exception=exc, event_bus=self.event_bus) raise finally: # CRITICAL: Mark run as completed BEFORE any await so that @@ -1063,13 +1022,6 @@ async def _consume_event_queue() -> None: if len(self._turn_timings) > self._max_turn_timing_history: self._turn_timings.pop(0) - # Clean up RunHandle if we created it - if created_run_handle and run_handle is not None: - if run_handle.status not in (RunStatus.completed, RunStatus.failed): - run_handle.complete() - run_handle.complete_event.set() - self.sessions._runs.pop(run_id, None) - async def run_turn( self, session_id: str, @@ -1127,7 +1079,7 @@ async def run_loop( await self._drain_post_turn_injections(session_id) await self._drain_post_turn_prompts(session_id) - async def inject_prompt(self, session_id: str, message: str, **kwargs: Any) -> bool: + async def inject_prompt(self, session_id: str, message: str) -> bool: """Inject a message into a session. If the session has an active turn, injects immediately. @@ -1138,7 +1090,6 @@ async def inject_prompt(self, session_id: str, message: str, **kwargs: Any) -> b Args: session_id: The session to inject into. message: The message to inject. - **kwargs: Additional arguments passed to the agent run. Returns: True if injected into active turn, False if queued. @@ -1172,12 +1123,12 @@ async def inject_prompt(self, session_id: str, message: str, **kwargs: Any) -> b self._post_turn_injections.setdefault(session_id, []).append(message) logger.debug("Queued injection for next turn, triggering auto-resume") - task = asyncio.create_task(self._trigger_auto_resume(session_id, **kwargs)) + task = asyncio.create_task(self._trigger_auto_resume(session_id)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) return False - async def queue_prompt(self, session_id: str, *prompts: Any, **kwargs: Any) -> bool: + async def queue_prompt(self, session_id: str, *prompts: Any) -> bool: """Queue prompts for a session. Similar to inject_prompt but for full prompts. @@ -1186,7 +1137,6 @@ async def queue_prompt(self, session_id: str, *prompts: Any, **kwargs: Any) -> b Args: session_id: The session to queue prompts for. *prompts: Prompts to queue. - **kwargs: Additional arguments passed to the agent run. Returns: True if queued into active turn, False if stored for later. @@ -1212,40 +1162,11 @@ async def queue_prompt(self, session_id: str, *prompts: Any, **kwargs: Any) -> b return False self._post_turn_prompts.setdefault(session_id, []).append(prompts) - logger.debug("Queued prompt for next turn, triggering auto-resume") - task = asyncio.create_task(self._trigger_auto_resume(session_id, **kwargs)) + task = asyncio.create_task(self._trigger_auto_resume(session_id)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) return False - async def _drain_post_turn_injections(self, session_id: str) -> list[str]: - """Drain and return post-turn injections for a session. - - Args: - session_id: Session to drain. - - Returns: - List of injection messages. - """ - lock = await self._get_injection_lock(session_id) - async with lock: - injections = self._post_turn_injections.pop(session_id, []) - return injections - - async def _drain_post_turn_prompts(self, session_id: str) -> list[tuple[Any, ...]]: - """Drain and return post-turn prompts for a session. - - Args: - session_id: Session to drain. - - Returns: - List of prompt tuples. - """ - lock = await self._get_injection_lock(session_id) - async with lock: - prompts = self._post_turn_prompts.pop(session_id, []) - return prompts - async def _process_queued_work( self, session_id: str, @@ -1260,16 +1181,12 @@ async def _process_queued_work( Args: session_id: The session to process queued work for. session: The session state. - **kwargs: Additional arguments passed to the agent run. + **kwargs: Additional arguments passed to the agent. """ if session.is_closing: logger.debug("Session is closing, skipping queued work") return - # Use session-stored input_provider if not provided in kwargs - if "input_provider" not in kwargs and session.input_provider is not None: - kwargs["input_provider"] = session.input_provider - injections = await self._drain_post_turn_injections(session_id) prompts = await self._drain_post_turn_prompts(session_id) @@ -1309,16 +1226,17 @@ async def _process_queued_work( if injections: await self._run_turn_unlocked(session_id, *injections, **kwargs) + for prompt_group in prompts: await self._run_turn_unlocked(session_id, *prompt_group, **kwargs) + else: + logger.warning( + "Auto-resume loop exceeded max iterations", + session_id=session_id, + max_iterations=self._max_auto_resume, + ) - logger.info( - "Auto-resume complete", - session_id=session_id, - max_iterations=self._max_auto_resume, - ) - - async def _trigger_auto_resume(self, session_id: str, **kwargs: Any) -> None: + async def _trigger_auto_resume(self, session_id: str) -> None: """Trigger auto-resume for a session if no turn is active. Fire-and-forget task that ensures post-turn work queued after @@ -1326,7 +1244,6 @@ async def _trigger_auto_resume(self, session_id: str, **kwargs: Any) -> None: Args: session_id: The session to trigger auto-resume for. - **kwargs: Additional arguments passed to the agent run. """ logger.debug("_trigger_auto_resume called for %s", session_id) try: @@ -1345,24 +1262,48 @@ async def _trigger_auto_resume(self, session_id: str, **kwargs: Any) -> None: logger.debug("Session changed") return - # Use session-stored input_provider if not provided in kwargs - if "input_provider" not in kwargs and session.input_provider is not None: - kwargs["input_provider"] = session.input_provider - if self._enable_auto_resume: logger.debug("Processing queued work") - await self._process_queued_work(session_id, session, **kwargs) + await self._process_queued_work(session_id, session) logger.debug("Finished processing queued work") else: injections = await self._drain_post_turn_injections(session_id) prompts = await self._drain_post_turn_prompts(session_id) if injections: - await self._run_turn_unlocked(session_id, *injections, **kwargs) + await self._run_turn_unlocked(session_id, *injections) for prompt_group in prompts: - await self._run_turn_unlocked(session_id, *prompt_group, **kwargs) + await self._run_turn_unlocked(session_id, *prompt_group) except asyncio.CancelledError: return + except Exception: + logger.exception("Auto-resume trigger failed", session_id=session_id) + + async def _drain_post_turn_injections(self, session_id: str) -> list[str]: + """Drain and return post-turn injections for a session (atomic). + + Args: + session_id: The session to drain injections from. + + Returns: + The drained injection messages. + """ + lock = await self._get_injection_lock(session_id) + async with lock: + return self._post_turn_injections.pop(session_id, []) + + async def _drain_post_turn_prompts(self, session_id: str) -> list[tuple[Any, ...]]: + """Drain and return post-turn prompts for a session (atomic). + + Args: + session_id: The session to drain prompts from. + + Returns: + The drained prompt groups. + """ + lock = await self._get_injection_lock(session_id) + async with lock: + return self._post_turn_prompts.pop(session_id, []) class SessionPool: @@ -1581,7 +1522,7 @@ def cancel_run(self, run_id: str) -> None: raise ValueError("No active run found with ID: " + run_id) run_handle.cancel() - async def run_stream(self, session_id: str, *prompts: str, **kwargs: Any) -> AsyncIterator[Any]: + async def run_stream(self, session_id: str, *prompts: str) -> AsyncIterator[Any]: """Process prompts and yield events from the EventBus. Convenience method for tests and standalone clients that want @@ -1590,14 +1531,12 @@ async def run_stream(self, session_id: str, *prompts: str, **kwargs: Any) -> Asy Args: session_id: The session to process the prompt for. *prompts: Prompts to process. - **kwargs: Additional arguments passed to the turn runner - (e.g. ``input_provider``). Yields: Events published to the EventBus for this session. """ queue = await self.event_bus.subscribe(session_id) - process_task = asyncio.create_task(self.process_prompt(session_id, *prompts, **kwargs)) + process_task = asyncio.create_task(self.process_prompt(session_id, *prompts)) get_task: asyncio.Task[Any] | None = None try: while not process_task.done(): @@ -1634,36 +1573,26 @@ async def run_stream(self, session_id: str, *prompts: str, **kwargs: Any) -> Asy await process_task await self.event_bus.unsubscribe(session_id, queue) - async def inject_prompt(self, session_id: str, message: str, **kwargs: Any) -> bool: + async def inject_prompt(self, session_id: str, message: str) -> bool: """Inject a message into a session. - If the session has an active turn, injects immediately. - Otherwise, queues for the next turn and triggers auto-resume. - - Does NOT acquire session.turn_lock. - Args: session_id: The session to inject into. message: The message to inject. - **kwargs: Additional arguments passed to the agent run. Returns: True if injected into active turn, False if queued. """ - return await self.turns.inject_prompt(session_id, message, **kwargs) + return await self.turns.inject_prompt(session_id, message) - async def queue_prompt(self, session_id: str, *prompts: Any, **kwargs: Any) -> bool: + async def queue_prompt(self, session_id: str, *prompts: Any) -> bool: """Queue prompts for a session. - Similar to inject_prompt but for full prompts. - Does NOT acquire session.turn_lock. - Args: session_id: The session to queue prompts for. *prompts: Prompts to queue. - **kwargs: Additional arguments passed to the agent run. Returns: True if queued into active turn, False if stored for later. """ - return await self.turns.queue_prompt(session_id, *prompts, **kwargs) + return await self.turns.queue_prompt(session_id, *prompts) diff --git a/src/agentpool/orchestrator/legacy_runner.py b/src/agentpool/orchestrator/legacy_runner.py new file mode 100644 index 000000000..fb63cfd0a --- /dev/null +++ b/src/agentpool/orchestrator/legacy_runner.py @@ -0,0 +1,494 @@ +"""Legacy turn runner for non-native agents. + +Preserves the manual queue-based turn execution system used by +non-native agents (ACP, ClaudeCode, AGUI). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time +from typing import TYPE_CHECKING, Any +import uuid + +from agentpool.log import get_logger +from agentpool.orchestrator.core import ( + DEFAULT_MAX_AUTO_RESUME, + EventBus, + SessionController, + SessionState, +) +from agentpool.orchestrator.run import RunHandle, RunStatus + + +if TYPE_CHECKING: + from agentpool.agents.context import AgentRunContext + + +logger = get_logger(__name__) + + +class LegacyTurnRunner: + """Manages turn lifecycle and auto-resume for non-native agents. + + Extracted from ``TurnRunner`` to preserve manual queue-based execution + for non-native agents (ACP, ClaudeCode, AGUI). Creates ``RunHandle`` + instances and registers them in ``SessionController._runs``. + + Safety features: + - Per-session injection queue locks + - Max auto-resume iterations (configurable) + - Turn serialization via ``SessionState.turn_lock`` + - Atomic drain operations + - RunHandle tracking in ``SessionController._runs`` + """ + + def __init__( + self, + session_controller: SessionController, + enable_auto_resume: bool = True, + max_auto_resume: int = DEFAULT_MAX_AUTO_RESUME, + ) -> None: + """Initialize the legacy turn runner. + + Args: + session_controller: The session controller for agent lifecycle. + enable_auto_resume: Whether to enable auto-resume loop. + max_auto_resume: Maximum auto-resume iterations. + """ + self.sessions = session_controller + self.event_bus = EventBus(session_controller=session_controller) + self._post_turn_injections: dict[str, list[str]] = {} + self._post_turn_prompts: dict[str, list[tuple[Any, ...]]] = {} + self._injection_locks: dict[str, asyncio.Lock] = {} + self._injection_locks_lock = asyncio.Lock() + self._enable_auto_resume = enable_auto_resume + self._max_auto_resume = max_auto_resume + self._turn_timings: list[tuple[float, float]] = [] + self._max_turn_timing_history: int = 100 + self._background_tasks: set[asyncio.Task[Any]] = set() + self._runs: dict[str, AgentRunContext] = {} + + async def _get_injection_lock(self, session_id: str) -> asyncio.Lock: + """Get or create per-session injection lock. + + Args: + session_id: The session to get the lock for. + + Returns: + The per-session injection lock. + """ + async with self._injection_locks_lock: + lock = self._injection_locks.get(session_id) + if lock is None: + lock = asyncio.Lock() + self._injection_locks[session_id] = lock + return lock + + async def _run_turn_unlocked( # noqa: PLR0915 + self, + session_id: str, + *prompts: Any, + **kwargs: Any, + ) -> None: + """Run a single turn - caller MUST hold ``session.turn_lock``. + + Creates a ``RunHandle`` (when none exists for the run) and + registers it in ``SessionController._runs``. The run context + is taken from the handle so that ``RunHandle.run_ctx`` is the + authoritative context for the turn. + + Args: + session_id: The session to run the turn for. + *prompts: Prompts to pass to the agent. + **kwargs: Additional arguments passed to the agent. + """ + agent = await self.sessions.get_or_create_session_agent(session_id) + _session = self.sessions.get_session(session_id) + + from agentpool.agents.base_agent import _current_run_ctx_var + + run_id_override = self.sessions._pending_run_ids.pop(session_id, None) + run_id = run_id_override or uuid.uuid4().hex + + # Get or create RunHandle + run_handle = self.sessions._runs.get(run_id) + created_run_handle = False + if run_handle is None: + agent_type = ( + _session.metadata.get("agent_type", "unknown") + if _session is not None + else "unknown" + ) + run_handle = RunHandle( + run_id=run_id, + session_id=session_id, + agent_type=agent_type, + ) + self.sessions._runs[run_id] = run_handle + created_run_handle = True + run_handle.start(asyncio.current_task()) + + # Use RunHandle's run_ctx as the authoritative context + run_ctx = run_handle.run_ctx + run_ctx.deps = kwargs.get("deps") + run_ctx.run_id = run_id + run_ctx.cancelled = False + run_ctx.current_task = asyncio.current_task() + run_ctx.event_bus = self.event_bus + run_ctx.session_id = session_id + _current_run_ctx_var.set(run_ctx) + + if _session is not None and _session.current_run_id is None: + _session.current_run_id = run_id + self._runs[run_id] = run_ctx + + async def _consume_event_queue() -> None: + """Consume events from run_ctx.event_queue and publish to EventBus.""" + try: + while True: + event = await run_ctx.event_queue.get() + if event is None: + break + await self.event_bus.publish(session_id, event) + except asyncio.CancelledError: + pass + + event_consumer = asyncio.create_task( + _consume_event_queue(), + name=f"event_consumer_{session_id}", + ) + + turn_start = time.monotonic() + try: + try: + async for event in agent._run_stream_once( + run_ctx, *prompts, session_id=session_id, **kwargs + ): + await self.event_bus.publish(session_id, event) + + run_ctx.injection_manager.flush_pending_to_queue() + while run_ctx.injection_manager.has_queued() and not run_ctx.cancelled: + current_prompts = run_ctx.injection_manager.pop_queued() + if current_prompts is None: + break + async for event in agent._run_stream_once( + run_ctx, *current_prompts, session_id=session_id, **kwargs + ): + await self.event_bus.publish(session_id, event) + run_ctx.injection_manager.flush_pending_to_queue() + except Exception as exc: + if run_handle is not None and run_handle.status not in ( + RunStatus.completed, + RunStatus.failed, + ): + run_handle.fail(exception=exc, event_bus=self.event_bus) + raise + finally: + run_ctx.completed = True + if _session is not None: + _session.current_run_id = None + self._runs.pop(run_id, None) + _current_run_ctx_var.set(None) + + event_consumer.cancel() + with contextlib.suppress(asyncio.CancelledError): + await event_consumer + + turn_end = time.monotonic() + self._turn_timings.append((turn_start, turn_end)) + if len(self._turn_timings) > self._max_turn_timing_history: + self._turn_timings.pop(0) + + # Clean up RunHandle if we created it + if created_run_handle and run_handle is not None: + if run_handle.status not in (RunStatus.completed, RunStatus.failed): + run_handle.complete() + run_handle.complete_event.set() + self.sessions._runs.pop(run_id, None) + + async def run_turn( + self, + session_id: str, + *prompts: Any, + **kwargs: Any, + ) -> None: + """Run a single turn for a session. + + Acquires ``session.turn_lock`` to enforce "1 turn per session". + Events are delivered exclusively via EventBus. + + Args: + session_id: The session to run the turn for. + *prompts: Prompts to pass to the agent. + **kwargs: Additional arguments passed to the agent. + """ + session = await self.sessions.get_or_create_session(session_id) + + async with session.turn_lock: + if session.is_closing: + logger.debug("Session is closing, skipping turn", session_id=session_id) + return + await self._run_turn_unlocked(session_id, *prompts, **kwargs) + + async def run_loop( + self, + session_id: str, + *initial_prompts: Any, + **kwargs: Any, + ) -> None: + """Run a turn loop until no more post-turn work. + + Only one ``run_loop`` per session at a time (enforced by + ``SessionState.turn_lock``). Events are delivered exclusively + via EventBus. + + Args: + session_id: The session to run the loop for. + *initial_prompts: Initial prompts to start the loop. + **kwargs: Additional arguments passed to the agent. + """ + session = await self.sessions.get_or_create_session(session_id) + + async with session.turn_lock: + if session.is_closing: + logger.debug("Session is closing, skipping turn", session_id=session_id) + return + + try: + await self._run_turn_unlocked(session_id, *initial_prompts, **kwargs) + await self._process_queued_work(session_id, session, **kwargs) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Turn loop failed", session_id=session_id) + await self._drain_post_turn_injections(session_id) + await self._drain_post_turn_prompts(session_id) + + async def inject_prompt(self, session_id: str, message: str) -> bool: + """Inject a message into a session. + + If the session has an active turn, injects immediately. + Otherwise, queues for the next turn and triggers auto-resume. + + Does NOT acquire ``session.turn_lock``. + + Args: + session_id: The session to inject into. + message: The message to inject. + + Returns: + True if injected into active turn, False if queued. + """ + session = self.sessions.get_session(session_id) + if session is None or session.agent is None or session.is_closing: + logger.debug( + "Cannot inject: session=%s agent=%s is_closing=%s", + session is not None, + session.agent is not None if session else False, + session.is_closing if session else False, + ) + return False + + agent = session.agent + run_ctx = agent.get_active_run_context() + if run_ctx is not None and not run_ctx.completed: + run_ctx.injection_manager.inject(message) + return True + + lock = await self._get_injection_lock(session_id) + async with lock: + run_ctx = agent.get_active_run_context() + if run_ctx is not None and not run_ctx.completed: + run_ctx.injection_manager.inject(message) + return True + session = self.sessions.get_session(session_id) + if session is None or session.is_closing: + logger.debug("Session closed while waiting for lock") + return False + self._post_turn_injections.setdefault(session_id, []).append(message) + + logger.debug("Queued injection for next turn, triggering auto-resume") + task = asyncio.create_task(self._trigger_auto_resume(session_id)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + return False + + async def queue_prompt(self, session_id: str, *prompts: Any) -> bool: + """Queue prompts for a session. + + Similar to ``inject_prompt`` but for full prompts. + Does NOT acquire ``session.turn_lock``. + + Args: + session_id: The session to queue prompts for. + *prompts: Prompts to queue. + + Returns: + True if queued into active turn, False if stored for later. + """ + session = self.sessions.get_session(session_id) + if session is None or session.agent is None or session.is_closing: + return False + + agent = session.agent + run_ctx = agent.get_active_run_context() + if run_ctx is not None: + run_ctx.injection_manager.queue(*prompts) + return True + + lock = await self._get_injection_lock(session_id) + async with lock: + run_ctx = agent.get_active_run_context() + if run_ctx is not None: + run_ctx.injection_manager.queue(*prompts) + return True + session = self.sessions.get_session(session_id) + if session is None or session.is_closing: + return False + self._post_turn_prompts.setdefault(session_id, []).append(prompts) + + task = asyncio.create_task(self._trigger_auto_resume(session_id)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + return False + + async def _process_queued_work( + self, + session_id: str, + session: SessionState, + **kwargs: Any, + ) -> None: + """Process queued post-turn work under ``turn_lock``. + + Shared logic used by both ``run_loop()`` and + ``_trigger_auto_resume()``. Caller MUST hold + ``session.turn_lock``. + + Args: + session_id: The session to process queued work for. + session: The session state. + **kwargs: Additional arguments passed to the agent. + """ + if session.is_closing: + logger.debug("Session is closing, skipping queued work") + return + + injections = await self._drain_post_turn_injections(session_id) + prompts = await self._drain_post_turn_prompts(session_id) + + logger.debug( + "Drained injections=%s prompts=%s", + len(injections), + len(prompts), + ) + + if injections: + logger.debug("Running turn with injections") + await self._run_turn_unlocked(session_id, *injections, **kwargs) + logger.debug("Turn with injections completed") + + for prompt_group in prompts: + await self._run_turn_unlocked(session_id, *prompt_group, **kwargs) + + for iteration in range(self._max_auto_resume): + if session.is_closing: + logger.debug("Session closing during auto-resume") + break + + injections = await self._drain_post_turn_injections(session_id) + prompts = await self._drain_post_turn_prompts(session_id) + + if not injections and not prompts: + logger.debug("No more queued work, stopping auto-resume") + break + + logger.info( + "Auto-resuming turn", + session_id=session_id, + iteration=iteration + 1, + injections=len(injections), + prompts=len(prompts), + ) + + if injections: + await self._run_turn_unlocked(session_id, *injections, **kwargs) + + for prompt_group in prompts: + await self._run_turn_unlocked(session_id, *prompt_group, **kwargs) + else: + logger.warning( + "Auto-resume loop exceeded max iterations", + session_id=session_id, + max_iterations=self._max_auto_resume, + ) + + async def _trigger_auto_resume(self, session_id: str) -> None: + """Trigger auto-resume for a session if no turn is active. + + Fire-and-forget task that ensures post-turn work queued after + ``run_loop()`` exits gets processed promptly. + + Args: + session_id: The session to trigger auto-resume for. + """ + logger.debug("_trigger_auto_resume called for %s", session_id) + try: + session = self.sessions.get_session(session_id) + if session is None or session.is_closing: + logger.debug("Session not found or closing") + return + + async with session.turn_lock: + if session.is_closing: + logger.debug("Session closing after acquiring lock") + return + + current_session = self.sessions.get_session(session_id) + if current_session is not session: + logger.debug("Session changed") + return + + if self._enable_auto_resume: + logger.debug("Processing queued work") + await self._process_queued_work(session_id, session) + logger.debug("Finished processing queued work") + else: + injections = await self._drain_post_turn_injections(session_id) + prompts = await self._drain_post_turn_prompts(session_id) + + if injections: + await self._run_turn_unlocked(session_id, *injections) + for prompt_group in prompts: + await self._run_turn_unlocked(session_id, *prompt_group) + except asyncio.CancelledError: + return + except Exception: + logger.exception("Auto-resume trigger failed", session_id=session_id) + + async def _drain_post_turn_injections(self, session_id: str) -> list[str]: + """Drain and return post-turn injections for a session (atomic). + + Args: + session_id: The session to drain injections from. + + Returns: + The drained injection messages. + """ + lock = await self._get_injection_lock(session_id) + async with lock: + return self._post_turn_injections.pop(session_id, []) + + async def _drain_post_turn_prompts(self, session_id: str) -> list[tuple[Any, ...]]: + """Drain and return post-turn prompts for a session (atomic). + + Args: + session_id: The session to drain prompts from. + + Returns: + The drained prompt groups. + """ + lock = await self._get_injection_lock(session_id) + async with lock: + return self._post_turn_prompts.pop(session_id, []) diff --git a/src/agentpool/resource_providers/base.py b/src/agentpool/resource_providers/base.py index a4e1c2a31..a4d54a004 100644 --- a/src/agentpool/resource_providers/base.py +++ b/src/agentpool/resource_providers/base.py @@ -200,7 +200,6 @@ async def wrapper(ctx: RunContext[AgentContext], *args: Any, **kwargs: Any) -> A # Copy metadata wrapper.__name__ = tool.name wrapper.__doc__ = tool.description - wrapper.__wrapped__ = original_fn # type: ignore[attr-defined] # Build signature: RunContext + other params (without AgentContext/RunContext) new_params = [inspect.Parameter("ctx", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=RunContext)] diff --git a/src/agentpool/resource_providers/mcp_provider.py b/src/agentpool/resource_providers/mcp_provider.py index d8fce2d9c..93c2bce45 100644 --- a/src/agentpool/resource_providers/mcp_provider.py +++ b/src/agentpool/resource_providers/mcp_provider.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: + from pydantic_ai.capabilities import AbstractCapability from collections.abc import Sequence from types import TracebackType from typing import Literal @@ -78,14 +79,9 @@ def __init__( def as_capability(self) -> AbstractCapability | None: """Return a pydantic-ai capability for this provider. - For ACP-transport MCP servers, falls back to the base class which - wraps get_tools() -> FunctionTool -> pydantic-ai Tool via Toolset - capability. Non-ACP transports rely on MCPManager.as_capability(). + Returns: + A pydantic-ai AbstractCapability instance, or None. """ - from agentpool_config.mcp_server import AcpMCPServerConfig - - if isinstance(self.client.config, AcpMCPServerConfig): - return super().as_capability() # type: ignore[no-any-return] return None def __repr__(self) -> str: diff --git a/src/agentpool/sessions/models.py b/src/agentpool/sessions/models.py index 351c35b86..280c3d1e1 100644 --- a/src/agentpool/sessions/models.py +++ b/src/agentpool/sessions/models.py @@ -125,3 +125,13 @@ def title(self) -> str | None: def updated_at(self) -> str | None: """ISO timestamp of last activity (for protocol compatibility).""" return self.last_active.isoformat() if self.last_active else None + + @property + def parent_tool_call_id(self) -> str | None: + """Parent tool call ID for subagent sessions (from metadata).""" + return self.metadata.get("parent_tool_call_id") + + @property + def subagent_id(self) -> str | None: + """Subagent identifier for delegated sessions (from metadata).""" + return self.metadata.get("subagent_id") diff --git a/src/agentpool/tools/base.py b/src/agentpool/tools/base.py index ec637d067..70e9cea7b 100644 --- a/src/agentpool/tools/base.py +++ b/src/agentpool/tools/base.py @@ -51,6 +51,7 @@ "think", "fetch", "switch_mode", + "subagent", "other", ] diff --git a/src/agentpool_cli/serve_acp.py b/src/agentpool_cli/serve_acp.py index 7872e7dae..6b392b42a 100644 --- a/src/agentpool_cli/serve_acp.py +++ b/src/agentpool_cli/serve_acp.py @@ -130,13 +130,6 @@ def acp_command( # noqa: PLR0915 help='MCP servers configuration as JSON (format: {"mcpServers": {...}})', ), ] = None, - subagent_display_mode: Annotated[ - Literal["inline", "tool_box"] | None, - t.Option( - "--subagent-display-mode", - help="Display subagent: 'inline' or 'tool_box'", - ), - ] = None, ) -> None: r"""Run agents as an ACP (Agent Client Protocol) server. @@ -241,7 +234,6 @@ def update_with_path(nodes: dict[str, Any]) -> dict[str, Any]: agent=agent, load_skills=load_skills, transport=transport_config, - subagent_display_mode=subagent_display_mode, show_events=show_events, show_events_detailed=show_events_detailed, ) diff --git a/src/agentpool_config/pool_server.py b/src/agentpool_config/pool_server.py index a0623ca2c..a8700ad1f 100644 --- a/src/agentpool_config/pool_server.py +++ b/src/agentpool_config/pool_server.py @@ -171,15 +171,6 @@ class ACPPoolServerConfig(BasePoolServerConfig): ) """Whether to raise exceptions during server start.""" - subagent_display_mode: Literal["inline", "tool_box"] = Field( - default="tool_box", - title="Subagent display mode", - ) - """How to display nested agent output in ACP clients: - - "tool_box": Displays subagent output in a tool box (current default) - - "inline": Displays subagent output inline with the main agent's text - """ - transport: Literal["stdio", "streamable-http"] = Field( default="stdio", title="Transport type", diff --git a/src/agentpool_config/session_pool.py b/src/agentpool_config/session_pool.py index 684fdb3f9..5cad61da4 100644 --- a/src/agentpool_config/session_pool.py +++ b/src/agentpool_config/session_pool.py @@ -39,12 +39,8 @@ class SessionPoolConfig(Schema): class ACPConfig(Schema): """ACP protocol-specific configuration.""" - use_session_pool: bool = Field(default=True, title="Use session pool") - """Whether to use the SessionPool for ACP protocol session management. - - Defaults to True as SessionPool is the mandatory execution entry point - per the sessionpool-only-execution spec. Setting to False is deprecated. - """ + use_session_pool: bool = Field(default=False, title="Use session pool") + """Whether to use the SessionPool for ACP protocol session management.""" model_config = ConfigDict(frozen=True) @@ -52,11 +48,7 @@ class ACPConfig(Schema): class OpenCodeConfig(Schema): """OpenCode protocol-specific configuration.""" - use_session_pool: bool = Field(default=True, title="Use session pool") - """Whether to use the SessionPool for OpenCode protocol session management. - - Defaults to True as SessionPool is the mandatory execution entry point - per the sessionpool-only-execution spec. Setting to False is deprecated. - """ + use_session_pool: bool = Field(default=False, title="Use session pool") + """Whether to use the SessionPool for OpenCode protocol session management.""" model_config = ConfigDict(frozen=True) diff --git a/src/agentpool_server/acp_server/acp_agent.py b/src/agentpool_server/acp_server/acp_agent.py index 2e803e56a..cd328acb9 100644 --- a/src/agentpool_server/acp_server/acp_agent.py +++ b/src/agentpool_server/acp_server/acp_agent.py @@ -37,6 +37,8 @@ SetSessionModelResponse, SetSessionModeRequest, SetSessionModeResponse, + SubagentCapabilities, + SubagentInfo, ) from agentpool.log import get_logger from agentpool.utils.tasks import TaskManager @@ -45,6 +47,7 @@ from agentpool_server.acp_server.converters import to_session_config_option, to_session_info from agentpool_server.acp_server.provider_router import ProviderRouter from agentpool_server.acp_server.session_manager import ACPSessionManager +from agentpool_server.acp_server.subagent_catalog import SubagentCatalogProvider if TYPE_CHECKING: @@ -225,15 +228,15 @@ class AgentPoolACPAgent(ACPAgent): server: ACPServer | None = field(default=None) """Reference to the ACPServer for pool hot-switching.""" - subagent_display_mode: Literal["inline", "tool_box"] = "tool_box" - """Display mode for subagent outputs (inline or tool_box).""" - _skill_bridge: ACPSkillBridge | None = field(init=False, default=None) """Bridge for exposing skill commands as ACP slash commands.""" _mcp_manager: AcpMcpConnectionManager = field(init=False) """Manager for MCP-over-ACP connection lifecycle.""" + _catalog_provider: SubagentCatalogProvider = field(init=False) + """Provider for subagent catalog with debounced updates.""" + _protocol_handler: ACPProtocolHandler | None = field(init=False, default=None) """SessionPool-backed protocol handler when ``acp.use_session_pool`` is enabled.""" @@ -259,8 +262,12 @@ def __post_init__(self) -> None: self._mcp_manager = AcpMcpConnectionManager() # RFC-0034: Initialize provider router with None manifest (will be updated in initialize) self.provider_router = ProviderRouter(None) + # Initialize subagent catalog provider with debounced updates + self._catalog_provider = SubagentCatalogProvider(pool=self.agent_pool) + self._catalog_provider.register_update_callback(self._on_catalog_updated) # NEW: Cache agent config for per-session creation (RFC-0031) from agentpool.models.agents import NativeAgentConfig + if ( self.agent_pool and self.agent_pool.main_agent @@ -273,19 +280,13 @@ def __post_init__(self) -> None: self._agent_config = cfg # Initialize SessionPool-backed protocol handler if feature flag is enabled - if ( - self.agent_pool - and self.agent_pool.manifest.acp.use_session_pool - ): + if self.agent_pool and self.agent_pool.manifest.acp.use_session_pool: from agentpool_server.acp_server.event_converter import ACPEventConverter from agentpool_server.acp_server.handler import ACPProtocolHandler self._protocol_handler = ACPProtocolHandler( agent_pool=self.agent_pool, - session_manager=self.session_manager, - event_converter=ACPEventConverter( - subagent_display_mode=self.subagent_display_mode, - ), + event_converter=ACPEventConverter(), client=self.client, client_capabilities=self.client_capabilities, ) @@ -365,7 +366,45 @@ def agent_pool(self) -> AgentPool[Any] | None: """Get the agent pool from the default agent.""" return self.default_agent.agent_pool - # Note: Tool registration happens after initialize() when we know client caps + def get_subagent_catalog( + self, ancestor_agent_ids: set[str] | None = None + ) -> list[SubagentInfo]: + """Get the current subagent catalog, optionally filtering ancestors. + + Args: + ancestor_agent_ids: Optional set of agent IDs to exclude to + prevent circular delegation. + + Returns: + List of SubagentInfo for available subagents. + """ + return self._catalog_provider.get_catalog(ancestor_agent_ids=ancestor_agent_ids) + + def _get_available_subagents(self) -> list[SubagentInfo]: + """Build list of available subagents from the pool. + + Returns: + List of SubagentInfo for each agent in the pool. + """ + return self.get_subagent_catalog() + + async def _on_catalog_updated(self, catalog: list[SubagentInfo]) -> None: + """Handle catalog updates by notifying all active sessions. + + Args: + catalog: The updated subagent catalog. + """ + from acp.schema import AvailableSubagentsUpdate + + update = AvailableSubagentsUpdate(available_subagents=catalog) + for session in list(self.session_manager._active.values()): + try: + await session.notifications.send_update(update) + except Exception: + logger.exception( + "Failed to send catalog update", + session_id=session.session_id, + ) async def initialize(self, params: InitializeRequest) -> InitializeResponse: """Initialize the agent and negotiate capabilities.""" @@ -402,9 +441,18 @@ async def initialize(self, params: InitializeRequest) -> InitializeResponse: embedded_context_prompts=True, image_prompts=True, providers=True, + subagents=SubagentCapabilities( + prompt_delegation=True, + background=True, + ), turn_complete=turn_complete, ) + @property + def prompt_delegation_enabled(self) -> bool: + """Whether prompt delegation capability is advertised to clients.""" + return True + async def new_session(self, params: NewSessionRequest) -> NewSessionResponse: """Create a new session.""" from agentpool.agents.acp_agent import ACPAgent as ACPAgentClient @@ -422,7 +470,6 @@ async def new_session(self, params: NewSessionRequest) -> NewSessionResponse: mcp_servers=params.mcp_servers, client_capabilities=self.client_capabilities, client_info=self.client_info, - subagent_display_mode=self.subagent_display_mode, ) state: SessionModeState | None = None models: SessionModelState | None = None @@ -476,6 +523,7 @@ async def new_session(self, params: NewSessionRequest) -> NewSessionResponse: modes=state, models=models, config_options=config_options if config_options else None, + available_subagents=self._get_available_subagents(), ) async def load_session(self, params: LoadSessionRequest) -> LoadSessionResponse: @@ -502,7 +550,6 @@ async def load_session(self, params: LoadSessionRequest) -> LoadSessionResponse: client_capabilities=self.client_capabilities, client_info=self.client_info, session_id=params.session_id, - subagent_display_mode=self.subagent_display_mode, ) session = self.session_manager.get_session(session_id) @@ -537,7 +584,12 @@ async def load_session(self, params: LoadSessionRequest) -> LoadSessionResponse: self.tasks.create_task(session.send_available_commands_update()) self.tasks.create_task(session.agent.load_rules(session.cwd)) logger.info("Session loaded", session_id=params.session_id) - return LoadSessionResponse(models=models, modes=mode_state, config_options=config_opts) + return LoadSessionResponse( + models=models, + modes=mode_state, + config_options=config_opts, + available_subagents=self._get_available_subagents(), + ) except Exception: logger.exception("Failed to load session", session_id=params.session_id) return LoadSessionResponse() @@ -601,9 +653,11 @@ async def fork_session(self, params: ForkSessionRequest) -> ForkSessionResponse: mcp_servers=params.mcp_servers, client_capabilities=self.client_capabilities, client_info=self.client_info, - subagent_display_mode=self.subagent_display_mode, ) - return ForkSessionResponse(session_id=session_id) + return ForkSessionResponse( + session_id=session_id, + available_subagents=self._get_available_subagents(), + ) async def resume_session(self, params: ResumeSessionRequest) -> ResumeSessionResponse: """Resume an existing session without replaying history. @@ -630,7 +684,6 @@ async def resume_session(self, params: ResumeSessionRequest) -> ResumeSessionRes client_capabilities=self.client_capabilities, client_info=self.client_info, session_id=params.session_id, - subagent_display_mode=self.subagent_display_mode, ) session = self.session_manager.get_session(session_id) @@ -648,7 +701,9 @@ async def resume_session(self, params: ResumeSessionRequest) -> ResumeSessionRes self.tasks.create_task(session.send_available_commands_update()) self.tasks.create_task(session.agent.load_rules(session.cwd)) logger.info("Session resumed", session_id=params.session_id) - return ResumeSessionResponse() + return ResumeSessionResponse( + available_subagents=self._get_available_subagents(), + ) except Exception: logger.exception("Failed to resume session", session_id=params.session_id) @@ -701,7 +756,6 @@ async def prompt(self, params: PromptRequest) -> PromptResponse: session_id=params.session_id, client_capabilities=self.client_capabilities, client_info=self.client_info, - subagent_display_mode=self.subagent_display_mode, ) if session := self.session_manager.get_session(params.session_id): # Initialize session extras @@ -714,7 +768,7 @@ async def prompt(self, params: PromptRequest) -> PromptResponse: try: if not session: raise ValueError(f"Session {params.session_id} not found") # noqa: TRY301 - stop_reason = await session.process_prompt(params.prompt) + stop_reason = await session.process_prompt(params.prompt, delegation=params.delegation) # Return the actual stop reason from the session except Exception as e: logger.exception("Failed to process prompt", session_id=params.session_id) @@ -790,6 +844,7 @@ async def set_provider(self, params: SetProvidersRequest) -> SetProvidersRespons ) except ValueError as e: from acp.exceptions import RequestError + raise RequestError.invalid_params({"id": params.id}) from e return SetProvidersResponse() @@ -799,6 +854,7 @@ async def disable_provider(self, params: DisableProvidersRequest) -> DisableProv await self.provider_router.disable_provider(params.id) except ValueError as e: from acp.exceptions import RequestError + raise RequestError.invalid_params({"id": params.id}) from e return DisableProvidersResponse() @@ -953,9 +1009,7 @@ async def send_to_client(message: dict[str, Any]) -> Any: with anyio.fail_after(30): return await self.client.send_request("mcp/message", message) - await self._mcp_manager.create_connection( - connection_id, server, send_to_client - ) + await self._mcp_manager.create_connection(connection_id, server, send_to_client) logger.info( "ACP MCP server connected", server_name=server.name, @@ -972,9 +1026,7 @@ async def disconnect_acp_mcp_server(self, connection_id: str) -> None: connection_id: The connection ID to disconnect. """ try: - await self.client.send_request( - "mcp/disconnect", {"connectionId": connection_id} - ) + await self.client.send_request("mcp/disconnect", {"connectionId": connection_id}) except Exception: logger.exception( "Failed to send mcp/disconnect to client", @@ -1251,6 +1303,7 @@ async def swap_pool(self, config_path: str, agent_name: str | None = None) -> li if pool.main_agent and pool.main_agent.name in pool.manifest.agents: cfg = pool.manifest.agents[pool.main_agent.name] from agentpool.models.agents import NativeAgentConfig + if isinstance(cfg, NativeAgentConfig): if cfg.name is None: cfg = cfg.model_copy(update={"name": pool.main_agent.name}) @@ -1258,6 +1311,7 @@ async def swap_pool(self, config_path: str, agent_name: str | None = None) -> li elif pool.manifest.agents: cfg = next(iter(pool.manifest.agents.values())) from agentpool.models.agents import NativeAgentConfig + if isinstance(cfg, NativeAgentConfig): self._agent_config = cfg else: diff --git a/src/agentpool_server/acp_server/acp_mcp_manager.py b/src/agentpool_server/acp_server/acp_mcp_manager.py index d0f4d4548..ae24eac65 100644 --- a/src/agentpool_server/acp_server/acp_mcp_manager.py +++ b/src/agentpool_server/acp_server/acp_mcp_manager.py @@ -172,9 +172,7 @@ async def handle_client_message(self, message: dict[str, Any]) -> None: if isinstance(message, SessionMessage): await self._to_session_send.send(message) else: - session_msg = SessionMessage( - message=JSONRPCMessage.model_validate(message) - ) + session_msg = SessionMessage(message=JSONRPCMessage.model_validate(message)) await self._to_session_send.send(session_msg) except (anyio.ClosedResourceError, anyio.EndOfStream): logger.debug( @@ -192,9 +190,7 @@ async def send_to_client(self, message: Any) -> Any: Response from client (for requests) or None (for notifications). """ if isinstance(message, SessionMessage): - message = message.message.model_dump( - by_alias=True, mode="json", exclude_none=True - ) + message = message.message.model_dump(by_alias=True, mode="json", exclude_none=True) # NEW: Check if this is a response to a pending client-initiated request if ( @@ -219,30 +215,7 @@ async def send_to_client(self, message: Any) -> Any: if isinstance(result, dict) and self._to_session_send is not None: if "jsonrpc" in result: # Client returned standard JSON-RPC response - try: - result = _sanitize_jsonrpc_error(result) - session_msg = SessionMessage( - message=JSONRPCMessage.model_validate(result) - ) - except Exception: - logger.exception( - "Invalid JSON-RPC response from mcp/message", - connection_id=self.connection_id, - response=result, - ) - # Build a fallback error response so the session doesn't hang - request_id = result.get("id", 0) - fallback = { - "jsonrpc": "2.0", - "id": request_id, - "error": { - "code": -32603, - "message": "Invalid JSON-RPC response from ACP client", - }, - } - session_msg = SessionMessage( - message=JSONRPCMessage.model_validate(fallback) - ) + session_msg = SessionMessage(message=JSONRPCMessage.model_validate(result)) try: await self._to_session_send.send(session_msg) except anyio.BrokenResourceError: diff --git a/src/agentpool_server/acp_server/acp_mcp_transport.py b/src/agentpool_server/acp_server/acp_mcp_transport.py index 0581559c1..d0e756762 100644 --- a/src/agentpool_server/acp_server/acp_mcp_transport.py +++ b/src/agentpool_server/acp_server/acp_mcp_transport.py @@ -34,7 +34,9 @@ class AcpMcpTransport(ClientTransport): with an MCP server over the existing ACP connection. """ - def __init__(self, connection: AcpMcpConnection, timeout: float = DEFAULT_READ_TIMEOUT_SECONDS) -> None: + def __init__( + self, connection: AcpMcpConnection, timeout: float = DEFAULT_READ_TIMEOUT_SECONDS + ) -> None: """Initialize the transport with an active ACP MCP connection. Args: diff --git a/src/agentpool_server/acp_server/commands/debug_commands.py b/src/agentpool_server/acp_server/commands/debug_commands.py index f72234cdd..58f4ad98e 100644 --- a/src/agentpool_server/acp_server/commands/debug_commands.py +++ b/src/agentpool_server/acp_server/commands/debug_commands.py @@ -101,7 +101,7 @@ async def execute_command( ctx: Command context title: Tool call title/description kind: Tool kind ('read', 'edit', 'delete', 'move', 'search', - 'execute', 'think', 'fetch', 'other') + 'execute', 'think', 'fetch', 'subagent', 'other') """ session = ctx.context.data assert session diff --git a/src/agentpool_server/acp_server/converters.py b/src/agentpool_server/acp_server/converters.py index fbf3f7ec7..4583c2fc7 100644 --- a/src/agentpool_server/acp_server/converters.py +++ b/src/agentpool_server/acp_server/converters.py @@ -196,11 +196,18 @@ def to_session_config_option(category: ModeCategory) -> SessionConfigOption: def to_session_info(session_data: SessionData) -> SessionInfo: + meta: dict[str, Any] = dict(session_data.metadata) if session_data.metadata else {} + # Compute depth: 0 for root sessions, 1 for direct children + # (full nested depth computation would require traversing the parent chain) + depth = 0 if session_data.parent_id is None else 1 return SessionInfo( session_id=session_data.session_id, cwd=session_data.cwd or "", title=session_data.title, updated_at=session_data.updated_at, + meta=meta if meta else None, + parent_session_id=session_data.parent_id, + depth=depth, ) diff --git a/src/agentpool_server/acp_server/event_converter.py b/src/agentpool_server/acp_server/event_converter.py index bfb3dfbd9..e1feb55de 100644 --- a/src/agentpool_server/acp_server/event_converter.py +++ b/src/agentpool_server/acp_server/event_converter.py @@ -11,8 +11,7 @@ from __future__ import annotations from dataclasses import dataclass, field -import os -from typing import TYPE_CHECKING, Any, Literal, assert_never +from typing import TYPE_CHECKING, Any import uuid from pydantic_ai import ( @@ -22,7 +21,6 @@ FunctionToolCallEvent, FunctionToolResultEvent, PartDeltaEvent, - PartEndEvent, PartStartEvent, RetryPromptPart, TextPart, @@ -33,7 +31,6 @@ ToolCallPartDelta, ToolReturnPart, ) -from pydantic_ai.messages import BuiltinToolCallEvent, BuiltinToolResultEvent from acp.schema import ( AgentMessageChunk, @@ -41,6 +38,7 @@ AgentThoughtChunk, ContentToolCallContent, Cost, + SubagentRunInfo, ToolCallLocation, ToolCallProgress, ToolCallStart, @@ -51,33 +49,28 @@ from acp.utils import generate_tool_title, infer_tool_kind, to_acp_content_blocks from agentpool.agents.events import ( CompactionEvent, - CustomEvent, DiffContentItem, FileContentItem, LocationContentItem, PlanUpdateEvent, RunErrorEvent, - RunStartedEvent, + RichAgentStreamEvent, SpawnSessionStart, StreamCompleteEvent, SubAgentEvent, TerminalContentItem, TextContentItem, - ToolCallCompleteEvent, ToolCallProgressEvent, ToolCallStartEvent, - ToolResultMetadataEvent, ) from agentpool.log import get_logger from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict if TYPE_CHECKING: - from collections.abc import AsyncGenerator, AsyncIterator + from collections.abc import AsyncIterator from acp.schema.tool_call import ToolCallContent, ToolCallKind - from agentpool.agents.events import RichAgentStreamEvent - from agentpool.tools.base import ToolKind logger = get_logger(__name__) @@ -102,20 +95,6 @@ # ============================================================================ -def _get_display_mode() -> Literal["legacy", "inline", "tool_box"]: - """Get the subagent display mode from environment variable. - - Reads from ACP_SUBAGENT_DISPLAY_MODE env var, defaults to "legacy". - - Returns: - Display mode value: "legacy", "inline", or "tool_box" - """ - mode = os.getenv("ACP_SUBAGENT_DISPLAY_MODE", "legacy") - if mode not in ("legacy", "inline", "tool_box"): - return "legacy" - return mode # type: ignore[return-value] - - def get_compaction_text(trigger: str) -> str: if trigger == "auto": return "\n\n---\n\n📦 **Context compaction** triggered. Summarizing...\n\n---\n\n" @@ -135,38 +114,6 @@ class _ToolState: has_content: bool = False -@dataclass -class _SubagentInlineState: - """State for inline subagent display mode. - - Tracks active tool call IDs and accumulated content for text output and thinking. - """ - - source_name: str - depth: int - text_output_call_id: str | None = None - thinking_call_id: str | None = None - text_content: list[str] = field(default_factory=list) - thinking_content: list[str] = field(default_factory=list) - created_at: float = field(default_factory=lambda: __import__("time").time()) - - -@dataclass -class _SubagentToolBoxState: - """State for tool_box subagent display mode. - - Tracks header status and content accumulation for subagent display. - """ - - source_name: str - depth: int - invocation_id: str - header_sent: bool = False - content: list[str] = field(default_factory=list) - title: str | None = None - created_at: float = field(default_factory=lambda: __import__("time").time()) - - # ============================================================================ # Event Converter # ============================================================================ @@ -188,16 +135,6 @@ class ACPEventConverter: ``` """ - # Feature flag for subagent display mode - # Reads from ACP_SUBAGENT_DISPLAY_MODE env var, defaults to "legacy" for backward compatibility - _display_mode: Literal["legacy", "inline", "tool_box"] = field( - default_factory=_get_display_mode, - ) - - # Legacy mode fields (deprecated) - subagent_display_mode: Literal["legacy", "inline", "tool_box"] = "legacy" - """How to display subagent output. Deprecated: Use ACP_SUBAGENT_DISPLAY_MODE env var instead.""" - # Feature flag for TurnCompleteUpdate emission client_supports_turn_complete: bool = False """Whether the connected ACP client supports TurnCompleteUpdate. @@ -214,62 +151,35 @@ class ACPEventConverter: _current_tool_inputs: dict[str, dict[str, Any]] = field(default_factory=dict) """Current tool inputs by tool_call_id.""" - _subagent_headers: set[str] = field(default_factory=set) - """Track which subagent headers have been sent (for inline mode).""" - - _subagent_content: dict[str, list[str]] = field(default_factory=dict) - """Accumulated content per subagent (for tool_box mode).""" - _current_message_id: str = field(default_factory=lambda: str(uuid.uuid4())) """Message ID for the current agent response.""" last_usage: Usage | None = field(default=None, init=False) """Usage from the last completed stream, if available.""" - """Accumulated content per subagent (for tool_box mode).""" - - _current_message_id: str = field(default_factory=lambda: str(uuid.uuid4())) - """Message ID for the current agent response.""" - """Accumulated content per subagent (for tool_box mode).""" - - # New state management - _subagent_inline_states: dict[str, _SubagentInlineState] = field(default_factory=dict) - """Inline subagent states keyed by composite key.""" - - _subagent_toolbox_states: dict[str, _SubagentToolBoxState] = field(default_factory=dict) - """Tool_box subagent states keyed by composite key.""" - MAX_STATES: int = 100 - """Maximum number of subagent states to prevent DoS attacks.""" + _subagent_tool_map: dict[str, str] = field(default_factory=dict) + """Maps child_session_id → tool_call_id for subagent tracking.""" - STATE_TTL: float = 3600.0 - """Time-to-live for subagent states in seconds (1 hour).""" - - def __post_init__(self) -> None: - """Reconcile _display_mode with subagent_display_mode if env var not set. - - The ACP_SUBAGENT_DISPLAY_MODE environment variable takes precedence. - If not set, use the deprecated subagent_display_mode parameter. - """ - if "ACP_SUBAGENT_DISPLAY_MODE" not in os.environ: - self._display_mode = self.subagent_display_mode + _foreground_children: set[str] = field(default_factory=set) + """Set of child_session_ids running in foreground mode.""" def reset(self) -> None: """Reset converter state for a new run.""" self._tool_states.clear() self._current_tool_inputs.clear() - self._subagent_headers.clear() - self._subagent_content.clear() - self._subagent_inline_states.clear() - self._subagent_toolbox_states.clear() + self._subagent_tool_map.clear() + self._foreground_children.clear() self._current_message_id = str(uuid.uuid4()) self.last_usage = None - """Reset converter state for a new run.""" - self._tool_states.clear() - self._current_tool_inputs.clear() - self._subagent_headers.clear() - self._subagent_content.clear() - self._subagent_inline_states.clear() - self._subagent_toolbox_states.clear() + + def cleanup(self) -> None: + """Clean up all tracked subagent state. + + Should be called when the parent session is closed to ensure + no memory leaks from orphaned subagent tracking state. + """ + self._subagent_tool_map.clear() + self._foreground_children.clear() async def cancel_pending_tools(self) -> AsyncIterator[ToolCallProgress]: """Cancel all pending tool calls. @@ -313,117 +223,6 @@ def _cleanup_tool_state(self, tool_call_id: str) -> None: self._tool_states.pop(tool_call_id, None) self._current_tool_inputs.pop(tool_call_id, None) - def _generate_composite_key(self, source_name: str, depth: int) -> str: - """Generate composite key for subagent state. - - Args: - source_name: Name of the subagent source - depth: Nesting depth of the subagent call - - Returns: - Composite key string in format "source_name:depth" - """ - return f"{source_name}:{depth}" - - def _cleanup_expired_states(self) -> None: - """Clean up expired states based on TTL to prevent memory leaks.""" - import time - - current_time = time.time() - cutoff_time = current_time - self.STATE_TTL - - # Clean inline states - self._subagent_inline_states = { - key: state - for key, state in self._subagent_inline_states.items() - if state.created_at > cutoff_time - } - - # Clean tool_box states - self._subagent_toolbox_states = { - key: state - for key, state in self._subagent_toolbox_states.items() - if state.created_at > cutoff_time - } - - def _get_or_create_inline_state(self, source_name: str, depth: int) -> _SubagentInlineState: - """Get existing inline state or create a new one. - - Args: - source_name: Name of the subagent source - depth: Nesting depth of the subagent call - - Returns: - _SubagentInlineState instance - - Raises: - RuntimeError: If maximum number of states exceeded (DoS protection) - """ - # Clean up expired states first - self._cleanup_expired_states() - - # Create composite key (using only source_name and depth) - key = self._generate_composite_key(source_name, depth) - - # Return existing state if found (preserves invocation_id) - if key in self._subagent_inline_states: - return self._subagent_inline_states[key] - - # Enforce MAX_STATES limit - if len(self._subagent_inline_states) >= self.MAX_STATES: - raise RuntimeError( - f"Maximum subagent states ({self.MAX_STATES}) exceeded. " - "This may indicate a DoS attack or memory leak." - ) - - # Create new state - new_state = _SubagentInlineState( - source_name=source_name, - depth=depth, - ) - self._subagent_inline_states[key] = new_state - return new_state - - def _get_or_create_toolbox_state(self, source_name: str, depth: int) -> _SubagentToolBoxState: - """Get existing toolbox state or create a new one. - - Args: - source_name: Name of the subagent source - depth: Nesting depth of the subagent call - - Returns: - _SubagentToolBoxState instance - - Raises: - RuntimeError: If maximum number of states exceeded (DoS protection) - """ - # Clean up expired states first - self._cleanup_expired_states() - - # Create composite key (using only source_name and depth) - key = self._generate_composite_key(source_name, depth) - - # Return existing state if found (preserves invocation_id) - if key in self._subagent_toolbox_states: - return self._subagent_toolbox_states[key] - - # Enforce MAX_STATES limit - if len(self._subagent_toolbox_states) >= self.MAX_STATES: - raise RuntimeError( - f"Maximum subagent states ({self.MAX_STATES}) exceeded. " - "This may indicate a DoS attack or memory leak." - ) - - # Create new state with fresh invocation_id - invocation_id = str(uuid.uuid4()) - new_state = _SubagentToolBoxState( - source_name=source_name, - depth=depth, - invocation_id=invocation_id, - ) - self._subagent_toolbox_states[key] = new_state - return new_state - async def convert( # noqa: PLR0915 self, event: RichAgentStreamEvent[Any] ) -> AsyncIterator[ACPSessionUpdate]: @@ -744,7 +543,6 @@ async def convert( # noqa: PLR0915 # See: https://github.com/agentclientprotocol/agent-client-protocol/pull/644 if self.client_supports_turn_complete: yield TurnCompleteUpdate(stop_reason="end_turn") - self.reset() # Clean up all subagent states when stream completes # Prevents memory leaks by removing accumulated state self.reset() @@ -761,37 +559,74 @@ async def convert( # noqa: PLR0915 yield AgentMessageChunk.text(text, message_id=self._current_message_id) case SpawnSessionStart( - # source_name=source_name, - # description=description, - # spawn_mechanism=spawn_mechanism, + child_session_id=child_id, + parent_session_id=_parent_id, + tool_call_id=tc_id, + source_name=source_name, + spawn_mechanism=mechanism, + description=description, + depth=depth, + run_mode=run_mode, ): - # icon = "⚡" if spawn_mechanism == "spawn" else "🚀" - # text = f"\n{icon} **`{source_name}`**: {description}\n" - # yield AgentMessageChunk.text(text) - ... + yield ToolCallStart( + tool_call_id=tc_id or f"subagent:{child_id}", + title=f"{'⚡' if mechanism == 'spawn' else '🚀'} {source_name}", + kind="subagent", + subagent=SubagentRunInfo( + subagent_id=source_name, + name=source_name, + description=description, + depth=depth, + status="running", + child_session_id=child_id, + run_mode=run_mode, + ), + status="in_progress", + ) + self._subagent_tool_map[child_id] = tc_id or f"subagent:{child_id}" + if run_mode == "foreground": + self._foreground_children.add(child_id) case SubAgentEvent( + child_session_id=child_id, source_name=source_name, - source_type=source_type, - event=inner_event, - depth=depth, - ): - match self._display_mode: - case "inline": - async for update in self._convert_subagent_inline( - source_name, source_type, inner_event, depth - ): - yield update - case "tool_box": - async for update in self._convert_subagent_tool_box( - source_name, source_type, inner_event, depth - ): - yield update - case _: - async for update in self._convert_subagent_legacy( - source_name, source_type, inner_event, depth - ): - yield update + event=StreamCompleteEvent(), + ) if child_id is not None: + tc_id = self._subagent_tool_map.get(child_id) + if tc_id: + yield ToolCallProgress( + tool_call_id=tc_id, + status="completed", + subagent=SubagentRunInfo( + subagent_id=source_name, + name=source_name, + child_session_id=child_id, + status="completed", + ), + ) + self._foreground_children.discard(child_id) + self._subagent_tool_map.pop(child_id, None) + + case SubAgentEvent( + child_session_id=child_id, + source_name=source_name, + event=RunErrorEvent(message=msg), + ) if child_id is not None: + tc_id = self._subagent_tool_map.get(child_id) + if tc_id: + yield ToolCallProgress( + tool_call_id=tc_id, + status="failed", + content=[ContentToolCallContent.text(f"Error: {msg}")], + subagent=SubagentRunInfo( + subagent_id=source_name, + name=source_name, + child_session_id=child_id, + status="failed", + ), + ) + self._foreground_children.discard(child_id) + self._subagent_tool_map.pop(child_id, None) case RunErrorEvent(message=message, agent_name=agent_name): # Display error as agent text with formatting @@ -804,345 +639,4 @@ async def convert( # noqa: PLR0915 # Handles future events like ToolRequiresAuthEvent without crashing logger.debug("Unhandled event", event_type=type(event).__name__) - async def _convert_subagent_inline( # noqa: PLR0915 - self, - source_name: str, - _source_type: Literal["agent", "team_parallel", "team_sequential"], - inner_event: RichAgentStreamEvent[Any], - depth: int, - ) -> AsyncIterator[ACPSessionUpdate]: - """Convert subagent event to inline tool notifications (New Mode). - - Each distinct event type (text, thinking, tool calls) becomes an independent - tool call with the subagent name prefixed to the tool name. - PartStartEvent creates a new tool call, PartDeltaEvent accumulates content. - Multi-turn patterns (think→output→tool_call→think) create independent tool calls. - """ - state = self._get_or_create_inline_state(source_name, depth) - - match inner_event: - case PartStartEvent(part=TextPart(content=delta)): - # New text part = new tool call - state.text_output_call_id = f"{source_name}:output:{uuid.uuid4()}" - if delta: - state.text_content = [delta] if delta else [] - full_content = "".join(state.text_content) - else: - full_content = None - yield ToolCallStart( - tool_call_id=state.text_output_call_id, - title=f"[`{source_name}`] Output", - kind="other", - status="pending", - content=[ContentToolCallContent.text(text=full_content)] - if full_content - else None, - ) - - case PartDeltaEvent(delta=TextPartDelta(content_delta=delta)): - # Accumulate text content and send update - if state.text_output_call_id and delta: - text_chunk: str = delta - state.text_content.append(text_chunk) - full_text = "".join(state.text_content) - yield ToolCallProgress( - tool_call_id=state.text_output_call_id, - status="in_progress", - content=[ContentToolCallContent.text(text=full_text)], - ) - - case PartStartEvent(part=ThinkingPart(content=delta)): - # New thinking part = new tool call - state.thinking_call_id = f"{source_name}:think:{uuid.uuid4()}" - state.thinking_content = [delta] if delta else [] - yield ToolCallStart( - tool_call_id=state.thinking_call_id, - title=f"[`{source_name}`] Thinking", - kind="think", - status="pending", - ) - # Send initial progress with accumulated content - full_text = "".join(state.thinking_content) - yield ToolCallProgress( - tool_call_id=state.thinking_call_id, - status="in_progress", - content=[ContentToolCallContent.text(text=full_text)], - ) - - case PartDeltaEvent(delta=ThinkingPartDelta(content_delta=delta)): - # Accumulate thinking content and send update - if state.thinking_call_id and delta: - thinking_chunk: str = delta - state.thinking_content.append(thinking_chunk) - full_text = "".join(state.thinking_content) - yield ToolCallProgress( - tool_call_id=state.thinking_call_id, - status="in_progress", - content=[ContentToolCallContent.text(text=full_text)], - ) - - case FunctionToolCallEvent(part=part): - # Each tool call is independent with prefixed name - prefixed_tool_name = f"{source_name}:{part.tool_name}" - tool_call_id = f"{prefixed_tool_name}:{part.tool_call_id}" - tool_input = safe_args_as_dict(part, default={}) - title = generate_tool_title(prefixed_tool_name, tool_input) - kind = infer_tool_kind(prefixed_tool_name) - - yield ToolCallStart( - tool_call_id=tool_call_id, - title=f"[`{source_name}`]: {title}", - kind=kind, - raw_input=tool_input, - status="pending", - ) - - case FunctionToolResultEvent( - result=ToolReturnPart() as result, - tool_call_id=original_id, - ): - # Complete tool call with prefixed name - prefixed_tool_name = f"{source_name}:{result.tool_name}" - tool_call_id = f"{prefixed_tool_name}:{original_id}" - - # Handle async generator content (same as main converter) - if isinstance(result.content, AsyncGenerator): - full_content = "" - async for chunk in result.content: - full_content += str(chunk) - yield ToolCallProgress( - tool_call_id=tool_call_id, - status="in_progress", - raw_output=chunk, - ) - result.content = full_content - final_output = full_content - else: - final_output = str(result.content) - - # Convert to content blocks and send completion - converted = to_acp_content_blocks(final_output) - content_items = [ContentToolCallContent(content=block) for block in converted] - yield ToolCallProgress( - tool_call_id=tool_call_id, - status="completed", - raw_output=final_output, - content=content_items, - ) - - case FunctionToolResultEvent( - result=RetryPromptPart(tool_name=tool_name) as result, - tool_call_id=original_id, - ): - # Mark tool call as failed with prefixed name - prefixed_tool_name = f"{source_name}:{tool_name}" - tool_call_id = f"{prefixed_tool_name}:{original_id}" - - error_msg = result.model_response() - yield ToolCallProgress( - tool_call_id=tool_call_id, - status="failed", - raw_output=error_msg, - content=[ContentToolCallContent.text(text=f"Error: {error_msg}")], - ) - - case StreamCompleteEvent(): - # Complete any pending text or thinking tool calls - if state.text_output_call_id: - yield ToolCallProgress( - tool_call_id=state.text_output_call_id, - status="completed", - ) - if state.thinking_call_id: - yield ToolCallProgress( - tool_call_id=state.thinking_call_id, - status="completed", - ) - # Clean up any state that was created - key = self._generate_composite_key(source_name, depth) - self._subagent_inline_states.pop(key, None) - - case _: - pass - - async def _convert_subagent_legacy( - self, - source_name: str, - source_type: Literal["agent", "team_parallel", "team_sequential"], - inner_event: RichAgentStreamEvent[Any], - depth: int, - ) -> AsyncIterator[ACPSessionUpdate]: - """Convert subagent event to legacy inline text notifications.""" - indent = " " * depth - icon = "🤖" if source_type == "agent" else "👥" - - match inner_event: - case ( - PartStartEvent(part=TextPart(content=delta)) - | PartDeltaEvent(delta=TextPartDelta(content_delta=delta)) - ): - header_key = f"`{source_name}`:{depth}" - if header_key not in self._subagent_headers: - self._subagent_headers.add(header_key) - yield AgentMessageChunk.text( - f"\n{indent}{icon} **{source_name}**: ", message_id=self._current_message_id - ) - yield AgentMessageChunk.text(delta, message_id=self._current_message_id) - - case ( - PartStartEvent(part=ThinkingPart(content=delta)) - | PartDeltaEvent(delta=ThinkingPartDelta(content_delta=delta)) - ): - yield AgentThoughtChunk.text(delta or "", message_id=self._current_message_id) - - case FunctionToolCallEvent(part=part): - text = f"\n{indent}- 🔧 [`{source_name}`] Using tool: ``{part.tool_name}``\n" - yield AgentMessageChunk.text(text=text, message_id=self._current_message_id) - - case FunctionToolResultEvent( - result=ToolReturnPart(content=content, tool_name=tool_name), - ): - result_str = str(content) - if len(result_str) > 200: # noqa: PLR2004 - result_str = result_str[:200] + "..." - text = f"{indent}- ✅ [`{source_name}`] `{tool_name}`\n" - yield AgentMessageChunk.text(text=text, message_id=self._current_message_id) - - case FunctionToolResultEvent(result=RetryPromptPart(tool_name=tool_name) as result): - error_msg = result.model_response() - text = f"{indent}- ❌ [`{source_name}`] `{tool_name}`: `{error_msg}`\n" - yield AgentMessageChunk.text(text=text, message_id=self._current_message_id) - - case StreamCompleteEvent(): - header_key = f"`{source_name}`:{depth}" - self._subagent_headers.discard(header_key) - yield AgentMessageChunk.text( - f"\n{indent}---\n", message_id=self._current_message_id - ) - - case ( - BuiltinToolCallEvent() # depracated - | BuiltinToolResultEvent() # depracated - | CompactionEvent() - | FinalResultEvent() - | FunctionToolResultEvent() - | PartDeltaEvent() - | PartEndEvent() - | PartStartEvent() - | PlanUpdateEvent() - | RunErrorEvent() - | RunStartedEvent() - | SpawnSessionStart() - | SubAgentEvent() - | ToolCallCompleteEvent() - | ToolCallProgressEvent() - | ToolCallStartEvent() - | ToolResultMetadataEvent() - | CustomEvent() - ): - pass # TODO - - case _ as unreachable: - assert_never(unreachable) - - async def _convert_subagent_tool_box( # noqa: PLR0915 - self, - source_name: str, - source_type: Literal["agent", "team_parallel", "team_sequential"], - inner_event: RichAgentStreamEvent[Any], - depth: int, - ) -> AsyncIterator[ACPSessionUpdate]: - """Convert subagent event to tool box notifications. - - Uses _SubagentToolBoxState to track header status and accumulates content - for full transcript in the content field. - """ - state = self._get_or_create_toolbox_state(source_name, depth) - tool_call_id = state.invocation_id - icon = "🤖" if source_type == "agent" else "👥" - - if not state.header_sent: - state.header_sent = True - initial_title = f"{icon} [`{source_name}`]: {source_type} start" - state.title = initial_title - yield ToolCallStart( - tool_call_id=tool_call_id, - title=initial_title, - kind="other", - raw_input={}, - status="pending", - ) - - new_title: str | None = None - kind: ToolKind = "other" - current_status: Literal["in_progress", "completed"] = "in_progress" - - match inner_event: - case PartStartEvent(part=TextPart(content=delta)): - tool_text = "\n" + delta - state.content.append(tool_text) - new_title = f"{icon} [`{source_name}`]: Output..." - kind = "other" - - case PartDeltaEvent(delta=TextPartDelta(content_delta=delta)): - if delta: - state.content.append(delta) - new_title = f"{icon} [`{source_name}`]: Output..." - kind = "other" - - case PartStartEvent(part=ThinkingPart(content=delta)): - tool_text = "\n> **Thinking** :" - if delta: - tool_text += delta.replace("\n", "\n> ") - state.content.append(tool_text) - new_title = f"💭 [`{source_name}`]: thinking..." - kind = "think" - - case PartDeltaEvent(delta=ThinkingPartDelta(content_delta=delta)): - if delta: - state.content.append(delta.replace("\n", "\n> ")) - new_title = f"💭 [`{source_name}`]: thinking..." - kind = "think" - - case FunctionToolCallEvent(part=part): - tool_text = f"\n- calling `{part.tool_name}`" - state.content.append(tool_text) - new_title = f"🔧 [`{source_name}`]: calling `{part.tool_name}`..." - kind = "other" - - case FunctionToolResultEvent( - result=ToolReturnPart(tool_name=tool_name), - ): - tool_text = f"\n- `{tool_name}` completed" - state.content.append(tool_text) - new_title = f"✅ [`{source_name}`]: `{tool_name}` completed" - kind = "other" - - case FunctionToolResultEvent(result=RetryPromptPart(tool_name=tool_name) as result): - error_msg = result.model_response() - error_text = f"\n- `{tool_name}` failed: `{error_msg}`" - state.content.append(error_text) - new_title = f"❌ [`{source_name}`]: `{tool_name}` failed" - kind = "other" - - case StreamCompleteEvent(): - # Complete the tool call - if tool_call_id in self._tool_states: - yield ToolCallProgress(tool_call_id=tool_call_id, status="completed") - self._cleanup_tool_state(tool_call_id) - self._subagent_content.pop(tool_call_id, None) - - case _: - pass - - if new_title and (new_title != state.title or kind == "think"): - state.title = new_title - full_text = "".join(state.content) - yield ToolCallProgress( - tool_call_id=tool_call_id, - title=new_title, - kind=kind, - status=current_status, - content=[ContentToolCallContent.text(text=full_text)], - ) diff --git a/src/agentpool_server/acp_server/handler.py b/src/agentpool_server/acp_server/handler.py index a09859a88..17982a5ce 100644 --- a/src/agentpool_server/acp_server/handler.py +++ b/src/agentpool_server/acp_server/handler.py @@ -28,7 +28,6 @@ from acp.schema import ContentBlock, PromptResponse, StopReason from agentpool import AgentPool from agentpool.agents.events import RichAgentStreamEvent - from agentpool_server.acp_server.session_manager import ACPSessionManager logger = get_logger(__name__) @@ -52,14 +51,12 @@ class ACPProtocolHandler: def __init__( self, agent_pool: AgentPool[Any], - session_manager: ACPSessionManager, event_converter: ACPEventConverter, client: Client, client_capabilities: ClientCapabilities | None = None, ) -> None: """Initialize the protocol handler.""" self.agent_pool = agent_pool - self.session_manager = session_manager self._event_converter_template = event_converter self.client = client self.client_capabilities = client_capabilities @@ -128,13 +125,11 @@ async def _event_consumer_loop(self, session_id: str) -> None: # Derive turn_complete support from stored client capabilities client_supports_turn_complete = ( - self.client_capabilities is not None - and self.client_capabilities.turn_complete is True + self.client_capabilities is not None and self.client_capabilities.turn_complete is True ) # Create a per-session converter so tool-call state is isolated converter = ACPEventConverter( - subagent_display_mode=self._event_converter_template.subagent_display_mode, client_supports_turn_complete=client_supports_turn_complete, ) @@ -162,6 +157,7 @@ async def _event_consumer_loop(self, session_id: str) -> None: break except Exception as e: import anyio + if isinstance(e, (anyio.ClosedResourceError, anyio.EndOfStream)): logger.debug( "Stream closed gracefully", @@ -221,30 +217,6 @@ async def handle_prompt( # Ensure the session exists in the SessionPool await session_pool.create_session(session_id) - # Add session MCP providers to SessionPool's per-session agent. - # Use deduplication because get_or_create_session_agent returns a cached - # per-session agent; adding the same provider repeatedly causes tool name - # conflicts in pydantic-ai's CombinedToolset. - acp_session = self.session_manager.get_session(session_id) - if acp_session is not None and acp_session.session_mcp_providers: - try: - session_agent = await session_pool.sessions.get_or_create_session_agent( - session_id - ) - for provider in acp_session.session_mcp_providers: - if provider not in session_agent.tools.external_providers: - session_agent.tools.add_provider(provider) - logger.info( - "Added session MCP providers to SessionPool agent", - session_id=session_id, - num_providers=len(acp_session.session_mcp_providers), - ) - except Exception: - logger.exception( - "Failed to add session MCP providers to SessionPool agent", - session_id=session_id, - ) - # Start event consumer before processing so no events are dropped self._ensure_event_consumer(session_id) @@ -268,8 +240,7 @@ async def handle_prompt( # Legacy clients (no turn_complete support) block until the run finishes # so they don't need session/update turn_complete notifications. if run_handle is not None and not ( - self.client_capabilities is not None - and self.client_capabilities.turn_complete + self.client_capabilities is not None and self.client_capabilities.turn_complete ): await run_handle.complete_event.wait() except asyncio.CancelledError: diff --git a/src/agentpool_server/acp_server/input_provider.py b/src/agentpool_server/acp_server/input_provider.py index 50d5ff871..febcdab54 100644 --- a/src/agentpool_server/acp_server/input_provider.py +++ b/src/agentpool_server/acp_server/input_provider.py @@ -443,9 +443,7 @@ async def _get_form_elicitation_fallback( # request_permission can only present a single question, so unwrap the first # elicitable property and remember the original key to wrap the result back. effective_schema, object_key = self._resolve_effective_schema(schema) - result = await self._dispatch_elicitation_by_schema( - effective_schema, tool_call_id, title - ) + result = await self._dispatch_elicitation_by_schema(effective_schema, tool_call_id, title) return self._wrap_object_result(result, object_key) def _resolve_effective_schema( @@ -477,28 +475,34 @@ async def _dispatch_elicitation_by_schema( if _is_boolean_schema(schema): options = _create_boolean_elicitation_options() perm_response = await self.session.requests.request_permission( - tool_call_id=tool_call_id, title=title, options=options, + tool_call_id=tool_call_id, + title=title, + options=options, ) result = self._handle_boolean_elicitation_response(perm_response, schema) - elif _is_enum_schema(schema) and ( - enum_options := _create_enum_elicitation_options(schema) - ): + elif _is_enum_schema(schema) and (enum_options := _create_enum_elicitation_options(schema)): perm_response = await self.session.requests.request_permission( - tool_call_id=tool_call_id, title=title, options=enum_options, + tool_call_id=tool_call_id, + title=title, + options=enum_options, ) result = _handle_enum_elicitation_response(perm_response, schema) elif _is_oneof_schema(schema) and ( oneof_options := _create_oneof_elicitation_options(schema) ): perm_response = await self.session.requests.request_permission( - tool_call_id=tool_call_id, title=title, options=oneof_options, + tool_call_id=tool_call_id, + title=title, + options=oneof_options, ) result = _handle_oneof_elicitation_response(perm_response, schema) elif _is_array_enum_schema(schema) and ( array_options := _create_array_enum_elicitation_options(schema) ): perm_response = await self.session.requests.request_permission( - tool_call_id=tool_call_id, title=title, options=array_options, + tool_call_id=tool_call_id, + title=title, + options=array_options, ) result = _handle_array_enum_elicitation_response(perm_response, schema) else: @@ -508,7 +512,9 @@ async def _dispatch_elicitation_by_schema( PermissionOption(option_id="decline", name="Decline", kind="reject_once"), ] perm_response = await self.session.requests.request_permission( - tool_call_id=tool_call_id, title=title, options=generic_options, + tool_call_id=tool_call_id, + title=title, + options=generic_options, ) match perm_response.outcome: diff --git a/src/agentpool_server/acp_server/server.py b/src/agentpool_server/acp_server/server.py index e19a697ff..792358fac 100644 --- a/src/agentpool_server/acp_server/server.py +++ b/src/agentpool_server/acp_server/server.py @@ -30,16 +30,6 @@ logger = get_logger(__name__) -SubagentDisplayMode = Literal["inline", "tool_box"] - - -def _coerce_subagent_display_mode(value: str) -> SubagentDisplayMode: - """Normalize config strings to the ACP literal union.""" - if value == "inline": - return "inline" - return "tool_box" - - def _acp_event_observer(show_detailed: bool = False): """Create an ACP stream observer that prints JSON-RPC messages to stderr. @@ -86,7 +76,6 @@ def __init__( load_skills: bool | None = None, config_path: str | None = None, transport: Transport = "stdio", - subagent_display_mode: SubagentDisplayMode = "tool_box", show_events: bool = False, show_events_detailed: bool = False, ) -> None: @@ -103,7 +92,6 @@ def __init__( If None (default), uses the manifest's skills.include_default setting. config_path: Path to the configuration file (for tracking/hot-switching) transport: Transport configuration ("stdio", "websocket", or transport object) - subagent_display_mode: How to display nested agent output in ACP clients show_events: Whether to print agent stream events to stderr show_events_detailed: Whether to print detailed agent stream events to stderr """ @@ -115,7 +103,6 @@ def __init__( self.load_skills = load_skills self.config_path = config_path self.transport: Transport = transport - self.subagent_display_mode: SubagentDisplayMode = subagent_display_mode self.show_events = show_events self.show_events_detailed = show_events_detailed @@ -130,7 +117,6 @@ def from_config( agent: str | None = None, load_skills: bool | None = None, transport: Transport = "stdio", - subagent_display_mode: SubagentDisplayMode | None = None, show_events: bool = False, show_events_detailed: bool = False, ) -> Self: @@ -145,7 +131,6 @@ def from_config( load_skills: Whether to load client-side skills from .claude/skills. If None (default), uses the manifest's skills.include_default setting. transport: Transport configuration ("stdio", "websocket", or transport object) - subagent_display_mode: Override for subagent display mode (argument > config > default) Returns: Configured ACP server instance with agent pool @@ -159,17 +144,6 @@ def from_config( # Determine config_path for tracking config_path = config.config_file_path if isinstance(config, AgentsManifest) else str(config) - # Resolve subagent_display_mode with priority: argument > config > default - resolved_display_mode: SubagentDisplayMode - if subagent_display_mode is not None: - resolved_display_mode = subagent_display_mode - # Fall back to config value - elif isinstance(config, AgentsManifest): - config_mode: str = getattr(config.pool_server, "subagent_display_mode", "tool_box") - resolved_display_mode = _coerce_subagent_display_mode(config_mode) - else: - resolved_display_mode = "tool_box" - # Resolve transport with priority: argument > config > default resolved_transport: Transport if transport != "stdio": @@ -207,7 +181,6 @@ def from_config( load_skills=resolved_load_skills, config_path=config_path, transport=resolved_transport, - subagent_display_mode=resolved_display_mode, show_events=show_events, show_events_detailed=show_events_detailed, ) @@ -255,7 +228,6 @@ async def _start_async(self) -> None: debug_commands=self.debug_commands, load_skills=self.load_skills, server=self, - subagent_display_mode=self.subagent_display_mode, ) debug_file = self.debug_file if self.debug_messages else None observers = None diff --git a/src/agentpool_server/acp_server/session.py b/src/agentpool_server/acp_server/session.py index 29e79c59d..3d91c2634 100644 --- a/src/agentpool_server/acp_server/session.py +++ b/src/agentpool_server/acp_server/session.py @@ -21,12 +21,15 @@ from acp.agent.acp_requests import ACPRequests from acp.agent.notifications import ACPNotifications +from acp.exceptions import RequestError from acp.filesystem import ACPFileSystem from acp.schema import AvailableCommand, ClientCapabilities from acp.schema.mcp import AcpMcpServer -from agentpool import Agent, AgentPool +from acp.schema.requests import PromptDelegation +from agentpool import Agent, AgentPool # noqa: TC001 from agentpool.agents.acp_agent import ACPAgent from agentpool.agents.modes import ConfigOptionChanged, ModeInfo +from agentpool.common_types import SupportsRunStream from agentpool.log import get_logger from agentpool.resource_providers.mcp_provider import MCPResourceProvider from agentpool_commands.base import NodeCommand @@ -145,6 +148,18 @@ def infer_stop_reason(error_msg: str) -> StopReason: return "max_tokens" # Default to max_tokens for other usage limits +def _is_subagent_tool(tool: Any) -> bool: + """Check if a tool is a subagent delegation tool. + + Args: + tool: A Tool instance to check. + + Returns: + True if the tool's category is "subagent", False otherwise. + """ + return tool.category == "subagent" + + @dataclass class ACPSession: """Individual ACP session state and management. @@ -191,12 +206,6 @@ class ACPSession: manager: ACPSessionManager | None = None """Session manager for managing sessions. Used for session management commands.""" - subagent_display_mode: Literal["inline", "tool_box"] = "tool_box" - """How to display subagent output: - - 'inline': Subagent output flows into main message stream - - 'tool_box': Subagent output contained in the tool call's progress box (default) - """ - def __post_init__(self) -> None: """Initialize session state and set up providers.""" self.mcp_servers = self.mcp_servers or [] @@ -204,6 +213,7 @@ def __post_init__(self) -> None: self._task_lock = asyncio.Lock() self._cancelled = False self._current_converter: ACPEventConverter | None = None + self._foreground_children: set[str] = set() self.last_usage: Usage | None = None self.fs = ACPFileSystem(self.client, session_id=self.session_id) self.command_store = CommandStore(commands=get_all_commands()) @@ -557,6 +567,9 @@ async def cancel(self) -> None: which handles protocol-specific cancellation (e.g., sending CancelNotification for ACP agents, calling SDK interrupt for ClaudeCodeAgent, etc.). + Foreground child sessions are also cancelled via the session manager. + Background child sessions survive this cancellation. + Note: Tool call cleanup is handled in process_prompt() to avoid race conditions with the converter state being modified from multiple async contexts. @@ -567,16 +580,42 @@ async def cancel(self) -> None: await self.agent.interrupt() except Exception: self.log.exception("Failed to interrupt agent") + # Cancel foreground children + await self._cancel_foreground_children() + + def _sync_foreground_children(self) -> None: + """Synchronize foreground children from the active event converter. + + Copies the current converter's _foreground_children into the session's + own set so that cancellation propagates to all foreground subagents. + """ + if self._current_converter is not None: + self._foreground_children.update(self._current_converter._foreground_children) + + async def _cancel_foreground_children(self) -> None: + """Cancel all foreground child sessions via the session manager.""" + self._sync_foreground_children() + for child_id in list(self._foreground_children): + if self.manager: + try: + await self.manager.cancel_session(child_id) + except Exception: + self.log.exception("Failed to cancel child session", child_session_id=child_id) def is_cancelled(self) -> bool: """Check if the session is cancelled.""" return self._cancelled - async def process_prompt(self, content_blocks: Sequence[ContentBlock]) -> StopReason: # noqa: PLR0911 + async def process_prompt( + self, + content_blocks: Sequence[ContentBlock], + delegation: PromptDelegation | None = None, + ) -> StopReason: # noqa: PLR0911 """Process a prompt request and stream responses. Args: content_blocks: List of content blocks from the prompt request + delegation: Optional delegation configuration for this prompt Returns: Stop reason @@ -589,6 +628,26 @@ async def process_prompt(self, content_blocks: Sequence[ContentBlock]) -> StopRe self.log.warning("Empty prompt received") return "refusal" commands, non_command_content = split_commands(contents, self.command_store) + + # Handle delegation policies if capability is advertised + if delegation and self.acp_agent.prompt_delegation_enabled: + match delegation.policy: + case "auto": + pass # Normal flow + case "disable": + return await self._process_prompt_with_disabled_subagent_tools( + non_command_content, commands + ) + case "prefer" | "require": + subagent_id = delegation.subagent_id + if subagent_id is not None and self.agent_pool is not None: + subagent = self.agent_pool.nodes.get(subagent_id) + if subagent and isinstance(subagent, SupportsRunStream): + return await self._run_subagent_directly(subagent, non_command_content) + if delegation.policy == "require": + msg = f"Subagent '{delegation.subagent_id}' not available" + raise RequestError(-32602, msg) + async with self._task_lock: if commands: # Process commands if found for command in commands: @@ -600,6 +659,66 @@ async def process_prompt(self, content_blocks: Sequence[ContentBlock]) -> StopRe return "end_turn" self.log.debug("Processing prompt", content_items=len(non_command_content)) + return await self._run_agent_stream(non_command_content) + + async def _process_prompt_with_disabled_subagent_tools( + self, + non_command_content: list[UserContent | PathReference], + commands: list[str], + ) -> StopReason: + """Process prompt with subagent tools temporarily disabled. + + Args: + non_command_content: Content to process after command splitting + commands: Slash commands to execute + + Returns: + Stop reason + """ + async with self._task_lock: + # Identify and disable currently-enabled subagent tools + all_tools = await self.agent.tools.get_tools() + subagent_tools = [t for t in all_tools if _is_subagent_tool(t) and t.enabled] + for tool in subagent_tools: + await self.agent.tools.disable_tool(tool.name) + + try: + if commands: + for command in commands: + self.log.info("Processing slash command", command=command) + await self.execute_slash_command(command) + + if not non_command_content and len(self.agent.staged_content) == 0: + return "end_turn" + + self.log.debug( + "Processing prompt with subagent tools disabled", + content_items=len(non_command_content), + ) + return await self._run_agent_stream(non_command_content) + finally: + for tool in subagent_tools: + try: + await self.agent.tools.enable_tool(tool.name) + except Exception: + self.log.exception("Failed to re-enable subagent tool", tool_name=tool.name) + + async def _run_subagent_directly( + self, + subagent: SupportsRunStream[Any], + contents: list[UserContent | PathReference], + ) -> StopReason: + """Run a subagent directly with the given content and stream results. + + Args: + subagent: The subagent node to run + contents: Content blocks to send to the subagent + + Returns: + Stop reason + """ + async with self._task_lock: + self.log.info("Running subagent directly") event_count = 0 # Derive turn-complete support from client capabilities client_supports_turn_complete = ( @@ -609,97 +728,38 @@ async def process_prompt(self, content_blocks: Sequence[ContentBlock]) -> StopRe ) # Create a new event converter for this prompt converter = ACPEventConverter( - subagent_display_mode=self.subagent_display_mode, client_supports_turn_complete=client_supports_turn_complete, ) self._current_converter = converter # Track for cancellation - try: # Use the session's persistent input provider - # Staged content is automatically injected by run_stream - # Inject session-level MCP providers for this run. - # We use add_provider/remove_provider instead of with_session_providers - # because NativeAgent.run_stream() may delegate to SessionPool, which - # re-enters agent.run_stream() in a different async context where - # with_session_providers() is no longer active. - # - # CRITICAL: SessionPool creates per-session agents via - # get_or_create_session_agent() which returns a fresh agent instance. - # We must add providers to BOTH the local agent and the SessionPool - # session agent to ensure tools are available regardless of which - # execution path is taken. - for provider in self.session_mcp_providers: - self.agent.tools.add_provider(provider) - - # Also add to SessionPool's per-session agent if SessionPool is active - session_pool_agent = None - agent_pool = getattr(self.agent, "agent_pool", None) - if agent_pool is not None and agent_pool.session_pool is not None: - try: - sp = agent_pool.session_pool.sessions - session_pool_agent = await sp.get_or_create_session_agent( - self.session_id - ) - for provider in self.session_mcp_providers: - session_pool_agent.tools.add_provider(provider) - except Exception: - self.log.exception( - "Failed to add MCP providers to SessionPool agent" + try: + async for event in subagent.run_stream( + *contents, + parent_session_id=self.session_id, + depth=1, + ): + if self._cancelled: + self.log.info( + "Cancelled during subagent event loop, cleaning up tool calls" ) - - try: - async for event in self.agent.run_stream( - *non_command_content, - input_provider=self.input_provider, - deps=self, - session_id=self.session_id, # Tie agent conversation to ACP session - ): - if self._cancelled: - self.log.info("Cancelled during event loop, cleaning up tool calls") - # Send cancellation notifications for any pending tool calls - # This happens in the same async context as the converter - async for cancel_update in converter.cancel_pending_tools(): - await self.notifications.send_update(cancel_update) - # CRITICAL: Allow time for client to process tool completion notifications - # before sending PromptResponse. Without this delay, the client may receive - # and process the PromptResponse before the tool notifications, causing UI - # state desync where subsequent prompts appear stuck/unresponsive. - # This is needed because even though send() awaits the write, the client - # may process messages asynchronously or out of order. - await anyio.sleep(0.05) - self._current_converter = None - return "cancelled" - - event_count += 1 - async for update in converter.convert(event): - await self.notifications.send_update(update) - # Yield control to allow notifications to be sent immediately - await anyio.sleep(0.01) - self.log.info("Streaming finished", events_processed=event_count) - finally: - from contextlib import suppress - - # Remove session-level MCP providers so they don't leak into other sessions - # or persist after this run. - for provider in self.session_mcp_providers: - with suppress(ValueError): - self.agent.tools.remove_provider(provider) - - # Also remove from SessionPool's per-session agent - if session_pool_agent is not None: - for provider in self.session_mcp_providers: - with suppress(ValueError): - session_pool_agent.tools.remove_provider(provider) + async for cancel_update in converter.cancel_pending_tools(): + await self.notifications.send_update(cancel_update) + await anyio.sleep(0.05) + self._current_converter = None + return "cancelled" + + event_count += 1 + async for update in converter.convert(event): + await self.notifications.send_update(update) + await anyio.sleep(0.01) + self.log.info("Subagent streaming finished", events_processed=event_count) except asyncio.CancelledError: - # Task was cancelled (e.g., via interrupt()) - return proper stop reason - # This is critical: CancelledError doesn't inherit from Exception, - # so we must catch it explicitly to send the PromptResponse - self.log.info("Stream cancelled via CancelledError, cleaning up tool calls") - # Send cancellation notifications for any pending tool calls + self.log.info( + "Subagent stream cancelled via CancelledError, cleaning up tool calls" + ) async for cancel_update in converter.cancel_pending_tools(): await self.notifications.send_update(cancel_update) - # CRITICAL: Allow time for client to process tool completion notifications - # before sending PromptResponse. See comment in cancellation branch above. await anyio.sleep(0.05) self._current_converter = None return "cancelled" @@ -707,21 +767,108 @@ async def process_prompt(self, content_blocks: Sequence[ContentBlock]) -> StopRe self.log.info("Usage limit exceeded", error=str(e)) return infer_stop_reason(str(e)) except Exception as e: - self._current_converter = None # Clear converter reference - self.log.exception("Error during streaming") - # Send error as toast notification instead of polluting chat history + self._current_converter = None + self.log.exception("Error during subagent streaming") await self._send_toast( - message=f"Agent error: {e}", + message=f"Subagent error: {e}", level="error", ) - await anyio.sleep(0.05) # Allow network buffers to flush + await anyio.sleep(0.05) return "end_turn" else: - # Title generation is now handled automatically by log_session self.last_usage = converter.last_usage - self._current_converter = None # Clear converter reference + self._current_converter = None return "end_turn" + async def _run_agent_stream( + self, + non_command_content: list[UserContent | PathReference], + ) -> StopReason: + """Run the agent stream and convert events to ACP updates. + + Args: + non_command_content: Content to send to the agent + + Returns: + Stop reason + """ + event_count = 0 + # Derive turn-complete support from client capabilities + client_supports_turn_complete = ( + bool(self.client_capabilities.turn_complete) + if self.client_capabilities is not None + else False + ) + converter = ACPEventConverter( + client_supports_turn_complete=client_supports_turn_complete, + ) + self._current_converter = converter # Track for cancellation + + try: # Use the session's persistent input provider + # Staged content is automatically injected by run_stream + # Inject session-level MCP providers for this run + async with self.agent.tools.with_session_providers(self.session_mcp_providers): + async for event in self.agent.run_stream( + *non_command_content, + input_provider=self.input_provider, + deps=self, + session_id=self.session_id, # Tie agent conversation to ACP session + ): + if self._cancelled: + self.log.info("Cancelled during event loop, cleaning up tool calls") + # Send cancellation notifications for any pending tool calls + # This happens in the same async context as the converter + async for cancel_update in converter.cancel_pending_tools(): + await self.notifications.send_update(cancel_update) + # CRITICAL: Allow time for client to process tool completion notifications + # before sending PromptResponse. Without this delay, the client may receive + # and process the PromptResponse before the tool notifications, causing UI + # state desync where subsequent prompts appear stuck/unresponsive. + # This is needed because even though send() awaits the write, the client + # may process messages asynchronously or out of order. + await anyio.sleep(0.05) + self._current_converter = None + return "cancelled" + + event_count += 1 + async for update in converter.convert(event): + await self.notifications.send_update(update) + # Yield control to allow notifications to be sent immediately + await anyio.sleep(0.01) + self.log.info("Streaming finished", events_processed=event_count) + + except asyncio.CancelledError: + # Task was cancelled (e.g., via interrupt()) - return proper stop reason + # This is critical: CancelledError doesn't inherit from Exception, + # so we must catch it explicitly to send the PromptResponse + self.log.info("Stream cancelled via CancelledError, cleaning up tool calls") + # Send cancellation notifications for any pending tool calls + async for cancel_update in converter.cancel_pending_tools(): + await self.notifications.send_update(cancel_update) + # CRITICAL: Allow time for client to process tool completion notifications + # before sending PromptResponse. See comment in cancellation branch above. + await anyio.sleep(0.05) + self._current_converter = None + return "cancelled" + except UsageLimitExceeded as e: + self.log.info("Usage limit exceeded", error=str(e)) + return infer_stop_reason(str(e)) + except Exception as e: + self._current_converter = None # Clear converter reference + self.log.exception("Error during streaming") + # Send error as toast notification instead of polluting chat history + await self._send_toast( + message=f"Agent error: {e}", + level="error", + ) + await anyio.sleep(0.05) # Allow network buffers to flush + return "end_turn" + else: + # Title generation is now handled automatically by log_session + self.last_usage = converter.last_usage + self._current_converter = None # Clear converter reference + return "end_turn" + async def _send_toast( self, message: str, @@ -788,6 +935,9 @@ async def close(self) -> None: ) self.session_mcp_providers.clear() + # Cancel foreground children before full cleanup + await self._cancel_foreground_children() + # NEW: Disconnect state_updated signal to prevent stale callbacks with suppress(Exception): self.agent.state_updated.disconnect(self._on_state_updated) diff --git a/src/agentpool_server/acp_server/session_manager.py b/src/agentpool_server/acp_server/session_manager.py index c2753cc1b..7175e596d 100644 --- a/src/agentpool_server/acp_server/session_manager.py +++ b/src/agentpool_server/acp_server/session_manager.py @@ -69,7 +69,6 @@ async def create_session( session_id: str | None = None, client_capabilities: ClientCapabilities | None = None, client_info: Implementation | None = None, - subagent_display_mode: Literal["inline", "tool_box"] = "tool_box", parent_session_id: str | None = None, ) -> str: """Create a new ACP session. @@ -83,7 +82,6 @@ async def create_session( session_id: Optional specific session ID (generated if None) client_capabilities: Client capabilities for tool registration client_info: Client implementation info (name, version) - subagent_display_mode: Display mode for subagent outputs parent_session_id: Optional parent session ID for child sessions. When provided, creates a child session that inherits project_id/cwd from the parent via SessionManager. @@ -167,7 +165,6 @@ async def create_session( client_capabilities=client_capabilities or ClientCapabilities(), client_info=client_info, manager=self, - subagent_display_mode=subagent_display_mode, ) session.register_update_callback(self._on_commands_updated) await session.initialize() @@ -180,6 +177,19 @@ def get_session(self, session_id: str) -> ACPSession | None: """Get an active session by ID.""" return self._active.get(session_id) + async def cancel_session(self, session_id: str) -> None: + """Cancel an active session. + + Delegates to the session's cancel() method if the session is active. + No-op if the session is not found. + + Args: + session_id: Session identifier to cancel + """ + session = self._active.get(session_id) + if session: + await session.cancel() + async def resume_session( self, session_id: str, @@ -187,7 +197,6 @@ async def resume_session( acp_agent: AgentPoolACPAgent, client_capabilities: ClientCapabilities | None = None, client_info: Implementation | None = None, - subagent_display_mode: Literal["inline", "tool_box"] = "tool_box", ) -> ACPSession | None: """Resume a session from storage. @@ -197,7 +206,6 @@ async def resume_session( acp_agent: ACP agent instance client_capabilities: Client capabilities client_info: Client implementation info (name, version) - subagent_display_mode: Display mode for subagent outputs Returns: Resumed ACPSession if found, None otherwise @@ -231,7 +239,6 @@ async def resume_session( client_capabilities=client_capabilities or ClientCapabilities(), client_info=client_info, manager=self, - subagent_display_mode=subagent_display_mode, ) session.register_update_callback(self._on_commands_updated) await session.initialize() diff --git a/src/agentpool_server/acp_server/subagent_catalog.py b/src/agentpool_server/acp_server/subagent_catalog.py new file mode 100644 index 000000000..811761382 --- /dev/null +++ b/src/agentpool_server/acp_server/subagent_catalog.py @@ -0,0 +1,157 @@ +"""Subagent catalog provider with debounced updates. + +Provides a live catalog of available subagents from the AgentPool, +with debounced notification emission to avoid flooding clients. +""" + +from __future__ import annotations + +import asyncio +import inspect +from collections.abc import Awaitable, Callable +from contextlib import suppress +from typing import TYPE_CHECKING, Any + +from agentpool.common_types import SupportsRunStream +from agentpool.log import get_logger + + +if TYPE_CHECKING: + from acp.schema import SubagentInfo + from agentpool import AgentPool + +logger = get_logger(__name__) + +CatalogUpdateCallback = Callable[[list[Any]], Awaitable[None]] + + +class SubagentCatalogProvider: + """Manages the catalog of available subagents with debounced updates. + + Iterates over the AgentPool's agents and produces SubagentInfo entries + for each agent that supports streaming (SupportsRunStream). Supports + cycle detection by filtering out ancestor agent IDs. + + Updates are debounced so that rapid pool changes result in a single + notification emission after the configured delay. + """ + + def __init__(self, pool: AgentPool[Any], debounce_ms: int = 500) -> None: + """Initialize the catalog provider. + + Args: + pool: The agent pool to source agents from. + debounce_ms: Milliseconds to debounce update notifications. + """ + self.pool = pool + self.debounce_ms = debounce_ms + self._pending_update: asyncio.Task[None] | None = None + self._update_callbacks: list[CatalogUpdateCallback] = [] + self._notification_channels: list[Any] = [] + + def register_update_callback(self, callback: CatalogUpdateCallback) -> None: + """Register an async callback to be invoked when the catalog updates. + + Args: + callback: Async function that receives the updated catalog. + """ + self._update_callbacks.append(callback) + + def register_notification_channel(self, channel: Any) -> None: + """Register a notification channel for session update emission. + + The channel must provide an async ``send_update(update)`` method. + When the catalog updates after debounce, an + ``AvailableSubagentsUpdate`` is sent to all registered channels. + + Args: + channel: A notification channel (e.g., ``ACPNotifications``). + """ + self._notification_channels.append(channel) + + def get_catalog(self, ancestor_agent_ids: set[str] | None = None) -> list[SubagentInfo]: + """Build the current subagent catalog from the pool. + + Args: + ancestor_agent_ids: Optional set of agent IDs to exclude from + the catalog to prevent circular delegation. + + Returns: + List of SubagentInfo for each available subagent. + """ + from acp.schema import SubagentCapabilities, SubagentInfo + + if self.pool is None: + return [] + + result: list[SubagentInfo] = [] + for name, node in self.pool.all_agents.items(): + if not isinstance(node, SupportsRunStream): + continue + if ancestor_agent_ids and name in ancestor_agent_ids: + continue + + title = getattr(node, "description", None) or name + system_prompt = getattr(node, "system_prompt", None) + if system_prompt is None: + sys_prompts = getattr(node, "sys_prompts", None) + if sys_prompts is not None: + prompts = getattr(sys_prompts, "prompts", None) + if prompts: + first_prompt = prompts[0] + system_prompt = ( + first_prompt if isinstance(first_prompt, str) else str(first_prompt) + ) + description = str(system_prompt)[:200] if system_prompt is not None else None + + result.append( + SubagentInfo( + subagent_id=name, + name=title, + description=description, + capabilities=SubagentCapabilities( + streaming=True, + tools=True, + ), + ) + ) + return result + + async def notify_update(self) -> None: + """Trigger a debounced catalog update notification. + + If an update is already pending, it is cancelled and a new one + is scheduled. The actual emission happens after debounce_ms. + """ + if self._pending_update is not None: + self._pending_update.cancel() + + self._pending_update = asyncio.create_task(self._send_update_after_delay()) + + async def _send_update_after_delay(self) -> None: + """Wait for the debounce delay, then emit the updated catalog.""" + from acp.schema.session_updates import AvailableSubagentsUpdate + + try: + await asyncio.sleep(self.debounce_ms / 1000) + catalog = self.get_catalog() + if not catalog: + return + + update = AvailableSubagentsUpdate(available_subagents=catalog) + for channel in list(self._notification_channels): + try: + await channel.send_update(update) + except Exception: + logger.exception("Notification channel send_update failed") + + for callback in list(self._update_callbacks): + try: + result = callback(catalog) + if inspect.isawaitable(result): + await result + except Exception: + logger.exception("Catalog update callback failed") + finally: + if self._pending_update is asyncio.current_task(): + self._pending_update = None diff --git a/src/agentpool_server/opencode_server/handler.py b/src/agentpool_server/opencode_server/handler.py index 54427579d..d77e87d84 100644 --- a/src/agentpool_server/opencode_server/handler.py +++ b/src/agentpool_server/opencode_server/handler.py @@ -261,14 +261,7 @@ async def handle_message( await self._ensure_event_consumer(session_id, agent_name) await session_pool.create_session(session_id) - input_provider = ( - self._state.ensure_input_provider(session_id) - if self._state is not None - else None - ) - await session_pool.receive_request( - session_id, message, input_provider=input_provider - ) + await session_pool.receive_request(session_id, message) async def close_session(self, session_id: str) -> None: """Close a session and clean up its EventBus subscription. diff --git a/src/agentpool_server/opencode_server/routes/message_routes.py b/src/agentpool_server/opencode_server/routes/message_routes.py index 7ef1f6d3a..63bc57f5e 100644 --- a/src/agentpool_server/opencode_server/routes/message_routes.py +++ b/src/agentpool_server/opencode_server/routes/message_routes.py @@ -406,8 +406,7 @@ async def _process_message_locked( # noqa: PLR0915 else: agent = all_agents[request.agent] # Ensure agent is bound to this session - input_provider = state.ensure_input_provider(session_id) - agent._input_provider = input_provider + agent._input_provider = state.ensure_input_provider(session_id) try: request_variant = request.model.variant if request.model else None diff --git a/src/agentpool_toolsets/builtin/subagent_tools.py b/src/agentpool_toolsets/builtin/subagent_tools.py index 0c592f6c5..97b35037b 100644 --- a/src/agentpool_toolsets/builtin/subagent_tools.py +++ b/src/agentpool_toolsets/builtin/subagent_tools.py @@ -199,9 +199,9 @@ def __init__( self._batch_stream_deltas = batch_stream_deltas for tool in [ self.create_tool( - self.list_available_nodes, category="search", read_only=True, idempotent=True + self.list_available_nodes, category="subagent", read_only=True, idempotent=True ), - self.create_tool(self.task, category="other"), + self.create_tool(self.task, category="subagent"), ]: self.add_tool(tool) @@ -333,6 +333,8 @@ async def task( # noqa: D417 child_session_id = await ctx.create_child_session( agent_name=agent_or_team, agent_type=node.agent_type, + parent_tool_call_id=ctx.tool_call_id, + subagent_id=agent_or_team, ) child_depth = current_depth + 1 @@ -353,6 +355,7 @@ async def task( # noqa: D417 description=f"Run {agent_or_team} task", metadata={"prompt": prompt[:200]} if prompt else {}, model_id=node_model_id, + run_mode="background" if async_mode else "foreground", ) await ctx.events.emit_event(spawn_event) @@ -367,7 +370,6 @@ async def task( # noqa: D417 # Use SessionPool if available for proper event routing session_pool = ctx.pool.session_pool if ctx.pool else None - input_provider = ctx.get_input_provider() if ctx.input_provider else None if session_pool is not None: # Subscribe to EventBus for child session events event_queue = await session_pool.event_bus.subscribe( @@ -378,7 +380,7 @@ async def _run_via_session_pool() -> None: """Run task through SessionPool and collect final result.""" try: await session_pool.receive_request( - child_session_id, prompt, input_provider=input_provider + child_session_id, prompt ) finally: # Signal event consumer to stop @@ -453,7 +455,6 @@ async def _consume_events_to_fs() -> None: session_id=child_session_id, parent_session_id=parent_session_id, depth=child_depth, - input_provider=input_provider, ), ), name=f"async_task_{task_id}", diff --git a/src/agentpool_toolsets/builtin/workers.py b/src/agentpool_toolsets/builtin/workers.py index b7efa0dc8..884662443 100644 --- a/src/agentpool_toolsets/builtin/workers.py +++ b/src/agentpool_toolsets/builtin/workers.py @@ -144,6 +144,7 @@ async def run(ctx: AgentContext, prompt: str) -> Any: depth=child_depth, description=f"Run {agent_name} worker", metadata={"prompt": prompt[:200]} if prompt else {}, + run_mode="foreground", ) await ctx.events.emit_event(spawn_event) @@ -250,6 +251,7 @@ async def run(ctx: AgentContext, prompt: str) -> str: depth=child_depth, description=f"Run {node_name} worker", metadata={"prompt": prompt[:200]} if prompt else {}, + run_mode="foreground", ) await ctx.events.emit_event(spawn_event) diff --git a/tests/__snapshots__/test_acp_event_converter_snapshots.ambr b/tests/__snapshots__/test_acp_event_converter_snapshots.ambr index 3bc60f0fe..c5c924185 100644 --- a/tests/__snapshots__/test_acp_event_converter_snapshots.ambr +++ b/tests/__snapshots__/test_acp_event_converter_snapshots.ambr @@ -2,304 +2,452 @@ # name: TestInlineModeSnapshots.test_long_text[asyncio] list([ dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': 'This is a long message that gets streamed in multi', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'kind': 'other', 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [writer]', - 'tool_call_id': '76840677-1954-48e5-9db4-25047acceb8b', + 'title': '[`writer`] Output', + 'tool_call_id': 'writer:output:', }), dict({ - 'raw_output': 'This is a long message that gets streamed in multi', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'This is a long message that gets streamed in multiple chunks. Each chunk should be a separate delta ', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'session_update': 'tool_call_update', 'status': 'in_progress', - 'tool_call_id': '76840677-1954-48e5-9db4-25047acceb8b', + 'tool_call_id': 'writer:output:', }), dict({ - 'raw_output': 'ple chunks. Each chunk should be a separate delta ', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'This is a long message that gets streamed in multiple chunks. Each chunk should be a separate delta event. The header should only be emitted once. Sub', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'session_update': 'tool_call_update', 'status': 'in_progress', - 'tool_call_id': '76840677-1954-48e5-9db4-25047acceb8b', + 'tool_call_id': 'writer:output:', }), dict({ - 'raw_output': 'event. The header should only be emitted once. Sub', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'This is a long message that gets streamed in multiple chunks. Each chunk should be a separate delta event. The header should only be emitted once. Subsequent deltas should have no prefix repetition.', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'session_update': 'tool_call_update', 'status': 'in_progress', - 'tool_call_id': '76840677-1954-48e5-9db4-25047acceb8b', - }), - dict({ - 'raw_output': 'sequent deltas should have no prefix repetition.', - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '76840677-1954-48e5-9db4-25047acceb8b', + 'tool_call_id': 'writer:output:', }), dict({ 'session_update': 'tool_call_update', 'status': 'completed', - 'tool_call_id': '76840677-1954-48e5-9db4-25047acceb8b', + 'tool_call_id': 'writer:output:', }), ]) # --- # name: TestInlineModeSnapshots.test_mixed_events[asyncio] list([ dict({ - 'kind': 'other', + 'kind': 'think', 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [analyzer]', - 'tool_call_id': '4fa12d75-8697-4488-b615-ba34cbf3ae15', + 'title': '[`analyzer`] Thinking', + 'tool_call_id': 'analyzer:think:', }), dict({ - 'raw_output': 'Thinking: Need to analyze', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Need to analyze', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'session_update': 'tool_call_update', 'status': 'in_progress', - 'tool_call_id': '4fa12d75-8697-4488-b615-ba34cbf3ae15', + 'tool_call_id': 'analyzer:think:', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Let me check', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', + 'session_update': 'tool_call', + 'status': 'pending', + 'title': '[`analyzer`] Output', + 'tool_call_id': 'analyzer:output:', }), dict({ - 'raw_output': 'Let me check', - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '4fa12d75-8697-4488-b615-ba34cbf3ae15', + 'kind': 'think', + 'raw_input': dict({ + 'pattern': 'error', + }), + 'session_update': 'tool_call', + 'status': 'pending', + 'title': '[`analyzer`]: Searching: error', + 'tool_call_id': 'analyzer:grep:call_002', }), dict({ - 'raw_output': ''' - - 🔧 [analyzer] Using tool: grep - - ''', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'No errors found', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'raw_output': 'No errors found', 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '4fa12d75-8697-4488-b615-ba34cbf3ae15', + 'status': 'completed', + 'tool_call_id': 'analyzer:grep:pyd_ai_', }), dict({ - 'raw_output': ''' - ✅ [analyzer] grep: No errors found - - ''', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Let me check - all good!', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'session_update': 'tool_call_update', 'status': 'in_progress', - 'tool_call_id': '4fa12d75-8697-4488-b615-ba34cbf3ae15', + 'tool_call_id': 'analyzer:output:', }), dict({ - 'raw_output': ' - all good!', 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '4fa12d75-8697-4488-b615-ba34cbf3ae15', + 'status': 'completed', + 'tool_call_id': 'analyzer:output:', }), dict({ 'session_update': 'tool_call_update', 'status': 'completed', - 'tool_call_id': '4fa12d75-8697-4488-b615-ba34cbf3ae15', + 'tool_call_id': 'analyzer:think:', }), ]) # --- # name: TestInlineModeSnapshots.test_nested_subagents[asyncio] list([ dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Delegating to researcher', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'kind': 'other', 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [coordinator]', - 'tool_call_id': '08e9f150-dad2-4e20-9328-206b4b18bae6', - }), - dict({ - 'raw_output': 'Delegating to researcher', - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '08e9f150-dad2-4e20-9328-206b4b18bae6', + 'title': '[`coordinator`] Output', + 'tool_call_id': 'coordinator:output:', }), dict({ 'session_update': 'tool_call_update', 'status': 'completed', - 'tool_call_id': '08e9f150-dad2-4e20-9328-206b4b18bae6', + 'tool_call_id': 'coordinator:output:', }), dict({ - 'kind': 'other', + 'kind': 'think', 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [researcher]', - 'tool_call_id': 'dcb69673-b51a-4644-bd0f-896b918b57ba', + 'title': '[`researcher`] Thinking', + 'tool_call_id': 'researcher:think:', }), dict({ - 'raw_output': 'Thinking: Searching', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Searching', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'session_update': 'tool_call_update', 'status': 'in_progress', - 'tool_call_id': 'dcb69673-b51a-4644-bd0f-896b918b57ba', + 'tool_call_id': 'researcher:think:', }), dict({ - 'raw_output': ''' - - 🔧 [researcher] Using tool: search - - ''', - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': 'dcb69673-b51a-4644-bd0f-896b918b57ba', + 'kind': 'search', + 'raw_input': dict({ + 'query': 'test', + }), + 'session_update': 'tool_call', + 'status': 'pending', + 'title': '[`researcher`]: Searching: test', + 'tool_call_id': 'researcher:search:call_nested_001', }), dict({ - 'raw_output': ''' - ✅ [researcher] search: Results found - - ''', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Results found', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'raw_output': 'Results found', 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': 'dcb69673-b51a-4644-bd0f-896b918b57ba', + 'status': 'completed', + 'tool_call_id': 'researcher:search:pyd_ai_', }), dict({ 'session_update': 'tool_call_update', 'status': 'completed', - 'tool_call_id': 'dcb69673-b51a-4644-bd0f-896b918b57ba', + 'tool_call_id': 'researcher:think:', }), ]) # --- # name: TestInlineModeSnapshots.test_text_stream[asyncio] list([ dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Hello', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'kind': 'other', 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [assistant]', - 'tool_call_id': '2c2f7e86-8684-464c-9ef8-ecfdbaa01aa5', - }), - dict({ - 'raw_output': 'Hello', - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '2c2f7e86-8684-464c-9ef8-ecfdbaa01aa5', + 'title': '[`assistant`] Output', + 'tool_call_id': 'assistant:output:', }), dict({ - 'raw_output': ' world', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Hello world', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'session_update': 'tool_call_update', 'status': 'in_progress', - 'tool_call_id': '2c2f7e86-8684-464c-9ef8-ecfdbaa01aa5', + 'tool_call_id': 'assistant:output:', }), dict({ - 'raw_output': '!', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Hello world!', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'session_update': 'tool_call_update', 'status': 'in_progress', - 'tool_call_id': '2c2f7e86-8684-464c-9ef8-ecfdbaa01aa5', + 'tool_call_id': 'assistant:output:', }), dict({ 'session_update': 'tool_call_update', 'status': 'completed', - 'tool_call_id': '2c2f7e86-8684-464c-9ef8-ecfdbaa01aa5', + 'tool_call_id': 'assistant:output:', }), ]) # --- # name: TestInlineModeSnapshots.test_thinking_stream[asyncio] list([ dict({ - 'kind': 'other', + 'kind': 'think', 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [researcher]', - 'tool_call_id': '7cd0bc57-8c2a-40d0-b885-c5133f706c14', + 'title': '[`researcher`] Thinking', + 'tool_call_id': 'researcher:think:', }), dict({ - 'raw_output': 'Thinking: Analyzing', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Analyzing', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'session_update': 'tool_call_update', 'status': 'in_progress', - 'tool_call_id': '7cd0bc57-8c2a-40d0-b885-c5133f706c14', + 'tool_call_id': 'researcher:think:', }), dict({ - 'raw_output': 'Thinking: the', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Analyzing the', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'session_update': 'tool_call_update', 'status': 'in_progress', - 'tool_call_id': '7cd0bc57-8c2a-40d0-b885-c5133f706c14', + 'tool_call_id': 'researcher:think:', }), dict({ - 'raw_output': 'Thinking: problem', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Analyzing the problem', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'session_update': 'tool_call_update', 'status': 'in_progress', - 'tool_call_id': '7cd0bc57-8c2a-40d0-b885-c5133f706c14', + 'tool_call_id': 'researcher:think:', }), dict({ 'session_update': 'tool_call_update', 'status': 'completed', - 'tool_call_id': '7cd0bc57-8c2a-40d0-b885-c5133f706c14', + 'tool_call_id': 'researcher:think:', }), ]) # --- # name: TestInlineModeSnapshots.test_tool_call[asyncio] list([ dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': "I'll search for files", + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'kind': 'other', 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [coder]', - 'tool_call_id': '5ca8188c-4017-4173-8606-8a6889b1a7a0', - }), - dict({ - 'raw_output': "I'll search for files", - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '5ca8188c-4017-4173-8606-8a6889b1a7a0', + 'title': '[`coder`] Output', + 'tool_call_id': 'coder:output:', }), dict({ - 'raw_output': ''' - - 🔧 [coder] Using tool: search - - ''', - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '5ca8188c-4017-4173-8606-8a6889b1a7a0', + 'kind': 'search', + 'raw_input': dict({ + 'pattern': '*.py', + }), + 'session_update': 'tool_call', + 'status': 'pending', + 'title': '[`coder`]: Searching: *.py', + 'tool_call_id': 'coder:search:call_001', }), dict({ - 'raw_output': ''' - ✅ [coder] search: Found 3 files - - ''', + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Found 3 files', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'raw_output': 'Found 3 files', 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '5ca8188c-4017-4173-8606-8a6889b1a7a0', + 'status': 'completed', + 'tool_call_id': 'coder:search:pyd_ai_', }), dict({ 'session_update': 'tool_call_update', 'status': 'completed', - 'tool_call_id': '5ca8188c-4017-4173-8606-8a6889b1a7a0', + 'tool_call_id': 'coder:output:', }), ]) # --- # name: TestInlineModeSnapshots.test_tool_call_error[asyncio] list([ dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': 'Executing command', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'kind': 'other', 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [executor]', - 'tool_call_id': '504ccf34-fed3-4a04-bec2-785e74c1ecf1', - }), - dict({ - 'raw_output': 'Executing command', - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '504ccf34-fed3-4a04-bec2-785e74c1ecf1', - }), - dict({ - 'raw_output': ''' - - 🔧 [executor] Using tool: bash - - ''', - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '504ccf34-fed3-4a04-bec2-785e74c1ecf1', + 'title': '[`executor`] Output', + 'tool_call_id': 'executor:output:', }), dict({ + 'kind': 'execute', + 'raw_input': dict({ + 'command': 'make build', + }), + 'session_update': 'tool_call', + 'status': 'pending', + 'title': '[`executor`]: Running: make build', + 'tool_call_id': 'executor:bash:call_003', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + Error: Build failed: missing dependency + + Fix the errors and try again. + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), 'raw_output': ''' - ❌ [executor] bash: Build failed: missing dependency + Build failed: missing dependency Fix the errors and try again. - ''', 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'tool_call_id': '504ccf34-fed3-4a04-bec2-785e74c1ecf1', + 'status': 'failed', + 'tool_call_id': 'executor:bash:pyd_ai_', }), dict({ 'session_update': 'tool_call_update', 'status': 'completed', - 'tool_call_id': '504ccf34-fed3-4a04-bec2-785e74c1ecf1', + 'tool_call_id': 'executor:output:', }), ]) # --- @@ -313,6 +461,7 @@ ''', 'type': 'text', }), + 'message_id': '', 'session_update': 'agent_message_chunk', }), dict({ @@ -320,6 +469,7 @@ 'text': 'Hello', 'type': 'text', }), + 'message_id': '', 'session_update': 'agent_message_chunk', }), dict({ @@ -327,6 +477,7 @@ 'text': ' world', 'type': 'text', }), + 'message_id': '', 'session_update': 'agent_message_chunk', }), dict({ @@ -334,6 +485,7 @@ 'text': '!', 'type': 'text', }), + 'message_id': '', 'session_update': 'agent_message_chunk', }), dict({ @@ -345,6 +497,7 @@ ''', 'type': 'text', }), + 'message_id': '', 'session_update': 'agent_message_chunk', }), ]) @@ -359,6 +512,7 @@ ''', 'type': 'text', }), + 'message_id': '', 'session_update': 'agent_message_chunk', }), dict({ @@ -366,27 +520,30 @@ 'text': "I'll search for files", 'type': 'text', }), + 'message_id': '', 'session_update': 'agent_message_chunk', }), dict({ 'content': dict({ 'text': ''' - 🔧 [coder] Using tool: search + - 🔧 [`coder`] Using tool: ``search`` ''', 'type': 'text', }), + 'message_id': '', 'session_update': 'agent_message_chunk', }), dict({ 'content': dict({ 'text': ''' - ✅ [coder] search: Found 3 files + - ✅ [`coder`] `search` ''', 'type': 'text', }), + 'message_id': '', 'session_update': 'agent_message_chunk', }), dict({ @@ -398,6 +555,7 @@ ''', 'type': 'text', }), + 'message_id': '', 'session_update': 'agent_message_chunk', }), ]) @@ -410,38 +568,27 @@ }), 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [writer]: agent start', - 'tool_call_id': '51b2d2eb-f2b2-4364-a2ce-224c37f7107f', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'title': '🤖 [writer]: streaming...', - 'tool_call_id': '51b2d2eb-f2b2-4364-a2ce-224c37f7107f', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'title': '🤖 [writer]: streaming...', - 'tool_call_id': '51b2d2eb-f2b2-4364-a2ce-224c37f7107f', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'title': '🤖 [writer]: streaming...', - 'tool_call_id': '51b2d2eb-f2b2-4364-a2ce-224c37f7107f', - }), - dict({ + 'title': '🤖 [`writer`]: agent start', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + This is a long message that gets streamed in multi + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '🤖 [writer]: streaming...', - 'tool_call_id': '51b2d2eb-f2b2-4364-a2ce-224c37f7107f', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'completed', - 'title': '✅ [writer]: completed', - 'tool_call_id': '51b2d2eb-f2b2-4364-a2ce-224c37f7107f', + 'title': '🤖 [`writer`]: Output...', + 'tool_call_id': '', }), ]) # --- @@ -453,44 +600,112 @@ }), 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [analyzer]: agent start', - 'tool_call_id': 'c48b8c18-ed5d-4af0-90e8-0b704ee23153', - }), - dict({ + 'title': '🤖 [`analyzer`]: agent start', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + > **Thinking** :Need to analyze + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'think', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '💭 [analyzer]: thinking...', - 'tool_call_id': 'c48b8c18-ed5d-4af0-90e8-0b704ee23153', - }), - dict({ + 'title': '💭 [`analyzer`]: thinking...', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + > **Thinking** :Need to analyze + Let me check + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '🤖 [analyzer]: streaming...', - 'tool_call_id': 'c48b8c18-ed5d-4af0-90e8-0b704ee23153', - }), - dict({ + 'title': '🤖 [`analyzer`]: Output...', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + > **Thinking** :Need to analyze + Let me check + - calling `grep` + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '🔧 [analyzer]: calling grep...', - 'tool_call_id': 'c48b8c18-ed5d-4af0-90e8-0b704ee23153', - }), - dict({ + 'title': '🔧 [`analyzer`]: calling `grep`...', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + > **Thinking** :Need to analyze + Let me check + - calling `grep` + - `grep` completed + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '✅ [analyzer]: grep completed', - 'tool_call_id': 'c48b8c18-ed5d-4af0-90e8-0b704ee23153', - }), - dict({ + 'title': '✅ [`analyzer`]: `grep` completed', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + > **Thinking** :Need to analyze + Let me check + - calling `grep` + - `grep` completed - all good! + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '🤖 [analyzer]: streaming...', - 'tool_call_id': 'c48b8c18-ed5d-4af0-90e8-0b704ee23153', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'completed', - 'title': '✅ [analyzer]: completed', - 'tool_call_id': 'c48b8c18-ed5d-4af0-90e8-0b704ee23153', + 'title': '🤖 [`analyzer`]: Output...', + 'tool_call_id': '', }), ]) # --- @@ -502,20 +717,27 @@ }), 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [coordinator]: agent start', - 'tool_call_id': 'bc8ffa5f-2ee2-4f96-b942-d1c5ca04d171', - }), - dict({ + 'title': '🤖 [`coordinator`]: agent start', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + Delegating to researcher + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '🤖 [coordinator]: streaming...', - 'tool_call_id': 'bc8ffa5f-2ee2-4f96-b942-d1c5ca04d171', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'completed', - 'title': '✅ [coordinator]: completed', - 'tool_call_id': 'bc8ffa5f-2ee2-4f96-b942-d1c5ca04d171', + 'title': '🤖 [`coordinator`]: Output...', + 'tool_call_id': '', }), dict({ 'kind': 'other', @@ -523,32 +745,68 @@ }), 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [researcher]: agent start', - 'tool_call_id': 'f77c6ca5-a8f8-4e5e-b646-63af107343e4', - }), - dict({ + 'title': '🤖 [`researcher`]: agent start', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + > **Thinking** :Searching + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'think', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '💭 [researcher]: thinking...', - 'tool_call_id': 'f77c6ca5-a8f8-4e5e-b646-63af107343e4', - }), - dict({ + 'title': '💭 [`researcher`]: thinking...', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + > **Thinking** :Searching + - calling `search` + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '🔧 [researcher]: calling search...', - 'tool_call_id': 'f77c6ca5-a8f8-4e5e-b646-63af107343e4', - }), - dict({ + 'title': '🔧 [`researcher`]: calling `search`...', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + > **Thinking** :Searching + - calling `search` + - `search` completed + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '✅ [researcher]: search completed', - 'tool_call_id': 'f77c6ca5-a8f8-4e5e-b646-63af107343e4', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'completed', - 'title': '✅ [researcher]: completed', - 'tool_call_id': 'f77c6ca5-a8f8-4e5e-b646-63af107343e4', + 'title': '✅ [`researcher`]: `search` completed', + 'tool_call_id': '', }), ]) # --- @@ -560,32 +818,27 @@ }), 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [assistant]: agent start', - 'tool_call_id': '44ae8044-93e8-4780-a99a-0a0b4a9291ab', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'title': '🤖 [assistant]: streaming...', - 'tool_call_id': '44ae8044-93e8-4780-a99a-0a0b4a9291ab', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'in_progress', - 'title': '🤖 [assistant]: streaming...', - 'tool_call_id': '44ae8044-93e8-4780-a99a-0a0b4a9291ab', - }), - dict({ + 'title': '🤖 [`assistant`]: agent start', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + Hello + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '🤖 [assistant]: streaming...', - 'tool_call_id': '44ae8044-93e8-4780-a99a-0a0b4a9291ab', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'completed', - 'title': '✅ [assistant]: completed', - 'tool_call_id': '44ae8044-93e8-4780-a99a-0a0b4a9291ab', + 'title': '🤖 [`assistant`]: Output...', + 'tool_call_id': '', }), ]) # --- @@ -597,32 +850,65 @@ }), 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [researcher]: agent start', - 'tool_call_id': '0f20b654-4185-4f70-b141-2a6b25e1ec2f', - }), - dict({ + 'title': '🤖 [`researcher`]: agent start', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + > **Thinking** :Analyzing + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'think', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '💭 [researcher]: thinking...', - 'tool_call_id': '0f20b654-4185-4f70-b141-2a6b25e1ec2f', - }), - dict({ + 'title': '💭 [`researcher`]: thinking...', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + > **Thinking** :Analyzing the + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'think', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '💭 [researcher]: thinking...', - 'tool_call_id': '0f20b654-4185-4f70-b141-2a6b25e1ec2f', - }), - dict({ + 'title': '💭 [`researcher`]: thinking...', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + > **Thinking** :Analyzing the problem + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'think', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '💭 [researcher]: thinking...', - 'tool_call_id': '0f20b654-4185-4f70-b141-2a6b25e1ec2f', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'completed', - 'title': '✅ [researcher]: completed', - 'tool_call_id': '0f20b654-4185-4f70-b141-2a6b25e1ec2f', + 'title': '💭 [`researcher`]: thinking...', + 'tool_call_id': '', }), ]) # --- @@ -634,32 +920,68 @@ }), 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [coder]: agent start', - 'tool_call_id': 'a0c8f0b2-d987-4c36-84a2-e5431ff832e2', - }), - dict({ + 'title': '🤖 [`coder`]: agent start', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + I'll search for files + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '🤖 [coder]: streaming...', - 'tool_call_id': 'a0c8f0b2-d987-4c36-84a2-e5431ff832e2', - }), - dict({ + 'title': '🤖 [`coder`]: Output...', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + I'll search for files + - calling `search` + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '🔧 [coder]: calling search...', - 'tool_call_id': 'a0c8f0b2-d987-4c36-84a2-e5431ff832e2', - }), - dict({ + 'title': '🔧 [`coder`]: calling `search`...', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + I'll search for files + - calling `search` + - `search` completed + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '✅ [coder]: search completed', - 'tool_call_id': 'a0c8f0b2-d987-4c36-84a2-e5431ff832e2', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'completed', - 'title': '✅ [coder]: completed', - 'tool_call_id': 'a0c8f0b2-d987-4c36-84a2-e5431ff832e2', + 'title': '✅ [`coder`]: `search` completed', + 'tool_call_id': '', }), ]) # --- @@ -671,32 +993,70 @@ }), 'session_update': 'tool_call', 'status': 'pending', - 'title': '🤖 [executor]: agent start', - 'tool_call_id': '51ecf6e9-e506-4653-9818-79f973c523b6', - }), - dict({ + 'title': '🤖 [`executor`]: agent start', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + Executing command + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '🤖 [executor]: streaming...', - 'tool_call_id': '51ecf6e9-e506-4653-9818-79f973c523b6', - }), - dict({ + 'title': '🤖 [`executor`]: Output...', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + Executing command + - calling `bash` + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '🔧 [executor]: calling bash...', - 'tool_call_id': '51ecf6e9-e506-4653-9818-79f973c523b6', - }), - dict({ + 'title': '🔧 [`executor`]: calling `bash`...', + 'tool_call_id': '', + }), + dict({ + 'content': list([ + dict({ + 'content': dict({ + 'text': ''' + + Executing command + - calling `bash` + - `bash` failed: `Build failed: missing dependency + + Fix the errors and try again.` + ''', + 'type': 'text', + }), + 'type': 'content', + }), + ]), + 'kind': 'other', 'session_update': 'tool_call_update', 'status': 'in_progress', - 'title': '❌ [executor]: bash failed', - 'tool_call_id': '51ecf6e9-e506-4653-9818-79f973c523b6', - }), - dict({ - 'session_update': 'tool_call_update', - 'status': 'completed', - 'title': '✅ [executor]: completed', - 'tool_call_id': '51ecf6e9-e506-4653-9818-79f973c523b6', + 'title': '❌ [`executor`]: `bash` failed', + 'tool_call_id': '', }), ]) # --- diff --git a/tests/acp/schema/test_subagent_types.py b/tests/acp/schema/test_subagent_types.py new file mode 100644 index 000000000..b4441dc29 --- /dev/null +++ b/tests/acp/schema/test_subagent_types.py @@ -0,0 +1,563 @@ +"""TDD tests for ACP subagent schema types (RFC-0042 Phase 1).""" + +from __future__ import annotations + +import pytest + +from acp.schema import ( + SubagentCapabilities, + SubagentInfo, + SubagentRunInfo, + ToolCallKind, +) +from acp.schema.agent_responses import ( + ForkSessionResponse, + LoadSessionResponse, + NewSessionResponse, + ResumeSessionResponse, +) +from acp.schema.session_state import SessionInfo +from acp.schema.session_updates import ToolCallProgress, ToolCallStart +from acp.schema.tool_call import ToolCallStatus + + +# ============================================================================= +# PromptDelegation tests (T11) +# ============================================================================= + + +def test_prompt_delegation_importable() -> None: + """PromptDelegation must be importable from acp.schema.""" + from acp.schema import PromptDelegation + + assert PromptDelegation is not None + + +def test_prompt_delegation_auto_policy() -> None: + """PromptDelegation should accept 'auto' policy.""" + from acp.schema import PromptDelegation + + d = PromptDelegation(policy="auto") + assert d.policy == "auto" + assert d.subagent_id is None + assert d.run_mode is None + + +def test_prompt_delegation_disable_policy() -> None: + """PromptDelegation should accept 'disable' policy.""" + from acp.schema import PromptDelegation + + d = PromptDelegation(policy="disable") + assert d.policy == "disable" + + +def test_prompt_delegation_prefer_policy() -> None: + """PromptDelegation should accept 'prefer' policy.""" + from acp.schema import PromptDelegation + + d = PromptDelegation(policy="prefer") + assert d.policy == "prefer" + + +def test_prompt_delegation_require_policy() -> None: + """PromptDelegation should accept 'require' policy.""" + from acp.schema import PromptDelegation + + d = PromptDelegation(policy="require") + assert d.policy == "require" + + +def test_prompt_delegation_with_subagent_id() -> None: + """PromptDelegation should accept subagent_id.""" + from acp.schema import PromptDelegation + + d = PromptDelegation(policy="require", subagent_id="sub_001") + assert d.subagent_id == "sub_001" + + +def test_prompt_delegation_with_run_mode_foreground() -> None: + """PromptDelegation should accept foreground run_mode.""" + from acp.schema import PromptDelegation + + d = PromptDelegation(policy="prefer", run_mode="foreground") + assert d.run_mode == "foreground" + + +def test_prompt_delegation_with_run_mode_background() -> None: + """PromptDelegation should accept background run_mode.""" + from acp.schema import PromptDelegation + + d = PromptDelegation(policy="prefer", run_mode="background") + assert d.run_mode == "background" + + +def test_prompt_delegation_full() -> None: + """PromptDelegation should accept all fields together.""" + from acp.schema import PromptDelegation + + d = PromptDelegation(policy="require", subagent_id="sub_001", run_mode="background") + assert d.policy == "require" + assert d.subagent_id == "sub_001" + assert d.run_mode == "background" + + +def test_prompt_delegation_json_roundtrip() -> None: + """PromptDelegation should serialize and deserialize correctly.""" + from acp.schema import PromptDelegation + + d = PromptDelegation(policy="prefer", subagent_id="sub_002", run_mode="foreground") + json_data = d.model_dump(mode="json") + assert json_data["policy"] == "prefer" + assert json_data["subagent_id"] == "sub_002" + assert json_data["run_mode"] == "foreground" + + restored = PromptDelegation.model_validate(json_data) + assert restored.policy == "prefer" + assert restored.subagent_id == "sub_002" + assert restored.run_mode == "foreground" + + +def test_prompt_request_accepts_delegation() -> None: + """PromptRequest should accept an optional delegation field.""" + from acp.schema import PromptDelegation, PromptRequest + from acp.schema.content_blocks import TextContentBlock + + delegation = PromptDelegation(policy="auto") + req = PromptRequest( + session_id="sess_001", + prompt=[TextContentBlock(text="Hello")], + delegation=delegation, + ) + assert req.delegation is not None + assert req.delegation.policy == "auto" + + +def test_prompt_request_delegation_none_by_default() -> None: + """PromptRequest delegation should default to None.""" + from acp.schema import PromptRequest + from acp.schema.content_blocks import TextContentBlock + + req = PromptRequest( + session_id="sess_001", + prompt=[TextContentBlock(text="Hello")], + ) + assert req.delegation is None + + +def test_prompt_request_with_delegation_json_roundtrip() -> None: + """PromptRequest with delegation should serialize and deserialize.""" + from acp.schema import PromptDelegation, PromptRequest + from acp.schema.content_blocks import TextContentBlock + + delegation = PromptDelegation(policy="require", subagent_id="sub_001", run_mode="background") + req = PromptRequest( + session_id="sess_001", + prompt=[TextContentBlock(text="Hello")], + delegation=delegation, + ) + json_data = req.model_dump(mode="json") + assert json_data["delegation"]["policy"] == "require" + assert json_data["delegation"]["subagent_id"] == "sub_001" + assert json_data["delegation"]["run_mode"] == "background" + + restored = PromptRequest.model_validate(json_data) + assert restored.delegation is not None + assert restored.delegation.policy == "require" + assert restored.delegation.subagent_id == "sub_001" + assert restored.delegation.run_mode == "background" + + +# ============================================================================= +# ToolCallKind tests +# ============================================================================= + + +def test_tool_call_kind_includes_subagent() -> None: + """ToolCallKind Literal must include 'subagent'.""" + from typing import get_args + + kinds = get_args(ToolCallKind) + assert "subagent" in kinds + + +def test_tool_call_kind_subagent_is_valid_value() -> None: + """'subagent' should be assignable to ToolCallKind.""" + kind: ToolCallKind = "subagent" + assert kind == "subagent" + + +def test_tool_kind_definitions_match() -> None: + """ACP ToolCallKind and AgentPool ToolKind must have identical members.""" + from acp.schema.tool_call import ToolCallKind + from agentpool.tools.base import ToolKind + + assert set(ToolCallKind.__args__) == set(ToolKind.__args__) + + +# ============================================================================= +# SubagentRunInfo tests +# ============================================================================= + + +def test_subagent_run_info_defaults() -> None: + """SubagentRunInfo should create with required fields only.""" + info = SubagentRunInfo(subagent_id="sub_001", name="coder") + assert info.subagent_id == "sub_001" + assert info.name == "coder" + assert info.description is None + assert info.status is None + assert info.depth is None + + +def test_subagent_run_info_full() -> None: + """SubagentRunInfo should accept all fields.""" + info = SubagentRunInfo( + subagent_id="sub_001", + name="coder", + description="A coding subagent", + status="running", + depth=1, + ) + assert info.description == "A coding subagent" + assert info.status == "running" + assert info.depth == 1 + + +def test_subagent_run_info_json_roundtrip() -> None: + """SubagentRunInfo should serialize/deserialize correctly.""" + info = SubagentRunInfo( + subagent_id="sub_001", + name="coder", + description="A coding subagent", + status="completed", + depth=2, + ) + json_data = info.model_dump(mode="json") + assert json_data["subagent_id"] == "sub_001" + assert json_data["name"] == "coder" + assert json_data["description"] == "A coding subagent" + assert json_data["status"] == "completed" + assert json_data["depth"] == 2 + + restored = SubagentRunInfo.model_validate(json_data) + assert restored.subagent_id == "sub_001" + assert restored.name == "coder" + + +def test_subagent_run_info_depth_must_be_non_negative() -> None: + """SubagentRunInfo depth must be >= 0.""" + with pytest.raises(ValueError): + SubagentRunInfo(subagent_id="sub_001", name="coder", depth=-1) + + +# ============================================================================= +# SessionInfo hierarchy tests +# ============================================================================= + + +def test_session_info_hierarchy_defaults() -> None: + """SessionInfo hierarchy fields should default to None.""" + info = SessionInfo(session_id="sess_001", cwd="/tmp") + assert info.parent_session_id is None + assert info.child_session_ids is None + assert info.depth is None + + +def test_session_info_with_parent() -> None: + """SessionInfo should accept parent_session_id.""" + info = SessionInfo( + session_id="sess_002", + cwd="/tmp", + parent_session_id="sess_001", + ) + assert info.parent_session_id == "sess_001" + + +def test_session_info_with_children() -> None: + """SessionInfo should accept child_session_ids.""" + info = SessionInfo( + session_id="sess_001", + cwd="/tmp", + child_session_ids=["sess_002", "sess_003"], + ) + assert info.child_session_ids == ["sess_002", "sess_003"] + + +def test_session_info_with_depth() -> None: + """SessionInfo should accept depth.""" + info = SessionInfo(session_id="sess_002", cwd="/tmp", depth=1) + assert info.depth == 1 + + +def test_session_info_json_roundtrip() -> None: + """SessionInfo should serialize hierarchy fields.""" + info = SessionInfo( + session_id="sess_002", + cwd="/tmp", + parent_session_id="sess_001", + child_session_ids=["sess_003"], + depth=1, + ) + json_data = info.model_dump(mode="json") + assert json_data["parent_session_id"] == "sess_001" + assert json_data["child_session_ids"] == ["sess_003"] + assert json_data["depth"] == 1 + + restored = SessionInfo.model_validate(json_data) + assert restored.parent_session_id == "sess_001" + assert restored.child_session_ids == ["sess_003"] + assert restored.depth == 1 + + +def test_session_info_depth_must_be_non_negative() -> None: + """SessionInfo depth must be >= 0.""" + with pytest.raises(ValueError): + SessionInfo(session_id="sess_001", cwd="/tmp", depth=-1) + + +# ============================================================================= +# SubagentCapabilities tests +# ============================================================================= + + +def test_subagent_capabilities_defaults() -> None: + """SubagentCapabilities should default to False.""" + caps = SubagentCapabilities() + assert caps.streaming is False + assert caps.tools is False + assert caps.delegation is False + + +def test_subagent_capabilities_all_true() -> None: + """SubagentCapabilities should accept all True.""" + caps = SubagentCapabilities(streaming=True, tools=True, delegation=True) + assert caps.streaming is True + assert caps.tools is True + assert caps.delegation is True + + +def test_subagent_capabilities_json_roundtrip() -> None: + """SubagentCapabilities should serialize with camelCase.""" + caps = SubagentCapabilities(streaming=True, tools=False, delegation=True) + json_data = caps.model_dump(mode="json") + assert json_data["streaming"] is True + assert json_data["tools"] is False + assert json_data["delegation"] is True + + restored = SubagentCapabilities.model_validate(json_data) + assert restored.streaming is True + assert restored.tools is False + assert restored.delegation is True + + +# ============================================================================= +# SubagentInfo tests +# ============================================================================= + + +def test_subagent_info_defaults() -> None: + """SubagentInfo should create with required fields only.""" + info = SubagentInfo(subagent_id="sub_001", name="coder") + assert info.subagent_id == "sub_001" + assert info.name == "coder" + assert info.description is None + assert info.capabilities is None + + +def test_subagent_info_with_capabilities() -> None: + """SubagentInfo should accept capabilities.""" + caps = SubagentCapabilities(streaming=True, tools=True) + info = SubagentInfo( + subagent_id="sub_001", + name="coder", + description="A coding subagent", + capabilities=caps, + ) + assert info.description == "A coding subagent" + assert info.capabilities is not None + assert info.capabilities.streaming is True + assert info.capabilities.tools is True + + +def test_subagent_info_json_roundtrip() -> None: + """SubagentInfo should serialize.""" + info = SubagentInfo( + subagent_id="sub_001", + name="coder", + description="A coding subagent", + capabilities=SubagentCapabilities(streaming=True), + ) + json_data = info.model_dump(mode="json") + assert json_data["subagent_id"] == "sub_001" + assert json_data["name"] == "coder" + assert json_data["description"] == "A coding subagent" + assert json_data["capabilities"]["streaming"] is True + + restored = SubagentInfo.model_validate(json_data) + assert restored.subagent_id == "sub_001" + assert restored.capabilities is not None + assert restored.capabilities.streaming is True + + +# ============================================================================= +# ToolCallStart / ToolCallProgress subagent field tests +# ============================================================================= + + +def test_tool_call_start_with_subagent() -> None: + """ToolCallStart should accept an optional subagent field.""" + subagent = SubagentRunInfo(subagent_id="sub_001", name="coder", status="running") + start = ToolCallStart( + tool_call_id="tc_001", + title="Running subagent", + subagent=subagent, + ) + assert start.subagent is not None + assert start.subagent.subagent_id == "sub_001" + assert start.subagent.name == "coder" + + +def test_tool_call_start_subagent_none_by_default() -> None: + """ToolCallStart subagent field should default to None.""" + start = ToolCallStart(tool_call_id="tc_001", title="Regular tool") + assert start.subagent is None + + +def test_tool_call_progress_with_subagent() -> None: + """ToolCallProgress should accept an optional subagent field.""" + subagent = SubagentRunInfo(subagent_id="sub_001", name="coder", status="completed") + progress = ToolCallProgress( + tool_call_id="tc_001", + status="completed", + subagent=subagent, + ) + assert progress.subagent is not None + assert progress.subagent.status == "completed" + + +def test_tool_call_progress_subagent_none_by_default() -> None: + """ToolCallProgress subagent field should default to None.""" + progress = ToolCallProgress(tool_call_id="tc_001", status="in_progress") + assert progress.subagent is None + + +def test_tool_call_start_subagent_json_serialization() -> None: + """ToolCallStart should serialize subagent field.""" + subagent = SubagentRunInfo(subagent_id="sub_001", name="coder", depth=1) + start = ToolCallStart( + tool_call_id="tc_001", + title="Running subagent", + subagent=subagent, + ) + json_data = start.model_dump(mode="json") + assert json_data["subagent"]["subagent_id"] == "sub_001" + assert json_data["subagent"]["name"] == "coder" + assert json_data["subagent"]["depth"] == 1 + + +# ============================================================================= +# Lifecycle response available_subagents tests +# ============================================================================= + + +def test_new_session_response_with_available_subagents() -> None: + """NewSessionResponse should accept available_subagents.""" + subagents = [ + SubagentInfo(subagent_id="sub_001", name="coder"), + SubagentInfo(subagent_id="sub_002", name="reviewer"), + ] + resp = NewSessionResponse(session_id="sess_001", available_subagents=subagents) + assert resp.available_subagents is not None + assert len(resp.available_subagents) == 2 + assert resp.available_subagents[0].name == "coder" + + +def test_new_session_response_available_subagents_none_by_default() -> None: + """NewSessionResponse available_subagents should default to None.""" + resp = NewSessionResponse(session_id="sess_001") + assert resp.available_subagents is None + + +def test_load_session_response_with_available_subagents() -> None: + """LoadSessionResponse should accept available_subagents.""" + subagents = [SubagentInfo(subagent_id="sub_001", name="coder")] + resp = LoadSessionResponse(available_subagents=subagents) + assert resp.available_subagents is not None + assert len(resp.available_subagents) == 1 + + +def test_fork_session_response_with_available_subagents() -> None: + """ForkSessionResponse should accept available_subagents.""" + subagents = [SubagentInfo(subagent_id="sub_001", name="coder")] + resp = ForkSessionResponse(session_id="sess_002", available_subagents=subagents) + assert resp.available_subagents is not None + + +def test_resume_session_response_with_available_subagents() -> None: + """ResumeSessionResponse should accept available_subagents.""" + subagents = [SubagentInfo(subagent_id="sub_001", name="coder")] + resp = ResumeSessionResponse(available_subagents=subagents) + assert resp.available_subagents is not None + + +def test_new_session_response_subagents_json_serialization() -> None: + """NewSessionResponse should serialize available_subagents field.""" + subagents = [ + SubagentInfo( + subagent_id="sub_001", + name="coder", + capabilities=SubagentCapabilities(streaming=True), + ), + ] + resp = NewSessionResponse(session_id="sess_001", available_subagents=subagents) + json_data = resp.model_dump(mode="json") + assert json_data["available_subagents"] is not None + assert len(json_data["available_subagents"]) == 1 + assert json_data["available_subagents"][0]["subagent_id"] == "sub_001" + assert json_data["available_subagents"][0]["capabilities"]["streaming"] is True + + +# ============================================================================= +# Export tests +# ============================================================================= + + +def test_all_new_types_exported_from_acp_schema() -> None: + """All new subagent types must be importable from acp.schema.""" + from acp.schema import ( + PromptDelegation, + SubagentCapabilities, + SubagentInfo, + SubagentRunInfo, + ) + + assert SubagentRunInfo is not None + assert SubagentInfo is not None + assert SubagentCapabilities is not None + assert PromptDelegation is not None + + +def test_tool_call_start_has_subagent_field() -> None: + """ToolCallStart model fields must include subagent.""" + fields = ToolCallStart.model_fields + assert "subagent" in fields + + +def test_tool_call_progress_has_subagent_field() -> None: + """ToolCallProgress model fields must include subagent.""" + fields = ToolCallProgress.model_fields + assert "subagent" in fields + + +def test_session_info_has_hierarchy_fields() -> None: + """SessionInfo model fields must include hierarchy fields.""" + fields = SessionInfo.model_fields + assert "parent_session_id" in fields + assert "child_session_ids" in fields + assert "depth" in fields + + +def test_new_session_response_has_available_subagents_field() -> None: + """NewSessionResponse model fields must include available_subagents.""" + fields = NewSessionResponse.model_fields + assert "available_subagents" in fields diff --git a/tests/acp/test_event_converter_snapshots.py b/tests/acp/test_event_converter_snapshots.py index 03bd4b970..75cfef5cc 100644 --- a/tests/acp/test_event_converter_snapshots.py +++ b/tests/acp/test_event_converter_snapshots.py @@ -21,7 +21,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import re +from typing import TYPE_CHECKING, Any import pytest @@ -34,6 +35,29 @@ from agentpool_server.acp_server.event_converter import ACPEventConverter from tests.fixtures.subagent_events import TEST_EVENT_SEQUENCES +_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +_INLINE_UUID_RE = re.compile( + r"^([a-zA-Z0-9_]+:(?:output|think):)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$" +) +_PYD_AI_ID_RE = re.compile(r"^(.*:pyd_ai_)([0-9a-f]{32}$)") + + +def _normalize_uuids(obj: object) -> Any: + """Recursively replace UUID strings with '' for deterministic snapshots.""" + if isinstance(obj, dict): + return {k: _normalize_uuids(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_normalize_uuids(v) for v in obj] + if isinstance(obj, str): + if _UUID_RE.match(obj): + return "" + if m := _INLINE_UUID_RE.match(obj): + return m.group(1) + "" + if m := _PYD_AI_ID_RE.match(obj): + return m.group(1) + "" + return obj + + from agentpool.agents.events import StreamCompleteEvent from agentpool.messaging.messages import ChatMessage from pydantic_ai.usage import RequestUsage @@ -43,15 +67,16 @@ async def collect_updates(converter: ACPEventConverter, event) -> list[dict[str, """Helper to collect all updates from an event and convert to dict for snapshots. Snapshot tests need serializable objects, so we convert to dict. + UUIDs are normalized to '' for deterministic comparison. """ updates: list[dict[str, object]] = [] async for update in converter.convert(event): # Convert Pydantic models to dict for snapshot comparison if hasattr(update, "model_dump"): - updates.append(update.model_dump(exclude_none=True)) + updates.append(_normalize_uuids(update.model_dump(exclude_none=True))) else: # Fallback for non-Pydantic objects - updates.append({"_str": str(update)}) + updates.append(_normalize_uuids({"_str": str(update)})) return updates diff --git a/tests/acp_server/test_delegation.py b/tests/acp_server/test_delegation.py new file mode 100644 index 000000000..052e99958 --- /dev/null +++ b/tests/acp_server/test_delegation.py @@ -0,0 +1,290 @@ +"""TDD tests for delegation handler with auto/disable/prefer/require policies (T12). + +Tests that ACPSession.process_prompt correctly handles PromptDelegation +based on the advertised prompt_delegation capability. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from acp.schema import TextContentBlock +from acp.schema.requests import PromptDelegation +from agentpool import Agent +from agentpool.agents.events import StreamCompleteEvent +from agentpool.delegation import AgentPool +from agentpool.messaging import ChatMessage +from agentpool.tool_impls.read.tool import ReadTool +from agentpool_toolsets.builtin.subagent_tools import SubagentTools +from agentpool_server.acp_server.session import ACPSession + + +def _make_stream_complete_event() -> StreamCompleteEvent: + """Create a simple StreamCompleteEvent for mocking run_stream.""" + return StreamCompleteEvent(message=ChatMessage(content="test", role="assistant")) + + +async def _mock_run_stream(*_args: object, **_kwargs: object) -> AsyncIterator[StreamCompleteEvent]: + """Async generator that yields a single StreamCompleteEvent.""" + yield _make_stream_complete_event() + + +@pytest.fixture +def agent_pool_with_agents() -> tuple[AgentPool, Agent, Agent]: + """Create a pool with a main agent and a subagent.""" + pool = AgentPool() + + def main_callback(message: str) -> str: + return f"Main: {message}" + + def subagent_callback(message: str) -> str: + return f"Sub: {message}" + + main_agent = Agent.from_callback( + name="main_agent", + callback=main_callback, + agent_pool=pool, + toolsets=[SubagentTools()], + ) + subagent = Agent.from_callback( + name="subagent_a", + callback=subagent_callback, + agent_pool=pool, + ) + pool.register("main_agent", main_agent) + pool.register("subagent_a", subagent) + return pool, main_agent, subagent + + +@pytest.fixture +def acp_session(agent_pool_with_agents: tuple[AgentPool, Agent, Agent]) -> ACPSession: + """Create an ACPSession with mocked dependencies for unit testing.""" + _pool, main_agent, _subagent = agent_pool_with_agents + mock_client = MagicMock() + mock_acp_agent = MagicMock() + mock_acp_agent.prompt_delegation_enabled = True + + session = ACPSession( + session_id="test-session", + agent=main_agent, + cwd="/tmp", + client=mock_client, + acp_agent=mock_acp_agent, + ) + + # Mock acp_env to avoid real cleanup errors in close() + session.acp_env = MagicMock() + session.acp_env.__aexit__ = AsyncMock() + + # Mock notifications + session.notifications = MagicMock() + session.notifications.send_update = AsyncMock() + + return session + + +@pytest.fixture +def text_content_block() -> TextContentBlock: + """Create a simple text content block.""" + return TextContentBlock(text="hello") + + +# ============================================================================= +# auto policy tests +# ============================================================================= + + +@pytest.mark.unit +async def test_auto_policy_runs_normal_agent_flow( + acp_session: ACPSession, + text_content_block: TextContentBlock, +) -> None: + """auto policy should call agent.run_stream normally, not route to subagent.""" + session = acp_session + delegation = PromptDelegation(policy="auto") + + with patch.object(session.agent, "run_stream", side_effect=_mock_run_stream) as mock_run: + with patch.object(session, "_run_subagent_directly", new_callable=AsyncMock) as mock_direct: + stop_reason = await session.process_prompt([text_content_block], delegation=delegation) + + assert stop_reason == "end_turn" + mock_run.assert_called_once() + mock_direct.assert_not_awaited() + + +# ============================================================================= +# disable policy tests +# ============================================================================= + + +@pytest.mark.unit +async def test_disable_policy_filters_subagent_tools( + acp_session: ACPSession, + text_content_block: TextContentBlock, +) -> None: + """disable policy should disable subagent tools before run and re-enable after.""" + session = acp_session + delegation = PromptDelegation(policy="disable") + + with patch.object(session.agent, "run_stream", side_effect=_mock_run_stream): + with patch.object( + session.agent.tools, "disable_tool", new_callable=AsyncMock + ) as mock_disable: + with patch.object( + session.agent.tools, "enable_tool", new_callable=AsyncMock + ) as mock_enable: + stop_reason = await session.process_prompt( + [text_content_block], delegation=delegation + ) + + assert stop_reason == "end_turn" + + # Should disable subagent tools + mock_disable.assert_awaited() + disabled_names = {call.args[0] for call in mock_disable.await_args_list} + # SubagentTools creates "list_available_nodes" and "task" + assert "list_available_nodes" in disabled_names + assert "task" in disabled_names + + # Should re-enable them after + mock_enable.assert_awaited() + enabled_names = {call.args[0] for call in mock_enable.await_args_list} + assert "list_available_nodes" in enabled_names + assert "task" in enabled_names + + +@pytest.mark.unit +async def test_disable_policy_leaves_non_subagent_tools_enabled( + acp_session: ACPSession, + text_content_block: TextContentBlock, +) -> None: + """disable policy should not disable tools that are not subagent tools.""" + session = acp_session + + # Add a non-subagent tool to the agent + read_tool = ReadTool(name="read") + session.agent.tools.builtin_provider.add_tool(read_tool) + + delegation = PromptDelegation(policy="disable") + + with patch.object(session.agent, "run_stream", side_effect=_mock_run_stream): + with patch.object( + session.agent.tools, "disable_tool", new_callable=AsyncMock + ) as mock_disable: + with patch.object( + session.agent.tools, "enable_tool", new_callable=AsyncMock + ) as mock_enable: + await session.process_prompt([text_content_block], delegation=delegation) + + disabled_names = {call.args[0] for call in mock_disable.await_args_list} + assert "read" not in disabled_names + + enabled_names = {call.args[0] for call in mock_enable.await_args_list} + assert "read" not in enabled_names + + +# ============================================================================= +# prefer policy tests +# ============================================================================= + + +@pytest.mark.unit +async def test_prefer_policy_routes_to_subagent_when_available( + acp_session: ACPSession, + text_content_block: TextContentBlock, +) -> None: + """prefer policy should route to subagent when subagent_id exists and supports streaming.""" + session = acp_session + delegation = PromptDelegation(policy="prefer", subagent_id="subagent_a") + + with patch.object( + session, "_run_subagent_directly", new_callable=AsyncMock, return_value="end_turn" + ) as mock_direct: + stop_reason = await session.process_prompt([text_content_block], delegation=delegation) + + assert stop_reason == "end_turn" + mock_direct.assert_awaited_once() + + +@pytest.mark.unit +async def test_prefer_policy_falls_back_to_normal_when_subagent_missing( + acp_session: ACPSession, + text_content_block: TextContentBlock, +) -> None: + """prefer policy should fall back to normal flow when subagent is not available.""" + session = acp_session + delegation = PromptDelegation(policy="prefer", subagent_id="nonexistent") + + with patch.object(session.agent, "run_stream", side_effect=_mock_run_stream) as mock_run: + with patch.object(session, "_run_subagent_directly", new_callable=AsyncMock) as mock_direct: + stop_reason = await session.process_prompt([text_content_block], delegation=delegation) + + assert stop_reason == "end_turn" + mock_direct.assert_not_awaited() + mock_run.assert_called_once() + + +# ============================================================================= +# require policy tests +# ============================================================================= + + +@pytest.mark.unit +async def test_require_policy_routes_to_subagent_when_available( + acp_session: ACPSession, + text_content_block: TextContentBlock, +) -> None: + """require policy should route to subagent when available.""" + session = acp_session + delegation = PromptDelegation(policy="require", subagent_id="subagent_a") + + with patch.object( + session, "_run_subagent_directly", new_callable=AsyncMock, return_value="end_turn" + ) as mock_direct: + stop_reason = await session.process_prompt([text_content_block], delegation=delegation) + + assert stop_reason == "end_turn" + mock_direct.assert_awaited_once() + + +@pytest.mark.unit +async def test_require_policy_errors_when_subagent_unavailable( + acp_session: ACPSession, + text_content_block: TextContentBlock, +) -> None: + """require policy should raise RequestError when subagent is not available.""" + session = acp_session + delegation = PromptDelegation(policy="require", subagent_id="nonexistent") + + from acp.exceptions import RequestError + + with pytest.raises(RequestError, match="Subagent 'nonexistent' not available"): + await session.process_prompt([text_content_block], delegation=delegation) + + +# ============================================================================= +# capability advertisement tests +# ============================================================================= + + +@pytest.mark.unit +async def test_delegation_ignored_when_capability_not_advertised( + acp_session: ACPSession, + text_content_block: TextContentBlock, +) -> None: + """Delegation should be ignored when prompt_delegation capability is False.""" + session = acp_session + session.acp_agent.prompt_delegation_enabled = False # type: ignore[reportAttributeAccessIssue] + + delegation = PromptDelegation(policy="require", subagent_id="subagent_a") + + with patch.object(session.agent, "run_stream", side_effect=_mock_run_stream) as mock_run: + with patch.object(session, "_run_subagent_directly", new_callable=AsyncMock) as mock_direct: + stop_reason = await session.process_prompt([text_content_block], delegation=delegation) + + assert stop_reason == "end_turn" + mock_direct.assert_not_awaited() + mock_run.assert_called_once() diff --git a/tests/acp_server/test_delegation_integration.py b/tests/acp_server/test_delegation_integration.py new file mode 100644 index 000000000..541a4e4aa --- /dev/null +++ b/tests/acp_server/test_delegation_integration.py @@ -0,0 +1,211 @@ +"""Integration tests for delegation through AgentPoolACPAgent (T18). + +Tests the full protocol flow: +1. initialize -> verify delegation capabilities advertised +2. new_session -> verify available_subagents in response +3. prompt with delegation policy -> verify routing behavior +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from unittest.mock import AsyncMock, patch + +import pytest + +from acp.schema import ( + InitializeRequest, + NewSessionRequest, + PromptRequest, + TextContentBlock, +) +from acp.schema.requests import PromptDelegation +from agentpool import Agent +from agentpool.agents.events import StreamCompleteEvent +from agentpool.delegation import AgentPool +from agentpool.messaging import ChatMessage +from agentpool_server.acp_server.acp_agent import AgentPoolACPAgent +from agentpool_toolsets.builtin.subagent_tools import SubagentTools + + +def _make_text_block(text: str = "hello") -> TextContentBlock: + """Create a text content block for prompts.""" + return TextContentBlock(text=text) + + +async def _mock_empty_stream( + *_args: object, **_kwargs: object +) -> AsyncIterator[StreamCompleteEvent]: + """Async generator that yields a single completion event.""" + yield StreamCompleteEvent(message=ChatMessage(content="done", role="assistant")) + + +@pytest.fixture +async def delegation_pool(): + """Create a pool with a main agent and a subagent for delegation tests.""" + pool = AgentPool() + + def main_callback(message: str) -> str: + return f"Main: {message}" + + def subagent_callback(message: str) -> str: + return f"Sub: {message}" + + main_agent = Agent.from_callback( + name="main_agent", + callback=main_callback, + agent_pool=pool, + toolsets=[SubagentTools()], + ) + subagent = Agent.from_callback( + name="subagent_a", + callback=subagent_callback, + agent_pool=pool, + system_prompt="You are subagent A", + ) + pool.register("main_agent", main_agent) + pool.register("subagent_a", subagent) + return pool, main_agent, subagent + + +@pytest.fixture +async def acp_agent_with_delegation(delegation_pool): + """Create an AgentPoolACPAgent wired to a pool with subagents.""" + pool, main_agent, _subagent = delegation_pool + mock_client = AsyncMock() + acp_agent = AgentPoolACPAgent(client=mock_client, default_agent=main_agent) + yield acp_agent, pool, mock_client + # Cleanup + await acp_agent.session_manager.close_all_sessions() + + +# ============================================================================= +# Full delegation flow +# ============================================================================= + + +@pytest.mark.integration +async def test_full_delegation_flow_prefer_policy(acp_agent_with_delegation) -> None: + """Full flow: initialize -> new_session -> prompt with prefer policy routes to subagent.""" + acp_agent, pool, _mock_client = acp_agent_with_delegation + + # Step 1: initialize + init_req = InitializeRequest(protocol_version=1) + init_resp = await acp_agent.initialize(init_req) + assert init_resp.agent_capabilities.subagents is not None + assert init_resp.agent_capabilities.subagents.prompt_delegation is True + assert init_resp.agent_capabilities.subagents.background is True + + # Step 2: new_session + new_sess_req = NewSessionRequest(cwd="/tmp", mcp_servers=[]) + new_sess_resp = await acp_agent.new_session(new_sess_req) + assert new_sess_resp.available_subagents is not None + subagent_ids = {s.subagent_id for s in new_sess_resp.available_subagents} + assert "subagent_a" in subagent_ids + assert "main_agent" in subagent_ids + + session_id = new_sess_resp.session_id + + # Step 3: prompt with prefer policy + delegation = PromptDelegation(policy="prefer", subagent_id="subagent_a") + prompt_req = PromptRequest( + session_id=session_id, + prompt=[_make_text_block("Do something")], + delegation=delegation, + ) + + # Patch the subagent's run_stream to verify it gets called + subagent = pool.all_agents["subagent_a"] + with patch.object(subagent, "run_stream", side_effect=_mock_empty_stream) as mock_run: + prompt_resp = await acp_agent.prompt(prompt_req) + + assert prompt_resp.stop_reason == "end_turn" + mock_run.assert_called_once() + # Verify parent_session_id and depth were passed + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs.get("parent_session_id") == session_id + assert call_kwargs.get("depth") == 1 + + +# ============================================================================= +# Error case: require policy with missing subagent +# ============================================================================= + + +@pytest.mark.integration +async def test_require_policy_missing_subagent_returns_error_response( + acp_agent_with_delegation, +) -> None: + """Require policy with missing subagent should return PromptResponse, not crash.""" + acp_agent, _pool, mock_client = acp_agent_with_delegation + + # Initialize and create session + await acp_agent.initialize(InitializeRequest(protocol_version=1)) + new_sess_resp = await acp_agent.new_session(NewSessionRequest(cwd="/tmp", mcp_servers=[])) + session_id = new_sess_resp.session_id + + delegation = PromptDelegation(policy="require", subagent_id="nonexistent") + prompt_req = PromptRequest( + session_id=session_id, + prompt=[_make_text_block("Do something")], + delegation=delegation, + ) + + prompt_resp = await acp_agent.prompt(prompt_req) + + # Should return a PromptResponse (not raise unhandled exception) + assert prompt_resp.stop_reason == "end_turn" + assert prompt_resp.user_message_id == prompt_req.message_id + # Should have sent an error toast notification via the client + mock_client.ext_notification.assert_called() + # Verify the toast contains error info about missing subagent + call_args = mock_client.ext_notification.call_args + assert call_args is not None + params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("params", {}) + assert "nonexistent" in str(params.get("message", "")) + + +# ============================================================================= +# Disable policy: verify subagent tools are filtered out +# ============================================================================= + + +@pytest.mark.integration +async def test_disable_policy_filters_subagent_tools(acp_agent_with_delegation) -> None: + """Disable policy should prevent subagent tools from being used during the prompt.""" + acp_agent, _pool, _mock_client = acp_agent_with_delegation + + # Initialize and create session + await acp_agent.initialize(InitializeRequest(protocol_version=1)) + new_sess_resp = await acp_agent.new_session(NewSessionRequest(cwd="/tmp", mcp_servers=[])) + session_id = new_sess_resp.session_id + + # Get the session to inspect tool states + session = acp_agent.session_manager.get_session(session_id) + assert session is not None + + # Verify subagent tools exist and are enabled before the prompt + all_tools = await session.agent.tools.get_tools() + subagent_tools = [t for t in all_tools if t.category == "subagent"] + assert len(subagent_tools) > 0 + for tool in subagent_tools: + assert tool.enabled is True + + delegation = PromptDelegation(policy="disable") + prompt_req = PromptRequest( + session_id=session_id, + prompt=[_make_text_block("Do something locally")], + delegation=delegation, + ) + + # Patch run_stream to avoid actual LLM call + with patch.object(session.agent, "run_stream", side_effect=_mock_empty_stream): + prompt_resp = await acp_agent.prompt(prompt_req) + + assert prompt_resp.stop_reason == "end_turn" + + # Verify subagent tools are re-enabled after the prompt + all_tools_after = await session.agent.tools.get_tools() + for tool in all_tools_after: + if tool.category == "subagent": + assert tool.enabled is True diff --git a/tests/acp_server/test_e2e_subagent.py b/tests/acp_server/test_e2e_subagent.py new file mode 100644 index 000000000..d1aaed13e --- /dev/null +++ b/tests/acp_server/test_e2e_subagent.py @@ -0,0 +1,300 @@ +"""End-to-end test for ACP subagent delegation flow (T20). + +Tests the complete lifecycle: +1. Initialize ACP server with AgentPool +2. Create session +3. Send prompt that triggers subagent delegation +4. Verify full protocol flow including subagent capabilities, + available_subagents, ToolCallStart/Progress emission, + and session hierarchy fields. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import Any +from unittest.mock import AsyncMock + +import anyio +import pytest + +from acp.schema import ( + InitializeRequest, + NewSessionRequest, + PromptRequest, + SessionNotification, + SubagentCapabilities, + SubagentInfo, + ToolCallProgress, + ToolCallStart, +) +from acp.schema.content_blocks import TextContentBlock +from agentpool import AgentPool, AgentsManifest +from agentpool_server.acp_server.acp_agent import AgentPoolACPAgent + + +@pytest.fixture +def manifest_yaml() -> str: + """Manifest with orchestrator that delegates to worker via subagent tool.""" + return """ +default_agent: orchestrator + +agents: + worker: + model: + type: test + custom_output_text: "Done" + system_prompt: "You are a worker agent." + + orchestrator: + model: + type: test + call_tools: ["task"] + tool_args: + task: + agent_or_team: worker + prompt: "Do some work" + description: "E2E subagent test" + tools: + - type: subagent + system_prompt: "You are an orchestrator. Delegate to worker." + +storage: + providers: + - type: memory +""" + + +@pytest.fixture +async def agent_pool(manifest_yaml: str) -> AsyncGenerator[AgentPool, None]: + """Create a real AgentPool from the test manifest.""" + manifest = AgentsManifest.from_yaml(manifest_yaml) + pool = AgentPool(manifest) + await pool.__aenter__() + yield pool + await pool.__aexit__(None, None, None) + + +@pytest.fixture +def mock_client() -> AsyncMock: + """Create a mock ACP client that captures all notifications.""" + client = AsyncMock() + client.session_update = AsyncMock() + client.send_request = AsyncMock(return_value={"connectionId": "test-conn"}) + return client + + +@pytest.fixture +def captured_notifications(mock_client: AsyncMock) -> list[SessionNotification]: + """Access the list of captured session notifications.""" + notifications: list[SessionNotification] = [] + + async def capture(notification: SessionNotification) -> None: + notifications.append(notification) + + mock_client.session_update.side_effect = capture + return notifications + + +@pytest.fixture +async def acp_agent(agent_pool: AgentPool, mock_client: AsyncMock) -> AgentPoolACPAgent: + """Create an initialized AgentPoolACPAgent with the test pool.""" + default_agent = agent_pool.get_agent("orchestrator") + acp_agent = AgentPoolACPAgent( + client=mock_client, + default_agent=default_agent, + ) + # Initialize + init_request = InitializeRequest(protocol_version=1) + await acp_agent.initialize(init_request) + return acp_agent + + +class TestE2ESubagentFlow: + """End-to-end subagent delegation flow tests.""" + + @pytest.mark.anyio + async def test_initialize_response_has_subagent_capabilities( + self, + agent_pool: AgentPool, + mock_client: AsyncMock, + ) -> None: + """InitializeResponse must advertise subagent capabilities.""" + default_agent = agent_pool.get_agent("orchestrator") + acp_agent = AgentPoolACPAgent(client=mock_client, default_agent=default_agent) + + request = InitializeRequest(protocol_version=1) + response = await acp_agent.initialize(request) + + assert response.agent_capabilities is not None + assert response.agent_capabilities.subagents is not None + assert isinstance(response.agent_capabilities.subagents, SubagentCapabilities) + assert response.agent_capabilities.subagents.prompt_delegation is True + assert response.agent_capabilities.subagents.background is True + + @pytest.mark.anyio + async def test_new_session_has_available_subagents( + self, + acp_agent: AgentPoolACPAgent, + ) -> None: + """NewSessionResponse must include available_subagents from the pool.""" + request = NewSessionRequest(mcp_servers=[], cwd="/tmp") + response = await acp_agent.new_session(request) + + assert response.session_id + assert response.available_subagents is not None + assert len(response.available_subagents) >= 2 + + ids = {s.subagent_id for s in response.available_subagents} + assert "orchestrator" in ids + assert "worker" in ids + + # Verify structure of subagent info + for info in response.available_subagents: + assert isinstance(info, SubagentInfo) + assert info.subagent_id + assert info.name + + @pytest.mark.anyio + async def test_prompt_triggers_subagent_and_emits_protocol_events( + self, + acp_agent: AgentPoolACPAgent, + captured_notifications: list[SessionNotification], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Full prompt flow: subagent delegation emits correct ACP protocol events.""" + monkeypatch.setenv("ACP_SUBAGENT_DISPLAY_MODE", "tool_box") + + # Create a session + session_request = NewSessionRequest(mcp_servers=[], cwd="/tmp") + session_response = await acp_agent.new_session(session_request) + session_id = session_response.session_id + + # Allow background tasks from session setup to complete + await anyio.sleep(0.1) + captured_notifications.clear() + + # Send prompt that triggers subagent delegation + prompt_request = PromptRequest( + session_id=session_id, + prompt=[TextContentBlock(text="Delegate to worker")], + message_id="msg-001", + ) + prompt_response = await acp_agent.prompt(prompt_request) + + assert prompt_response.stop_reason == "end_turn" + assert prompt_response.user_message_id == "msg-001" + + # Allow any remaining background notifications to flush + await anyio.sleep(0.1) + + # Extract all updates from captured notifications + updates: list[Any] = [] + for notification in captured_notifications: + if isinstance(notification, SessionNotification): + updates.append(notification.update) + + # Find ToolCallStart with kind="subagent" + tool_call_starts = [ + u for u in updates if isinstance(u, ToolCallStart) and u.kind == "subagent" + ] + assert len(tool_call_starts) >= 1, ( + f"Expected at least one ToolCallStart(kind='subagent'), " + f"got {len(tool_call_starts)}. Updates: {[type(u).__name__ for u in updates]}" + ) + + start = tool_call_starts[0] + assert start.status == "in_progress" + assert start.subagent is not None + assert start.subagent.subagent_id == "worker" + assert start.subagent.name == "worker" + assert start.subagent.child_session_id is not None + assert start.subagent.run_mode == "foreground" + assert start.subagent.depth is not None + assert start.subagent.depth >= 1 + + child_session_id = start.subagent.child_session_id + tool_call_id = start.tool_call_id + + # Find ToolCallProgress with status="completed" for the same tool call + completed_progresses = [ + u + for u in updates + if isinstance(u, ToolCallProgress) + and u.status == "completed" + and u.tool_call_id == tool_call_id + ] + assert len(completed_progresses) >= 1, ( + f"Expected at least one ToolCallProgress(status='completed') for {tool_call_id}, " + f"got {len(completed_progresses)}" + ) + + completed = completed_progresses[0] + assert completed.subagent is not None + assert completed.subagent.subagent_id == "worker" + assert completed.subagent.child_session_id == child_session_id + assert completed.subagent.status == "completed" + + @pytest.mark.skip( + reason="SessionPool does not yet support parent_tool_call_id/subagent_id fields on SessionData" + ) + @pytest.mark.anyio + async def test_session_hierarchy_fields_correct( + self, + agent_pool: AgentPool, + acp_agent: AgentPoolACPAgent, + captured_notifications: list[SessionNotification], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Child session created by subagent delegation has correct hierarchy fields.""" + monkeypatch.setenv("ACP_SUBAGENT_DISPLAY_MODE", "tool_box") + + session_request = NewSessionRequest(mcp_servers=[], cwd="/tmp") + session_response = await acp_agent.new_session(session_request) + parent_session_id = session_response.session_id + + await anyio.sleep(0.1) + captured_notifications.clear() + + prompt_request = PromptRequest( + session_id=parent_session_id, + prompt=[TextContentBlock(text="Delegate to worker")], + message_id="msg-002", + ) + await acp_agent.prompt(prompt_request) + await anyio.sleep(0.1) + + # Extract child_session_id from the subagent ToolCallStart + child_session_id: str | None = None + tool_call_id: str | None = None + for notification in captured_notifications: + if isinstance(notification, SessionNotification): + update = notification.update + if isinstance(update, ToolCallStart) and update.kind == "subagent": + child_session_id = update.subagent.child_session_id if update.subagent else None + tool_call_id = update.tool_call_id + break + + assert child_session_id is not None, "Child session ID not found in notifications" + assert tool_call_id is not None, "Tool call ID not found in notifications" + + # Load child session data from the store + assert agent_pool.session_pool is not None + assert agent_pool.session_pool.sessions.store is not None + child_data = await agent_pool.session_pool.sessions.store.load(child_session_id) + + assert child_data is not None, f"Child session {child_session_id} not found in store" + assert child_data.parent_id == parent_session_id, ( + f"Expected parent_id={parent_session_id}, got {child_data.parent_id}" + ) + assert child_data.agent_name == "worker", ( + f"Expected agent_name='worker', got {child_data.agent_name}" + ) + + # Verify hierarchy metadata fields (T4) + assert child_data.parent_tool_call_id == tool_call_id, ( + f"Expected parent_tool_call_id={tool_call_id}, got {child_data.parent_tool_call_id}" + ) + assert child_data.subagent_id == "worker", ( + f"Expected subagent_id='worker', got {child_data.subagent_id}" + ) diff --git a/tests/acp_server/test_subagent_cancellation.py b/tests/acp_server/test_subagent_cancellation.py new file mode 100644 index 000000000..87c4d3d40 --- /dev/null +++ b/tests/acp_server/test_subagent_cancellation.py @@ -0,0 +1,247 @@ +"""Tests for foreground child session cancellation propagation in ACPSession (T9).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from agentpool import Agent +from agentpool.delegation import AgentPool +from agentpool_server.acp_server.event_converter import ACPEventConverter +from agentpool_server.acp_server.session import ACPSession + + +@pytest.fixture +def agent_pool_with_agent() -> tuple[AgentPool, Agent]: + """Create a pool with a simple test agent.""" + pool = AgentPool() + + def simple_callback(message: str) -> str: + return f"Test response: {message}" + + agent = Agent.from_callback(name="test_agent", callback=simple_callback, agent_pool=pool) + pool.register("test_agent", agent) + return pool, agent + + +@pytest.fixture +def acp_session(agent_pool_with_agent: tuple[AgentPool, Agent]) -> ACPSession: + """Create an ACPSession with mocked dependencies for unit testing.""" + _pool, agent = agent_pool_with_agent + mock_client = MagicMock() + mock_acp_agent = MagicMock() + + session = ACPSession( + session_id="test-session", + agent=agent, + cwd="/tmp", + client=mock_client, + acp_agent=mock_acp_agent, + ) + + # Mock acp_env to avoid real cleanup errors in close() + session.acp_env = MagicMock() + session.acp_env.__aexit__ = AsyncMock() + + # Mock manager with cancel_session + mock_manager = MagicMock() + mock_manager.cancel_session = AsyncMock() + session.manager = mock_manager + + # Mock agent.interrupt to avoid side effects + agent.interrupt = AsyncMock() # type: ignore[method-assign] + + return session + + +async def test_cancel_cancels_foreground_children(acp_session: ACPSession): + """cancel() should cancel all foreground child sessions via the manager.""" + session = acp_session + session._foreground_children = {"child-1", "child-2"} + + await session.cancel() + + assert session._cancelled is True + assert session.manager is not None + assert session.manager.cancel_session.call_count == 2 # type: ignore[attr-defined] + session.manager.cancel_session.assert_any_call("child-1") # type: ignore[attr-defined] + session.manager.cancel_session.assert_any_call("child-2") # type: ignore[attr-defined] + + +async def test_background_children_survive_parent_cancellation(acp_session: ACPSession): + """Background child sessions should NOT be cancelled when parent is cancelled.""" + session = acp_session + # Only foreground children are tracked in _foreground_children + session._foreground_children = {"foreground-child"} + # A background child would never be added to _foreground_children + + await session.cancel() + + assert session.manager is not None + # Only foreground child should be cancelled + session.manager.cancel_session.assert_called_once_with("foreground-child") # type: ignore[attr-defined] + + +async def test_close_cancels_foreground_children(acp_session: ACPSession): + """close() should cancel all foreground child sessions before cleanup.""" + session = acp_session + session._foreground_children = {"child-a", "child-b"} + + await session.close() + + assert session.manager is not None + assert session.manager.cancel_session.call_count == 2 # type: ignore[attr-defined] + session.manager.cancel_session.assert_any_call("child-a") # type: ignore[attr-defined] + session.manager.cancel_session.assert_any_call("child-b") # type: ignore[attr-defined] + + +async def test_cancel_reads_foreground_children_from_active_converter(acp_session: ACPSession): + """cancel() should read foreground children from the active event converter.""" + session = acp_session + # Set up an active converter with foreground children + converter = ACPEventConverter() + converter._foreground_children = {"converter-child-1", "converter-child-2"} + session._current_converter = converter + + await session.cancel() + + assert session.manager is not None + assert session.manager.cancel_session.call_count == 2 # type: ignore[attr-defined] + session.manager.cancel_session.assert_any_call("converter-child-1") # type: ignore[attr-defined] + session.manager.cancel_session.assert_any_call("converter-child-2") # type: ignore[attr-defined] + + +async def test_cancel_skips_when_no_manager(acp_session: ACPSession): + """cancel() should not fail when manager is None.""" + session = acp_session + session.manager = None + session._foreground_children = {"orphan-child"} + + # Should not raise + await session.cancel() + + assert session._cancelled is True + + +async def test_close_skips_when_no_manager(acp_session: ACPSession): + """close() should not fail when manager is None.""" + session = acp_session + session.manager = None + session._foreground_children = {"orphan-child"} + + # Should not raise + await session.close() + + +async def test_cancel_uses_list_to_avoid_mutation_during_iteration(acp_session: ACPSession): + """cancel() should use list() to avoid mutation-during-iteration issues.""" + session = acp_session + session._foreground_children = {"child-1", "child-2", "child-3"} + + # Make cancel_session mutate the set to simulate a side effect + async def side_effect_cancel(child_id: str) -> None: + session._foreground_children.discard(child_id) + + assert session.manager is not None + session.manager.cancel_session.side_effect = side_effect_cancel # type: ignore[attr-defined] + + # Should not raise RuntimeError about set size changed during iteration + await session.cancel() + + assert len(session._foreground_children) == 0 + + +async def test_close_uses_list_to_avoid_mutation_during_iteration(acp_session: ACPSession): + """close() should use list() to avoid mutation-during-iteration issues.""" + session = acp_session + session._foreground_children = {"child-1", "child-2", "child-3"} + + async def side_effect_cancel(child_id: str) -> None: + session._foreground_children.discard(child_id) + + assert session.manager is not None + session.manager.cancel_session.side_effect = side_effect_cancel # type: ignore[attr-defined] + + # Should not raise RuntimeError about set size changed during iteration + await session.close() + + assert len(session._foreground_children) == 0 + + +async def test_manager_cancel_session_delegates_to_session_cancel(): + """ACPSessionManager.cancel_session() should delegate to the session's cancel().""" + from agentpool_server.acp_server.session_manager import ACPSessionManager + + pool = AgentPool() + manager = ACPSessionManager(pool=pool) + mock_session = MagicMock() + mock_session.cancel = AsyncMock() + manager._active["session-1"] = mock_session + + await manager.cancel_session("session-1") + + mock_session.cancel.assert_awaited_once() + + +async def test_manager_cancel_session_is_noop_for_missing_session(): + """ACPSessionManager.cancel_session() should be a no-op for non-existent sessions.""" + from agentpool_server.acp_server.session_manager import ACPSessionManager + + pool = AgentPool() + manager = ACPSessionManager(pool=pool) + + # Should not raise + await manager.cancel_session("non-existent-session") + + +async def test_background_children_survive_parent_close(acp_session: ACPSession): + """Background child sessions should NOT be cancelled when parent is closed.""" + session = acp_session + # Only foreground children are tracked in _foreground_children + session._foreground_children = {"foreground-child"} + # A background child would never be added to _foreground_children + + await session.close() + + assert session.manager is not None + # Only foreground child should be cancelled + session.manager.cancel_session.assert_called_once_with("foreground-child") # type: ignore[attr-defined] + + +async def test_background_mode_advertised_in_capabilities(): + """Phase 2: background=True and prompt_delegation=True must be advertised.""" + from acp.schema import InitializeRequest + from agentpool import Agent + from agentpool.delegation import AgentPool + from agentpool_server.acp_server.acp_agent import AgentPoolACPAgent + + pool = AgentPool() + agent = Agent.from_callback( + name="test_agent", + callback=lambda msg: f"Test: {msg}", + agent_pool=pool, + ) + pool.register("test_agent", agent) + + mock_connection = MagicMock() + acp_agent = AgentPoolACPAgent(client=mock_connection, default_agent=agent) + acp_agent._initialized = False + + request = InitializeRequest(protocol_version=1) + response = await acp_agent.initialize(request) + + assert response.agent_capabilities is not None + assert response.agent_capabilities.subagents is not None + assert response.agent_capabilities.subagents.prompt_delegation is True + assert response.agent_capabilities.subagents.background is True + + +async def test_cancel_preserves_existing_interrupt_behavior(acp_session: ACPSession): + """cancel() should still call agent.interrupt() and set _cancelled flag.""" + session = acp_session + + await session.cancel() + + assert session._cancelled is True + session.agent.interrupt.assert_awaited_once() # type: ignore[attr-defined] diff --git a/tests/acp_server/test_subagent_capabilities.py b/tests/acp_server/test_subagent_capabilities.py new file mode 100644 index 000000000..d9e24ae3e --- /dev/null +++ b/tests/acp_server/test_subagent_capabilities.py @@ -0,0 +1,316 @@ +"""TDD tests for subagent capability advertisement (T10). + +Tests that AgentPoolACPAgent advertises subagent capabilities during +initialization and available subagents during session lifecycle. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from acp.schema import ( + ForkSessionRequest, + InitializeRequest, + LoadSessionRequest, + NewSessionRequest, + ResumeSessionRequest, + SubagentCapabilities, + SubagentInfo, +) +from acp.schema.capabilities import AgentCapabilities +from acp.schema.agent_responses import InitializeResponse +from agentpool import Agent +from agentpool.delegation import AgentPool +from agentpool_server.acp_server.acp_agent import AgentPoolACPAgent + + +@pytest.fixture +def mock_connection(): + """Create a mock ACP connection.""" + return AsyncMock() + + +@pytest.fixture +def mock_agent_pool_with_multiple_agents(): + """Create a mock agent pool with multiple test agents.""" + + def callback_a(message: str) -> str: + return f"Agent A: {message}" + + def callback_b(message: str) -> str: + return f"Agent B: {message}" + + pool = AgentPool() + agent_a = Agent.from_callback( + name="agent_a", + callback=callback_a, + agent_pool=pool, + system_prompt="You are agent A", + ) + agent_b = Agent.from_callback( + name="agent_b", + callback=callback_b, + agent_pool=pool, + system_prompt="You are agent B", + ) + pool.register("agent_a", agent_a) + pool.register("agent_b", agent_b) + return pool, agent_a, agent_b + + +@pytest.fixture +def default_test_agent(mock_agent_pool_with_multiple_agents): + """Get the first test agent from the mock pool.""" + return mock_agent_pool_with_multiple_agents[1] + + +@pytest.fixture +def mock_acp_agent(mock_connection, default_test_agent): + """Create a mock ACP agent for testing.""" + return AgentPoolACPAgent(client=mock_connection, default_agent=default_test_agent) + + +@pytest.fixture +def mock_session(): + """Create a mock ACPSession with all required attributes.""" + session = MagicMock() + session.session_id = "test-session-id" + session.cwd = "/tmp" + + session.agent = MagicMock() + session.agent.conversation = MagicMock() + session.agent.conversation.chat_messages = [] + session.agent.load_session = AsyncMock(return_value=True) + session.agent.load_rules = AsyncMock() + session.agent.get_modes = AsyncMock(return_value=[]) + session.agent.get_available_models = AsyncMock(return_value=[]) + session.agent.model_name = "test-model" + + session.notifications = MagicMock() + session.notifications.replay = AsyncMock() + + session.send_available_commands_update = AsyncMock() + session._register_prompt_hub_commands = AsyncMock() + session.init_client_skills = AsyncMock() + + return session + + +# ============================================================================= +# Schema-level tests +# ============================================================================= + + +@pytest.mark.unit +def test_agent_capabilities_create_accepts_subagents() -> None: + """AgentCapabilities.create() should accept subagents parameter.""" + subagents = SubagentCapabilities( + streaming=True, + tools=True, + prompt_delegation=False, + background=False, + ) + caps = AgentCapabilities.create(subagents=subagents) + assert caps.subagents is not None + assert caps.subagents.streaming is True + assert caps.subagents.tools is True + assert caps.subagents.prompt_delegation is False + assert caps.subagents.background is False + + +@pytest.mark.unit +def test_agent_capabilities_create_subagents_defaults_to_none() -> None: + """AgentCapabilities.create() should default subagents to None.""" + caps = AgentCapabilities.create() + assert caps.subagents is None + + +@pytest.mark.unit +def test_initialize_response_create_accepts_subagents() -> None: + """InitializeResponse.create() should pass subagents through to capabilities.""" + subagents = SubagentCapabilities( + streaming=True, + prompt_delegation=False, + background=False, + ) + resp = InitializeResponse.create( + name="test", + title="Test", + version="1.0", + protocol_version=1, + subagents=subagents, + ) + assert resp.agent_capabilities is not None + assert resp.agent_capabilities.subagents is not None + assert resp.agent_capabilities.subagents.streaming is True + assert resp.agent_capabilities.subagents.prompt_delegation is False + assert resp.agent_capabilities.subagents.background is False + + +# ============================================================================= +# AgentPoolACPAgent.initialize() tests +# ============================================================================= + + +@pytest.mark.unit +async def test_initialize_includes_subagent_capabilities(mock_acp_agent) -> None: + """initialize() should include subagent capabilities in response.""" + mock_acp_agent._initialized = False + + request = InitializeRequest(protocol_version=1) + response = await mock_acp_agent.initialize(request) + + assert response.agent_capabilities is not None + assert response.agent_capabilities.subagents is not None + assert response.agent_capabilities.subagents.prompt_delegation is True + assert response.agent_capabilities.subagents.background is True + + +@pytest.mark.unit +async def test_initialize_subagents_phase_two_enabled(mock_acp_agent) -> None: + """Phase 2: prompt_delegation and background must be True.""" + mock_acp_agent._initialized = False + + request = InitializeRequest(protocol_version=1) + response = await mock_acp_agent.initialize(request) + + assert response.agent_capabilities is not None + assert response.agent_capabilities.subagents is not None + assert response.agent_capabilities.subagents.prompt_delegation is True + assert response.agent_capabilities.subagents.background is True + + +# ============================================================================= +# AgentPoolACPAgent.new_session() tests +# ============================================================================= + + +@pytest.mark.unit +async def test_new_session_includes_available_subagents(mock_acp_agent, mock_session) -> None: + """new_session() should include available_subagents in response.""" + mock_acp_agent._initialized = True + mock_acp_agent.session_manager.create_session = AsyncMock(return_value="test-session-id") + mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) + + request = NewSessionRequest(mcp_servers=[], cwd="/tmp") + response = await mock_acp_agent.new_session(request) + + assert response.available_subagents is not None + assert len(response.available_subagents) == 2 + + # Check that pool agents are reflected + ids = {s.subagent_id for s in response.available_subagents} + assert "agent_a" in ids + assert "agent_b" in ids + + +@pytest.mark.unit +async def test_new_session_subagent_info_structure(mock_acp_agent, mock_session) -> None: + """available_subagents entries should have correct structure.""" + mock_acp_agent._initialized = True + mock_acp_agent.session_manager.create_session = AsyncMock(return_value="test-session-id") + mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) + + request = NewSessionRequest(mcp_servers=[], cwd="/tmp") + response = await mock_acp_agent.new_session(request) + + assert response.available_subagents is not None + agent_a_info = next( + (s for s in response.available_subagents if s.subagent_id == "agent_a"), None + ) + assert agent_a_info is not None + assert agent_a_info.name == "agent_a" + # description should come from system_prompt (truncated) + assert agent_a_info.description is not None + assert "You are agent A" in agent_a_info.description + + +# ============================================================================= +# AgentPoolACPAgent.load_session() tests +# ============================================================================= + + +@pytest.mark.unit +async def test_load_session_includes_available_subagents(mock_acp_agent, mock_session) -> None: + """load_session() should include available_subagents in response.""" + mock_acp_agent._initialized = True + mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) + + request = LoadSessionRequest(session_id="test-session-id", cwd="/tmp", mcp_servers=[]) + response = await mock_acp_agent.load_session(request) + + assert response.available_subagents is not None + assert len(response.available_subagents) == 2 + ids = {s.subagent_id for s in response.available_subagents} + assert "agent_a" in ids + assert "agent_b" in ids + + +# ============================================================================= +# AgentPoolACPAgent.fork_session() tests +# ============================================================================= + + +@pytest.mark.unit +async def test_fork_session_includes_available_subagents(mock_acp_agent, mock_session) -> None: + """fork_session() should include available_subagents in response.""" + mock_acp_agent._initialized = True + mock_acp_agent.session_manager.create_session = AsyncMock(return_value="forked-session-id") + + request = ForkSessionRequest(session_id="test-session-id", cwd="/tmp") + response = await mock_acp_agent.fork_session(request) + + assert response.available_subagents is not None + assert len(response.available_subagents) == 2 + ids = {s.subagent_id for s in response.available_subagents} + assert "agent_a" in ids + assert "agent_b" in ids + + +# ============================================================================= +# AgentPoolACPAgent.resume_session() tests +# ============================================================================= + + +@pytest.mark.unit +async def test_resume_session_includes_available_subagents(mock_acp_agent, mock_session) -> None: + """resume_session() should include available_subagents in response.""" + mock_acp_agent._initialized = True + mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) + + request = ResumeSessionRequest(session_id="test-session-id", cwd="/tmp") + response = await mock_acp_agent.resume_session(request) + + assert response.available_subagents is not None + assert len(response.available_subagents) == 2 + ids = {s.subagent_id for s in response.available_subagents} + assert "agent_a" in ids + assert "agent_b" in ids + + +# ============================================================================= +# available_subagents reflects pool agents tests +# ============================================================================= + + +@pytest.mark.unit +async def test_available_subagents_reflects_pool_agents(mock_acp_agent, mock_session) -> None: + """available_subagents must reflect agents registered in the pool.""" + mock_acp_agent._initialized = True + mock_acp_agent.session_manager.create_session = AsyncMock(return_value="test-session-id") + mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) + + request = NewSessionRequest(mcp_servers=[], cwd="/tmp") + response = await mock_acp_agent.new_session(request) + + # Should have exactly the two pool agents + assert response.available_subagents is not None + assert len(response.available_subagents) == 2 + + # Verify structure + for info in response.available_subagents: + assert isinstance(info, SubagentInfo) + assert info.subagent_id + assert info.name diff --git a/tests/acp_server/test_subagent_catalog.py b/tests/acp_server/test_subagent_catalog.py new file mode 100644 index 000000000..ea6942ac0 --- /dev/null +++ b/tests/acp_server/test_subagent_catalog.py @@ -0,0 +1,412 @@ +"""TDD tests for SubagentCatalogProvider (T13). + +Tests static catalog generation, debounced dynamic updates, +cycle detection, and SupportsRunStream filtering. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from acp.schema import SubagentInfo +from acp.schema.session_updates import AvailableSubagentsUpdate +from agentpool import Agent +from agentpool.common_types import SupportsRunStream +from agentpool.delegation import AgentPool +from agentpool_server.acp_server.acp_agent import AgentPoolACPAgent +from agentpool_server.acp_server.subagent_catalog import SubagentCatalogProvider + + +@pytest.fixture +def mock_connection(): + """Create a mock ACP connection.""" + return AsyncMock() + + +@pytest.fixture +def mock_agent_pool_with_multiple_agents(): + """Create a mock agent pool with streaming and non-streaming agents.""" + + def callback_a(message: str) -> str: + return f"Agent A: {message}" + + def callback_b(message: str) -> str: + return f"Agent B: {message}" + + pool = AgentPool() + agent_a = Agent.from_callback( + name="agent_a", + callback=callback_a, + agent_pool=pool, + system_prompt="You are agent A", + ) + agent_b = Agent.from_callback( + name="agent_b", + callback=callback_b, + agent_pool=pool, + system_prompt="You are agent B", + ) + pool.register("agent_a", agent_a) + pool.register("agent_b", agent_b) + return pool, agent_a, agent_b + + +@pytest.fixture +def default_test_agent(mock_agent_pool_with_multiple_agents): + """Get the first test agent from the mock pool.""" + return mock_agent_pool_with_multiple_agents[1] + + +@pytest.fixture +def mock_acp_agent(mock_connection, default_test_agent): + """Create a mock ACP agent for testing.""" + return AgentPoolACPAgent(client=mock_connection, default_agent=default_test_agent) + + +# ============================================================================= +# Static catalog tests +# ============================================================================= + + +@pytest.mark.unit +def test_catalog_reflects_pool_agents(mock_agent_pool_with_multiple_agents) -> None: + """get_catalog() must reflect agents registered in the pool.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool) + catalog = provider.get_catalog() + + ids = {info.subagent_id for info in catalog} + assert "agent_a" in ids + assert "agent_b" in ids + + +@pytest.mark.unit +def test_catalog_returns_subagent_info_instances(mock_agent_pool_with_multiple_agents) -> None: + """Catalog entries must be SubagentInfo instances.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool) + catalog = provider.get_catalog() + + for info in catalog: + assert isinstance(info, SubagentInfo) + assert info.subagent_id + assert info.name + + +@pytest.mark.unit +def test_catalog_includes_system_prompt_as_description( + mock_agent_pool_with_multiple_agents, +) -> None: + """Catalog entries should include truncated system prompt as description.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool) + catalog = provider.get_catalog() + + agent_a_info = next((s for s in catalog if s.subagent_id == "agent_a"), None) + assert agent_a_info is not None + assert agent_a_info.description is not None + assert "You are agent A" in agent_a_info.description + + +@pytest.mark.unit +def test_catalog_includes_capabilities(mock_agent_pool_with_multiple_agents) -> None: + """Catalog entries should include SubagentCapabilities.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool) + catalog = provider.get_catalog() + + for info in catalog: + assert info.capabilities is not None + assert info.capabilities.streaming is True + assert info.capabilities.tools is True + + +# ============================================================================= +# SupportsRunStream filtering tests +# ============================================================================= + + +class FakeNonStreamingAgent: + """Fake agent without run_stream for filtering tests.""" + + def __init__(self, name: str) -> None: + self.name = name + self.description = f"Description for {name}" + self.system_prompt = "You are a fake agent" + + +@pytest.mark.unit +def test_catalog_filters_non_streaming_agents(mock_agent_pool_with_multiple_agents) -> None: + """Catalog must exclude agents that do not support run_stream.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool) + + # Temporarily inject a non-streaming agent into pool._items + fake_agent = FakeNonStreamingAgent("fake_no_stream") + pool._items["fake_no_stream"] = fake_agent + try: + catalog = provider.get_catalog() + finally: + del pool._items["fake_no_stream"] + + ids = {info.subagent_id for info in catalog} + assert "agent_a" in ids + assert "agent_b" in ids + assert "fake_no_stream" not in ids + + +@pytest.mark.unit +def test_catalog_uses_isinstance_supports_run_stream(mock_agent_pool_with_multiple_agents) -> None: + """Catalog filtering must use isinstance(node, SupportsRunStream).""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool) + + fake_agent = FakeNonStreamingAgent("fake_no_stream") + pool._items["fake_no_stream"] = fake_agent + try: + catalog = provider.get_catalog() + finally: + del pool._items["fake_no_stream"] + + for info in catalog: + agent = pool.all_agents[info.subagent_id] + assert isinstance(agent, SupportsRunStream) + + +# ============================================================================= +# Cycle detection tests +# ============================================================================= + + +@pytest.mark.unit +def test_catalog_filters_ancestor_agents(mock_agent_pool_with_multiple_agents) -> None: + """get_catalog() must exclude ancestor agent IDs to prevent cycles.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool) + + catalog = provider.get_catalog(ancestor_agent_ids={"agent_a"}) + ids = {info.subagent_id for info in catalog} + assert "agent_a" not in ids + assert "agent_b" in ids + + +@pytest.mark.unit +def test_catalog_with_empty_ancestor_set_returns_all(mock_agent_pool_with_multiple_agents) -> None: + """Empty ancestor_agent_ids should not filter anything.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool) + + catalog = provider.get_catalog(ancestor_agent_ids=set()) + ids = {info.subagent_id for info in catalog} + assert "agent_a" in ids + assert "agent_b" in ids + + +@pytest.mark.unit +def test_catalog_with_none_ancestor_returns_all(mock_agent_pool_with_multiple_agents) -> None: + """None ancestor_agent_ids should not filter anything.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool) + + catalog = provider.get_catalog(ancestor_agent_ids=None) + ids = {info.subagent_id for info in catalog} + assert "agent_a" in ids + assert "agent_b" in ids + + +# ============================================================================= +# Debounced update tests +# ============================================================================= + + +@pytest.mark.unit +async def test_notify_update_debounces_at_500ms(mock_agent_pool_with_multiple_agents) -> None: + """Multiple rapid notify_update calls should only emit once after debounce.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool, debounce_ms=100) + + callbacks: list[list[SubagentInfo]] = [] + + def capture_callback(catalog: list[SubagentInfo]) -> None: + callbacks.append(catalog) + + provider.register_update_callback(capture_callback) + + # Fire multiple rapid updates + await provider.notify_update() + await provider.notify_update() + await provider.notify_update() + + # Should not have emitted yet (within debounce window) + assert len(callbacks) == 0 + + # Wait for debounce to complete + await asyncio.sleep(0.15) + + # Should have exactly one emission + assert len(callbacks) == 1 + assert len(callbacks[0]) == 2 + + +@pytest.mark.unit +async def test_notify_update_cancels_pending_task(mock_agent_pool_with_multiple_agents) -> None: + """A new notify_update should cancel the previous pending task.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool, debounce_ms=200) + + callbacks: list[list[SubagentInfo]] = [] + + def capture_callback(catalog: list[SubagentInfo]) -> None: + callbacks.append(catalog) + + provider.register_update_callback(capture_callback) + + await provider.notify_update() + await asyncio.sleep(0.05) + await provider.notify_update() # Cancels first, starts second + await asyncio.sleep(0.05) + await provider.notify_update() # Cancels second, starts third + + # Should not have emitted yet + assert len(callbacks) == 0 + + # Wait for final debounce + await asyncio.sleep(0.25) + + # Exactly one emission from the final task + assert len(callbacks) == 1 + + +@pytest.mark.unit +async def test_notify_update_uses_asyncio_task(mock_agent_pool_with_multiple_agents) -> None: + """notify_update must use asyncio.create_task for debounce cancellation.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool, debounce_ms=100) + + await provider.notify_update() + assert provider._pending_update is not None + assert isinstance(provider._pending_update, asyncio.Task) + + # Clean up + provider._pending_update.cancel() + with pytest.raises(asyncio.CancelledError): + await provider._pending_update + + +@pytest.mark.unit +async def test_default_debounce_is_500ms(mock_agent_pool_with_multiple_agents) -> None: + """Default debounce_ms should be 500.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool) + assert provider.debounce_ms == 500 + + +# ============================================================================= +# Integration with AgentPoolACPAgent tests +# ============================================================================= + + +@pytest.mark.unit +def test_acp_agent_has_catalog_provider(mock_acp_agent) -> None: + """AgentPoolACPAgent must have a _catalog_provider attribute.""" + assert hasattr(mock_acp_agent, "_catalog_provider") + assert isinstance(mock_acp_agent._catalog_provider, SubagentCatalogProvider) + + +@pytest.mark.unit +def test_acp_agent_exposes_get_catalog(mock_acp_agent) -> None: + """AgentPoolACPAgent must expose get_subagent_catalog method.""" + assert hasattr(mock_acp_agent, "get_subagent_catalog") + catalog = mock_acp_agent.get_subagent_catalog() + assert len(catalog) == 2 + ids = {info.subagent_id for info in catalog} + assert "agent_a" in ids + assert "agent_b" in ids + + +@pytest.mark.unit +def test_acp_agent_get_catalog_delegates_to_provider(mock_acp_agent) -> None: + """get_subagent_catalog must delegate to SubagentCatalogProvider.""" + with patch.object(mock_acp_agent._catalog_provider, "get_catalog", return_value=[]) as mock_get: + mock_acp_agent.get_subagent_catalog() + mock_get.assert_called_once() + + +@pytest.mark.unit +def test_acp_agent_get_catalog_with_ancestors(mock_acp_agent) -> None: + """get_subagent_catalog must pass ancestor_agent_ids to provider.""" + with patch.object( + mock_acp_agent._catalog_provider, + "get_catalog", + return_value=[], + ) as mock_get: + mock_acp_agent.get_subagent_catalog(ancestor_agent_ids={"agent_a"}) + mock_get.assert_called_once_with(ancestor_agent_ids={"agent_a"}) + + +# ============================================================================= +# Schema tests +# ============================================================================= + + +@pytest.mark.unit +async def test_notification_emitted_after_debounce(mock_agent_pool_with_multiple_agents) -> None: + """AvailableSubagentsUpdate notification must be emitted after debounce.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool, debounce_ms=100) + + mock_channel = AsyncMock() + provider.register_notification_channel(mock_channel) + + await provider.notify_update() + + # Should not have emitted yet (within debounce window) + mock_channel.send_update.assert_not_called() + + # Wait for debounce to complete + await asyncio.sleep(0.15) + + # Should have exactly one emission + mock_channel.send_update.assert_called_once() + update = mock_channel.send_update.call_args[0][0] + assert isinstance(update, AvailableSubagentsUpdate) + + +@pytest.mark.unit +async def test_notification_contains_updated_catalog(mock_agent_pool_with_multiple_agents) -> None: + """Notification must contain the current catalog entries.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool, debounce_ms=100) + + mock_channel = AsyncMock() + provider.register_notification_channel(mock_channel) + + await provider.notify_update() + await asyncio.sleep(0.15) + + update = mock_channel.send_update.call_args[0][0] + assert isinstance(update, AvailableSubagentsUpdate) + ids = {info.subagent_id for info in update.available_subagents} + assert "agent_a" in ids + assert "agent_b" in ids + + +@pytest.mark.unit +async def test_no_notification_for_empty_catalog(mock_agent_pool_with_multiple_agents) -> None: + """Empty catalog must not emit a notification.""" + pool, _, _ = mock_agent_pool_with_multiple_agents + provider = SubagentCatalogProvider(pool=pool, debounce_ms=100) + + # Empty the pool so catalog is empty + pool._items.clear() + + mock_channel = AsyncMock() + provider.register_notification_channel(mock_channel) + + await provider.notify_update() + await asyncio.sleep(0.15) + + mock_channel.send_update.assert_not_called() diff --git a/tests/acp_server/test_subagent_integration.py b/tests/acp_server/test_subagent_integration.py new file mode 100644 index 000000000..3f9c8b7c9 --- /dev/null +++ b/tests/acp_server/test_subagent_integration.py @@ -0,0 +1,291 @@ +"""Integration tests for subagent functionality through ACP server (T17). + +Tests the full end-to-end subagent flow through AgentPoolACPAgent: +1. Full flow: initialize -> new_session -> prompt with subagent -> verify ToolCallStart emitted +2. Session hierarchy fields returned in session info +3. Capability gating: subagent features only work when advertised +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from acp.schema import ( + InitializeRequest, + NewSessionRequest, + PromptRequest, + TextContentBlock, + ToolCallStart, +) +from acp.schema.requests import PromptDelegation +from agentpool import AgentPool, AgentsManifest +from agentpool.sessions import SessionData +from agentpool_server.acp_server.acp_agent import AgentPoolACPAgent +from agentpool_server.acp_server.converters import to_session_info + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +async def subagent_pool(): + """Create an AgentPool with orchestrator and worker agents.""" + manifest = AgentsManifest.from_yaml(""" +default_agent: orchestrator + +agents: + worker: + model: + type: test + custom_output_text: "Worker done" + system_prompt: You are a worker agent. + + orchestrator: + model: + type: test + call_tools: ["task"] + tool_args: + task: + agent_or_team: worker + prompt: "Do some work" + description: "Test task" + tools: + - type: subagent + system_prompt: You are an orchestrator. +""") + async with AgentPool(manifest) as pool: + yield pool + + +@pytest.fixture +def mock_client(): + """Create a mock ACP client that captures session updates and ext notifications.""" + client = AsyncMock() + updates = [] + ext_notifications = [] + + async def capture_session_update(notification): + updates.append(notification) + + async def capture_ext_notification(method, params=None): + ext_notifications.append({"method": method, "params": params}) + + client.session_update = capture_session_update + client.ext_notification = capture_ext_notification + client.updates = updates + client.ext_notifications = ext_notifications + return client + + +@pytest.fixture +async def acp_agent(subagent_pool, mock_client): + """Create an initialized AgentPoolACPAgent with subagent pool.""" + orchestrator = subagent_pool.get_agent("orchestrator") + agent = AgentPoolACPAgent(client=mock_client, default_agent=orchestrator) + agent._initialized = False + await agent.initialize(InitializeRequest(protocol_version=1)) + return agent + + +# ============================================================================= +# Test 1: Full subagent flow +# ============================================================================= + + +@pytest.mark.anyio +async def test_full_subagent_flow_emits_tool_call_start(acp_agent, mock_client): + """Full flow through ACP agent emits ToolCallStart(kind='subagent') during subagent delegation.""" + # Create a new session + new_session_req = NewSessionRequest(mcp_servers=[], cwd="/tmp") + new_session_resp = await acp_agent.new_session(new_session_req) + session_id = new_session_resp.session_id + assert session_id is not None + + # Clear any updates from session creation + mock_client.updates.clear() + + # Send a prompt that triggers the task tool (orchestrator model is configured to call "task") + prompt_req = PromptRequest( + session_id=session_id, + prompt=[TextContentBlock(text="Please delegate to worker")], + ) + + prompt_resp = await acp_agent.prompt(prompt_req) + + # Verify the prompt was processed successfully + assert prompt_resp.stop_reason == "end_turn" + + # Collect all ToolCallStart updates from notifications + tool_starts: list[ToolCallStart] = [] + for notification in mock_client.updates: + update = getattr(notification, "update", None) + if isinstance(update, ToolCallStart): + tool_starts.append(update) + + # Verify at least one ToolCallStart with kind="subagent" was emitted + subagent_starts = [t for t in tool_starts if t.kind == "subagent"] + assert len(subagent_starts) >= 1, ( + f"Expected at least one ToolCallStart(kind='subagent'), " + f"got {len(subagent_starts)} subagent starts out of {len(tool_starts)} total tool starts. " + f"Updates: {[type(n.update).__name__ for n in mock_client.updates if hasattr(n, 'update')]}" + ) + + # Verify the subagent run info structure + start = subagent_starts[0] + assert start.subagent is not None + assert start.subagent.subagent_id == "worker" + assert start.status == "in_progress" + assert start.title is not None + assert "worker" in start.title + + +# ============================================================================= +# Test 2: Session hierarchy fields returned in session info +# ============================================================================= + + +@pytest.mark.anyio +async def test_session_hierarchy_fields_in_session_info(): + """to_session_info maps SessionData parent_id to SessionInfo.parent_session_id and sets depth.""" + # Create a root session + root_data = SessionData( + session_id="root-session", + agent_name="test_agent", + cwd="/tmp", + ) + + # Create a child session + child_data = SessionData( + session_id="child-session", + agent_name="subagent", + cwd="/tmp", + parent_id="root-session", + ) + + root_info = to_session_info(root_data) + child_info = to_session_info(child_data) + + # Root should have no parent and depth 0 + assert root_info.parent_session_id is None + assert root_info.depth == 0 + + # Child should have parent_session_id mapped from parent_id and depth 1 + assert child_info.parent_session_id == "root-session" + assert child_info.depth == 1 + + +@pytest.mark.anyio +async def test_session_hierarchy_fields_via_list_sessions(acp_agent, subagent_pool, mock_client): + """list_sessions returns SessionInfo with hierarchy fields populated.""" + # Seed the memory storage provider with parent and child sessions + provider = subagent_pool.storage.providers[0] + provider.sessions["parent-ses"] = SessionData( + session_id="parent-ses", + agent_name="orchestrator", + cwd="/tmp", + ) + provider.sessions["child-ses"] = SessionData( + session_id="child-ses", + agent_name="worker", + cwd="/tmp", + parent_id="parent-ses", + ) + + # Clear cache to force fresh read + acp_agent._sessions_cache = None + acp_agent._sessions_cache_time = 0.0 + + from acp.schema import ListSessionsRequest + + response = await acp_agent.list_sessions(ListSessionsRequest()) + + # Find child session in response + child_info = next((s for s in response.sessions if s.session_id == "child-ses"), None) + assert child_info is not None, "Child session not found in list_sessions response" + assert child_info.parent_session_id == "parent-ses" + assert child_info.depth == 1 + + # Find parent session + parent_info = next((s for s in response.sessions if s.session_id == "parent-ses"), None) + assert parent_info is not None + assert parent_info.parent_session_id is None + assert parent_info.depth == 0 + + +# ============================================================================= +# Test 3: Capability gating +# ============================================================================= + + +@pytest.mark.anyio +async def test_capability_gating_delegation_ignored_when_not_advertised(acp_agent, mock_client): + """When prompt_delegation is not advertised, delegation policy is ignored.""" + # Create a new session + new_session_req = NewSessionRequest(mcp_servers=[], cwd="/tmp") + new_session_resp = await acp_agent.new_session(new_session_req) + session_id = new_session_resp.session_id + + # Disable prompt delegation capability by patching the property + with patch.object(type(acp_agent), "prompt_delegation_enabled", new=False): + mock_client.updates.clear() + mock_client.ext_notifications.clear() + + # Send a prompt with require delegation policy + prompt_req = PromptRequest( + session_id=session_id, + prompt=[TextContentBlock(text="Hello")], + delegation=PromptDelegation(policy="require", subagent_id="nonexistent"), + ) + + # Should NOT raise - delegation is ignored, normal flow runs + prompt_resp = await acp_agent.prompt(prompt_req) + assert prompt_resp.stop_reason == "end_turn" + + # Verify no error toast was sent (no ext notifications with _agentpool/toast) + toast_notifications = [ + n for n in mock_client.ext_notifications if n.get("method") == "_agentpool/toast" + ] + assert len(toast_notifications) == 0, ( + "Delegation with missing subagent should be ignored when capability not advertised, " + "but error toasts were sent" + ) + + +@pytest.mark.anyio +async def test_capability_gating_require_errors_when_advertised_and_missing(acp_agent, mock_client): + """When prompt_delegation IS advertised, require policy errors on missing subagent.""" + # Create a new session + new_session_req = NewSessionRequest(mcp_servers=[], cwd="/tmp") + new_session_resp = await acp_agent.new_session(new_session_req) + session_id = new_session_resp.session_id + + # Ensure capability is advertised (default is True) + assert acp_agent.prompt_delegation_enabled is True + + mock_client.updates.clear() + mock_client.ext_notifications.clear() + + # Send a prompt with require delegation for a nonexistent subagent + prompt_req = PromptRequest( + session_id=session_id, + prompt=[TextContentBlock(text="Hello")], + delegation=PromptDelegation(policy="require", subagent_id="nonexistent"), + ) + + # Should return end_turn after catching the RequestError internally + prompt_resp = await acp_agent.prompt(prompt_req) + assert prompt_resp.stop_reason == "end_turn" + + # Verify an error toast WAS sent because the subagent is missing + toast_notifications = [ + n for n in mock_client.ext_notifications if n.get("method") == "_agentpool/toast" + ] + assert len(toast_notifications) >= 1, ( + "Require delegation with missing subagent should produce error toast " + "when capability is advertised" + ) diff --git a/tests/agents/native_agent/test_interrupt.py b/tests/agents/native_agent/test_interrupt.py index 93b9612c8..cbc7ddf0b 100644 --- a/tests/agents/native_agent/test_interrupt.py +++ b/tests/agents/native_agent/test_interrupt.py @@ -187,7 +187,10 @@ async def run_stream() -> None: async def test_interrupt_without_run_ctx_cancels_stream_task(slow_agent: Agent[None]) -> None: """interrupt() called with no run_ctx must cancel the asyncio.Task running run_stream. - Cross-task access requires SessionPool fallback (since _active_run_ctx was removed). + Before the fix: interrupt() passes run_ctx=None to _interrupt(), + which checks `run_ctx.current_task if run_ctx else None` → None → no task cancelled. + After the fix: _interrupt() finds run_ctx via ContextVar (same task) or + SessionPool fallback (cross-task) and cancels current_task. """ from agentpool.agents.base_agent import _current_run_ctx_var @@ -207,14 +210,8 @@ async def run_stream() -> None: # Wait for stream to start await asyncio.wait_for(stream_started.wait(), timeout=2.0) - assert len(captured_run_ctx) == 1 - run_ctx = captured_run_ctx[0] - - # Set up SessionPool fallback for cross-task access - _mock_session_pool(slow_agent, run_ctx) - - # Call interrupt with NO run_ctx but WITH session_id for SessionPool lookup - await slow_agent.interrupt(session_id="test-session") + # Call interrupt with NO run_ctx + await slow_agent.interrupt() # The task should be cancelled (not still running) try: diff --git a/tests/orchestrator/test_legacy_runner.py b/tests/orchestrator/test_legacy_runner.py new file mode 100644 index 000000000..3e93ce44a --- /dev/null +++ b/tests/orchestrator/test_legacy_runner.py @@ -0,0 +1,855 @@ +"""Unit tests for LegacyTurnRunner. + +Tests that LegacyTurnRunner preserves all non-native queue behaviour +and correctly integrates with RunHandle lifecycle management. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from agentpool.agents.context import AgentRunContext +from agentpool.agents.events import RunFailedEvent, RunStartedEvent +from agentpool.orchestrator.core import SessionController +from agentpool.orchestrator.legacy_runner import LegacyTurnRunner +from agentpool.orchestrator.run import RunHandle, RunStatus + + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_pool() -> MagicMock: + """Return a mocked AgentPool.""" + pool = MagicMock() + pool.main_agent = MagicMock() + pool.main_agent.name = "main-agent" + pool.manifest = MagicMock() + pool.manifest.agents = {} + return pool + + +@pytest.fixture +def controller(mock_pool: MagicMock) -> SessionController: + """Return a real SessionController backed by the mock pool.""" + return SessionController(pool=mock_pool) + + +@pytest.fixture +def legacy_runner(controller: SessionController) -> LegacyTurnRunner: + """Return a LegacyTurnRunner with auto-resume enabled.""" + return LegacyTurnRunner(session_controller=controller, enable_auto_resume=True) + + +@pytest.fixture +def mock_agent() -> MagicMock: + """Return a mocked BaseAgent with _run_stream_once.""" + agent = MagicMock() + agent.get_active_run_context.return_value = None + + async def _fake_stream( + run_ctx: AgentRunContext, + *prompts: Any, + **kwargs: Any, + ) -> AsyncIterator[RunStartedEvent]: + yield RunStartedEvent(session_id=kwargs.get("session_id", "default"), run_id="run-1") + + agent._run_stream_once = _fake_stream + return agent + + +@pytest.fixture +def mock_agent_with_delay() -> MagicMock: + """Return a mocked BaseAgent whose stream takes a noticeable time.""" + agent = MagicMock() + agent.get_active_run_context.return_value = None + + async def _fake_stream( + run_ctx: AgentRunContext, + *prompts: Any, + **kwargs: Any, + ) -> AsyncIterator[RunStartedEvent]: + await asyncio.sleep(0.05) + yield RunStartedEvent(session_id=kwargs.get("session_id", "default"), run_id="run-1") + + agent._run_stream_once = _fake_stream + return agent + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _setup_session( + controller: SessionController, + session_id: str, + agent: MagicMock, + mock_pool: MagicMock, + legacy_runner: LegacyTurnRunner | None = None, +) -> Any: + """Create a session and attach the mock agent directly.""" + state = await controller.get_or_create_session(session_id) + state.agent = agent + controller._session_agents[session_id] = agent + mock_pool.get_agent.return_value = agent + + from agentpool.agents.base_agent import _current_run_ctx_var + + def _mock_get_active_run_context() -> AgentRunContext | None: + run_ctx = _current_run_ctx_var.get() + if run_ctx is not None and not run_ctx.completed: + return run_ctx + session = controller.get_session(session_id) + if session is not None and session.current_run_id is not None and legacy_runner is not None: + run_ctx = legacy_runner._runs.get(session.current_run_id) + if run_ctx is not None and not run_ctx.completed: + return run_ctx + if agent._background_run_ctx is not None and not agent._background_run_ctx.completed: + return agent._background_run_ctx + return None + + agent.get_active_run_context.side_effect = _mock_get_active_run_context + return state + + +# --------------------------------------------------------------------------- +# RunHandle lifecycle +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_run_turn_creates_run_handle_when_called_directly( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """When run_turn is called directly it creates a RunHandle in _runs.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + assert len(controller._runs) == 0 + + await legacy_runner.run_turn("sess-1", "hello") + + # RunHandle should have been created, completed, and cleaned up + assert len(controller._runs) == 0 + + +@pytest.mark.anyio +async def test_run_turn_uses_existing_run_handle_from_receive_request( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """run_turn uses an existing RunHandle created by receive_request.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + + run_handle = controller._create_run("sess-1", "hello") + controller._runs[run_handle.run_id] = run_handle + session = controller.get_session("sess-1") + assert session is not None + session.current_run_id = run_handle.run_id + controller._pending_run_ids["sess-1"] = run_handle.run_id + + await legacy_runner.run_turn("sess-1", "hello") + + # Existing RunHandle should NOT be removed by LegacyTurnRunner + assert run_handle.run_id in controller._runs + assert run_handle.status == RunStatus.running # not completed by us + + +@pytest.mark.anyio +async def test_run_turn_sets_and_clears_current_run_id( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """run_turn sets session.current_run_id during execution and clears after.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + session = controller.get_session("sess-1") + assert session is not None + assert session.current_run_id is None + + await legacy_runner.run_turn("sess-1", "hello") + + assert session.current_run_id is None + + +@pytest.mark.anyio +async def test_run_turn_completes_run_handle_on_success( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """Direct run_turn calls complete() the RunHandle it creates.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + + await legacy_runner.run_turn("sess-1", "hello") + + # No RunHandle left in _runs because LegacyTurnRunner cleaned it up + assert len(controller._runs) == 0 + + +@pytest.mark.anyio +async def test_run_turn_fails_run_handle_on_exception( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_pool: MagicMock, +) -> None: + """When _run_stream_once raises, the RunHandle is marked failed.""" + agent = MagicMock() + agent.get_active_run_context.return_value = None + + async def broken_stream(*args: Any, **kwargs: Any) -> AsyncIterator[Any]: + raise RuntimeError("boom") + yield # make it an async generator + + agent._run_stream_once = broken_stream + await _setup_session(controller, "sess-1", agent, mock_pool) + + event_queue = await legacy_runner.event_bus.subscribe("sess-1") + events: list[Any] = [] + + async def _consume() -> None: + try: + while True: + event = await asyncio.wait_for(event_queue.get(), timeout=0.5) + if event is None: + break + events.append(event) + except TimeoutError: + pass + + consumer = asyncio.create_task(_consume()) + + with pytest.raises(RuntimeError, match="boom"): + await legacy_runner.run_turn("sess-1", "hello") + + await asyncio.sleep(0.05) + await legacy_runner.event_bus.publish("sess-1", None) + await consumer + + failed_events = [e for e in events if isinstance(e, RunFailedEvent)] + assert len(failed_events) == 1 + assert failed_events[0].session_id == "sess-1" + assert isinstance(failed_events[0].exception, RuntimeError) + + # RunHandle should have been cleaned up + assert len(controller._runs) == 0 + + +# --------------------------------------------------------------------------- +# run_loop RunHandle integration +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_run_loop_creates_run_handle_for_initial_turn( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """run_loop creates and completes a RunHandle for the initial turn.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + assert len(controller._runs) == 0 + + await legacy_runner.run_loop("sess-1", "hello") + + # RunHandle created by initial turn is cleaned up + assert len(controller._runs) == 0 + + +@pytest.mark.anyio +async def test_run_loop_uses_existing_run_handle_from_receive_request( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """run_loop uses an existing RunHandle without completing it.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + + run_handle = controller._create_run("sess-1", "hello") + controller._runs[run_handle.run_id] = run_handle + session = controller.get_session("sess-1") + assert session is not None + session.current_run_id = run_handle.run_id + controller._pending_run_ids["sess-1"] = run_handle.run_id + + await legacy_runner.run_loop("sess-1", "hello") + + # Existing RunHandle should NOT be removed or completed + assert run_handle.run_id in controller._runs + assert run_handle.status == RunStatus.running + + +# --------------------------------------------------------------------------- +# RED FLAG TEST – inject_prompt must trigger second iteration +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_inject_prompt_triggers_second_iteration( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_pool: MagicMock, +) -> None: + """inject_prompt during an active turn MUST trigger a second _run_stream_once.""" + call_count = 0 + received_prompts: list[tuple[Any, ...]] = [] + + async def _fake_stream( + run_ctx: AgentRunContext, + *prompts: Any, + **kwargs: Any, + ) -> AsyncIterator[RunStartedEvent]: + nonlocal call_count + call_count += 1 + received_prompts.append(prompts) + + if call_count == 1: + run_ctx.injection_manager.inject("injected message") + yield RunStartedEvent(session_id="sess-1", run_id="run-1") + else: + yield RunStartedEvent(session_id="sess-1", run_id="run-2") + + agent = MagicMock() + agent.get_active_run_context.return_value = None + agent._run_stream_once = _fake_stream + + await _setup_session(controller, "sess-1", agent, mock_pool) + await legacy_runner.run_turn("sess-1", "initial") + + assert call_count == 2, ( + f"inject_prompt BROKEN: _run_stream_once called {call_count} time(s), " + f"expected 2 (initial + injected)." + ) + assert received_prompts[1] == ("injected message",), ( + f"Second iteration should process injected prompt, got {received_prompts[1]}" + ) + + +@pytest.mark.anyio +async def test_post_turn_inject_prompt_triggers_auto_resume( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_pool: MagicMock, +) -> None: + """inject_prompt AFTER turn ends MUST trigger auto-resume.""" + call_count = 0 + received_prompts: list[tuple[Any, ...]] = [] + + async def _fake_stream( + run_ctx: AgentRunContext, + *prompts: Any, + **kwargs: Any, + ) -> AsyncIterator[RunStartedEvent]: + nonlocal call_count + call_count += 1 + received_prompts.append(prompts) + yield RunStartedEvent(session_id="sess-1", run_id=f"run-{call_count}") + + agent = MagicMock() + agent.get_active_run_context.return_value = None + agent._run_stream_once = _fake_stream + + await _setup_session(controller, "sess-1", agent, mock_pool) + + await legacy_runner.run_turn("sess-1", "initial") + assert call_count == 1 + + injected = await legacy_runner.inject_prompt("sess-1", "late message") + assert injected is False + + await asyncio.sleep(0.1) + + assert call_count == 2, ( + f"post-turn inject_prompt BROKEN: _run_stream_once called {call_count} time(s), " + f"expected 2 (initial + auto-resume)." + ) + assert received_prompts[1] == ("late message",) + + +# --------------------------------------------------------------------------- +# run_turn – serialization +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_run_turn_serializes_per_session( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent_with_delay: MagicMock, + mock_pool: MagicMock, +) -> None: + """Only one turn executes per session at a time.""" + await _setup_session(controller, "sess-1", mock_agent_with_delay, mock_pool) + + timestamps: list[float] = [] + + async def record(task_id: str) -> None: + await legacy_runner.run_turn("sess-1", f"prompt-{task_id}") + timestamps.append(asyncio.get_event_loop().time()) + + t1 = asyncio.create_task(record("A")) + await asyncio.sleep(0.01) + t2 = asyncio.create_task(record("B")) + await asyncio.gather(t1, t2) + + assert len(timestamps) == 2 + assert timestamps[1] >= timestamps[0] + 0.04 + + +@pytest.mark.anyio +async def test_run_turn_skips_closing_session( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """run_turn silently returns when the session is already closing.""" + state = await _setup_session(controller, "sess-1", mock_agent, mock_pool) + state.is_closing = True + await legacy_runner.run_turn("sess-1", "hello") + + +@pytest.mark.anyio +async def test_run_turn_publishes_events( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """Events from the agent stream are published to the EventBus.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + queue = await legacy_runner.event_bus.subscribe("sess-1") + await legacy_runner.run_turn("sess-1", "hello") + event = await asyncio.wait_for(queue.get(), timeout=0.5) + assert event is not None + assert isinstance(event, RunStartedEvent) + + +@pytest.mark.anyio +async def test_run_turn_records_timing( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent_with_delay: MagicMock, + mock_pool: MagicMock, +) -> None: + """Turn timings are recorded after a turn completes.""" + await _setup_session(controller, "sess-1", mock_agent_with_delay, mock_pool) + assert len(legacy_runner._turn_timings) == 0 + await legacy_runner.run_turn("sess-1", "hello") + assert len(legacy_runner._turn_timings) == 1 + start, end = legacy_runner._turn_timings[0] + assert end > start + + +# --------------------------------------------------------------------------- +# run_loop – auto-resume +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_run_loop_processes_queued_injections( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """Post-turn injections are processed automatically by run_loop.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + await legacy_runner.inject_prompt("sess-1", "injected-msg") + await legacy_runner.run_loop("sess-1", "initial") + assert len(legacy_runner._turn_timings) == 2 + + +@pytest.mark.anyio +async def test_run_loop_processes_queued_prompts( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """Post-turn prompts are processed automatically by run_loop.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + await legacy_runner.queue_prompt("sess-1", "queued-prompt") + await legacy_runner.run_loop("sess-1", "initial") + assert len(legacy_runner._turn_timings) == 2 + + +@pytest.mark.anyio +async def test_run_loop_drains_on_exception( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_pool: MagicMock, +) -> None: + """If the turn loop raises, queued work is drained so it does not leak.""" + agent = MagicMock() + agent.get_active_run_context.return_value = None + + async def broken_stream(*args: Any, **kwargs: Any) -> AsyncIterator[Any]: + raise RuntimeError("boom") + yield # make it an async generator + + agent._run_stream_once = broken_stream + await _setup_session(controller, "sess-1", agent, mock_pool) + await legacy_runner.inject_prompt("sess-1", "injected-msg") + await legacy_runner.queue_prompt("sess-1", "queued-prompt") + await legacy_runner.run_loop("sess-1", "initial") + assert legacy_runner._post_turn_injections.get("sess-1") in (None, []) + assert legacy_runner._post_turn_prompts.get("sess-1") in (None, []) + + +# --------------------------------------------------------------------------- +# inject_prompt +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_inject_prompt_into_active_turn( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent_with_delay: MagicMock, + mock_pool: MagicMock, +) -> None: + """inject_prompt returns True and injects immediately when a turn is active.""" + await _setup_session(controller, "sess-1", mock_agent_with_delay, mock_pool, legacy_runner) + + injected = False + + async def delayed_inject() -> None: + nonlocal injected + await asyncio.sleep(0.02) + injected = await legacy_runner.inject_prompt("sess-1", "injected-msg") + + await asyncio.gather( + legacy_runner.run_turn("sess-1", "hello"), + delayed_inject(), + ) + assert injected is True + + +@pytest.mark.anyio +async def test_inject_prompt_queues_when_idle( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """inject_prompt returns False and queues when no turn is active.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + result = await legacy_runner.inject_prompt("sess-1", "injected-msg") + assert result is False + assert legacy_runner._post_turn_injections.get("sess-1") == ["injected-msg"] + + +@pytest.mark.anyio +async def test_inject_prompt_returns_false_for_missing_session( + legacy_runner: LegacyTurnRunner, +) -> None: + """inject_prompt returns False when the session does not exist.""" + result = await legacy_runner.inject_prompt("missing", "msg") + assert result is False + + +@pytest.mark.anyio +async def test_inject_prompt_returns_false_for_closing_session( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """inject_prompt returns False when the session is closing.""" + state = await _setup_session(controller, "sess-1", mock_agent, mock_pool) + state.is_closing = True + result = await legacy_runner.inject_prompt("sess-1", "msg") + assert result is False + + +# --------------------------------------------------------------------------- +# queue_prompt +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_queue_prompt_into_active_turn( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent_with_delay: MagicMock, + mock_pool: MagicMock, +) -> None: + """queue_prompt returns True and queues into active run context.""" + await _setup_session(controller, "sess-1", mock_agent_with_delay, mock_pool, legacy_runner) + + queued = False + + async def delayed_queue() -> None: + nonlocal queued + await asyncio.sleep(0.02) + queued = await legacy_runner.queue_prompt("sess-1", "queued-msg") + + await asyncio.gather( + legacy_runner.run_turn("sess-1", "hello"), + delayed_queue(), + ) + assert queued is True + + +@pytest.mark.anyio +async def test_queue_prompt_stores_when_idle( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """queue_prompt returns False and stores prompts for later.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + result = await legacy_runner.queue_prompt("sess-1", "prompt-a", "prompt-b") + assert result is False + stored = legacy_runner._post_turn_prompts.get("sess-1") + assert stored is not None + assert stored == [("prompt-a", "prompt-b")] + + +@pytest.mark.anyio +async def test_queue_prompt_returns_false_for_missing_session( + legacy_runner: LegacyTurnRunner, +) -> None: + """queue_prompt returns False when the session does not exist.""" + result = await legacy_runner.queue_prompt("missing", "msg") + assert result is False + + +# --------------------------------------------------------------------------- +# auto-resume trigger +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_auto_resume_trigger_processes_queued_work( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """_trigger_auto_resume picks up queued work after run_turn finishes.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + await legacy_runner.run_turn("sess-1", "initial") + await legacy_runner.inject_prompt("sess-1", "injected-msg") + await legacy_runner._trigger_auto_resume("sess-1") + assert len(legacy_runner._turn_timings) == 2 + + +@pytest.mark.anyio +async def test_auto_resume_trigger_noop_when_locked( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent_with_delay: MagicMock, + mock_pool: MagicMock, +) -> None: + """_trigger_auto_resume is a no-op when turn_lock is already held.""" + await _setup_session(controller, "sess-1", mock_agent_with_delay, mock_pool) + task = asyncio.create_task(legacy_runner.run_turn("sess-1", "hello")) + await asyncio.sleep(0.01) + await legacy_runner._trigger_auto_resume("sess-1") + await task + assert len(legacy_runner._turn_timings) == 1 + + +@pytest.mark.anyio +async def test_auto_resume_trigger_noop_when_disabled( + controller: SessionController, + mock_pool: MagicMock, + mock_agent: MagicMock, +) -> None: + """When auto-resume is disabled, _trigger_auto_resume still runs queued work.""" + runner = LegacyTurnRunner(controller, enable_auto_resume=False) + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + await runner.inject_prompt("sess-1", "injected-msg") + await runner._trigger_auto_resume("sess-1") + assert len(runner._turn_timings) == 1 + + +@pytest.mark.anyio +async def test_auto_resume_trigger_noop_for_closing_session( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """_trigger_auto_resume exits early when the session is closing.""" + state = await _setup_session(controller, "sess-1", mock_agent, mock_pool) + state.is_closing = True + await legacy_runner.inject_prompt("sess-1", "msg") + await legacy_runner._trigger_auto_resume("sess-1") + assert len(legacy_runner._turn_timings) == 0 + + +# --------------------------------------------------------------------------- +# cancellation +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_turn_cancellation_stops_current_turn( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_pool: MagicMock, +) -> None: + """Cancelling the task running run_turn aborts the turn.""" + agent = MagicMock() + agent.get_active_run_context.return_value = None + + async def slow_stream(*args: Any, **kwargs: Any) -> AsyncIterator[Any]: + for _ in range(100): + await asyncio.sleep(0.01) + yield RunStartedEvent(session_id="sess-1", run_id="r") + + agent._run_stream_once = slow_stream + await _setup_session(controller, "sess-1", agent, mock_pool) + + task = asyncio.create_task(legacy_runner.run_turn("sess-1", "hello")) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.anyio +async def test_run_loop_cancellation( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_pool: MagicMock, +) -> None: + """Cancelling the task running run_loop raises CancelledError.""" + agent = MagicMock() + agent.get_active_run_context.return_value = None + + async def slow_stream(*args: Any, **kwargs: Any) -> AsyncIterator[Any]: + await asyncio.sleep(10) + yield RunStartedEvent(session_id="sess-1", run_id="r") + + agent._run_stream_once = slow_stream + await _setup_session(controller, "sess-1", agent, mock_pool) + + task = asyncio.create_task(legacy_runner.run_loop("sess-1", "hello")) + await asyncio.sleep(0.02) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +# --------------------------------------------------------------------------- +# _process_queued_work – max auto-resume +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_max_auto_resume_limits_iterations( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_agent: MagicMock, + mock_pool: MagicMock, +) -> None: + """The auto-resume loop stops after max_auto_resume iterations.""" + await _setup_session(controller, "sess-1", mock_agent, mock_pool) + legacy_runner._max_auto_resume = 2 + state = controller.get_session("sess-1") + assert state is not None + + legacy_runner._post_turn_injections["sess-1"] = ["msg"] + + await legacy_runner._process_queued_work("sess-1", state) + assert len(legacy_runner._turn_timings) >= 1 + + +# --------------------------------------------------------------------------- +# drain helpers +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_drain_post_turn_injections_is_atomic( + controller: SessionController, + legacy_runner: LegacyTurnRunner, +) -> None: + """_drain_post_turn_injections removes and returns all injections.""" + legacy_runner._post_turn_injections["sess-1"] = ["a", "b", "c"] + drained = await legacy_runner._drain_post_turn_injections("sess-1") + assert drained == ["a", "b", "c"] + assert "sess-1" not in legacy_runner._post_turn_injections + + +@pytest.mark.anyio +async def test_drain_post_turn_prompts_is_atomic( + controller: SessionController, + legacy_runner: LegacyTurnRunner, +) -> None: + """_drain_post_turn_prompts removes and returns all prompt groups.""" + legacy_runner._post_turn_prompts["sess-1"] = [("p1",), ("p2", "p3")] + drained = await legacy_runner._drain_post_turn_prompts("sess-1") + assert drained == [("p1",), ("p2", "p3")] + assert "sess-1" not in legacy_runner._post_turn_prompts + + +@pytest.mark.anyio +async def test_drain_returns_empty_for_unknown_session( + controller: SessionController, + legacy_runner: LegacyTurnRunner, +) -> None: + """Draining an unknown session returns an empty list.""" + assert await legacy_runner._drain_post_turn_injections("missing") == [] + assert await legacy_runner._drain_post_turn_prompts("missing") == [] + + +# --------------------------------------------------------------------------- +# input_provider propagation (RED FLAG) +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_run_turn_passes_input_provider_to_agent( + controller: SessionController, + legacy_runner: LegacyTurnRunner, + mock_pool: MagicMock, +) -> None: + """input_provider must be forwarded to agent._run_stream_once.""" + from agentpool.ui.base import InputProvider + + calls: list[dict[str, Any]] = [] + + agent = MagicMock() + agent.get_active_run_context.return_value = None + + async def _capture_stream( + run_ctx: AgentRunContext, + *prompts: Any, + **kwargs: Any, + ) -> AsyncIterator[RunStartedEvent]: + calls.append(kwargs) + yield RunStartedEvent(session_id=kwargs.get("session_id", "default"), run_id="run-1") + + agent._run_stream_once = _capture_stream + + await _setup_session(controller, "sess-1", agent, mock_pool) + + fake_provider = MagicMock(spec=InputProvider) + await legacy_runner.run_turn("sess-1", "hello", input_provider=fake_provider) + + assert len(calls) == 1 + assert calls[0].get("input_provider") is fake_provider diff --git a/tests/orchestrator/test_phase2_native_queue.py b/tests/orchestrator/test_phase2_native_queue.py index 52d3ad6cb..53c1061b3 100644 --- a/tests/orchestrator/test_phase2_native_queue.py +++ b/tests/orchestrator/test_phase2_native_queue.py @@ -5,7 +5,7 @@ - enqueue() during tool execution on native agents - inject_prompt() tool result augmentation pipeline - RunExecutor event stream parity with _stream_events() -- Non-native agents use manual queue (TurnRunner) +- Non-native agents use manual queue (LegacyTurnRunner) - Native agent interrupt() via SessionPool - receive_request() routing for native agents - Full integration: native agent auto-resumes with queued prompts @@ -37,7 +37,8 @@ ToolCallStartEvent, ) from agentpool.messaging import ChatMessage, MessageHistory -from agentpool.orchestrator.core import SessionController, SessionPool, TurnRunner +from agentpool.orchestrator.core import SessionController, SessionPool +from agentpool.orchestrator.legacy_runner import LegacyTurnRunner from agentpool.orchestrator.run import RunHandle, RunStatus from agentpool.orchestrator.run_executor import RunExecutor @@ -107,9 +108,9 @@ def controller(mock_pool: MagicMock) -> SessionController: @pytest.fixture -def turn_runner(controller: SessionController) -> TurnRunner: - """Return a TurnRunner with auto-resume enabled.""" - return TurnRunner(session_controller=controller, enable_auto_resume=True) +def legacy_runner(controller: SessionController) -> LegacyTurnRunner: + """Return a LegacyTurnRunner with auto-resume enabled.""" + return LegacyTurnRunner(session_controller=controller, enable_auto_resume=True) @pytest.fixture @@ -508,7 +509,7 @@ async def test_run_executor_event_stream_matches_stream_events( # --------------------------------------------------------------------------- -# 8. Non-native agents still use manual queue (TurnRunner) +# 8. Non-native agents still use manual queue (LegacyTurnRunner) # --------------------------------------------------------------------------- @@ -541,16 +542,16 @@ async def test_non_native_agent_uses_manual_injection_manager( @pytest.mark.anyio -async def test_non_native_agent_uses_turn_runner( +async def test_non_native_agent_uses_legacy_turn_runner( controller: SessionController, - turn_runner: TurnRunner, + legacy_runner: LegacyTurnRunner, mock_pool: MagicMock, ) -> None: - """Non-native agents are processed by TurnRunner with manual queue.""" + """Non-native agents are processed by LegacyTurnRunner with manual queue.""" session_id = "non-native-sess" state = await controller.get_or_create_session(session_id) - agent = _MockNonNativeAgent(name="non-native-test") + agent = _MockNonNativeAgent(name="legacy-test") state.agent = agent controller._session_agents[session_id] = agent mock_pool.get_agent.return_value = agent @@ -575,10 +576,10 @@ async def _fake_stream( agent._run_stream_once = _fake_stream # type: ignore[method-assign] - await turn_runner.run_turn(session_id, "initial") + await legacy_runner.run_turn(session_id, "initial") assert call_count == 2, ( - f"TurnRunner should process injection + initial turn, got {call_count} calls" + f"LegacyTurnRunner should process injection + initial turn, got {call_count} calls" ) assert received_prompts[1] == ("injected message",) diff --git a/tests/orchestrator/test_turn_runner.py b/tests/orchestrator/test_turn_runner.py index 64f75fed5..54536c048 100644 --- a/tests/orchestrator/test_turn_runner.py +++ b/tests/orchestrator/test_turn_runner.py @@ -128,246 +128,334 @@ def _mock_get_active_run_context() -> AgentRunContext | None: # --------------------------------------------------------------------------- -# RunHandle lifecycle +# RED FLAG TEST – inject_prompt must trigger second iteration # --------------------------------------------------------------------------- - @pytest.mark.anyio -async def test_run_turn_creates_run_handle_when_called_directly( +async def test_inject_prompt_triggers_second_iteration( controller: SessionController, turn_runner: TurnRunner, - mock_agent: MagicMock, mock_pool: MagicMock, ) -> None: - """When run_turn is called directly it creates a RunHandle in _runs.""" - await _setup_session(controller, "sess-1", mock_agent, mock_pool) - assert len(controller._runs) == 0 + """inject_prompt during an active turn MUST trigger a second _run_stream_once. - await turn_runner.run_turn("sess-1", "hello") + This is a **red flag test** — if it fails, inject_prompt is broken. - # RunHandle should have been created, completed, and cleaned up - assert len(controller._runs) == 0 + Scenario: + 1. run_turn starts → calls _run_stream_once (iteration 1) + 2. During iteration 1, a tool calls inject_prompt("msg") + → message goes into run_ctx.injection_manager._pending_injections + 3. Iteration 1 completes + 4. flush_pending_to_queue() moves "msg" to _queued_prompts + 5. while has_queued() → pop_queued() → _run_stream_once (iteration 2) + 6. Iteration 2 processes the injected message + Expected: _run_stream_once called exactly TWICE. + """ + call_count = 0 + received_prompts: list[tuple[Any, ...]] = [] -@pytest.mark.anyio -async def test_run_turn_uses_existing_run_handle_from_receive_request( - controller: SessionController, - turn_runner: TurnRunner, - mock_agent: MagicMock, - mock_pool: MagicMock, -) -> None: - """run_turn uses an existing RunHandle created by receive_request.""" - await _setup_session(controller, "sess-1", mock_agent, mock_pool) + async def _fake_stream( + run_ctx: AgentRunContext, + *prompts: Any, + **kwargs: Any, + ) -> AsyncIterator[RunStartedEvent]: + nonlocal call_count + call_count += 1 + received_prompts.append(prompts) - run_handle = controller._create_run("sess-1", "hello") - controller._runs[run_handle.run_id] = run_handle - session = controller.get_session("sess-1") - assert session is not None - session.current_run_id = run_handle.run_id - controller._pending_run_ids["sess-1"] = run_handle.run_id + if call_count == 1: + # Simulate a tool injecting a prompt mid-turn + run_ctx.injection_manager.inject("injected message") + yield RunStartedEvent(session_id="sess-1", run_id="run-1") + else: + yield RunStartedEvent(session_id="sess-1", run_id="run-2") - await turn_runner.run_turn("sess-1", "hello") + agent = MagicMock() + agent.get_active_run_context.return_value = None + agent._run_stream_once = _fake_stream - # Existing RunHandle should NOT be removed by TurnRunner - assert run_handle.run_id in controller._runs - from agentpool.orchestrator.run import RunStatus - assert run_handle.status == RunStatus.running # not completed by us + await _setup_session(controller, "sess-1", agent, mock_pool) + await turn_runner.run_turn("sess-1", "initial") + + # RED FLAG: if this is 1 instead of 2, inject_prompt is silently broken + assert call_count == 2, ( + f"inject_prompt BROKEN: _run_stream_once called {call_count} time(s), " + f"expected 2 (initial + injected). " + f"Queued prompts were not processed after flush." + ) + assert received_prompts[1] == ("injected message",), ( + f"Second iteration should process injected prompt, got {received_prompts[1]}" + ) @pytest.mark.anyio -async def test_run_turn_sets_and_clears_current_run_id( +async def test_post_turn_inject_prompt_triggers_auto_resume( controller: SessionController, turn_runner: TurnRunner, - mock_agent: MagicMock, mock_pool: MagicMock, ) -> None: - """run_turn sets session.current_run_id during execution and clears after.""" - await _setup_session(controller, "sess-1", mock_agent, mock_pool) - session = controller.get_session("sess-1") - assert session is not None - assert session.current_run_id is None + """inject_prompt AFTER turn ends MUST trigger auto-resume. - await turn_runner.run_turn("sess-1", "hello") + This is a **red flag test** — if it fails, post-turn inject_prompt is broken. - assert session.current_run_id is None + Scenario: + 1. run_turn completes + 2. Caller calls turn_runner.inject_prompt("sess-1", "msg") + → msg goes to _post_turn_injections + → _trigger_auto_resume fires + 3. Auto-resume should process the injection in a new turn + Expected: _run_stream_once called TWICE (initial + auto-resume). + """ + call_count = 0 + received_prompts: list[tuple[Any, ...]] = [] -@pytest.mark.anyio -async def test_run_turn_completes_run_handle_on_success( - controller: SessionController, - turn_runner: TurnRunner, - mock_agent: MagicMock, - mock_pool: MagicMock, -) -> None: - """Direct run_turn calls complete() the RunHandle it creates.""" - await _setup_session(controller, "sess-1", mock_agent, mock_pool) + async def _fake_stream( + run_ctx: AgentRunContext, + *prompts: Any, + **kwargs: Any, + ) -> AsyncIterator[RunStartedEvent]: + nonlocal call_count + call_count += 1 + received_prompts.append(prompts) + yield RunStartedEvent(session_id="sess-1", run_id=f"run-{call_count}") - await turn_runner.run_turn("sess-1", "hello") + agent = MagicMock() + agent.get_active_run_context.return_value = None + agent._run_stream_once = _fake_stream - # No RunHandle left in _runs because TurnRunner cleaned it up - assert len(controller._runs) == 0 + await _setup_session(controller, "sess-1", agent, mock_pool) + + # 1. Initial turn completes + await turn_runner.run_turn("sess-1", "initial") + assert call_count == 1 + + # 2. Post-turn injection (simulates tool calling inject after turn ended) + injected = await turn_runner.inject_prompt("sess-1", "late message") + assert injected is False # Queued, not injected into active turn + + # 3. Wait for auto-resume to fire and complete + await asyncio.sleep(0.1) + + # RED FLAG: auto-resume should have triggered a second turn + assert call_count == 2, ( + f"post-turn inject_prompt BROKEN: _run_stream_once called {call_count} time(s), " + f"expected 2 (initial + auto-resume). " + f"_trigger_auto_resume did not process queued injection." + ) + assert received_prompts[1] == ("late message",), ( + f"Auto-resume should process injected prompt, got {received_prompts[1]}" + ) @pytest.mark.anyio -async def test_run_turn_fails_run_handle_on_exception( +async def test_background_task_child_agent_events_reach_event_bus( controller: SessionController, turn_runner: TurnRunner, mock_pool: MagicMock, ) -> None: - """When _run_stream_once raises, the RunHandle is marked failed.""" + """Background task child-agent events MUST reach EventBus. + + This is a **red flag test** — if it fails, background task events are lost. + + Scenario (real-world from xeno-agent): + 1. SessionPool calls _run_stream_once for lead agent + 2. Lead agent's tool spawns a background task (subagent) + 3. Subagent creates its OWN run_ctx with its OWN event_queue + 4. Subagent calls ctx.events.emit_event(SubAgentEvent(...)) + → event goes to subagent's run_ctx.event_queue + → StreamEventEmitter._emit forwards to EventBus (when SessionPool active) + 5. ACP/OpenCode handler receives event via EventBus + + Expected: SubAgentEvent published to EventBus. + """ + from agentpool import ChatMessage + from agentpool.agents.events import StreamCompleteEvent, SubAgentEvent + from agentpool.agents.events.event_emitter import StreamEventEmitter + + event_bus_events: list[Any] = [] + + async def _fake_stream( + run_ctx: AgentRunContext, + *prompts: Any, + **kwargs: Any, + ) -> AsyncIterator[RunStartedEvent]: + # Lead agent starts background task + yield RunStartedEvent(session_id="sess-1", run_id="run-1") + + # Simulate background task creating its own run_ctx and emitting events + # via StreamEventEmitter (what xeno-agent's BackgroundTaskProvider does) + child_run_ctx = AgentRunContext(session_id="child-sess", deps=None) + child_run_ctx.cancelled = False + + # Create a mock AgentContext for the child + child_agent = MagicMock() + child_agent.session_id = "sess-1" # Same session for EventBus routing + child_run_ctx = AgentRunContext(session_id="child-sess", deps=None) + child_run_ctx.event_bus = turn_runner.event_bus + child_ctx = MagicMock() + child_ctx.agent = child_agent + child_ctx.run_ctx = child_run_ctx + child_ctx.tool_name = "background_task" + child_ctx.tool_call_id = "tc-1" + + # Use StreamEventEmitter (real code path) + emitter = StreamEventEmitter(child_ctx, event_bus=child_run_ctx.event_bus) + await emitter.emit_event( + SubAgentEvent( + source_name="bg-task", + source_type="background", + event=StreamCompleteEvent( + message=ChatMessage(content="background done", role="assistant"), + ), + child_session_id="child-sess", + parent_session_id="sess-1", + ) + ) + + yield StreamCompleteEvent( + message=ChatMessage(content="lead done", role="assistant"), + ) + agent = MagicMock() agent.get_active_run_context.return_value = None + agent._run_stream_once = _fake_stream - async def broken_stream(*args: Any, **kwargs: Any) -> AsyncIterator[Any]: - raise RuntimeError("boom") - yield # make it an async generator - - agent._run_stream_once = broken_stream await _setup_session(controller, "sess-1", agent, mock_pool) + # Subscribe to EventBus BEFORE running the turn event_queue = await turn_runner.event_bus.subscribe("sess-1") - events: list[Any] = [] - async def _consume() -> None: + async def _bus_consumer() -> None: + """Consume events from pre-subscribed queue.""" try: while True: event = await asyncio.wait_for(event_queue.get(), timeout=0.5) if event is None: break - events.append(event) + event_bus_events.append(event) except TimeoutError: - pass + pass # No more events - consumer = asyncio.create_task(_consume()) + # Start EventBus consumer + consumer_task = asyncio.create_task(_bus_consumer()) - with pytest.raises(RuntimeError, match="boom"): - await turn_runner.run_turn("sess-1", "hello") + # Run the turn + await turn_runner.run_turn("sess-1", "initial") - await asyncio.sleep(0.05) - await turn_runner.event_bus.publish("sess-1", None) - await consumer + # Wait for EventBus consumer + await asyncio.sleep(0.1) + await turn_runner.event_bus.publish("sess-1", None) # sentinel + await consumer_task - from agentpool.agents.events import RunFailedEvent - failed_events = [e for e in events if isinstance(e, RunFailedEvent)] - assert len(failed_events) == 1 - assert failed_events[0].session_id == "sess-1" - assert isinstance(failed_events[0].exception, RuntimeError) + # Filter for SubAgentEvent + subagent_events = [e for e in event_bus_events if isinstance(e, SubAgentEvent)] - # RunHandle should have been cleaned up - assert len(controller._runs) == 0 + # RED FLAG: background task events must reach EventBus + assert len(subagent_events) == 1, ( + f"background task events LOST: found {len(subagent_events)} SubAgentEvent(s) " + f"in EventBus, expected 1. " + f"Total events in bus: {len(event_bus_events)}. " + f"StreamEventEmitter did not forward to EventBus." + ) + assert subagent_events[0].source_name == "bg-task" -# --------------------------------------------------------------------------- -# RED FLAG TEST – inject_prompt must trigger second iteration -# --------------------------------------------------------------------------- - @pytest.mark.anyio -async def test_inject_prompt_triggers_second_iteration( +async def test_background_task_events_reach_acp_client_after_end_turn( controller: SessionController, turn_runner: TurnRunner, mock_pool: MagicMock, ) -> None: - """inject_prompt during an active turn MUST trigger a second _run_stream_once. + """Background task events emitted after StreamCompleteEvent reach EventBus. - This is a **red flag test** — if it fails, inject_prompt is broken. + This is a **red flag test** — if it fails, post-end-turn background events + are lost before reaching the ACP client. Scenario: - 1. run_turn starts → calls _run_stream_once (iteration 1) - 2. During iteration 1, a tool calls inject_prompt("msg") - → message goes into run_ctx.injection_manager._pending_injections - 3. Iteration 1 completes - 4. flush_pending_to_queue() moves "msg" to _queued_prompts - 5. while has_queued() → pop_queued() → _run_stream_once (iteration 2) - 6. Iteration 2 processes the injected message + 1. Agent stream yields RunStartedEvent then StreamCompleteEvent (end_turn) + 2. In the generator's cleanup (finally), a background task event is queued + to run_ctx.event_queue + 3. _run_turn_unlocked's event consumer is still running and should pick it up + 4. Event reaches EventBus and thus the ACP client - Expected: _run_stream_once called exactly TWICE. + Expected: SubAgentEvent published to EventBus after end_turn. """ - call_count = 0 - received_prompts: list[tuple[Any, ...]] = [] + from agentpool import ChatMessage + from agentpool.agents.events import RunStartedEvent, StreamCompleteEvent, SubAgentEvent async def _fake_stream( run_ctx: AgentRunContext, *prompts: Any, **kwargs: Any, ) -> AsyncIterator[RunStartedEvent]: - nonlocal call_count - call_count += 1 - received_prompts.append(prompts) - - if call_count == 1: - # Simulate a tool injecting a prompt mid-turn - run_ctx.injection_manager.inject("injected message") + try: yield RunStartedEvent(session_id="sess-1", run_id="run-1") - else: - yield RunStartedEvent(session_id="sess-1", run_id="run-2") + yield StreamCompleteEvent( + message=ChatMessage(content="main done", role="assistant"), + ) + finally: + # Simulate background task emitting event after main stream completes + # via EventBus (new pattern: StreamEventEmitter publishes directly) + await turn_runner.event_bus.publish( + "sess-1", + SubAgentEvent( + source_name="bg-task-post-turn", + source_type="background", + event=StreamCompleteEvent( + message=ChatMessage(content="background done", role="assistant"), + ), + child_session_id="child-sess", + parent_session_id="sess-1", + ), + ) agent = MagicMock() agent.get_active_run_context.return_value = None agent._run_stream_once = _fake_stream await _setup_session(controller, "sess-1", agent, mock_pool) - await turn_runner.run_turn("sess-1", "initial") - # RED FLAG: if this is 1 instead of 2, inject_prompt is silently broken - assert call_count == 2, ( - f"inject_prompt BROKEN: _run_stream_once called {call_count} time(s), " - f"expected 2 (initial + injected). " - f"Queued prompts were not processed after flush." - ) - assert received_prompts[1] == ("injected message",), ( - f"Second iteration should process injected prompt, got {received_prompts[1]}" - ) - - -# --------------------------------------------------------------------------- -# run_loop RunHandle integration -# --------------------------------------------------------------------------- - - -@pytest.mark.anyio -async def test_run_loop_creates_run_handle_for_initial_turn( - controller: SessionController, - turn_runner: TurnRunner, - mock_agent: MagicMock, - mock_pool: MagicMock, -) -> None: - """run_loop creates and completes a RunHandle for the initial turn.""" - await _setup_session(controller, "sess-1", mock_agent, mock_pool) - assert len(controller._runs) == 0 - - await turn_runner.run_loop("sess-1", "hello") + # Subscribe to EventBus BEFORE running the turn + event_queue = await turn_runner.event_bus.subscribe("sess-1") + event_bus_events: list[Any] = [] - # RunHandle created by initial turn is cleaned up - assert len(controller._runs) == 0 + async def _bus_consumer() -> None: + """Consume events from pre-subscribed queue.""" + try: + while True: + event = await asyncio.wait_for(event_queue.get(), timeout=0.5) + if event is None: + break + event_bus_events.append(event) + except TimeoutError: + pass # No more events + # Start EventBus consumer + consumer_task = asyncio.create_task(_bus_consumer()) -@pytest.mark.anyio -async def test_run_loop_uses_existing_run_handle_from_receive_request( - controller: SessionController, - turn_runner: TurnRunner, - mock_agent: MagicMock, - mock_pool: MagicMock, -) -> None: - """run_loop uses an existing RunHandle without completing it.""" - await _setup_session(controller, "sess-1", mock_agent, mock_pool) + # Run the turn + await turn_runner.run_turn("sess-1", "initial") - run_handle = controller._create_run("sess-1", "hello") - controller._runs[run_handle.run_id] = run_handle - session = controller.get_session("sess-1") - assert session is not None - session.current_run_id = run_handle.run_id - controller._pending_run_ids["sess-1"] = run_handle.run_id + # Wait for EventBus consumer + await asyncio.sleep(0.1) + await turn_runner.event_bus.publish("sess-1", None) # sentinel + await consumer_task - await turn_runner.run_loop("sess-1", "hello") + # Filter for SubAgentEvent + subagent_events = [e for e in event_bus_events if isinstance(e, SubAgentEvent)] - # Existing RunHandle should NOT be removed or completed - assert run_handle.run_id in controller._runs - from agentpool.orchestrator.run import RunStatus - assert run_handle.status == RunStatus.running + # RED FLAG: background task events after end_turn must reach EventBus + assert len(subagent_events) == 1, ( + f"post-end-turn background events LOST: found {len(subagent_events)} SubAgentEvent(s) " + f"in EventBus, expected 1. " + f"Total events in bus: {len(event_bus_events)}. " + f"Event consumer did not pick up background task event after stream completion." + ) + assert subagent_events[0].source_name == "bg-task-post-turn" -# --------------------------------------------------------------------------- -# run_loop – auto-resume # --------------------------------------------------------------------------- # run_turn – serialization # --------------------------------------------------------------------------- diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestEditFileSnapshots.test_edit_file_multiple_replacements.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestEditFileSnapshots.test_edit_file_multiple_replacements.json deleted file mode 100644 index 582dc063f..000000000 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestEditFileSnapshots.test_edit_file_multiple_replacements.json +++ /dev/null @@ -1,67 +0,0 @@ -[ - { - "payload": { - "kind": "edit", - "rawInput": { - "description": "Rename both functions", - "path": "/test/multi.py", - "replacements": [ - [ - "func1", - "renamed1" - ], - [ - "func2", - "renamed2" - ] - ] - }, - "sessionUpdate": "tool_call", - "status": "pending", - "title": "Editing: /test/multi.py", - "toolCallId": "pyd_ai_tool_call_id__edit" - }, - "type": "tool_call" - }, - { - "payload": { - "content": [ - { - "content": { - "text": "Error: 3 validation errors:\n```json\n[\n {\n \"type\": \"missing\",\n \"loc\": [\n \"old_string\"\n ],\n \"msg\": \"Field required\",\n \"input\": {\n \"path\": \"/test/multi.py\",\n \"replacements\": [\n [\n \"func1\",\n \"renamed1\"\n ],\n [\n \"func2\",\n \"renamed2\"\n ]\n ],\n \"description\": \"Rename both functions\"\n }\n },\n {\n \"type\": \"missing\",\n \"loc\": [\n \"new_string\"\n ],\n \"msg\": \"Field required\",\n \"input\": {\n \"path\": \"/test/multi.py\",\n \"replacements\": [\n [\n \"func1\",\n \"renamed1\"\n ],\n [\n \"func2\",\n \"renamed2\"\n ]\n ],\n \"description\": \"Rename both functions\"\n }\n },\n {\n \"type\": \"extra_forbidden\",\n \"loc\": [\n \"replacements\"\n ],\n \"msg\": \"Extra inputs are not permitted\",\n \"input\": [\n [\n \"func1\",\n \"renamed1\"\n ],\n [\n \"func2\",\n \"renamed2\"\n ]\n ]\n }\n]\n```\n\nFix the errors and try again.", - "type": "text" - }, - "type": "content" - } - ], - "sessionUpdate": "tool_call_update", - "status": "failed", - "toolCallId": "pyd_ai_tool_call_id__edit" - }, - "type": "tool_call_update" - }, - { - "payload": { - "kind": "edit", - "rawInput": { - "description": "Rename both functions", - "path": "/test/multi.py", - "replacements": [ - [ - "func1", - "renamed1" - ], - [ - "func2", - "renamed2" - ] - ] - }, - "sessionUpdate": "tool_call", - "status": "pending", - "title": "Editing: /test/multi.py", - "toolCallId": "pyd_ai_be4e1811b5cf4e2eaf6b6dd0c970649b" - }, - "type": "tool_call" - } -] diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestEditFileSnapshots.test_edit_file_simple.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestEditFileSnapshots.test_edit_file_simple.json index ab23ce14b..fe51488c7 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestEditFileSnapshots.test_edit_file_simple.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestEditFileSnapshots.test_edit_file_simple.json @@ -1,65 +1 @@ -[ - { - "payload": { - "kind": "edit", - "rawInput": { - "description": "Rename function from old to new", - "new_string": "def new_function():", - "old_string": "def old_function():", - "path": "/test/example.py" - }, - "sessionUpdate": "tool_call", - "status": "pending", - "title": "Editing: /test/example.py", - "toolCallId": "pyd_ai_tool_call_id__edit" - }, - "type": "tool_call" - }, - { - "payload": { - "kind": "edit", - "locations": [ - { - "line": 0, - "path": "/test/example.py" - } - ], - "sessionUpdate": "tool_call_update", - "title": "Editing file: /test/example.py (Rename function from old to new)", - "toolCallId": "pyd_ai_tool_call_id__edit" - }, - "type": "tool_call_update" - }, - { - "payload": { - "content": [ - { - "newText": "def new_function():\n pass\n", - "oldText": "def old_function():\n pass\n", - "path": "/test/example.py", - "type": "diff" - } - ], - "locations": [ - { - "line": 0, - "path": "/test/example.py" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Editing: /test/example.py", - "toolCallId": "pyd_ai_tool_call_id__edit" - }, - "type": "tool_call_update" - }, - { - "payload": { - "rawOutput": "Successfully edited example.py: Rename function from old to new (1 lines changed)", - "sessionUpdate": "tool_call_update", - "status": "completed", - "toolCallId": "pyd_ai_tool_call_id__edit" - }, - "type": "tool_call_update" - } -] +[] diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestEditFileSnapshots.test_edit_file_with_replace_all.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestEditFileSnapshots.test_edit_file_with_replace_all.json index e76ce3bae..fe51488c7 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestEditFileSnapshots.test_edit_file_with_replace_all.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestEditFileSnapshots.test_edit_file_with_replace_all.json @@ -1,66 +1 @@ -[ - { - "payload": { - "kind": "edit", - "rawInput": { - "description": "Replace all func1 occurrences", - "new_string": "renamed", - "old_string": "func1", - "path": "/test/multi.py", - "replace_all": true - }, - "sessionUpdate": "tool_call", - "status": "pending", - "title": "Editing: /test/multi.py", - "toolCallId": "pyd_ai_tool_call_id__edit" - }, - "type": "tool_call" - }, - { - "payload": { - "kind": "edit", - "locations": [ - { - "line": 0, - "path": "/test/multi.py" - } - ], - "sessionUpdate": "tool_call_update", - "title": "Editing file: /test/multi.py (Replace all func1 occurrences)", - "toolCallId": "pyd_ai_tool_call_id__edit" - }, - "type": "tool_call_update" - }, - { - "payload": { - "content": [ - { - "newText": "def renamed():\n renamed()\n\ndef func2():\n renamed()\n", - "oldText": "def func1():\n func1()\n\ndef func2():\n func1()\n", - "path": "/test/multi.py", - "type": "diff" - } - ], - "locations": [ - { - "line": 0, - "path": "/test/multi.py" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Editing: /test/multi.py", - "toolCallId": "pyd_ai_tool_call_id__edit" - }, - "type": "tool_call_update" - }, - { - "payload": { - "rawOutput": "Successfully edited multi.py: Replace all func1 occurrences (3 lines changed)", - "sessionUpdate": "tool_call_update", - "status": "completed", - "toolCallId": "pyd_ai_tool_call_id__edit" - }, - "type": "tool_call_update" - } -] +[] diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_multiline.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_multiline.json index 4dbe2857a..91d560bf5 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_multiline.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_multiline.json @@ -13,17 +13,26 @@ }, "type": "tool_call" }, + { + "payload": { + "kind": "other", + "sessionUpdate": "tool_call_update", + "title": "Executing: execute_code", + "toolCallId": "pyd_ai_tool_call_id__execute_code" + }, + "type": "tool_call_update" + }, { "payload": { "content": [ { - "terminalId": "code_0001", + "terminalId": "local_4630070480", "type": "terminal" } ], "sessionUpdate": "tool_call_update", "status": "in_progress", - "title": "Running: python", + "title": "Running: execute(24 chars)", "toolCallId": "pyd_ai_tool_call_id__execute_code" }, "type": "tool_call_update" @@ -32,13 +41,13 @@ "payload": { "content": [ { - "terminalId": "code_0001", + "terminalId": "local_4630070480", "type": "terminal" } ], "sessionUpdate": "tool_call_update", "status": "in_progress", - "title": "3\n", + "title": "3", "toolCallId": "pyd_ai_tool_call_id__execute_code" }, "type": "tool_call_update" @@ -47,12 +56,12 @@ "payload": { "content": [ { - "terminalId": "code_0001", + "terminalId": "local_4630070480", "type": "terminal" }, { "content": { - "text": "3\n", + "text": "3", "type": "text" }, "type": "content" @@ -67,7 +76,7 @@ }, { "payload": { - "rawOutput": "3\n", + "rawOutput": "3", "sessionUpdate": "tool_call_update", "status": "completed", "toolCallId": "pyd_ai_tool_call_id__execute_code" diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_simple.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_simple.json index 3342ba3fd..1c7408f53 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_simple.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_simple.json @@ -13,17 +13,26 @@ }, "type": "tool_call" }, + { + "payload": { + "kind": "other", + "sessionUpdate": "tool_call_update", + "title": "Executing: execute_code", + "toolCallId": "pyd_ai_tool_call_id__execute_code" + }, + "type": "tool_call_update" + }, { "payload": { "content": [ { - "terminalId": "code_0001", + "terminalId": "local_4627961552", "type": "terminal" } ], "sessionUpdate": "tool_call_update", "status": "in_progress", - "title": "Running: python", + "title": "Running: execute(14 chars)", "toolCallId": "pyd_ai_tool_call_id__execute_code" }, "type": "tool_call_update" @@ -32,13 +41,13 @@ "payload": { "content": [ { - "terminalId": "code_0001", + "terminalId": "local_4627961552", "type": "terminal" } ], "sessionUpdate": "tool_call_update", "status": "in_progress", - "title": "hello\n", + "title": "hello", "toolCallId": "pyd_ai_tool_call_id__execute_code" }, "type": "tool_call_update" @@ -47,12 +56,12 @@ "payload": { "content": [ { - "terminalId": "code_0001", + "terminalId": "local_4627961552", "type": "terminal" }, { "content": { - "text": "hello\n", + "text": "hello", "type": "text" }, "type": "content" @@ -67,7 +76,7 @@ }, { "payload": { - "rawOutput": "hello\n", + "rawOutput": "hello", "sessionUpdate": "tool_call_update", "status": "completed", "toolCallId": "pyd_ai_tool_call_id__execute_code" diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_with_error.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_with_error.json index 28a392c32..67bd3e42e 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_with_error.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCodeSnapshots.test_execute_code_with_error.json @@ -15,15 +15,9 @@ }, { "payload": { - "content": [ - { - "terminalId": "code_0001", - "type": "terminal" - } - ], + "kind": "other", "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Running: python", + "title": "Executing: execute_code", "toolCallId": "pyd_ai_tool_call_id__execute_code" }, "type": "tool_call_update" @@ -32,13 +26,13 @@ "payload": { "content": [ { - "terminalId": "code_0001", + "terminalId": "local_4629512880", "type": "terminal" } ], "sessionUpdate": "tool_call_update", "status": "in_progress", - "title": "ValueError: test error\n", + "title": "Running: execute(30 chars)", "toolCallId": "pyd_ai_tool_call_id__execute_code" }, "type": "tool_call_update" @@ -47,27 +41,20 @@ "payload": { "content": [ { - "terminalId": "code_0001", + "terminalId": "local_4629512880", "type": "terminal" - }, - { - "content": { - "text": "ValueError: test error", - "type": "text" - }, - "type": "content" } ], "sessionUpdate": "tool_call_update", "status": "in_progress", - "title": "Process exited [✗ exit 1]", + "title": "Process exited [✓ exit 0]", "toolCallId": "pyd_ai_tool_call_id__execute_code" }, "type": "tool_call_update" }, { "payload": { - "rawOutput": "ValueError: test error\n\n\nError: ValueError: test error\nExit code: 1", + "rawOutput": "", "sessionUpdate": "tool_call_update", "status": "completed", "toolCallId": "pyd_ai_tool_call_id__execute_code" diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_simple.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_simple.json index 1f60940ee..fe51488c7 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_simple.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_simple.json @@ -1,76 +1 @@ -[ - { - "payload": { - "kind": "execute", - "rawInput": { - "command": "echo hello" - }, - "sessionUpdate": "tool_call", - "status": "pending", - "title": "Running: echo hello", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call" - }, - { - "payload": { - "content": [ - { - "terminalId": "cmd_0001", - "type": "terminal" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Running: echo hello", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call_update" - }, - { - "payload": { - "content": [ - { - "terminalId": "cmd_0001", - "type": "terminal" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "hello\n", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call_update" - }, - { - "payload": { - "content": [ - { - "terminalId": "cmd_0001", - "type": "terminal" - }, - { - "content": { - "text": "hello\n", - "type": "text" - }, - "type": "content" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Process exited [✓ exit 0]", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call_update" - }, - { - "payload": { - "rawOutput": "hello\n", - "sessionUpdate": "tool_call_update", - "status": "completed", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call_update" - } -] +[] diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_with_output_limit.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_with_output_limit.json index 6103f3f22..fe51488c7 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_with_output_limit.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_with_output_limit.json @@ -1,77 +1 @@ -[ - { - "payload": { - "kind": "execute", - "rawInput": { - "command": "cat bigfile", - "output_limit": 50 - }, - "sessionUpdate": "tool_call", - "status": "pending", - "title": "Running: cat bigfile", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call" - }, - { - "payload": { - "content": [ - { - "terminalId": "cmd_0001", - "type": "terminal" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Running: cat bigfile", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call_update" - }, - { - "payload": { - "content": [ - { - "terminalId": "cmd_0001", - "type": "terminal" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Output: line\nline\nline\nline\nline\nline\nline\nline\nline\nline\n...", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call_update" - }, - { - "payload": { - "content": [ - { - "terminalId": "cmd_0001", - "type": "terminal" - }, - { - "content": { - "text": "line\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\n", - "type": "text" - }, - "type": "content" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Process exited [✓ exit 0]", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call_update" - }, - { - "payload": { - "rawOutput": "...[truncated]\nline\nline\nline\nline\nline\nline\nline\nline\nline\nline\n\n\n[output truncated]", - "sessionUpdate": "tool_call_update", - "status": "completed", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call_update" - } -] +[] diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_with_stderr.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_with_stderr.json index 227327875..fe51488c7 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_with_stderr.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestExecuteCommandSnapshots.test_execute_command_with_stderr.json @@ -1,54 +1 @@ -[ - { - "payload": { - "kind": "execute", - "rawInput": { - "command": "ls /nonexistent" - }, - "sessionUpdate": "tool_call", - "status": "pending", - "title": "Running: ls /nonexistent", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call" - }, - { - "payload": { - "content": [ - { - "terminalId": "cmd_0001", - "type": "terminal" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Running: ls /nonexistent", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call_update" - }, - { - "payload": { - "content": [ - { - "terminalId": "cmd_0001", - "type": "terminal" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Output: ls: cannot access '/nonexistent': No such file or ...", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call_update" - }, - { - "payload": { - "rawOutput": "ls: cannot access '/nonexistent': No such file or directory\n\n\nError: Command failed\nExit code: 2", - "sessionUpdate": "tool_call_update", - "status": "completed", - "toolCallId": "pyd_ai_tool_call_id__bash" - }, - "type": "tool_call_update" - } -] +[] diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestMCPToolSnapshots.test_mcp_tool_with_progress.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestMCPToolSnapshots.test_mcp_tool_with_progress.json index 59bdcd72f..85ff17104 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestMCPToolSnapshots.test_mcp_tool_with_progress.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestMCPToolSnapshots.test_mcp_tool_with_progress.json @@ -14,27 +14,9 @@ }, { "payload": { + "kind": "other", "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "first step (0%)", - "toolCallId": "pyd_ai_tool_call_id__test_progress" - }, - "type": "tool_call_update" - }, - { - "payload": { - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "second step (50%)", - "toolCallId": "pyd_ai_tool_call_id__test_progress" - }, - "type": "tool_call_update" - }, - { - "payload": { - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "third step (99%)", + "title": "Executing: test_progress", "toolCallId": "pyd_ai_tool_call_id__test_progress" }, "type": "tool_call_update" diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestReadFileSnapshots.test_read_file_basic.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestReadFileSnapshots.test_read_file_basic.json index a2a0b0ead..fe51488c7 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestReadFileSnapshots.test_read_file_basic.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestReadFileSnapshots.test_read_file_basic.json @@ -1,78 +1 @@ -[ - { - "payload": { - "kind": "read", - "rawInput": { - "path": "/test/hello.txt" - }, - "sessionUpdate": "tool_call", - "status": "pending", - "title": "Reading: /test/hello.txt", - "toolCallId": "pyd_ai_tool_call_id__read" - }, - "type": "tool_call" - }, - { - "payload": { - "locations": [ - { - "line": 0, - "path": "/test/hello.txt" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Reading file: /test/hello.txt", - "toolCallId": "pyd_ai_tool_call_id__read" - }, - "type": "tool_call_update" - }, - { - "payload": { - "locations": [ - { - "line": 0, - "path": "/test/hello.txt" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Read: /test/hello.txt", - "toolCallId": "pyd_ai_tool_call_id__read" - }, - "type": "tool_call_update" - }, - { - "payload": { - "content": [ - { - "content": { - "text": "```/test/hello.txt\nHello, World!\n```", - "type": "text" - }, - "type": "content" - } - ], - "locations": [ - { - "line": 0, - "path": "/test/hello.txt" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Read: /test/hello.txt", - "toolCallId": "pyd_ai_tool_call_id__read" - }, - "type": "tool_call_update" - }, - { - "payload": { - "rawOutput": "Hello, World!", - "sessionUpdate": "tool_call_update", - "status": "completed", - "toolCallId": "pyd_ai_tool_call_id__read" - }, - "type": "tool_call_update" - } -] +[] diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestReadFileSnapshots.test_read_file_with_line_range.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestReadFileSnapshots.test_read_file_with_line_range.json index 7e48ffbac..fe51488c7 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestReadFileSnapshots.test_read_file_with_line_range.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestReadFileSnapshots.test_read_file_with_line_range.json @@ -1,80 +1 @@ -[ - { - "payload": { - "kind": "read", - "rawInput": { - "limit": 2, - "line": 3, - "path": "/test/lines.txt" - }, - "sessionUpdate": "tool_call", - "status": "pending", - "title": "Reading: /test/lines.txt", - "toolCallId": "pyd_ai_tool_call_id__read" - }, - "type": "tool_call" - }, - { - "payload": { - "locations": [ - { - "line": 3, - "path": "/test/lines.txt" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Reading file: /test/lines.txt", - "toolCallId": "pyd_ai_tool_call_id__read" - }, - "type": "tool_call_update" - }, - { - "payload": { - "locations": [ - { - "line": 3, - "path": "/test/lines.txt" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Read: /test/lines.txt", - "toolCallId": "pyd_ai_tool_call_id__read" - }, - "type": "tool_call_update" - }, - { - "payload": { - "content": [ - { - "content": { - "text": "```/test/lines.txt#L3\nLine 3\nLine 4\n```", - "type": "text" - }, - "type": "content" - } - ], - "locations": [ - { - "line": 3, - "path": "/test/lines.txt" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Read: /test/lines.txt", - "toolCallId": "pyd_ai_tool_call_id__read" - }, - "type": "tool_call_update" - }, - { - "payload": { - "rawOutput": "Line 3\nLine 4", - "sessionUpdate": "tool_call_update", - "status": "completed", - "toolCallId": "pyd_ai_tool_call_id__read" - }, - "type": "tool_call_update" - } -] +[] diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestWriteFileSnapshots.test_write_file_new.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestWriteFileSnapshots.test_write_file_new.json index 5e9cd28a9..fe51488c7 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestWriteFileSnapshots.test_write_file_new.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestWriteFileSnapshots.test_write_file_new.json @@ -1,63 +1 @@ -[ - { - "payload": { - "kind": "edit", - "rawInput": { - "content": "New content here", - "path": "/test/new_file.txt" - }, - "sessionUpdate": "tool_call", - "status": "pending", - "title": "Editing: /test/new_file.txt", - "toolCallId": "pyd_ai_tool_call_id__write" - }, - "type": "tool_call" - }, - { - "payload": { - "kind": "edit", - "locations": [ - { - "line": 0, - "path": "/test/new_file.txt" - } - ], - "sessionUpdate": "tool_call_update", - "title": "Writing file: /test/new_file.txt", - "toolCallId": "pyd_ai_tool_call_id__write" - }, - "type": "tool_call_update" - }, - { - "payload": { - "content": [ - { - "newText": "New content here", - "oldText": "", - "path": "/test/new_file.txt", - "type": "diff" - } - ], - "locations": [ - { - "line": 0, - "path": "/test/new_file.txt" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Wrote: /test/new_file.txt", - "toolCallId": "pyd_ai_tool_call_id__write" - }, - "type": "tool_call_update" - }, - { - "payload": { - "rawOutput": "Wrote /test/new_file.txt (16 bytes)", - "sessionUpdate": "tool_call_update", - "status": "completed", - "toolCallId": "pyd_ai_tool_call_id__write" - }, - "type": "tool_call_update" - } -] +[] diff --git a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestWriteFileSnapshots.test_write_file_overwrite.json b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestWriteFileSnapshots.test_write_file_overwrite.json index 88aa07218..fe51488c7 100644 --- a/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestWriteFileSnapshots.test_write_file_overwrite.json +++ b/tests/servers/acp_server/__snapshots__/test_tool_call_snapshots/TestWriteFileSnapshots.test_write_file_overwrite.json @@ -1,64 +1 @@ -[ - { - "payload": { - "kind": "edit", - "rawInput": { - "content": "Updated content", - "overwrite": true, - "path": "/test/existing.txt" - }, - "sessionUpdate": "tool_call", - "status": "pending", - "title": "Editing: /test/existing.txt", - "toolCallId": "pyd_ai_tool_call_id__write" - }, - "type": "tool_call" - }, - { - "payload": { - "kind": "edit", - "locations": [ - { - "line": 0, - "path": "/test/existing.txt" - } - ], - "sessionUpdate": "tool_call_update", - "title": "Writing file: /test/existing.txt", - "toolCallId": "pyd_ai_tool_call_id__write" - }, - "type": "tool_call_update" - }, - { - "payload": { - "content": [ - { - "newText": "Updated content", - "oldText": "", - "path": "/test/existing.txt", - "type": "diff" - } - ], - "locations": [ - { - "line": 0, - "path": "/test/existing.txt" - } - ], - "sessionUpdate": "tool_call_update", - "status": "in_progress", - "title": "Wrote: /test/existing.txt", - "toolCallId": "pyd_ai_tool_call_id__write" - }, - "type": "tool_call_update" - }, - { - "payload": { - "rawOutput": "Wrote /test/existing.txt (15 bytes)", - "sessionUpdate": "tool_call_update", - "status": "completed", - "toolCallId": "pyd_ai_tool_call_id__write" - }, - "type": "tool_call_update" - } -] +[] diff --git a/tests/servers/acp_server/test_acp_per_session_agent_red_flags.py b/tests/servers/acp_server/test_acp_per_session_agent_red_flags.py deleted file mode 100644 index 701ddd9a6..000000000 --- a/tests/servers/acp_server/test_acp_per_session_agent_red_flags.py +++ /dev/null @@ -1,489 +0,0 @@ -"""Red flag tests for RFC-0031: ACP per-session agent isolation. - -These tests guard against regression of the config-relative path resolution -bug discovered during RFC-0031 implementation. The bug: _config_dir_global -is reset to None by ConfigContextManager.__exit__ after pool loading, causing -tool schema paths (and other config-relative paths) to fail during per-session -agent creation. - -Run with: pytest tests/servers/acp_server/test_acp_per_session_agent_red_flags.py -v -""" - -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import Any -from unittest.mock import MagicMock, patch - -import pytest -from upathtools import UPath - -from agentpool.delegation import AgentPool -from agentpool_server.acp_server.acp_agent import AgentPoolACPAgent - - -class FakeManifest: - """Fake manifest for testing.""" - - def __init__(self, config_file_path: str | None = None) -> None: - self.config_file_path = config_file_path - self.agents: dict[str, Any] = {} - - -class FakePool: - """Fake AgentPool for testing.""" - - def __init__(self, config_file_path: str | None = None) -> None: - self.main_agent = FakeAgent("test_agent") - self.manifest = FakeManifest(config_file_path) - self.storage = MagicMock() - self.storage.metadata_generated = MagicMock() - self.storage.metadata_generated.connect = MagicMock() - - -class FakeAgent: - """Fake BaseAgent for testing.""" - - def __init__(self, name: str) -> None: - self.name = name - self.agent_pool: AgentPool[Any] | None = None - - -class TestConfigPathResolutionRedFlags: - """Red flag tests: config path resolution during per-session agent creation.""" - - def test_resolve_agent_config_path_returns_manifest_dir( - self, - tmp_path: Path, - ) -> None: - """RED FLAG: _resolve_agent_config_path must return the manifest config directory. - - Regression: If _resolve_agent_config_path returns None, tool schemas - with relative paths will fail during agent.__aenter__(). - """ - from agentpool.models.agents import NativeAgentConfig - - config_file = tmp_path / "config.yml" - config_file.write_text("agents:\n test_agent:\n type: native\n model: test\n") - - agent_config = NativeAgentConfig( - name="test_agent", - model="test", - config_file_path=str(config_file), - ) - - pool = FakePool(config_file_path=str(config_file)) - pool.manifest.agents["test_agent"] = agent_config - - default_agent = FakeAgent("test_agent") - default_agent.agent_pool = pool # type: ignore[assignment] - - acp_agent = AgentPoolACPAgent( - default_agent=default_agent, # type: ignore[arg-type] - client=MagicMock(), - ) - - result = acp_agent._resolve_agent_config_path() - assert result is not None, ( - "_resolve_agent_config_path returned None - tool schemas will fail! " - "This is a regression of the RFC-0031 config path bug." - ) - assert Path(result) == tmp_path, ( - f"_resolve_agent_config_path returned wrong directory: {result}, expected {tmp_path}" - ) - - def test_resolve_agent_config_path_falls_back_to_manifest_level( - self, - tmp_path: Path, - ) -> None: - """RED FLAG: When agent config has no config_file_path, must fall back to manifest level. - - Regression: AgentsManifest.from_file propagates config_file_path to agents, - but serve_acp.py may not. If fallback fails, _config_dir_global stays None. - """ - from agentpool.models.agents import NativeAgentConfig - - config_file = tmp_path / "config.yml" - config_file.write_text("agents:\n test_agent:\n type: native\n model: test\n") - - # Agent config has NO config_file_path (simulating serve_acp.py behavior) - agent_config = NativeAgentConfig( - name="test_agent", - model="test", - # config_file_path is None - must fall back to manifest level - ) - - pool = FakePool(config_file_path=str(config_file)) - pool.manifest.agents["test_agent"] = agent_config - - default_agent = FakeAgent("test_agent") - default_agent.agent_pool = pool # type: ignore[assignment] - - acp_agent = AgentPoolACPAgent( - default_agent=default_agent, # type: ignore[arg-type] - client=MagicMock(), - ) - - result = acp_agent._resolve_agent_config_path() - assert result is not None, ( - "_resolve_agent_config_path returned None when agent config_file_path is None - " - "manifest-level fallback failed! This is a regression of the RFC-0031 config path bug." - ) - assert Path(result) == tmp_path, ( - f"Fallback returned wrong directory: {result}, expected {tmp_path}" - ) - - @pytest.mark.asyncio - async def test_config_dir_contextvar_set_during_agent_creation( - self, - tmp_path: Path, - ) -> None: - """RED FLAG: CONFIG_DIR ContextVar must be set during get_or_create_session_agent. - - Regression: Some providers (e.g., xeno-agent) use CONFIG_DIR.get() directly - instead of get_config_dir(). If CONFIG_DIR is None, tool schema paths fail. - """ - import agentpool_config.context as ctx - from agentpool.models.agents import NativeAgentConfig - - config_file = tmp_path / "config.yml" - config_file.write_text("agents:\n test_agent:\n type: native\n model: test\n") - - agent_config = NativeAgentConfig( - name="test_agent", - model="test", - config_file_path=str(config_file), - ) - - pool = FakePool(config_file_path=str(config_file)) - pool.manifest.agents["test_agent"] = agent_config - - default_agent = FakeAgent("test_agent") - default_agent.agent_pool = pool # type: ignore[assignment] - - # Simulate post-pool-loading state: CONFIG_DIR is None - original_config_dir = ctx.CONFIG_DIR.get() - ctx._config_dir_global = None - ctx.CONFIG_DIR.set(None) - - try: - acp_agent = AgentPoolACPAgent( - default_agent=default_agent, # type: ignore[arg-type] - client=MagicMock(), - ) - - # Track CONFIG_DIR during creation - captured_values: list[str | None] = [] - - def tracking_get_agent(*args: Any, **kwargs: Any) -> Any: - config_dir = ctx.CONFIG_DIR.get() - captured_values.append(str(config_dir) if config_dir is not None else None) - mock_agent = MagicMock() - mock_agent.session_id = None - mock_agent.__aenter__ = MagicMock(return_value=asyncio.Future()) - mock_agent.__aenter__.return_value.set_result(mock_agent) - mock_agent.__aexit__ = MagicMock(return_value=asyncio.Future()) - mock_agent.__aexit__.return_value.set_result(None) - return mock_agent - - with patch.object(NativeAgentConfig, "get_agent", tracking_get_agent): - await acp_agent.get_or_create_session_agent("test-session") - - assert len(captured_values) > 0, "get_agent was not called" - assert captured_values[0] is not None, ( - "CONFIG_DIR ContextVar was None during get_agent() call - " - "providers using CONFIG_DIR.get() directly will fail! " - "This is a regression of the RFC-0031 config path bug." - ) - assert captured_values[0] == str(tmp_path), ( - f"CONFIG_DIR was wrong during get_agent(): {captured_values[0]}, " - f"expected {tmp_path}" - ) - - finally: - ctx._config_dir_global = None - if original_config_dir is not None: - ctx.CONFIG_DIR.set(original_config_dir) - else: - ctx.CONFIG_DIR.set(None) - - @pytest.mark.asyncio - async def test_config_dir_global_set_during_agent_creation( - self, - tmp_path: Path, - ) -> None: - """RED FLAG: _config_dir_global must be set during get_or_create_session_agent. - - Regression: If _config_dir_global is None during agent.__aenter__(), - tool providers that load schema files from relative paths will fail. - """ - import agentpool_config.context as ctx - from agentpool.models.agents import NativeAgentConfig - - config_file = tmp_path / "config.yml" - config_file.write_text("agents:\n test_agent:\n type: native\n model: test\n") - - agent_config = NativeAgentConfig( - name="test_agent", - model="test", - config_file_path=str(config_file), - ) - - pool = FakePool(config_file_path=str(config_file)) - pool.manifest.agents["test_agent"] = agent_config - - default_agent = FakeAgent("test_agent") - default_agent.agent_pool = pool # type: ignore[assignment] - - # Simulate post-pool-loading state: _config_dir_global is None - original_dir = ctx._config_dir_global - ctx._config_dir_global = None - - try: - acp_agent = AgentPoolACPAgent( - default_agent=default_agent, # type: ignore[arg-type] - client=MagicMock(), - ) - - # Track _config_dir_global during creation - captured_values: list[str | None] = [] - - # Create a mock get_agent that tracks _config_dir_global - original_get_agent = agent_config.get_agent - def tracking_get_agent(*args: Any, **kwargs: Any) -> Any: - captured_values.append(str(ctx._config_dir_global) if ctx._config_dir_global is not None else None) - # Return a mock agent that supports async context manager - mock_agent = MagicMock() - mock_agent.session_id = None - mock_agent.__aenter__ = MagicMock(return_value=asyncio.Future()) - mock_agent.__aenter__.return_value.set_result(mock_agent) - mock_agent.__aexit__ = MagicMock(return_value=asyncio.Future()) - mock_agent.__aexit__.return_value.set_result(None) - return mock_agent - - # Patch the class method, not instance method - with patch.object(NativeAgentConfig, "get_agent", tracking_get_agent): - await acp_agent.get_or_create_session_agent("test-session") - - # Verify _config_dir_global was set during get_agent() - assert len(captured_values) > 0, "get_agent was not called" - assert captured_values[0] is not None, ( - "_config_dir_global was None during get_agent() call - " - "tool schema paths will fail! This is a regression of the RFC-0031 config path bug." - ) - assert captured_values[0] == str(tmp_path), ( - f"_config_dir_global was wrong during get_agent(): {captured_values[0]}, " - f"expected {tmp_path}" - ) - - finally: - ctx._config_dir_global = original_dir - - @pytest.mark.asyncio - async def test_config_dir_global_restored_after_agent_creation( - self, - tmp_path: Path, - ) -> None: - """RED FLAG: _config_dir_global must be restored after get_or_create_session_agent. - - Regression: If _config_dir_global leaks after agent creation, subsequent - operations (e.g., loading another agent) may use wrong base directory. - """ - import agentpool_config.context as ctx - from agentpool.models.agents import NativeAgentConfig - - config_file = tmp_path / "config.yml" - config_file.write_text("agents:\n test_agent:\n type: native\n model: test\n") - - agent_config = NativeAgentConfig( - name="test_agent", - model="test", - config_file_path=str(config_file), - ) - - pool = FakePool(config_file_path=str(config_file)) - pool.manifest.agents["test_agent"] = agent_config - - default_agent = FakeAgent("test_agent") - default_agent.agent_pool = pool # type: ignore[assignment] - - # Set an original value to verify restoration - original_dir = ctx._config_dir_global - ctx._config_dir_global = UPath("/some/other/dir") - - try: - acp_agent = AgentPoolACPAgent( - default_agent=default_agent, # type: ignore[arg-type] - client=MagicMock(), - ) - - # Mock get_agent to return an async-context-manager-compatible agent - mock_agent = MagicMock() - mock_agent.session_id = None - mock_agent.__aenter__ = MagicMock(return_value=asyncio.Future()) - mock_agent.__aenter__.return_value.set_result(mock_agent) - mock_agent.__aexit__ = MagicMock(return_value=asyncio.Future()) - mock_agent.__aexit__.return_value.set_result(None) - - with patch.object(NativeAgentConfig, "get_agent", return_value=mock_agent): - await acp_agent.get_or_create_session_agent("test-session") - - # Verify _config_dir_global was restored - assert ctx._config_dir_global == UPath("/some/other/dir"), ( - f"_config_dir_global was not restored after agent creation: " - f"{ctx._config_dir_global}, expected UPath('/some/other/dir'). " - f"This is a regression - the context leaks between sessions." - ) - - finally: - ctx._config_dir_global = original_dir - - @pytest.mark.asyncio - async def test_config_dir_global_set_during_aenter( - self, - tmp_path: Path, - ) -> None: - """RED FLAG: _config_dir_global must be set during agent.__aenter__(). - - Regression: Tool providers initialize during __aenter__() and need - _config_dir_global to resolve relative schema paths. If it's None, - FileNotFoundError will be raised. - """ - import agentpool_config.context as ctx - from agentpool.models.agents import NativeAgentConfig - - config_file = tmp_path / "config.yml" - config_file.write_text("agents:\n test_agent:\n type: native\n model: test\n") - - agent_config = NativeAgentConfig( - name="test_agent", - model="test", - config_file_path=str(config_file), - ) - - pool = FakePool(config_file_path=str(config_file)) - pool.manifest.agents["test_agent"] = agent_config - - default_agent = FakeAgent("test_agent") - default_agent.agent_pool = pool # type: ignore[assignment] - - original_dir = ctx._config_dir_global - ctx._config_dir_global = None - - try: - acp_agent = AgentPoolACPAgent( - default_agent=default_agent, # type: ignore[arg-type] - client=MagicMock(), - ) - - # Track _config_dir_global during __aenter__ - aenter_values: list[str | None] = [] - - mock_agent = MagicMock() - mock_agent.session_id = None - - async def tracking_aenter(*args: Any, **kwargs: Any) -> Any: - aenter_values.append(str(ctx._config_dir_global) if ctx._config_dir_global is not None else None) - return mock_agent - - mock_agent.__aenter__ = tracking_aenter - mock_agent.__aexit__ = MagicMock(return_value=asyncio.Future()) - mock_agent.__aexit__.return_value.set_result(None) - - with patch.object(NativeAgentConfig, "get_agent", return_value=mock_agent): - await acp_agent.get_or_create_session_agent("test-session") - - # Verify _config_dir_global was set during __aenter__() - assert len(aenter_values) > 0, "__aenter__ was not called" - assert aenter_values[0] is not None, ( - "_config_dir_global was None during agent.__aenter__() - " - "tool providers will fail to resolve schema paths! " - "This is a regression of the RFC-0031 config path bug." - ) - assert aenter_values[0] == str(tmp_path), ( - f"_config_dir_global was wrong during __aenter__(): {aenter_values[0]}, " - f"expected {tmp_path}" - ) - - finally: - ctx._config_dir_global = original_dir - - def test_resolve_agent_config_path_with_agent_name( - self, - tmp_path: Path, - ) -> None: - """RED FLAG: _resolve_agent_config_path must resolve correct config for agent_name. - - Regression: switch_active_agent() passes agent_name but if wrong config - is resolved, the switched agent will use wrong base directory for paths. - """ - from agentpool.models.agents import NativeAgentConfig - - config_dir_a = tmp_path / "agent_a" - config_dir_a.mkdir() - config_file_a = config_dir_a / "config.yml" - config_file_a.write_text("agents:\n agent_a:\n type: native\n model: test\n") - - config_dir_b = tmp_path / "agent_b" - config_dir_b.mkdir() - config_file_b = config_dir_b / "config.yml" - config_file_b.write_text("agents:\n agent_b:\n type: native\n model: test\n") - - agent_config_a = NativeAgentConfig( - name="agent_a", - model="test", - config_file_path=str(config_file_a), - ) - agent_config_b = NativeAgentConfig( - name="agent_b", - model="test", - config_file_path=str(config_file_b), - ) - - pool = FakePool(config_file_path=str(tmp_path / "config.yml")) - pool.manifest.agents["agent_a"] = agent_config_a - pool.manifest.agents["agent_b"] = agent_config_b - - default_agent = FakeAgent("test_agent") - default_agent.agent_pool = pool # type: ignore[assignment] - - acp_agent = AgentPoolACPAgent( - default_agent=default_agent, # type: ignore[arg-type] - client=MagicMock(), - ) - - result_a = acp_agent._resolve_agent_config_path("agent_a") - assert result_a is not None - assert Path(result_a) == config_dir_a, ( - f"Wrong config path for agent_a: {result_a}, expected {config_dir_a}" - ) - - result_b = acp_agent._resolve_agent_config_path("agent_b") - assert result_b is not None - assert Path(result_b) == config_dir_b, ( - f"Wrong config path for agent_b: {result_b}, expected {config_dir_b}" - ) - - def test_resolve_agent_config_path_returns_none_for_nonexistent_agent( - self, - tmp_path: Path, - ) -> None: - """RED FLAG: _resolve_agent_config_path must return None for unknown agents. - - This ensures graceful fallback instead of crashing. - """ - pool = FakePool(config_file_path=str(tmp_path / "config.yml")) - default_agent = FakeAgent("test_agent") - default_agent.agent_pool = pool # type: ignore[assignment] - - acp_agent = AgentPoolACPAgent( - default_agent=default_agent, # type: ignore[arg-type] - client=MagicMock(), - ) - - result = acp_agent._resolve_agent_config_path("nonexistent_agent") - assert result is None, ( - f"_resolve_agent_config_path should return None for unknown agent, " - f"got {result}" - ) diff --git a/tests/servers/acp_server/test_acp_protocol_handler_input_provider.py b/tests/servers/acp_server/test_acp_protocol_handler_input_provider.py index 800c41b97..390b62ec7 100644 --- a/tests/servers/acp_server/test_acp_protocol_handler_input_provider.py +++ b/tests/servers/acp_server/test_acp_protocol_handler_input_provider.py @@ -52,7 +52,6 @@ async def _mock_queue_get(): def mock_event_converter() -> MagicMock: """Return a mocked ACPEventConverter.""" converter = MagicMock() - converter.subagent_display_mode = "tool_box" return converter @@ -62,23 +61,15 @@ def mock_client() -> MagicMock: return MagicMock() -@pytest.fixture -def mock_session_manager() -> MagicMock: - """Return a mocked ACPSessionManager.""" - return MagicMock() - - @pytest.fixture def handler( mock_pool: MagicMock, - mock_session_manager: MagicMock, mock_event_converter: MagicMock, mock_client: MagicMock, ) -> ACPProtocolHandler: """Return an ACPProtocolHandler backed by mocked dependencies.""" return ACPProtocolHandler( agent_pool=mock_pool, - session_manager=mock_session_manager, event_converter=mock_event_converter, client=mock_client, client_capabilities=None, @@ -88,7 +79,6 @@ def handler( @pytest.fixture def handler_with_elicitation( mock_pool: MagicMock, - mock_session_manager: MagicMock, mock_event_converter: MagicMock, mock_client: MagicMock, ) -> ACPProtocolHandler: @@ -97,7 +87,6 @@ def handler_with_elicitation( return ACPProtocolHandler( agent_pool=mock_pool, - session_manager=mock_session_manager, event_converter=mock_event_converter, client=mock_client, client_capabilities=ClientCapabilities( @@ -197,7 +186,6 @@ async def test_handle_prompt_skips_when_canary_disabled( mock_pool.main_agent.metadata = {"use_session_pool": False} handler = ACPProtocolHandler( agent_pool=mock_pool, - session_manager=MagicMock(), event_converter=mock_event_converter, client=mock_client, ) @@ -219,7 +207,6 @@ async def test_handle_prompt_skips_when_session_pool_missing( mock_pool.session_pool = None handler = ACPProtocolHandler( agent_pool=mock_pool, - session_manager=MagicMock(), event_converter=mock_event_converter, client=mock_client, ) @@ -274,15 +261,12 @@ async def test_event_consumer_passes_turn_complete_true( handler = ACPProtocolHandler( agent_pool=mock_pool, - session_manager=MagicMock(), event_converter=mock_event_converter, client=mock_client, client_capabilities=ClientCapabilities(turn_complete=True), ) - with patch.object( - ACPEventConverter, "__init__", return_value=None - ) as mock_init: + with patch.object(ACPEventConverter, "__init__", return_value=None) as mock_init: await handler._event_consumer_loop("sess-1") mock_init.assert_called_once() @@ -333,7 +317,6 @@ async def test_modern_client_returns_immediately( handler = ACPProtocolHandler( agent_pool=mock_pool, - session_manager=MagicMock(), event_converter=mock_event_converter, client=mock_client, client_capabilities=ClientCapabilities(turn_complete=True), @@ -440,15 +423,12 @@ async def test_event_consumer_defaults_turn_complete_when_no_capabilities( handler = ACPProtocolHandler( agent_pool=mock_pool, - session_manager=MagicMock(), event_converter=mock_event_converter, client=mock_client, client_capabilities=None, ) - with patch.object( - ACPEventConverter, "__init__", return_value=None - ) as mock_init: + with patch.object(ACPEventConverter, "__init__", return_value=None) as mock_init: await handler._event_consumer_loop("sess-1") mock_init.assert_called_once() diff --git a/tests/servers/acp_server/test_acp_session_load.py b/tests/servers/acp_server/test_acp_session_load.py index 5c2f55de1..96b826e9a 100644 --- a/tests/servers/acp_server/test_acp_session_load.py +++ b/tests/servers/acp_server/test_acp_session_load.py @@ -82,7 +82,9 @@ def load_session_request(): @pytest.mark.unit -async def test_load_session_calls_agent_load_session(mock_acp_agent, mock_session, load_session_request): +async def test_load_session_calls_agent_load_session( + mock_acp_agent, mock_session, load_session_request +): """Test that session.agent.load_session() is called with the session ID.""" mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) mock_acp_agent._initialized = True @@ -93,7 +95,9 @@ async def test_load_session_calls_agent_load_session(mock_acp_agent, mock_sessio @pytest.mark.unit -async def test_load_session_calls_replay_with_messages(mock_acp_agent, mock_session, load_session_request): +async def test_load_session_calls_replay_with_messages( + mock_acp_agent, mock_session, load_session_request +): """Test that session.notifications.replay() is called with correct messages.""" chat_msg = ChatMessage[str]( content="Hello", @@ -113,7 +117,9 @@ async def test_load_session_calls_replay_with_messages(mock_acp_agent, mock_sess @pytest.mark.unit -async def test_load_session_schedules_commands_update(mock_acp_agent, mock_session, load_session_request): +async def test_load_session_schedules_commands_update( + mock_acp_agent, mock_session, load_session_request +): """Test that send_available_commands_update() is scheduled after load.""" mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) mock_acp_agent._initialized = True @@ -155,7 +161,9 @@ async def test_load_session_agent_load_fails(mock_acp_agent, mock_session, load_ @pytest.mark.unit -async def test_load_session_response_contains_config_options(mock_acp_agent, mock_session, load_session_request): +async def test_load_session_response_contains_config_options( + mock_acp_agent, mock_session, load_session_request +): """Test that LoadSessionResponse contains correct config_options.""" class _MockMode: @@ -184,7 +192,9 @@ def __init__(self) -> None: @pytest.mark.unit -async def test_load_session_response_contains_config_options(mock_acp_agent, mock_session, load_session_request): +async def test_load_session_response_contains_config_options( + mock_acp_agent, mock_session, load_session_request +): """Test that LoadSessionResponse contains correct config_options.""" mock_session.agent.get_modes = AsyncMock(return_value=[]) mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) @@ -197,7 +207,9 @@ async def test_load_session_response_contains_config_options(mock_acp_agent, moc @pytest.mark.unit -async def test_load_session_creates_session_if_not_found(mock_acp_agent, mock_session, load_session_request): +async def test_load_session_creates_session_if_not_found( + mock_acp_agent, mock_session, load_session_request +): """Test that load_session creates a new session wrapper if session not found.""" mock_acp_agent.session_manager.get_session = MagicMock(side_effect=[None, mock_session]) mock_acp_agent.session_manager.create_session = AsyncMock(return_value="test-session-id") @@ -209,7 +221,9 @@ async def test_load_session_creates_session_if_not_found(mock_acp_agent, mock_se @pytest.mark.unit -async def test_load_session_exception_returns_empty_response(mock_acp_agent, mock_session, load_session_request): +async def test_load_session_exception_returns_empty_response( + mock_acp_agent, mock_session, load_session_request +): """Test that load_session returns empty LoadSessionResponse on exception.""" mock_session.agent.load_session = AsyncMock(side_effect=RuntimeError("boom")) mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) @@ -223,7 +237,9 @@ async def test_load_session_exception_returns_empty_response(mock_acp_agent, moc @pytest.mark.unit -async def test_load_session_with_nested_acp_agent(mock_acp_agent, mock_session, load_session_request): +async def test_load_session_with_nested_acp_agent( + mock_acp_agent, mock_session, load_session_request +): """Test load_session with nested ACP agent populates models/modes from agent state.""" from agentpool.agents.acp_agent import ACPAgent @@ -233,9 +249,7 @@ async def test_load_session_with_nested_acp_agent(mock_acp_agent, mock_session, available_modes=[SessionMode(id="chat", name="Chat", description="Chat mode")], current_mode_id="chat", ) - nested_agent._state.models = SessionModelState( - available_models=[], current_model_id="gpt-4" - ) + nested_agent._state.models = SessionModelState(available_models=[], current_model_id="gpt-4") nested_agent.load_session = AsyncMock(return_value=True) nested_agent.conversation = MagicMock() nested_agent.conversation.chat_messages = [] diff --git a/tests/servers/acp_server/test_acp_session_manager_child_session.py b/tests/servers/acp_server/test_acp_session_manager_child_session.py index c50212b29..d329ed59d 100644 --- a/tests/servers/acp_server/test_acp_session_manager_child_session.py +++ b/tests/servers/acp_server/test_acp_session_manager_child_session.py @@ -180,7 +180,9 @@ async def test_child_session_uses_effective_cwd_for_acp_session(): # Verify ACPSession was constructed with the inherited cwd call_kwargs = MockSession.call_args - assert call_kwargs.kwargs.get("cwd") == parent_cwd or call_kwargs[1].get("cwd") == parent_cwd + assert ( + call_kwargs.kwargs.get("cwd") == parent_cwd or call_kwargs[1].get("cwd") == parent_cwd + ) async def test_no_parent_session_id_preserves_existing_behavior(): diff --git a/tests/servers/acp_server/test_acp_session_process_prompt_turn_complete.py b/tests/servers/acp_server/test_acp_session_process_prompt_turn_complete.py index 7b1a66e7e..15e66aecf 100644 --- a/tests/servers/acp_server/test_acp_session_process_prompt_turn_complete.py +++ b/tests/servers/acp_server/test_acp_session_process_prompt_turn_complete.py @@ -57,7 +57,7 @@ async def test_process_prompt_passes_turn_complete_true( mock_acp_agent: MagicMock, ) -> None: """When client_capabilities.turn_complete=True, ACPEventConverter must be -created with client_supports_turn_complete=True.""" + created with client_supports_turn_complete=True.""" agent = agent_pool.get_agent("test_agent") mock_client = AsyncMock() @@ -101,7 +101,7 @@ async def test_process_prompt_passes_turn_complete_false( mock_acp_agent: MagicMock, ) -> None: """When client_capabilities.turn_complete=False, ACPEventConverter must be -created with client_supports_turn_complete=False.""" + created with client_supports_turn_complete=False.""" agent = agent_pool.get_agent("test_agent") mock_client = AsyncMock() @@ -143,7 +143,7 @@ async def test_process_prompt_defaults_turn_complete_when_none( mock_acp_agent: MagicMock, ) -> None: """When client_capabilities.turn_complete=None, ACPEventConverter must be -created with client_supports_turn_complete=False (default).""" + created with client_supports_turn_complete=False (default).""" agent = agent_pool.get_agent("test_agent") mock_client = AsyncMock() diff --git a/tests/servers/acp_server/test_acp_session_resume.py b/tests/servers/acp_server/test_acp_session_resume.py index 6b405d607..9a077a510 100644 --- a/tests/servers/acp_server/test_acp_session_resume.py +++ b/tests/servers/acp_server/test_acp_session_resume.py @@ -71,7 +71,9 @@ def resume_session_request(): @pytest.mark.unit -async def test_resume_session_calls_agent_load_session(mock_acp_agent, mock_session, resume_session_request): +async def test_resume_session_calls_agent_load_session( + mock_acp_agent, mock_session, resume_session_request +): """Test that agent.load_session() is called during resume_session.""" mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) mock_acp_agent._initialized = True @@ -82,7 +84,9 @@ async def test_resume_session_calls_agent_load_session(mock_acp_agent, mock_sess @pytest.mark.unit -async def test_resume_session_does_not_call_replay(mock_acp_agent, mock_session, resume_session_request): +async def test_resume_session_does_not_call_replay( + mock_acp_agent, mock_session, resume_session_request +): """Test that notifications.replay() is NOT called during resume_session.""" mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) mock_acp_agent._initialized = True @@ -93,7 +97,9 @@ async def test_resume_session_does_not_call_replay(mock_acp_agent, mock_session, @pytest.mark.unit -async def test_resume_session_schedules_commands_update(mock_acp_agent, mock_session, resume_session_request): +async def test_resume_session_schedules_commands_update( + mock_acp_agent, mock_session, resume_session_request +): """Test that send_available_commands_update() is scheduled after resume.""" mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) mock_acp_agent._initialized = True @@ -106,7 +112,9 @@ async def test_resume_session_schedules_commands_update(mock_acp_agent, mock_ses @pytest.mark.unit -async def test_resume_session_agent_load_fails(mock_acp_agent, mock_session, resume_session_request): +async def test_resume_session_agent_load_fails( + mock_acp_agent, mock_session, resume_session_request +): """Test resume_session handles agent.load_session() failure gracefully.""" mock_session.agent.load_session = AsyncMock(return_value=False) mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) @@ -120,7 +128,9 @@ async def test_resume_session_agent_load_fails(mock_acp_agent, mock_session, res @pytest.mark.unit -async def test_resume_session_creates_session_if_not_found(mock_acp_agent, mock_session, resume_session_request): +async def test_resume_session_creates_session_if_not_found( + mock_acp_agent, mock_session, resume_session_request +): """Test that resume_session creates a new session wrapper if session not found.""" mock_acp_agent.session_manager.get_session = MagicMock(side_effect=[None, mock_session]) mock_acp_agent.session_manager.create_session = AsyncMock(return_value="test-session-id") @@ -132,7 +142,9 @@ async def test_resume_session_creates_session_if_not_found(mock_acp_agent, mock_ @pytest.mark.unit -async def test_resume_session_exception_returns_empty_response(mock_acp_agent, mock_session, resume_session_request): +async def test_resume_session_exception_returns_empty_response( + mock_acp_agent, mock_session, resume_session_request +): """Test that resume_session returns empty ResumeSessionResponse on exception.""" mock_session.agent.load_session = AsyncMock(side_effect=RuntimeError("boom")) mock_acp_agent.session_manager.get_session = MagicMock(return_value=mock_session) diff --git a/tests/servers/acp_server/test_acp_skills_red_flags.py b/tests/servers/acp_server/test_acp_skills_red_flags.py index 5fbc67dfc..577838997 100644 --- a/tests/servers/acp_server/test_acp_skills_red_flags.py +++ b/tests/servers/acp_server/test_acp_skills_red_flags.py @@ -191,6 +191,7 @@ def test_manifest_include_default_controls_acp_skill_loading(self) -> None: # Without explicit override, load_skills should follow manifest from agentpool_server.acp_server.server import ACPServer + server = ACPServer.from_config(manifest) assert server.load_skills is False, ( "ACP server should not load skills when manifest has include_default=False " @@ -262,13 +263,18 @@ async def test_local_resource_provider_respects_include_default_false(self, tmp_ try: # Debug: print paths from agentpool.resource_providers.local import LocalResourceProvider - print(f"DEBUG: skills_manager.registry.skills_dirs = {skills_manager.registry.skills_dirs}") + + print( + f"DEBUG: skills_manager.registry.skills_dirs = {skills_manager.registry.skills_dirs}" + ) provider = skills_manager.resource_provider assert isinstance(provider, LocalResourceProvider) print(f"DEBUG: provider.skills_dirs = {provider.skills_dirs}") print(f"DEBUG: provider._registry.skills_dirs = {provider._registry.skills_dirs}") - print(f"DEBUG: skills_manager.registry.list_items() = {skills_manager.registry.list_items()}") + print( + f"DEBUG: skills_manager.registry.list_items() = {skills_manager.registry.list_items()}" + ) skills = await provider.get_skills() skill_names = {s.name for s in skills} diff --git a/tests/servers/acp_server/test_converters.py b/tests/servers/acp_server/test_converters.py new file mode 100644 index 000000000..89422d181 --- /dev/null +++ b/tests/servers/acp_server/test_converters.py @@ -0,0 +1,64 @@ +"""Tests for ACP server converters.""" + +from __future__ import annotations + +import pytest + +from agentpool.sessions import SessionData +from agentpool_server.acp_server.converters import to_session_info + + +async def test_to_session_info_populates_hierarchy_fields() -> None: + """Test to_session_info includes hierarchy fields in meta.""" + session_data = SessionData( + session_id="ses_abc123", + agent_name="test_agent", + cwd="/tmp/test", + metadata={ + "title": "Test Session", + "parent_tool_call_id": "tc_xyz789", + "subagent_id": "sub_agent_1", + }, + ) + + info = to_session_info(session_data) + + assert info.session_id == "ses_abc123" + assert info.cwd == "/tmp/test" + assert info.title == "Test Session" + assert info.meta is not None + assert info.meta.get("parent_tool_call_id") == "tc_xyz789" + assert info.meta.get("subagent_id") == "sub_agent_1" + + +async def test_to_session_info_without_hierarchy_fields() -> None: + """Test to_session_info works when hierarchy fields are absent.""" + session_data = SessionData( + session_id="ses_def456", + agent_name="test_agent", + cwd="/tmp/other", + ) + + info = to_session_info(session_data) + + assert info.session_id == "ses_def456" + assert info.meta is None or info.meta.get("parent_tool_call_id") is None + assert info.meta is None or info.meta.get("subagent_id") is None + + +async def test_to_session_info_preserves_other_metadata() -> None: + """Test to_session_info preserves non-hierarchy metadata in meta.""" + session_data = SessionData( + session_id="ses_ghi789", + agent_name="test_agent", + metadata={ + "custom_key": "custom_value", + "parent_tool_call_id": "tc_123", + }, + ) + + info = to_session_info(session_data) + + assert info.meta is not None + assert info.meta.get("custom_key") == "custom_value" + assert info.meta.get("parent_tool_call_id") == "tc_123" diff --git a/tests/servers/acp_server/test_skill_command_staged_content.py b/tests/servers/acp_server/test_skill_command_staged_content.py index cdbd40162..882681308 100644 --- a/tests/servers/acp_server/test_skill_command_staged_content.py +++ b/tests/servers/acp_server/test_skill_command_staged_content.py @@ -147,8 +147,7 @@ async def tracked_run_stream(*args, **kwargs): agent.run_stream = original_run_stream # type: ignore[method-assign] assert run_stream_called, ( - "agent.run_stream should be called when skill command " - "injects content into staged_content" + "agent.run_stream should be called when skill command injects content into staged_content" ) diff --git a/tests/servers/acp_server/test_streamable_http_integration.py b/tests/servers/acp_server/test_streamable_http_integration.py index 030262410..94ae57cab 100644 --- a/tests/servers/acp_server/test_streamable_http_integration.py +++ b/tests/servers/acp_server/test_streamable_http_integration.py @@ -203,6 +203,7 @@ async def test_serve_streamable_http_uses_internal_shutdown_when_no_event( mock_uvicorn: MagicMock, ) -> None: """When no shutdown_event is provided, an internal event should be used.""" + # Use a serve method that can be cancelled async def cancellable_serve() -> None: while True: @@ -309,9 +310,10 @@ async def quick_serve() -> None: mock_uvicorn._server.serve = quick_serve - with patch("acp.transports._StarletteWebSocketReadStream") as mock_reader, patch( - "acp.transports._StarletteWebSocketWriteStream" - ) as mock_writer: + with ( + patch("acp.transports._StarletteWebSocketReadStream") as mock_reader, + patch("acp.transports._StarletteWebSocketWriteStream") as mock_writer, + ): task = asyncio.create_task( _serve_streamable_http( TestAgent(), diff --git a/tests/sessions/test_session_controller.py b/tests/sessions/test_session_controller.py index 6da50ec25..2c77bfe83 100644 --- a/tests/sessions/test_session_controller.py +++ b/tests/sessions/test_session_controller.py @@ -71,6 +71,40 @@ def test_with_metadata(self) -> None: # Original should be unchanged assert "key2" not in original.metadata + def test_parent_tool_call_id_from_metadata(self) -> None: + """Test parent_tool_call_id property reads from metadata.""" + data = SessionData( + session_id="test_session", + agent_name="test_agent", + metadata={"parent_tool_call_id": "tc_abc123"}, + ) + assert data.parent_tool_call_id == "tc_abc123" + + def test_parent_tool_call_id_none_when_missing(self) -> None: + """Test parent_tool_call_id returns None when not in metadata.""" + data = SessionData( + session_id="test_session", + agent_name="test_agent", + ) + assert data.parent_tool_call_id is None + + def test_subagent_id_from_metadata(self) -> None: + """Test subagent_id property reads from metadata.""" + data = SessionData( + session_id="test_session", + agent_name="test_agent", + metadata={"subagent_id": "sub_def456"}, + ) + assert data.subagent_id == "sub_def456" + + def test_subagent_id_none_when_missing(self) -> None: + """Test subagent_id returns None when not in metadata.""" + data = SessionData( + session_id="test_session", + agent_name="test_agent", + ) + assert data.subagent_id is None + class TestMemoryProviderSessions: """Tests for session CRUD on MemoryStorageProvider.""" diff --git a/tests/test_event_converter_subagent.py b/tests/test_event_converter_subagent.py new file mode 100644 index 000000000..6e5b02d75 --- /dev/null +++ b/tests/test_event_converter_subagent.py @@ -0,0 +1,647 @@ +"""Tests for ACPEventConverter subagent emission in tool_box and inline modes. + +TDD tests for T6/T7/T8: ACPEventConverter subagent emission and state management. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +import pytest + +from acp.schema import AgentMessageChunk, ContentToolCallContent, SubagentRunInfo, ToolCallProgress, ToolCallStart +from agentpool.agents.events import ( + RunErrorEvent, + SpawnSessionStart, + StreamCompleteEvent, + SubAgentEvent, +) +from agentpool_server.acp_server.event_converter import ACPEventConverter + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from agentpool.agents.events import RichAgentStreamEvent + + +async def _collect_updates( + converter: ACPEventConverter, + event: RichAgentStreamEvent, +) -> list: + """Collect all updates from converter.convert().""" + return [u async for u in converter.convert(event)] + + +class TestToolBoxModeSubagent: + """Test tool_box mode SpawnSessionStart emission (T6).""" + + @pytest.fixture + def tool_box_converter(self, monkeypatch: pytest.MonkeyPatch) -> ACPEventConverter: + """Converter configured for tool_box mode.""" + monkeypatch.setenv("ACP_SUBAGENT_DISPLAY_MODE", "tool_box") + return ACPEventConverter() + + @pytest.mark.anyio + async def test_spawn_session_start_emits_tool_call_start( + self, + tool_box_converter: ACPEventConverter, + ) -> None: + """tool_box mode: SpawnSessionStart emits ToolCallStart with kind='subagent'.""" + event = SpawnSessionStart( + child_session_id="child_001", + parent_session_id="parent_001", + tool_call_id="tc_001", + spawn_mechanism="spawn", + source_name="coder_agent", + source_type="agent", + description="Spawning coder_agent for code review", + run_mode="foreground", + ) + + updates = await _collect_updates(tool_box_converter, event) + + assert len(updates) == 1 + assert isinstance(updates[0], ToolCallStart) + assert updates[0].tool_call_id == "tc_001" + assert updates[0].kind == "subagent" + assert updates[0].status == "in_progress" + assert updates[0].title == "⚡ coder_agent" + assert updates[0].subagent is not None + assert updates[0].subagent.child_session_id == "child_001" + assert updates[0].subagent.subagent_id == "coder_agent" + assert updates[0].subagent.name == "coder_agent" + assert updates[0].subagent.run_mode == "foreground" + + @pytest.mark.anyio + async def test_spawn_session_start_fallback_tool_call_id( + self, + tool_box_converter: ACPEventConverter, + ) -> None: + """tool_box mode: when tool_call_id is None, fallback to 'subagent:{child_id}'.""" + event = SpawnSessionStart( + child_session_id="child_004", + parent_session_id="parent_001", + tool_call_id=None, + spawn_mechanism="spawn", + source_name="analyzer_agent", + source_type="agent", + description="Spawning analyzer_agent", + run_mode="foreground", + ) + + updates = await _collect_updates(tool_box_converter, event) + + assert len(updates) == 1 + assert isinstance(updates[0], ToolCallStart) + assert updates[0].tool_call_id == "subagent:child_004" + assert updates[0].subagent is not None + assert updates[0].subagent.child_session_id == "child_004" + + @pytest.mark.anyio + async def test_subagent_tool_map_tracks_mapping( + self, + tool_box_converter: ACPEventConverter, + ) -> None: + """_subagent_tool_map tracks child_session_id → tool_call_id.""" + event = SpawnSessionStart( + child_session_id="child_005", + parent_session_id="parent_001", + tool_call_id="tc_005", + spawn_mechanism="spawn", + source_name="test_agent", + source_type="agent", + description="Test", + run_mode="foreground", + ) + + await _collect_updates(tool_box_converter, event) + + assert "child_005" in tool_box_converter._subagent_tool_map + assert tool_box_converter._subagent_tool_map["child_005"] == "tc_005" + + @pytest.mark.anyio + async def test_subagent_tool_map_fallback_tracks_mapping( + self, + tool_box_converter: ACPEventConverter, + ) -> None: + """_subagent_tool_map tracks fallback tool_call_id when tc_id is None.""" + event = SpawnSessionStart( + child_session_id="child_006", + parent_session_id="parent_001", + tool_call_id=None, + spawn_mechanism="task", + source_name="test_agent", + source_type="agent", + description="Test", + run_mode="background", + ) + + await _collect_updates(tool_box_converter, event) + + assert "child_006" in tool_box_converter._subagent_tool_map + assert tool_box_converter._subagent_tool_map["child_006"] == "subagent:child_006" + + @pytest.mark.anyio + async def test_foreground_children_tracked( + self, + tool_box_converter: ACPEventConverter, + ) -> None: + """_foreground_children contains child_id when run_mode='foreground'.""" + event = SpawnSessionStart( + child_session_id="child_fg", + parent_session_id="parent_001", + tool_call_id="tc_fg", + spawn_mechanism="spawn", + source_name="fg_agent", + source_type="agent", + description="Test", + run_mode="foreground", + ) + + await _collect_updates(tool_box_converter, event) + + assert "child_fg" in tool_box_converter._foreground_children + + @pytest.mark.anyio + async def test_background_children_not_tracked( + self, + tool_box_converter: ACPEventConverter, + ) -> None: + """_foreground_children does not contain child_id when run_mode='background'.""" + event = SpawnSessionStart( + child_session_id="child_bg", + parent_session_id="parent_001", + tool_call_id="tc_bg", + spawn_mechanism="task", + source_name="bg_agent", + source_type="agent", + description="Test", + run_mode="background", + ) + + await _collect_updates(tool_box_converter, event) + + assert "child_bg" not in tool_box_converter._foreground_children + + @pytest.mark.anyio + async def test_spawn_session_start_task_mechanism_icon( + self, + tool_box_converter: ACPEventConverter, + ) -> None: + """task mechanism uses 🚀 icon, spawn uses ⚡ icon.""" + event_task = SpawnSessionStart( + child_session_id="child_task", + parent_session_id="parent_001", + tool_call_id="tc_task", + spawn_mechanism="task", + source_name="task_agent", + source_type="agent", + description="Task", + run_mode="foreground", + ) + event_spawn = SpawnSessionStart( + child_session_id="child_spawn", + parent_session_id="parent_001", + tool_call_id="tc_spawn", + spawn_mechanism="spawn", + source_name="spawn_agent", + source_type="agent", + description="Spawn", + run_mode="foreground", + ) + + updates_task = await _collect_updates(tool_box_converter, event_task) + updates_spawn = await _collect_updates(tool_box_converter, event_spawn) + + assert "🚀" in updates_task[0].title + assert "⚡" in updates_spawn[0].title + + @pytest.mark.anyio + async def test_reset_clears_subagent_state( + self, + tool_box_converter: ACPEventConverter, + ) -> None: + """reset() clears _subagent_tool_map and _foreground_children.""" + event = SpawnSessionStart( + child_session_id="child_007", + parent_session_id="parent_001", + tool_call_id="tc_007", + spawn_mechanism="spawn", + source_name="test_agent", + source_type="agent", + description="Test", + run_mode="foreground", + ) + + await _collect_updates(tool_box_converter, event) + assert len(tool_box_converter._subagent_tool_map) > 0 + assert len(tool_box_converter._foreground_children) > 0 + + tool_box_converter.reset() + + assert len(tool_box_converter._subagent_tool_map) == 0 + assert len(tool_box_converter._foreground_children) == 0 + + +class TestInlineModeSubagent: + """Test inline mode SpawnSessionStart emission (T6/T7).""" + + @pytest.fixture + def inline_converter(self, monkeypatch: pytest.MonkeyPatch) -> ACPEventConverter: + """Converter configured for inline mode.""" + monkeypatch.setenv("ACP_SUBAGENT_DISPLAY_MODE", "inline") + return ACPEventConverter() + + @pytest.mark.anyio + async def test_spawn_session_start_emits_tool_call_start( + self, + inline_converter: ACPEventConverter, + ) -> None: + """inline mode: SpawnSessionStart emits ToolCallStart with kind='subagent'.""" + event = SpawnSessionStart( + child_session_id="child_002", + parent_session_id="parent_001", + tool_call_id="tc_002", + spawn_mechanism="task", + source_name="reviewer_agent", + source_type="agent", + description="Task reviewer_agent for review", + run_mode="background", + ) + + updates = await _collect_updates(inline_converter, event) + + assert len(updates) == 1 + assert isinstance(updates[0], ToolCallStart) + assert updates[0].tool_call_id == "tc_002" + assert updates[0].kind == "subagent" + assert updates[0].status == "in_progress" + assert updates[0].title == "🚀 reviewer_agent" + assert updates[0].subagent is not None + assert updates[0].subagent.child_session_id == "child_002" + assert updates[0].subagent.subagent_id == "reviewer_agent" + assert updates[0].subagent.run_mode == "background" + + @pytest.mark.anyio + async def test_spawn_session_start_tool_call_id_unique_from_internals( + self, + inline_converter: ACPEventConverter, + ) -> None: + """Canonical ToolCallStart ID is unique from internal SubAgentEvent tool calls.""" + from pydantic_ai import FunctionToolCallEvent, ToolCallPart + + from agentpool.agents.events import SubAgentEvent + + spawn_event = SpawnSessionStart( + child_session_id="child-009", + parent_session_id="parent_001", + tool_call_id=None, + spawn_mechanism="spawn", + source_name="coder", + source_type="agent", + description="Write code", + ) + spawn_updates = await _collect_updates(inline_converter, spawn_event) + canonical_id = spawn_updates[0].tool_call_id + + inner_event = FunctionToolCallEvent( + part=ToolCallPart( + tool_call_id="tc-1", + tool_name="bash", + args={"command": "ls"}, + ), + ) + subagent_event = SubAgentEvent( + source_name="coder", + source_type="agent", + event=inner_event, + depth=1, + ) + inner_updates = await _collect_updates(inline_converter, subagent_event) + internal_ids = { + u.tool_call_id for u in inner_updates if isinstance(u, ToolCallStart) + } + + assert canonical_id not in internal_ids + assert canonical_id == "subagent:child-009" + + @pytest.mark.anyio + async def test_subagent_event_internal_tool_call_retains_original_kind( + self, + inline_converter: ACPEventConverter, + ) -> None: + """Internal SubAgentEvent tool calls keep their original inferred kind.""" + from pydantic_ai import FunctionToolCallEvent, ToolCallPart + + from agentpool.agents.events import SubAgentEvent + + inner_event = FunctionToolCallEvent( + part=ToolCallPart( + tool_call_id="tc-1", + tool_name="bash", + args={"command": "ls"}, + ), + ) + event = SubAgentEvent( + source_name="coder", + source_type="agent", + event=inner_event, + depth=1, + ) + + updates = await _collect_updates(inline_converter, event) + + tool_starts = [u for u in updates if isinstance(u, ToolCallStart)] + assert len(tool_starts) == 1 + assert tool_starts[0].kind == "execute" + + @pytest.mark.anyio + async def test_subagent_event_text_output_retains_other_kind( + self, + inline_converter: ACPEventConverter, + ) -> None: + """Internal text output retains kind='other', not 'subagent'.""" + from pydantic_ai import PartStartEvent, TextPart + + from agentpool.agents.events import SubAgentEvent + + inner_event = PartStartEvent(part=TextPart(content="Hello"), index=0) + event = SubAgentEvent( + source_name="coder", + source_type="agent", + event=inner_event, + depth=1, + ) + + updates = await _collect_updates(inline_converter, event) + + tool_starts = [u for u in updates if isinstance(u, ToolCallStart)] + assert len(tool_starts) == 1 + assert tool_starts[0].kind == "other" + assert "coder" in tool_starts[0].title + + +class TestLegacyModeSubagent: + """Test legacy mode subagent emission remains unchanged.""" + + @pytest.fixture + def legacy_converter(self, monkeypatch: pytest.MonkeyPatch) -> ACPEventConverter: + """Converter configured for legacy mode.""" + monkeypatch.setenv("ACP_SUBAGENT_DISPLAY_MODE", "legacy") + return ACPEventConverter() + + @pytest.mark.anyio + async def test_spawn_session_start_yields_agent_message_chunk( + self, + legacy_converter: ACPEventConverter, + ) -> None: + """legacy mode: SpawnSessionStart still emits AgentMessageChunk (unchanged).""" + event = SpawnSessionStart( + child_session_id="child_003", + parent_session_id="parent_001", + tool_call_id="tc_003", + spawn_mechanism="spawn", + source_name="coder_agent", + source_type="agent", + description="Spawning coder_agent", + ) + + updates = await _collect_updates(legacy_converter, event) + + assert len(updates) == 1 + assert isinstance(updates[0], AgentMessageChunk) + assert "coder_agent" in updates[0].content.text + assert "⚡" in updates[0].content.text + + +class TestSubagentStateManagement: + """Test T8: subagent state management and cleanup in ACPEventConverter.""" + + @pytest.fixture + def converter(self, monkeypatch: pytest.MonkeyPatch) -> ACPEventConverter: + """Converter configured for tool_box mode.""" + monkeypatch.setenv("ACP_SUBAGENT_DISPLAY_MODE", "tool_box") + return ACPEventConverter() + + @pytest.mark.anyio + async def test_subagent_event_stream_complete_emits_completed_and_cleans_up( + self, + converter: ACPEventConverter, + ) -> None: + """SubAgentEvent with StreamCompleteEvent emits status='completed' and cleans state.""" + # Seed state via SpawnSessionStart + spawn = SpawnSessionStart( + child_session_id="child_complete", + parent_session_id="parent_001", + tool_call_id="tc_complete", + spawn_mechanism="spawn", + source_name="coder", + source_type="agent", + description="Code review", + run_mode="foreground", + ) + await _collect_updates(converter, spawn) + assert "child_complete" in converter._subagent_tool_map + assert "child_complete" in converter._foreground_children + + # Emit completion + complete_event = SubAgentEvent( + child_session_id="child_complete", + source_name="coder", + source_type="agent", + event=StreamCompleteEvent(message=None), # type: ignore[arg-type] + depth=1, + ) + updates = await _collect_updates(converter, complete_event) + + # Should emit ToolCallProgress with completed status + assert len(updates) == 1 + assert isinstance(updates[0], ToolCallProgress) + assert updates[0].tool_call_id == "tc_complete" + assert updates[0].status == "completed" + assert updates[0].subagent is not None + assert updates[0].subagent.child_session_id == "child_complete" + + # State should be cleaned up + assert "child_complete" not in converter._subagent_tool_map + assert "child_complete" not in converter._foreground_children + + @pytest.mark.anyio + async def test_subagent_event_run_error_emits_failed_and_cleans_up( + self, + converter: ACPEventConverter, + ) -> None: + """SubAgentEvent with RunErrorEvent emits status='failed' with error content and cleans state.""" + # Seed state + spawn = SpawnSessionStart( + child_session_id="child_err", + parent_session_id="parent_001", + tool_call_id="tc_err", + spawn_mechanism="task", + source_name="analyzer", + source_type="agent", + description="Analyze code", + run_mode="foreground", + ) + await _collect_updates(converter, spawn) + assert "child_err" in converter._subagent_tool_map + assert "child_err" in converter._foreground_children + + # Emit error + error_event = SubAgentEvent( + child_session_id="child_err", + source_name="analyzer", + source_type="agent", + event=RunErrorEvent(message="Connection timeout"), + depth=1, + ) + updates = await _collect_updates(converter, error_event) + + # Should emit ToolCallProgress with failed status + assert len(updates) == 1 + assert isinstance(updates[0], ToolCallProgress) + assert updates[0].tool_call_id == "tc_err" + assert updates[0].status == "failed" + assert updates[0].subagent is not None + assert updates[0].subagent.child_session_id == "child_err" + + # Should include error content + assert updates[0].content is not None + assert len(updates[0].content) == 1 + assert isinstance(updates[0].content[0], ContentToolCallContent) + assert "Error: Connection timeout" in updates[0].content[0].content.text + + # State should be cleaned up + assert "child_err" not in converter._subagent_tool_map + assert "child_err" not in converter._foreground_children + + @pytest.mark.anyio + async def test_subagent_event_stream_complete_without_tool_map_still_cleans_foreground( + self, + converter: ACPEventConverter, + ) -> None: + """StreamCompleteEvent for unknown child_session_id still discards from foreground set.""" + # Manually add to foreground without tool_map entry + converter._foreground_children.add("orphan_child") + + complete_event = SubAgentEvent( + child_session_id="orphan_child", + source_name="coder", + source_type="agent", + event=StreamCompleteEvent(message=None), # type: ignore[arg-type] + depth=1, + ) + updates = await _collect_updates(converter, complete_event) + + # No ToolCallProgress since no mapping + assert len(updates) == 0 + # But foreground child should be cleaned up + assert "orphan_child" not in converter._foreground_children + + @pytest.mark.anyio + async def test_subagent_event_run_error_without_tool_map_still_cleans_foreground( + self, + converter: ACPEventConverter, + ) -> None: + """RunErrorEvent for unknown child_session_id still discards from foreground set.""" + converter._foreground_children.add("orphan_err") + + error_event = SubAgentEvent( + child_session_id="orphan_err", + source_name="coder", + source_type="agent", + event=RunErrorEvent(message="Unknown error"), + depth=1, + ) + updates = await _collect_updates(converter, error_event) + + assert len(updates) == 0 + assert "orphan_err" not in converter._foreground_children + + @pytest.mark.anyio + async def test_cleanup_clears_all_tracked_state(self, converter: ACPEventConverter) -> None: + """cleanup() removes all entries from _subagent_tool_map and _foreground_children.""" + # Seed multiple children + converter._subagent_tool_map["child_a"] = "tc_a" + converter._subagent_tool_map["child_b"] = "tc_b" + converter._foreground_children.add("child_a") + converter._foreground_children.add("child_b") + converter._foreground_children.add("child_c") + + converter.cleanup() + + assert len(converter._subagent_tool_map) == 0 + assert len(converter._foreground_children) == 0 + + @pytest.mark.anyio + async def test_multiple_children_independent_cleanup( + self, + converter: ACPEventConverter, + ) -> None: + """Completing one child does not affect state of other tracked children.""" + # Seed two children + for child_id, tc_id in [("child_1", "tc_1"), ("child_2", "tc_2")]: + spawn = SpawnSessionStart( + child_session_id=child_id, + parent_session_id="parent_001", + tool_call_id=tc_id, + spawn_mechanism="spawn", + source_name="coder", + source_type="agent", + description="Task", + run_mode="foreground", + ) + await _collect_updates(converter, spawn) + + assert len(converter._subagent_tool_map) == 2 + assert len(converter._foreground_children) == 2 + + # Complete only child_1 + complete_event = SubAgentEvent( + child_session_id="child_1", + source_name="coder", + source_type="agent", + event=StreamCompleteEvent(message=None), # type: ignore[arg-type] + depth=1, + ) + await _collect_updates(converter, complete_event) + + # child_1 cleaned up, child_2 remains + assert "child_1" not in converter._subagent_tool_map + assert "child_1" not in converter._foreground_children + assert "child_2" in converter._subagent_tool_map + assert "child_2" in converter._foreground_children + + @pytest.mark.anyio + async def test_subagent_event_stream_complete_fallback_tool_call_id( + self, + converter: ACPEventConverter, + ) -> None: + """StreamCompleteEvent uses fallback tool_call_id when tc_id was None at spawn.""" + spawn = SpawnSessionStart( + child_session_id="child_fb", + parent_session_id="parent_001", + tool_call_id=None, + spawn_mechanism="spawn", + source_name="coder", + source_type="agent", + description="Task", + run_mode="foreground", + ) + await _collect_updates(converter, spawn) + assert converter._subagent_tool_map["child_fb"] == "subagent:child_fb" + + complete_event = SubAgentEvent( + child_session_id="child_fb", + source_name="coder", + source_type="agent", + event=StreamCompleteEvent(message=None), # type: ignore[arg-type] + depth=1, + ) + updates = await _collect_updates(converter, complete_event) + + assert len(updates) == 1 + assert isinstance(updates[0], ToolCallProgress) + assert updates[0].tool_call_id == "subagent:child_fb" + assert updates[0].status == "completed" + + assert "child_fb" not in converter._subagent_tool_map diff --git a/tests/tools/test_workers.py b/tests/tools/test_workers.py index 93e1a42d3..c12bd7493 100644 --- a/tests/tools/test_workers.py +++ b/tests/tools/test_workers.py @@ -531,5 +531,31 @@ async def test_subagent_event_depth_propagation(tmp_path: Path): assert sa_event.depth == expected_depth +async def test_worker_spawn_session_start_has_run_mode_foreground(tmp_path: Path): + """Worker tool emits SpawnSessionStart with run_mode='foreground'.""" + config_path = write_config(BASIC_WORKERS, tmp_path) + manifest = AgentsManifest.from_file(config_path) + + spawn_events: list[SpawnSessionStart] = [] + + async with AgentPool(manifest) as pool: + main_agent = pool.get_agent("main") + worker = pool.get_agent("worker") + assert isinstance(main_agent, Agent) + assert isinstance(worker, Agent) + + main_model = TestModel(call_tools=["ask_worker"]) + worker_model = TestModel(custom_output_text="Worker result") + await main_agent.set_model(main_model) + await worker.set_model(worker_model) + + async for event in main_agent.run_stream("Ask worker: do something"): + if isinstance(event, SpawnSessionStart): + spawn_events.append(event) + + assert len(spawn_events) == 1 + assert spawn_events[0].run_mode == "foreground" + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/toolsets/test_subagent_child_session.py b/tests/toolsets/test_subagent_child_session.py index 3e61de281..807c22c41 100644 --- a/tests/toolsets/test_subagent_child_session.py +++ b/tests/toolsets/test_subagent_child_session.py @@ -372,3 +372,77 @@ async def test_subagent_tools_does_not_import_identifiers() -> None: assert not hasattr(mod, "identifier"), ( "subagent_tools should not import 'identifier' — it should use ctx.create_child_session()" ) + + +# --------------------------------------------------------------------------- +# run_mode field on SpawnSessionStart (RFC-0028 T3) +# --------------------------------------------------------------------------- + + +async def test_task_sync_mode_sets_run_mode_foreground() -> None: + """task() with async_mode=False sets run_mode='foreground' on SpawnSessionStart.""" + manifest = AgentsManifest.from_yaml(""" +agents: + worker: + model: + type: test + custom_output_text: "Done" + system_prompt: Worker. + + orchestrator: + model: + type: test + call_tools: ["task"] + tool_args: + task: + agent_or_team: worker + prompt: "Work" + description: "Sync run_mode test" + tools: + - type: subagent +""") + spawn_events: list[SpawnSessionStart] = [] + + async with AgentPool(manifest) as pool: + orch = pool.get_agent("orchestrator") + async for event in orch.run_stream("Delegate"): + if isinstance(event, SpawnSessionStart): + spawn_events.append(event) + + assert len(spawn_events) == 1 + assert spawn_events[0].run_mode == "foreground" + + +async def test_task_async_mode_sets_run_mode_background() -> None: + """task() with async_mode=True sets run_mode='background' on SpawnSessionStart.""" + manifest = AgentsManifest.from_yaml(""" +agents: + worker: + model: + type: test + custom_output_text: "Done" + system_prompt: Worker. + + orchestrator: + model: + type: test + call_tools: ["task"] + tool_args: + task: + agent_or_team: worker + prompt: "Work" + description: "Async run_mode test" + async_mode: true + tools: + - type: subagent +""") + spawn_events: list[SpawnSessionStart] = [] + + async with AgentPool(manifest) as pool: + orch = pool.get_agent("orchestrator") + async for event in orch.run_stream("Delegate"): + if isinstance(event, SpawnSessionStart): + spawn_events.append(event) + + assert len(spawn_events) == 1 + assert spawn_events[0].run_mode == "background" diff --git a/tests/verification/test_acp_display_config.py b/tests/verification/test_acp_display_config.py deleted file mode 100755 index ef88c2935..000000000 --- a/tests/verification/test_acp_display_config.py +++ /dev/null @@ -1,385 +0,0 @@ -#!/usr/bin/env python3 -"""Verification test for ACP subagent_display_mode feature. - -This script tests the complete data flow for subagent_display_mode: -- Config model field validation -- Default value preservation -- CLI argument parsing -- End-to-end flow: Server → Agent → Session - -Run with: uv run python tests/verification/test_acp_display_config.py -""" - -from __future__ import annotations - -import subprocess -import sys - - -def print_section(title: str) -> None: - """Print a section header.""" - print(f"\n{'=' * 60}") - print(f" {title}") - print("=" * 60) - - -def print_success(message: str) -> None: - """Print success message.""" - print(f"✓ {message}") - - -def print_error(message: str) -> None: - """Print error message.""" - print(f"✗ {message}") - - -def test_config_model() -> bool: - """Test 1: Config model field exists and works.""" - print_section("Test 1: Config Model Field") - - try: - from agentpool_config.pool_server import ACPPoolServerConfig - - # Test 1.1: Field accepts "inline" - config_inline = ACPPoolServerConfig(subagent_display_mode="inline") - assert config_inline.subagent_display_mode == "inline" - print_success('ACPPoolServerConfig(subagent_display_mode="inline") works') - - # Test 1.2: Field accepts "tool_box" - config_tool_box = ACPPoolServerConfig(subagent_display_mode="tool_box") - assert config_tool_box.subagent_display_mode == "tool_box" - print_success('ACPPoolServerConfig(subagent_display_mode="tool_box") works') - - # Test 1.3: Type validation - invalid value should fail - try: - ACPPoolServerConfig(subagent_display_mode="invalid") # type: ignore[arg-type] - except (ValueError, TypeError): - print_success("Config model correctly rejects invalid values") - return True - else: - print_error("Config model should reject invalid values") - return False - - except (ValueError, TypeError, ImportError) as e: - print_error(f"Config model test failed: {e}") - return False - - -def test_default_value() -> bool: - """Test 2: Default value is preserved.""" - print_section("Test 2: Default Value") - - try: - from agentpool_config.pool_server import ACPPoolServerConfig - - # Test default value - config_default = ACPPoolServerConfig() - assert config_default.subagent_display_mode == "tool_box" - print_success('ACPPoolServerConfig() defaults to "tool_box"') - - except (ValueError, TypeError, ImportError) as e: - print_error(f"Default value test failed: {e}") - return False - else: - return True - - -def test_cli_option() -> bool: - """Test 3: CLI option is recognized.""" - print_section("Test 3: CLI Option Recognition") - - try: - # Test that help shows the option - result = subprocess.run( - ["uv", "run", "agentpool", "serve-acp", "--help"], - capture_output=True, - text=True, - timeout=30, - check=False, - ) - except subprocess.TimeoutExpired: - print_error("CLI help command timed out") - return False - except (subprocess.SubprocessError, OSError) as e: - print_error(f"CLI option test failed: {e}") - return False - else: - # Check for partial match (help might wrap or truncate) - if "subagent" in result.stdout.lower() and "display" in result.stdout.lower(): - print_success('CLI option "--subagent-display-mode" is recognized in help output') - return True - - print_error('CLI option "--subagent-display-mode" not found in help') - print(" Searched for 'subagent' and 'display' in output") - return False - - -def test_server_initialization() -> bool: - """Test 4: Server can be initialized with mode.""" - print_section("Test 4: Server Initialization") - - try: - from agentpool import AgentPool - from agentpool.models.manifest import AgentsManifest - from agentpool_config.pool_server import ACPPoolServerConfig - from agentpool_server.acp_server.server import ACPServer - - # Create a minimal manifest - manifest_dict = { - "agents": { - "test_agent": { - "type": "native", - "model": "openai:gpt-4o-mini", - "system_prompt": "Test agent for display mode verification", - } - } - } - - # Test 4.1: Manifest with inline mode in pool_server config - manifest_dict_with_config = { - **manifest_dict, - "pool_server": { - "type": "acp", - "subagent_display_mode": "inline", - }, - } - manifest = AgentsManifest.model_validate(manifest_dict_with_config) - - # pool_server is a union type - check if it's ACPPoolServerConfig - - assert isinstance(manifest.pool_server, ACPPoolServerConfig) - assert manifest.pool_server.subagent_display_mode == "inline" - print_success('Manifest accepts subagent_display_mode="inline" in pool_server') - - # Test 4.2: Server from_config with inline mode via argument - server_inline = ACPServer.from_config( - manifest, - subagent_display_mode="inline", - ) - assert server_inline.subagent_display_mode == "inline" - print_success("ACPServer.from_config() accepts subagent_display_mode argument") - - # Test 4.3: Server from_config defaults to config value when arg not provided - server_from_config = ACPServer.from_config( - manifest, # manifest has inline mode in pool_server - ) - assert server_from_config.subagent_display_mode == "inline" - print_success("ACPServer.from_config() uses config value when arg not provided") - - # Test 4.4: Server __init__ accepts mode directly - # Need to use manifest object, not dict - manifest_for_pool = AgentsManifest.model_validate(manifest_dict) - pool = AgentPool(manifest=manifest_for_pool) - server_direct = ACPServer(pool, subagent_display_mode="inline") - assert server_direct.subagent_display_mode == "inline" - print_success("ACPServer.__init__() accepts subagent_display_mode argument") - - except (ValueError, TypeError, ImportError, AttributeError) as e: - print_error(f"Server initialization test failed: {e}") - import traceback - - traceback.print_exc() - return False - else: - return True - - -def test_agent_display_mode() -> bool: - """Test 5: Agent receives and stores mode.""" - print_section("Test 5: Agent Display Mode") - - try: - from dataclasses import fields - import inspect - - from agentpool_server.acp_server.acp_agent import AgentPoolACPAgent - - # Test 5.1: AgentPoolACPAgent has subagent_display_mode field - field_names = [f.name for f in fields(AgentPoolACPAgent)] - assert "subagent_display_mode" in field_names - print_success("AgentPoolACPAgent has subagent_display_mode field") - - # Test 5.2: AgentPoolACPAgent default value is "tool_box" - # We can't fully instantiate AgentPoolACPAgent without a real client, - # but we can verify that type annotation exists - sig = inspect.signature(AgentPoolACPAgent.__init__) - params = sig.parameters - - if "subagent_display_mode" in params: - param = params["subagent_display_mode"] - default = param.default - if default == "tool_box": - print_success('AgentPoolACPAgent subagent_display_mode defaults to "tool_box"') - else: - print_error(f'Expected default "tool_box", got {default}') - return False - else: - print_error("AgentPoolACPAgent.__init__ missing subagent_display_mode parameter") - return False - - except (ValueError, TypeError, ImportError, AttributeError) as e: - print_error(f"Agent display mode test failed: {e}") - import traceback - - traceback.print_exc() - return False - else: - return True - - -def test_session_display_mode() -> bool: - """Test 6: Session can be created with mode.""" - print_section("Test 6: Session Display Mode") - - try: - from dataclasses import fields - - from agentpool_server.acp_server.session import ACPSession - - # Test 6.1: ACPSession has subagent_display_mode field - field_names = [f.name for f in fields(ACPSession)] - assert "subagent_display_mode" in field_names - print_success("ACPSession has subagent_display_mode field") - - # Test 6.2: ACPSession default value is "tool_box" - sig_fields = {f.name: f for f in fields(ACPSession)} - subagent_field = sig_fields["subagent_display_mode"] - default = subagent_field.default - if default == "tool_box": - print_success('ACPSession subagent_display_mode defaults to "tool_box"') - else: - print_error(f'Expected default "tool_box", got {default}') - return False - except (ValueError, TypeError, ImportError, AttributeError) as e: - print_error(f"Session display mode test failed: {e}") - import traceback - - traceback.print_exc() - return False - else: - return True - - -def test_end_to_end_flow() -> bool: - """Test 7: End-to-end flow (Server → Agent → Session).""" - print_section("Test 7: End-to-End Data Flow") - - try: - from dataclasses import fields - - from agentpool.models.manifest import AgentsManifest - from agentpool_server.acp_server.server import ACPServer - - # Create minimal manifest - manifest_dict = { - "agents": { - "test_agent": { - "type": "native", - "model": "openai:gpt-4o-mini", - "system_prompt": "Test agent", - } - } - } - manifest = AgentsManifest.model_validate(manifest_dict) - - # Test 7.1: Create server with inline mode - server = ACPServer.from_config(manifest, subagent_display_mode="inline") - assert server.subagent_display_mode == "inline" - print_success("Server initialized with inline mode") - - # Test 7.2: Verify agent has access to mode via server reference - # (AgentPoolACPAgent gets subagent_display_mode from server at instantiation) - from agentpool_server.acp_server.acp_agent import AgentPoolACPAgent - - # Check that AgentPoolACPAgent stores the mode - sig_fields = {f.name: f for f in fields(AgentPoolACPAgent)} - assert "subagent_display_mode" in sig_fields - print_success("Agent can store subagent_display_mode") - - # Test 7.3: Verify session creation passes mode - # SessionManager.create_session accepts subagent_display_mode parameter - import inspect - - from agentpool_server.acp_server.session_manager import ACPSessionManager - - sig = inspect.signature(ACPSessionManager.create_session) - params = sig.parameters - - if "subagent_display_mode" in params: - param = params["subagent_display_mode"] - default = param.default - if default == "tool_box": - print_success( - "SessionManager.create_session() accepts " - 'subagent_display_mode with default "tool_box"' - ) - else: - print_error(f'Expected default "tool_box", got {default}') - return False - else: - print_error("SessionManager.create_session() missing subagent_display_mode parameter") - return False - - # Test 7.4: Verify ACPSession stores the mode - from agentpool_server.acp_server.session import ACPSession - - sig_fields = {f.name: f for f in fields(ACPSession)} - assert "subagent_display_mode" in sig_fields - print_success("ACPSession stores subagent_display_mode") - - except (ValueError, TypeError, ImportError, AttributeError) as e: - print_error(f"End-to-end flow test failed: {e}") - import traceback - - traceback.print_exc() - return False - else: - return True - - -def main() -> int: - """Run all verification tests.""" - print("\n" + "=" * 60) - print(" ACP Subagent Display Mode Verification Tests") - print("=" * 60) - - tests = [ - ("Config Model Field", test_config_model), - ("Default Value", test_default_value), - ("CLI Option Recognition", test_cli_option), - ("Server Initialization", test_server_initialization), - ("Agent Display Mode", test_agent_display_mode), - ("Session Display Mode", test_session_display_mode), - ("End-to-End Flow", test_end_to_end_flow), - ] - - results = [] - for name, test_func in tests: - try: - result = test_func() - results.append((name, result)) - except (ValueError, TypeError, ImportError, AttributeError) as e: - print(f"Unexpected error in {name}: {e}") - results.append((name, False)) - - # Print summary - print_section("Test Summary") - passed = sum(1 for _, result in results if result) - total = len(results) - - for name, result in results: - status = "PASS" if result else "FAIL" - symbol = "✓" if result else "✗" - print(f"{symbol} {name}: {status}") - - print(f"\n{passed}/{total} tests passed") - - if passed == total: - print("\n✓ All verification tests passed!") - return 0 - print(f"\n✗ {total - passed} test(s) failed") - return 1 - - -if __name__ == "__main__": - sys.exit(main())