diff --git a/docs/ai/design/2026-06-16-feature-agent-groups.md b/docs/ai/design/2026-06-16-feature-agent-groups.md new file mode 100644 index 00000000..dae67572 --- /dev/null +++ b/docs/ai/design/2026-06-16-feature-agent-groups.md @@ -0,0 +1,220 @@ +--- +phase: design +title: Agent Groups - Design +description: Technical design for local agent group management and group fan-out sends +--- + +# Agent Groups - Design + +## Architecture Overview + +```mermaid +graph TD + GroupCLI["agent group create/update/add/remove/list/detail"] + SendCLI["agent send [message] --group <name>"] + Message["resolveSendMessage()"] + GroupService["AgentGroupService"] + AM["AgentManager"] + Resolve["resolve group members"] + Focus["TerminalFocusManager.findTerminal(pid)"] + Writer["TtyWriter.send(location, message)"] + File[("~/.ai-devkit/agent-groups.json")] + + GroupCLI --> GroupService + GroupService --> File + SendCLI --> Message + SendCLI --> GroupService + SendCLI --> AM + AM --> Resolve + Resolve --> Focus + Focus --> Writer +``` + +Agent group management is a local CLI feature. The group service owns validation, persistence, and duplicate handling. `agent send --group` reads the stored member identifiers, resolves them against live agents, and reuses the existing terminal delivery path for each target. + +## Data Models + +```typescript +export interface AgentGroup { + name: string; + members: string[]; + createdAt: string; + updatedAt: string; +} + +export interface AgentGroupFile { + version: 1; + groups: AgentGroup[]; +} +``` + +Storage path: + +- Default: `path.join(os.homedir(), '.ai-devkit', 'agent-groups.json')` +- The file is user-scoped and local-machine scoped. +- Missing file means no configured groups. +- Writes use the existing temp-file-and-rename pattern from `AgentRegistry` so partial writes do not leave a truncated group file. + +Validation: + +- Group names use the existing `NAME_REGEX` convention. +- Members are non-empty strings after trimming. +- Members are unique within a group. +- Groups are unique by name. +- Unknown file versions and malformed JSON produce explicit user-facing errors instead of being silently treated as empty state. + +## API Design + +### CLI Interface + +Management: + +```text +ai-devkit agent group create --agent [--agent ...] +ai-devkit agent group update --agent [--agent ...] +ai-devkit agent group add +ai-devkit agent group remove-agent +ai-devkit agent group remove +ai-devkit agent group list +ai-devkit agent group detail +``` + +Delivery: + +```text +ai-devkit agent send [message] --group [--stdin] +``` + +Rules: + +- `agent send` accepts exactly one target selector: `--id` or `--group`. +- The current `requiredOption('--id ...')` must become optional, with action-level validation enforcing exactly one target selector. This preserves the existing `--id` UX while allowing `--group`. +- `--timeout` and `--wait` are valid only with `--id`. +- `--json` remains wait-result JSON for single-target sends only; group send rejects it before delivery in this feature. +- Positional message, explicit `--stdin`, and implicit piped stdin keep the existing `resolveSendMessage()` behavior. + +### Internal Interfaces + +Location: `packages/cli/src/services/agent/agent-group.service.ts` + +```typescript +export class AgentGroupService { + constructor(filePath?: string); + list(): AgentGroup[]; + get(name: string): AgentGroup | undefined; + create(name: string, members: string[]): AgentGroup; + update(name: string, members: string[]): AgentGroup; + addMember(name: string, member: string): AgentGroup; + removeMember(name: string, member: string): AgentGroup; + remove(name: string): void; +} + +export function createDefaultAgentGroupService(): AgentGroupService; +``` + +The service should use synchronous file operations like `AgentRegistry` does because commands are short-lived CLI actions. The constructor accepts a file path so tests can use a temporary file without mutating real user config. CLI code uses `createDefaultAgentGroupService()` so command handlers depend on group behavior rather than storage construction. + +Send orchestration lives in `packages/cli/src/services/agent/agent.service.ts`: + +```typescript +export function assertSendTargetOptions(options: SendTargetOptions): void; +export async function sendToAgent(options: SendToAgentOptions): Promise; +export async function waitForAgentResponse(params: WaitForAgentResponseParams): Promise; +export async function sendToAgentGroup(options: SendToAgentGroupOptions): Promise; +``` + +`commands/agent.ts` stays responsible for root agent command wiring, prompt-source resolution, group lookup for send, and user-facing output adapters. `commands/agent/group.command.ts` owns `agent group` management command registration. Terminal delivery, wait-mode preparation, response polling, group member resolution, deduplication, and delivery summaries are service-layer responsibilities. + +Typed errors: + +- `AgentGroupNotFoundError` +- `AgentGroupConflictError` +- `AgentGroupInvalidNameError` +- `AgentGroupInvalidMemberError` +- `AgentGroupEmptyMembersError` +- `AgentGroupStorageError` + +## Component Breakdown + +### AgentGroupService + +- Reads and writes the JSON group file. +- Creates the parent `~/.ai-devkit` directory when needed. +- Normalizes and validates group names and members. +- Throws typed errors for not found, conflict, invalid name, invalid member, and empty members. +- Writes formatted JSON for user inspectability. + +### CLI `agent group` + +- Registers a nested command under `agent`. +- Maps command arguments and options to `AgentGroupService`. +- Prints concise success output for mutations. +- Prints table output for list/detail. +- Handles group service typed errors in the command layer and exits non-zero for invalid operations. +- Does not resolve members against live agents during create/update/add; groups may be prepared before agents are running. + +### CLI `agent send --group` + +- Parses `--group ` alongside existing `--id`. +- Calls `assertSendTargetOptions()` to validate mutually exclusive target and unsupported option combinations before reading live agents or sending. +- Resolves the group name through `AgentGroupService`. +- Delegates group fan-out to `sendToAgentGroup()`. +- Keeps single-target delivery delegated to `sendToAgent()`, which also owns wait-mode response handling through `waitForAgentResponse()`. + +Resolution phases: + +1. Load the group and validate it has members. +2. List live agents once. +3. Resolve every stored member identifier against that same live-agent snapshot. +4. If any member is missing or ambiguous, print all resolution errors and return before terminal delivery. +5. Deduplicate resolved live agents. +6. Send to each target sequentially, continuing after terminal discovery or send failures. + +## Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Storage location | `~/.ai-devkit/agent-groups.json` | Matches user-local agent metadata expectations and avoids project-specific files for runtime sessions. | +| Storage owner | CLI agent service layer | Groups are local CLI configuration. `agent-manager` remains focused on live agent discovery, registries, and terminal interaction. | +| Membership value | User-entered agent identifier string | Keeps groups stable across process restarts and lets existing agent resolution handle names, slugs, and partials. | +| Command namespace | `agent group ...` | Keeps lifecycle under the existing `agent` command without crowding top-level CLI commands. | +| Send selector | Add `--group`; make it mutually exclusive with `--id` | Keeps single-target behavior intact and avoids accidental double delivery. | +| Delivery order | Sequential in configured member order | Deterministic output and easier partial-failure reporting. | +| Missing member | Report and continue resolving, but do not deliver if any missing or ambiguous members exist | Avoids partial sends when the requested group target set is not well-defined. | +| Member validation timing | Validate syntax at write time; resolve liveness at send time | Allows users to create groups before agents are running while still preventing malformed config. | +| Runtime delivery failure | Continue to remaining resolved targets and exit non-zero | A terminal failure for one agent should not block delivery to other valid agents. | +| Group wait mode | Reject `--wait --group` | Aggregated wait semantics need separate design. | +| JSON output | Reject `--json --group` in this feature | Current JSON means wait result; group result schema should be designed when needed. | + +Alternatives considered: + +- **Shell loop documentation only:** rejected because it leaves persistence, validation, and failure behavior to every user. +- **Dynamic groups by query:** rejected for first version because explicit group membership is simpler and safer for message fan-out. +- **Store process IDs:** rejected because PIDs are unstable and stale quickly. + +## Non-Functional Requirements + +- **Reliability:** Group file corruption should produce a clear error that names the file path. +- **Security:** The feature must not introduce shell interpolation; terminal delivery continues through existing `TtyWriter` safeguards. +- **Performance:** Group operations are local file reads/writes. Send fan-out should be acceptable for small human-managed groups. +- **Compatibility:** Existing command behavior and tests for `agent send --id` remain unchanged. +- **Observability:** Group send output names each target agent and summarizes success/failure counts. + +## Requirements Coverage + +| Requirement area | Design coverage | +|---|---| +| Create/update/remove/list/detail groups | `agent group` CLI plus `AgentGroupService` CRUD methods. | +| Local user-scoped storage | `~/.ai-devkit/agent-groups.json` with explicit file schema and atomic writes. | +| Multiple agent identifiers per group | `members: string[]`, duplicate validation, and write-time member validation. | +| `agent send --group` fan-out | Group send resolution phases and sequential terminal delivery. | +| Existing `agent send --id` compatibility | Optional target selector with action-level exactly-one validation. | +| Stdin behavior | Reuses `resolveSendMessage()` before fan-out so stdin is read once. | +| Missing or ambiguous members | Pre-delivery resolution failure with all missing/ambiguous members reported. | +| Runtime per-agent failures | Continue delivery, report per target, set non-zero exit code. | +| Unsupported `--wait` and `--json` combinations | Explicit pre-delivery rejection for group sends. | +| Security constraints | No shell interpolation; reuse `TtyWriter` and existing execFile-based paths. | + +## Open Questions + +None blocking for implementation. Group wait mode, group JSON output, nested groups, and dynamic groups remain deferred future enhancements. diff --git a/docs/ai/implementation/2026-06-16-feature-agent-groups.md b/docs/ai/implementation/2026-06-16-feature-agent-groups.md new file mode 100644 index 00000000..72fb73e7 --- /dev/null +++ b/docs/ai/implementation/2026-06-16-feature-agent-groups.md @@ -0,0 +1,156 @@ +--- +phase: implementation +title: Agent Groups - Implementation Notes +description: Implementation progress, decisions, verification, and edge cases for agent groups +--- + +# Agent Groups - Implementation Notes + +## Development Setup + +- Active worktree: `.worktrees/feature-agent-groups` +- Branch: `feature-agent-groups` +- Feature lint command: `npx ai-devkit@latest lint --feature agent-groups` +- Focused CLI test command: `npm --workspace packages/cli test -- --run ` + +## Code Structure + +- `packages/cli/src/services/agent/agent-group.service.ts`: user-scoped local group storage, validation, and typed errors. +- `packages/cli/src/services/agent/agent.service.ts`: send target validation, single-agent delivery, wait-mode response handling, group member resolution, deduplication, sequential group delivery, and summary reporting. +- `packages/cli/src/__tests__/services/agent/agent-group.service.test.ts`: focused unit tests for the group service. +- `packages/cli/src/__tests__/services/agent/agent.service.test.ts`: focused unit tests for group send fan-out behavior. +- `packages/cli/src/commands/agent.ts`: root `agent` command wiring plus thin `agent send` routing into service functions. +- `packages/cli/src/commands/agent/group.command.ts`: `agent group` management command wiring. +- `packages/cli/src/__tests__/commands/agent.test.ts`: commander-driven tests for group management commands and send command routing. + +## Implementation Notes + +### Storage Foundation + +- Implemented `AgentGroupService` with injectable file path for tests and default path `~/.ai-devkit/agent-groups.json`. +- File schema is `{ version: 1, groups: AgentGroup[] }`. +- Writes use parent-directory creation, a `.tmp` file, and `renameSync` to avoid truncated final files on successful writes. +- Missing storage file is treated as an empty group list. +- Malformed JSON and unsupported file versions throw `AgentGroupStorageError`; malformed storage is not rewritten. +- Group names use the existing agent naming convention: lowercase letters, digits, and hyphens, starting and ending with alphanumeric characters. +- Member identifiers are trimmed, must be non-blank, and must be unique per group. +- `get()` validates group names before lookup so read paths such as `agent group detail` and `agent send --group` report invalid names instead of treating malformed names as missing groups. +- `addMember()` is idempotent for an existing member after trimming. +- `removeMember()` rejects removing the final member so persisted groups remain non-empty. + +### Group Management CLI + +- Added nested `agent group` command under the existing `agent` command. +- `create` and `update` collect repeated `--agent ` options and pass them to `AgentGroupService`. +- `add` and `remove-agent` mutate one member. +- `remove` deletes a group. +- `list` renders configured groups with `ui.table()`. +- `detail` renders one group and its members, and throws `AgentGroupNotFoundError` when missing. +- Command tests mock `ConsoleApp` to keep the command suite focused on CLI parsing without loading channel TUI dependencies. + +### Group Send Fan-Out + +- Changed `agent send` target selection from Commander-level required `--id` to action-level validation that requires exactly one of `--id` or `--group`. +- `--group` rejects `--wait`, `--timeout`, and `--json` before delivery because group wait and group JSON result schemas are out of scope. +- Group sends reuse the existing prompt source resolver, so positional messages, explicit `--stdin`, and implicit piped stdin are read once before fan-out. +- Group sends load the group through `createDefaultAgentGroupService()`, list live agents once, and resolve every stored member identifier against the same live-agent snapshot before terminal delivery. +- Missing and ambiguous members are collected and reported together; no prompt is delivered when the group target set cannot be fully resolved. +- Resolved live agents are deduplicated by PID when available, falling back to name. +- Delivery is sequential through `TerminalFocusManager.findTerminal()` and `TtyWriter.send()`. +- Runtime terminal discovery or send failures are reported per agent; later targets still receive the prompt and the command sets `process.exitCode = 1` if any target fails. +- Refactor pass consolidated single-agent send, wait-mode response handling, and group fan-out in `agent.service.ts` through `sendToAgent()`, `waitForAgentResponse()`, and `sendToAgentGroup()`. `commands/agent.ts` now handles root command wiring, send parsing, prompt-source resolution, group lookup, and output adapter wiring. + +## Integration Points + +- `agent group create/update/add/remove-agent/remove/list/detail` now call `createDefaultAgentGroupService()`. +- Group management uses the same `withErrorHandler()` pattern as adjacent agent commands. +- `agent send --group` reuses the existing `AgentManager`, `TerminalFocusManager`, and `TtyWriter` integration points used by single-target send. +- No `agent-manager` package changes were made for storage or group management. + +## Error Handling + +- `AgentGroupNotFoundError`: missing group for update, add, remove-member, or remove. +- `AgentGroupConflictError`: create attempted for an existing group. +- `AgentGroupInvalidNameError`: invalid group name. +- `AgentGroupInvalidMemberError`: blank, duplicate, or absent member operation. +- `AgentGroupEmptyMembersError`: empty group creation/update or removing the last member. +- `AgentGroupStorageError`: malformed JSON, unsupported version, or malformed schema. + +## Verification + +- Red step: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts` failed with missing module before `AgentGroupService` existed. +- Green step: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts` exited 0 with 18 tests passed. +- Group command red step: `npm --workspace packages/cli test -- --run src/__tests__/commands/agent.test.ts` failed with the new `agent group` tests because no group commands were wired. +- Group command green step: `npm --workspace packages/cli test -- --run src/__tests__/commands/agent.test.ts` exited 0 with 57 tests passed. +- Group send red step: `npm --workspace packages/cli test -- --run src/__tests__/commands/agent.test.ts` failed with new group-send tests because `agent send` still treated `--group` as a single-target send. +- Group send green step: `npm --workspace packages/cli test -- --run src/__tests__/commands/agent.test.ts` exited 0 with 64 tests passed. +- Focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/commands/agent.test.ts` exited 0 with 82 tests passed. +- Simplification red step: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent.service.test.ts` failed before group fan-out was consolidated into `agent.service.ts`. +- Simplification green step: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` exited 0 with 68 tests passed. +- Simplification focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` exited 0 with 86 tests passed. +- Lint: `npm --workspace packages/cli run lint` exited 0. It reported 5 pre-existing warnings outside the new group service files. +- Build: `npm --workspace packages/cli run build` exited 0 and compiled 172 files with SWC before declaration/template output after simplification. +- Dependency package build for full CLI tests: `npm --workspace packages/agent-manager run build` exited 0 and compiled 39 files. +- Dependency package build for full CLI tests: `npm --workspace packages/channel-connector run build` exited 0 and compiled 11 files. +- Full CLI regression: `npm --workspace packages/cli test` exited 0 with 67 test files passed and 800 tests passed. +- Feature docs lint: `npx ai-devkit@latest lint --feature agent-groups` exited 0. +- Phase 7 implementation check: implementation aligns with requirements and design for local group storage, group management commands, group send fan-out, single-target send compatibility, unsupported group `--wait`/`--json` rejection, and pre-delivery resolution. No code deviations requiring design changes were found. +- Phase 7 fresh regression: `npm --workspace packages/cli test` exited 0 with 68 test files passed and 804 tests passed. +- Phase 7 fresh lint: `npm --workspace packages/cli run lint` exited 0 with the same 5 pre-existing warnings outside the agent-groups changes. +- Phase 7 fresh build: `npm --workspace packages/cli run build` exited 0 and compiled 172 files with SWC before declaration/template output. +- Phase 9 review finding: `AgentGroupService.get()` did not validate group names, so `agent group detail ` and `agent send --group ` reported not-found instead of invalid-name. Fixed by validating in `get()` and adding service plus command regression tests. +- Phase 9 focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` exited 0 with 89 tests passed. +- Phase 9 feature-scoped coverage: `npm --workspace packages/cli exec -- vitest run --coverage --coverage.include=src/services/agent/agent-group.service.ts --coverage.include=src/services/agent/agent.service.ts --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` exited 0 with statements 92.89%, branches 85.36%, functions 100%, and lines 93.14%. +- Phase 9 full CLI regression: `npm --workspace packages/cli test` exited 0 with 68 test files passed and 807 tests passed. +- Phase 9 final lint: `npm --workspace packages/cli run lint` exited 0 with the same 5 pre-existing warnings outside the agent-groups changes. +- Phase 9 final build: `npm --workspace packages/cli run build` exited 0 and compiled 172 files with SWC before declaration/template output. +- Phase 9 final feature docs lint: `npx ai-devkit@latest lint --feature agent-groups` exited 0. +- Refactor follow-up: renamed `agent-group.store.ts` to `agent-group.service.ts`, removed the standalone group-send service split, and consolidated send orchestration into `agent.service.ts` with `sendToAgent()`, `waitForAgentResponse()`, and `sendToAgentGroup()`. +- Refactor focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` exited 0 with 113 tests passed. +- Refactor feature-scoped coverage: `npm --workspace packages/cli exec -- vitest run --coverage --coverage.include=src/services/agent/agent-group.service.ts --coverage.include=src/services/agent/agent.service.ts --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` exited 0 with statements 94.75%, branches 88.23%, functions 97.01%, and lines 95.07%. +- Refactor full CLI regression: `npm --workspace packages/cli test` exited 0 with 67 test files passed and 806 tests passed. +- Refactor lint: `npm --workspace packages/cli run lint` exited 0 with the same 5 pre-existing warnings outside the agent-groups changes. +- Refactor build: `npm --workspace packages/cli run build` exited 0 and compiled 170 files with SWC before declaration/template output. +- Refactor feature docs lint: `npx ai-devkit@latest lint --feature agent-groups` exited 0. +- Final review finding: invalid single-target `--timeout` was parsed inside `sendToAgent()` after prompt resolution, so an invalid timeout could be reported after message-source errors. Fixed by validating timeout syntax in `assertSendTargetOptions()` before `resolveSendMessage()`. +- Final review focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` exited 0 with 114 tests passed. +- Final review full CLI regression: `npm --workspace packages/cli test` exited 0 with 67 test files passed and 807 tests passed. +- Final review lint: `npm --workspace packages/cli run lint` exited 0 with the same 5 pre-existing warnings outside the agent-groups changes. +- Final review build: `npm --workspace packages/cli run build` exited 0 and compiled 170 files with SWC before declaration/template output. +- Final review feature docs lint: `npx ai-devkit@latest lint --feature agent-groups` exited 0. +- Simplification follow-up: extracted the inline single-agent send reporter adapter from `agent send` into `createCommandSendReporter()`, keeping command routing readable while preserving service/reporting behavior. +- Simplification focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` exited 0 with 114 tests passed. +- Test stability follow-up: widened the polling timeout in the `startAgent` polling unit test to avoid full-suite scheduling jitter while preserving the same polling behavior assertion. +- Final simplification regression: `npm --workspace packages/cli test` exited 0 with 67 test files passed and 807 tests passed. +- Final simplification lint: `npm --workspace packages/cli run lint` exited 0 with the same 5 pre-existing warnings outside the agent-groups changes. +- Final simplification build: `npm --workspace packages/cli run build` exited 0 and compiled 170 files with SWC before declaration/template output. +- Final simplification feature docs lint: `npx ai-devkit@latest lint --feature agent-groups` exited 0. +- Naming simplification follow-up: renamed the public group persistence abstraction to `AgentGroupService` and replaced the static default accessor with `createDefaultAgentGroupService()` so command code uses service terminology. +- Naming simplification focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` exited 0 with 114 tests passed. +- Naming simplification full regression: `npm --workspace packages/cli test` exited 0 with 67 test files passed and 807 tests passed. +- Naming simplification lint: `npm --workspace packages/cli run lint` exited 0 with the same 5 pre-existing warnings outside the agent-groups changes. +- Naming simplification build: `npm --workspace packages/cli run build` exited 0 and compiled 170 files with SWC before declaration/template output. +- Naming simplification feature docs lint: `npx ai-devkit@latest lint --feature agent-groups` exited 0. +- Command split follow-up: extracted `agent group` command registration from `packages/cli/src/commands/agent.ts` into `packages/cli/src/commands/agent/group.command.ts`, reducing the root command file to 739 lines while preserving the existing command behavior. +- Command split focused regression: `npm --workspace packages/cli test -- --run src/__tests__/commands/agent.test.ts src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts` exited 0 with 114 tests passed. +- Command split full regression: `npm --workspace packages/cli test` exited 0 with 67 test files passed and 807 tests passed. +- Command split lint: `npm --workspace packages/cli run lint` exited 0 with the same 5 pre-existing warnings outside the agent-groups changes. +- Command split build: `npm --workspace packages/cli run build` exited 0 and compiled 171 files with SWC before declaration/template output. +- Command split feature docs lint: `npx ai-devkit@latest lint --feature agent-groups` exited 0. +- Second review finding: the `keeps polling until findAgentPid returns a PID` test still used the original 50ms polling timeout and could repeat the earlier full-suite scheduling flake. Fixed by widening that test's timeout to 250ms while preserving the same polling assertion. +- Second review focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent.service.test.ts` exited 0 with 29 tests passed. +- Second review feature regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` exited 0 with 114 tests passed. +- Second review full regression: `npm --workspace packages/cli test` exited 0 with 67 test files passed and 807 tests passed. +- API naming cleanup: removed the `waitForResponse` alias and kept the explicit `waitForAgentResponse()` export. +- API naming cleanup verification: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent.service.test.ts` exited 0 with 29 tests passed; focused feature tests, full CLI tests, build, lint, and feature docs lint also exited 0. +- Test cleanup: removed stale `mockWaitForAgentResponse` scaffolding from `agent.test.ts` after send wait behavior moved into the real `sendToAgent()` service path. +- Test cleanup verification: `npm --workspace packages/cli test -- --run src/__tests__/commands/agent.test.ts` exited 0 with 66 tests passed; focused feature tests, full CLI tests, build, and lint also exited 0. + +## Manual Testing + +- Live-agent smoke is pending. I did not send prompts to arbitrary running agents; a controlled pair of tmux-managed test agents is needed for the final manual check. + +## Security Notes + +- The storage foundation does not invoke shell commands. +- User-provided group names and members are validated or normalized before persistence. diff --git a/docs/ai/planning/2026-06-16-feature-agent-groups.md b/docs/ai/planning/2026-06-16-feature-agent-groups.md new file mode 100644 index 00000000..643ab821 --- /dev/null +++ b/docs/ai/planning/2026-06-16-feature-agent-groups.md @@ -0,0 +1,170 @@ +--- +phase: planning +title: Agent Groups - Implementation Plan +description: Task breakdown for local agent group management and group send fan-out +--- + +# Agent Groups - Implementation Plan + +## Milestones + +- [x] Milestone 1: Group storage foundation with validation, typed errors, and focused unit tests. +- [x] Milestone 2: `agent group` management commands for create, update, add, remove-agent, remove, list, and detail. +- [x] Milestone 3: `agent send --group` fan-out path with selector validation, pre-delivery resolution, deduplication, and partial failure reporting. +- [ ] Milestone 4: Regression coverage, docs reconciliation, and manual smoke checklist. + +## Task Breakdown + +### Phase 1: Storage Foundation + +- [x] Task 1.1: Implement `AgentGroupService` in `packages/cli/src/services/agent/agent-group.service.ts`. + - Outcome: local `~/.ai-devkit/agent-groups.json` storage supports list, get, create, update, add member, remove member, and remove. + - Dependencies: requirements and design docs. + - Validation evidence: new unit tests for missing file, parent directory creation, formatted writes, atomic temp-file rename, CRUD behavior, and malformed JSON. + - Related tests: `AgentGroupService` scenarios for file creation, missing file, create, update, add, remove member, remove, and malformed JSON. + +- [x] Task 1.2: Add group schema validation, normalization, and typed errors. + - Outcome: invalid group names, empty member lists, blank members, duplicate members, unknown file versions, conflicts, and not-found operations produce explicit typed errors. + - Dependencies: Task 1.1. + - Validation evidence: unit tests assert each typed error and unchanged storage for invalid operations. + - Related tests: invalid names, empty members, blank members, duplicate members, existing group conflict, missing group, removing the last member, malformed storage. + +- [x] Task 1.3: Decide and implement duplicate add-member semantics. + - Outcome: `agent group add ` has one explicit behavior for an existing member, documented in implementation notes. + - Dependencies: Task 1.2. + - Validation evidence: unit test for duplicate add-member behavior. + - Related tests: add-member duplicate scenario. + +Storage foundation status: completed with `AgentGroupService` and `agent-group.service.test.ts`. Duplicate add-member semantics are idempotent after trimming. + +### Phase 2: Group Management CLI + +- [x] Task 2.1: Register `agent group` nested commands via `packages/cli/src/commands/agent/group.command.ts`. + - Outcome: create, update, add, remove-agent, remove, list, and detail commands are available under `agent group`. + - Dependencies: Task 1.1 and Task 1.2. + - Validation evidence: commander-driven tests parse each command and call the store through test-controlled storage. + - Related tests: CLI group create, update, add, remove-agent, remove, list, detail. + +- [x] Task 2.2: Add command output and error handling for group management. + - Outcome: mutations print concise success messages, list/detail print readable group/member output, and service typed errors become user-readable non-zero command results. + - Dependencies: Task 2.1. + - Validation evidence: CLI tests assert output for success paths and not-found, conflict, invalid name, invalid member, empty members, and malformed storage errors. + - Related tests: CLI group user-readable errors and group output scenarios. + +Group management CLI status: completed with `agent group create/update/add/remove-agent/remove/list/detail` command wiring and command tests. + +### Phase 3: Group Send Fan-Out + +- [x] Task 3.1: Change `agent send` target selector validation. + - Outcome: `--id` becomes optional at Commander registration, and action-level validation requires exactly one of `--id` or `--group`. + - Dependencies: existing `agent send` command behavior. + - Validation evidence: existing `--id` tests still pass; new tests reject missing target and `--id` plus `--group`. + - Related tests: omit both selectors, provide both selectors, existing single-target regression. + +- [x] Task 3.2: Add pre-delivery group send option validation and prompt resolution. + - Outcome: `--group` rejects `--wait`, `--timeout`, and `--json` before sending, then reads positional or stdin prompt exactly once for valid group sends. + - Dependencies: Task 3.1 and existing `resolveSendMessage()`. + - Validation evidence: CLI tests prove invalid option combinations do not call terminal send; stdin tests prove the same prompt is sent to every target. + - Related tests: reject `--wait`, reject `--timeout`, reject `--json`, explicit stdin, implicit piped stdin. + +- [x] Task 3.3: Implement group member resolution. + - Outcome: group send loads the named group, lists live agents once, resolves each stored member against that snapshot, reports all missing and ambiguous members, and fails before delivery when the target set is not fully resolved. + - Dependencies: Task 1.1, Task 1.2, Task 3.1. + - Validation evidence: CLI tests verify missing group, empty group, missing member, ambiguous member, and no terminal delivery before resolution success. + - Related tests: group not found, no members, unresolved member, ambiguous member. + +- [x] Task 3.4: Implement distinct target fan-out delivery. + - Outcome: resolved live agents are deduplicated, non-waiting targets warn, delivery runs sequentially through `TerminalFocusManager.findTerminal()` and `TtyWriter.send()`, and successful sends are reported per agent. + - Dependencies: Task 3.3. + - Validation evidence: CLI tests assert target deduplication, delivery order, non-waiting warnings, and use of existing terminal writer path. + - Related tests: send positional message to every distinct target, dedupe same live agent, warn for non-waiting target, terminal path integration. + +- [x] Task 3.5: Implement runtime failure handling and summary output. + - Outcome: terminal discovery or send failure for one resolved target does not stop later targets; the command reports success/failure counts and exits non-zero if any target fails. + - Dependencies: Task 3.4. + - Validation evidence: CLI tests assert continued delivery after one failure, non-zero exit code, and summary output. + - Related tests: continues after terminal send failure, sets non-zero exit code, prints summary. + +Group send fan-out status: completed with selector validation, unsupported group option rejection, stdin reuse, pre-delivery resolution, deduplication, sequential terminal delivery, partial failure reporting, and focused command tests. + +### Phase 4: Regression, Docs, and Smoke + +- [x] Task 4.1: Run focused CLI and service tests. + - Outcome: new and existing command coverage passes locally. + - Dependencies: Task 1.1 through Task 3.5. + - Validation evidence: `npm test -- --run packages/cli/src/__tests__/commands/agent.test.ts` plus any new focused store test file. + - Related tests: all unit and commander-driven scenarios. + +- [x] Task 4.2: Run feature lint and update implementation/testing docs. + - Outcome: implementation notes describe files changed, behavior decisions, and verification commands; testing doc checkboxes reflect completed automated coverage and manual smoke status. + - Dependencies: Task 4.1. + - Validation evidence: `npx ai-devkit@latest lint --feature agent-groups`. + - Related tests: docs lifecycle validation. + +- [ ] Task 4.3: Perform manual smoke when live agents are available. + - Outcome: two tmux-managed agents receive one group send, and deleted groups produce not-found output. + - Dependencies: implementation complete and local live agents available. + - Validation evidence: command transcript or implementation note. If live agents are unavailable, record the skipped manual smoke and reason. + - Related tests: manual smoke scenarios. + +Manual smoke status: pending. No controlled pair of live tmux-managed test agents was available during implementation, so automated coverage and build verification are complete while live-agent smoke remains a follow-up check. + +Phase 6 reconciliation: implementation tasks 1.1 through 4.2 are complete and verified. Task 4.3 remains pending because it requires controlled live tmux-managed agents; it is not blocking automated implementation verification. No new implementation tasks were discovered during reconciliation. + +Phase 8 testing review: automated unit, command, focused feature coverage, full CLI regression, build, lint, and feature-doc lint checks are complete. Task 4.3 remains pending for a controlled live-agent smoke only. + +Phase 9 review: final review found one invalid-name boundary gap in `AgentGroupService.get()`. The gap is fixed with service and command regression tests. No blocking review findings remain after re-verification. + +Refactor follow-up: group persistence now lives behind `AgentGroupService` in `agent-group.service.ts`, while `agent.service.ts` owns `sendToAgent()`, `waitForAgentResponse()`, and `sendToAgentGroup()`. This keeps `commands/agent.ts` focused on root command wiring, send parsing, prompt resolution, group lookup, and service invocation. + +Command split follow-up: `agent group` management command registration now lives in `packages/cli/src/commands/agent/group.command.ts`, reducing the root `agent.ts` file and keeping group CRUD command wiring colocated. + +## Dependencies + +- Storage tasks must land before CLI management commands. +- CLI management commands should land before group send, because group send depends on persisted group state. +- `agent send` selector validation should be changed before group fan-out logic to keep invalid combinations from reaching delivery. +- Runtime delivery can reuse existing `TerminalFocusManager` and `TtyWriter`; no agent-manager package changes are planned. +- Manual smoke depends on available live agents and supported terminal setup. + +## Timeline & Estimates + +- Storage foundation: medium, mostly local file validation and tests. +- Group management CLI: medium, command wiring plus output/error tests. +- Group send fan-out: large, highest risk due to preserving existing `agent send --id` and wait behavior while adding a second selector. +- Regression/docs/manual smoke: small to medium, depending on live-agent availability. + +## Risks & Mitigation + +- **Risk:** Changing `--id` from required to optional could regress existing error behavior. + - Mitigation: add explicit target-selector tests and run existing `agent send --id` tests. +- **Risk:** Group sends could partially deliver after a stale or ambiguous member if resolution is interleaved with delivery. + - Mitigation: implement a strict pre-delivery resolution phase before any terminal lookup or send. +- **Risk:** Tests could mutate real `~/.ai-devkit/agent-groups.json`. + - Mitigation: inject store file paths and use temporary test directories. +- **Risk:** Malformed storage handling could hide user data problems. + - Mitigation: typed storage errors include the file path and do not rewrite malformed files. +- **Risk:** Group wait mode scope may creep into this feature. + - Mitigation: reject `--wait --group` before sending and document group wait as a future enhancement. + +## Resources Needed + +- Existing CLI command test harness in `packages/cli/src/__tests__/commands/agent.test.ts`. +- Existing terminal delivery mocks for `TerminalFocusManager` and `TtyWriter`. +- Temporary filesystem fixtures for `AgentGroupService` tests. +- Optional live tmux-managed agents for final manual smoke. + +## Next Actions + +- If live smoke is required before merge, start two controlled tmux-managed test agents and run `agent send --group` against only those agents. +- Prepare the branch for commit/PR once the pending manual smoke decision is made. + +## Test Coverage Traceability + +- `AgentGroupService` tests are covered by Tasks 1.1, 1.2, and 1.3. +- CLI `agent group` tests are covered by Tasks 2.1 and 2.2. +- CLI `agent send --group` selector and option tests are covered by Tasks 3.1 and 3.2. +- Group member resolution, missing, ambiguous, and dedupe tests are covered by Tasks 3.3 and 3.4. +- Runtime failure and summary tests are covered by Task 3.5. +- Integration and regression tests are covered by Task 4.1. +- Manual smoke tests are covered by Task 4.3. diff --git a/docs/ai/requirements/2026-06-16-feature-agent-groups.md b/docs/ai/requirements/2026-06-16-feature-agent-groups.md new file mode 100644 index 00000000..561348c6 --- /dev/null +++ b/docs/ai/requirements/2026-06-16-feature-agent-groups.md @@ -0,0 +1,120 @@ +--- +phase: requirements +title: Agent Groups +description: Create, update, remove, list, and send messages to named groups of running agents +--- + +# Agent Groups + +## Problem Statement + +Developers using `ai-devkit agent send` can target one running agent at a time with `--id`, but common multi-agent workflows require sending the same instruction to several agents. Today the user must repeat the same command for every target agent, keep a separate note of which agent names belong together, and manually handle partial failures. + +Affected users are developers coordinating multiple Codex, Claude Code, Gemini CLI, OpenCode, Copilot, or Pi sessions from the CLI or TUI-adjacent scripts. + +The current workaround is shell scripting around repeated `agent send --id ` invocations. That duplicates target lists, makes reuse awkward, and gives each user a different failure-handling behavior. + +## Goals & Objectives + +Primary goals: + +- Let users create named agent groups that contain multiple agent identifiers. +- Let users update a group by replacing, adding, or removing members. +- Let users remove groups when they are no longer useful. +- Let users list configured groups and inspect their members. +- Add `agent send --group ` so one prompt is delivered to every currently resolvable member in the group. +- Preserve existing single-target `agent send --id ` behavior. +- Store groups locally in the same user-scoped configuration area as other AI DevKit agent metadata. + +Secondary goals: + +- Reuse the existing agent resolution and terminal delivery path for each group member. +- Provide clear output for successful sends and per-member failures. +- Make the storage format simple enough for users to inspect if needed, while keeping mutation through CLI commands. + +Non-goals: + +- Do not add nested groups in the first version. +- Do not add queueing, retries, or durable delivery guarantees. +- Do not start missing agents automatically. +- Do not make group membership dynamic by type, cwd, status, tag, or query. +- Do not add group management to the interactive console in this feature. +- Do not support `--wait` with `--group` in the first version. + +## User Stories & Use Cases + +- As a developer coordinating parallel implementation agents, I want to create `backend-team` with several agent IDs so that I can reuse the same target set. + - `ai-devkit agent group create backend-team --agent api --agent worker --agent tests` +- As a developer, I want to add or remove agents from a group as sessions change so that the group stays useful during a feature. + - `ai-devkit agent group add backend-team docs` + - `ai-devkit agent group remove-agent backend-team tests` +- As a developer, I want to replace a group's members in one command so that scripts can set the exact desired state. + - `ai-devkit agent group update backend-team --agent api-v2 --agent worker-v2` +- As a developer, I want to remove a stale group so that old agent names do not clutter my setup. + - `ai-devkit agent group remove backend-team` +- As a developer, I want to send one prompt to all members of a group so that I can coordinate multiple agents without repeated commands. + - `ai-devkit agent send "status update please" --group backend-team` +- As a developer using pipelines, I want stdin prompt behavior to work for groups just like it works for single agents. + - `npm test 2>&1 | ai-devkit agent send --group backend-team --stdin` + +Edge cases: + +- Group name already exists on create. +- Group name does not exist for update, remove, inspect, or send. +- Group has no members after an invalid operation attempt. +- One stored member no longer resolves to a running agent. +- One stored member resolves ambiguously. +- Two stored members resolve to the same live agent. +- A target agent is not in `waiting` or `idle` status. +- One target terminal cannot be found or does not support send. +- Both `--id` and `--group` are provided. +- `--wait` or `--json` is provided with `--group`. + +## Success Criteria + +- `ai-devkit agent group create --agent --agent ` creates a named group with at least one member. +- `ai-devkit agent group list` displays configured groups and their member identifiers. +- `ai-devkit agent group detail ` displays one group's member identifiers. +- `ai-devkit agent group update --agent ...` replaces the full member list. +- `ai-devkit agent group add ` appends a member and avoids duplicates. +- `ai-devkit agent group remove-agent ` removes one member and leaves the group in a valid state. +- `ai-devkit agent group remove-agent ` exits non-zero and keeps the group unchanged, because empty groups are invalid. +- `ai-devkit agent group remove ` deletes the group. +- Group commands report clear not-found, conflict, invalid-name, invalid-member, and malformed-storage errors. +- Group names follow the existing agent name convention: lowercase letters, digits, and hyphens; start and end with a letter or digit; 2-64 characters. +- Group member identifiers are stored as user-provided agent identifiers and resolved at send time, not copied as process IDs. +- `agent send --group ` sends the same resolved prompt to every distinct live agent in the group. +- `agent send --group ` supports explicit `--stdin` and implicit piped stdin with the same validation as single-target send. +- `agent send --group ` reports per-agent success and per-agent failure, and exits non-zero if any member fails. +- `agent send --group ` fails before sending anything when the group does not exist, has no members, any member identifier is missing, or any member identifier is ambiguous. +- `--id` and `--group` are mutually exclusive. +- `--wait` with `--group` exits non-zero before sending and explains that wait mode is single-target only. +- `--json` with `--group` exits non-zero before sending because group result JSON is out of scope for this feature. +- Existing `agent send --id `, `agent list`, `agent open`, `agent rename`, and `agent detail` behavior is unchanged. + +## Constraints & Assumptions + +- Group storage is local to the user and machine. It is not project-shared or synced. +- Group membership uses stable user-facing agent identifiers, normally registry names, because process IDs and session IDs are not stable across restarts. +- Send fan-out is sequential in the first version to keep output deterministic and reuse existing error handling safely. +- The same terminal support constraints as single-target `agent send` apply: tmux, iTerm2, Terminal.app, and Linux via tmux. +- Subprocess calls must continue to use shell-safe patterns already used by the single-target send implementation. +- A group with duplicate member identifiers is invalid at write time. +- If two different member identifiers resolve to the same live agent at send time, the prompt is sent once and the output reports the deduplication. + +## Resolved Decisions + +- **Group command shape:** Use `agent group ` for management and `agent send --group ` for delivery. This keeps group storage separate from message delivery while making send usage concise. +- **Wait mode:** Defer group `--wait`; it introduces response aggregation, timeout semantics, and output ordering decisions that should be designed separately. +- **Partial failures:** Send to all unambiguous, distinct resolved agents and return non-zero if any delivery fails. Ambiguous member resolution fails before sending to avoid delivering to an unintended agent. +- **Storage owner:** Implement group storage in the CLI agent service layer because groups are local CLI configuration, while `agent-manager` should stay focused on live agent discovery and terminal interaction. + +## Alternatives Considered + +- **Document shell loops only:** rejected because it would not provide persistent reusable groups, shared validation, or consistent failure behavior. +- **Dynamic groups by agent type, cwd, or status:** deferred because explicit groups are safer for first-version fan-out and avoid surprising targets. +- **Nested groups:** deferred because cycle detection and expansion errors add complexity without being necessary for the requested workflow. + +## Questions & Open Items + +- None blocking for requirements review. Group `--wait` and group JSON result output are intentionally deferred future enhancements. diff --git a/docs/ai/testing/2026-06-16-feature-agent-groups.md b/docs/ai/testing/2026-06-16-feature-agent-groups.md new file mode 100644 index 00000000..9b3575b8 --- /dev/null +++ b/docs/ai/testing/2026-06-16-feature-agent-groups.md @@ -0,0 +1,178 @@ +--- +phase: testing +title: Agent Groups - Testing Strategy +description: Test coverage for local agent group management and group send fan-out +--- + +# Agent Groups - Testing Strategy + +## Test Coverage Goals + +- Unit test 100% of new group service behavior. +- Cover new CLI command branches in `packages/cli/src/__tests__/commands/agent.test.ts`. +- Preserve existing `agent send --id` and `agent send --wait` tests unchanged. +- Mock file storage, `AgentManager`, `TerminalFocusManager`, and `TtyWriter` consistently with existing command tests. + +## Unit Tests + +### `AgentGroupService` + +- [x] Creates the group file parent directory when needed. +- [x] Treats a missing group file as an empty list. +- [x] Lists groups sorted or in persisted order, matching the implemented CLI contract. +- [x] Creates a group with valid name and members. +- [x] Rejects invalid group names. +- [x] Rejects empty member lists. +- [x] Rejects blank member identifiers. +- [x] Rejects duplicate member identifiers on create and update. +- [x] Rejects creating an existing group. +- [x] Updates a group by replacing all members. +- [x] Adds a new member to an existing group. +- [x] Keeps add-member idempotent or errors on duplicates, matching the final implementation decision. +- [x] Removes one member from an existing group. +- [x] Rejects removing the last member from a group. +- [x] Removes a group. +- [x] Throws a clear parse error for malformed JSON. + +Storage test evidence: + +- `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts`: exit 0, 18 tests passed. +- `npm --workspace packages/cli run lint`: exit 0, with 5 pre-existing warnings outside the new group service files. + +### CLI `agent group` + +- [x] `agent group create --agent a --agent b` persists a group and prints success. +- [x] `agent group create` with no `--agent` exits non-zero before writing. +- [x] `agent group update` replaces members and prints success. +- [x] `agent group add` appends a member. +- [x] `agent group remove-agent` removes a member. +- [x] `agent group remove` deletes a group. +- [x] `agent group list` prints configured groups. +- [x] `agent group detail ` prints group members. +- [x] Not-found, conflict, invalid name, invalid member, and malformed file errors are user-readable. + +Group management CLI evidence: + +- `npm --workspace packages/cli test -- --run src/__tests__/commands/agent.test.ts`: exit 0, 57 tests passed. +- `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 75 tests passed. + +### CLI `agent send --group` + +- [x] Rejects calls that omit both `--id` and `--group`. +- [x] Rejects calls that provide both `--id` and `--group`. +- [x] Rejects `--group` with `--wait` before sending. +- [x] Rejects `--group` with `--timeout` before sending. +- [x] Rejects `--group` with `--json` before sending. +- [x] Resolves a group and sends the positional message to every distinct target. +- [x] Reads explicit `--stdin` once and sends the same prompt to every target. +- [x] Reads implicit piped stdin once and sends the same prompt to every target. +- [x] Fails before sending when the group does not exist. +- [x] Fails before sending when the group has no members. +- [x] Fails before sending when any member does not resolve. +- [x] Fails before sending when any member resolves ambiguously. +- [x] Deduplicates two group members that resolve to the same live agent. +- [x] Warns for non-waiting targets and still attempts delivery. +- [x] Continues delivery after one target terminal send fails. +- [x] Sets a non-zero exit code when any target delivery fails. +- [x] Prints a success/failure summary. + +Group send evidence: + +- `npm --workspace packages/cli test -- --run src/__tests__/commands/agent.test.ts`: exit 0, 64 tests passed. +- `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 82 tests passed. +- `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 68 tests passed after simplification. +- `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 86 tests passed after simplification. +- Phase 9 review fix: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 89 tests passed after adding invalid-name coverage for `get()`, `group detail`, and `send --group`. +- `npm --workspace packages/agent-manager run build`: exit 0, compiled 39 files. +- `npm --workspace packages/channel-connector run build`: exit 0, compiled 11 files. +- `npm --workspace packages/cli test`: exit 0, 67 test files passed, 800 tests passed. +- Phase 7 fresh regression: `npm --workspace packages/cli test`: exit 0, 68 test files passed, 804 tests passed. +- Phase 7 fresh build: `npm --workspace packages/cli run build`: exit 0, compiled 172 files with SWC before declaration/template output. +- Phase 7 fresh lint: `npm --workspace packages/cli run lint`: exit 0, with 5 pre-existing warnings outside the agent-groups changes. +- Phase 9 final regression: `npm --workspace packages/cli test`: exit 0, 68 test files passed, 807 tests passed. +- Phase 9 final build: `npm --workspace packages/cli run build`: exit 0, compiled 172 files with SWC before declaration/template output. +- Phase 9 final lint: `npm --workspace packages/cli run lint`: exit 0, with the same 5 pre-existing warnings outside the agent-groups changes. +- Refactor focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 113 tests passed after consolidating send orchestration in `agent.service.ts`. +- Refactor feature-scoped coverage: `npm --workspace packages/cli exec -- vitest run --coverage --coverage.include=src/services/agent/agent-group.service.ts --coverage.include=src/services/agent/agent.service.ts --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0; statements 94.75%, branches 88.23%, functions 97.01%, lines 95.07%. +- Refactor full CLI regression: `npm --workspace packages/cli test`: exit 0, 67 test files passed, 806 tests passed. +- Refactor build: `npm --workspace packages/cli run build`: exit 0, compiled 170 files with SWC before declaration/template output. +- Refactor lint: `npm --workspace packages/cli run lint`: exit 0, with the same 5 pre-existing warnings outside the agent-groups changes. +- Refactor feature docs lint: `npx ai-devkit@latest lint --feature agent-groups`: exit 0. +- Final review focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 114 tests passed after adding early invalid-timeout validation coverage. +- Final review full CLI regression: `npm --workspace packages/cli test`: exit 0, 67 test files passed, 807 tests passed. +- Final review build: `npm --workspace packages/cli run build`: exit 0, compiled 170 files with SWC before declaration/template output. +- Final review lint: `npm --workspace packages/cli run lint`: exit 0, with the same 5 pre-existing warnings outside the agent-groups changes. +- Final review feature docs lint: `npx ai-devkit@latest lint --feature agent-groups`: exit 0. +- Simplification focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 114 tests passed after extracting the command reporter adapter. +- Simplification final regression: `npm --workspace packages/cli test`: exit 0, 67 test files passed, 807 tests passed after stabilizing the `startAgent` polling unit test timeout. +- Simplification final build: `npm --workspace packages/cli run build`: exit 0, compiled 170 files with SWC before declaration/template output. +- Simplification final lint: `npm --workspace packages/cli run lint`: exit 0, with the same 5 pre-existing warnings outside the agent-groups changes. +- Simplification final feature docs lint: `npx ai-devkit@latest lint --feature agent-groups`: exit 0. +- Naming simplification focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 114 tests passed after renaming the public group abstraction to `AgentGroupService`. +- Naming simplification full regression: `npm --workspace packages/cli test`: exit 0, 67 test files passed, 807 tests passed. +- Naming simplification build: `npm --workspace packages/cli run build`: exit 0, compiled 170 files with SWC before declaration/template output. +- Naming simplification lint: `npm --workspace packages/cli run lint`: exit 0, with the same 5 pre-existing warnings outside the agent-groups changes. +- Naming simplification feature docs lint: `npx ai-devkit@latest lint --feature agent-groups`: exit 0. +- Command split focused regression: `npm --workspace packages/cli test -- --run src/__tests__/commands/agent.test.ts src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts`: exit 0, 114 tests passed after extracting `agent group` registration to `commands/agent/group.command.ts`. +- Command split full regression: `npm --workspace packages/cli test`: exit 0, 67 test files passed, 807 tests passed. +- Command split build: `npm --workspace packages/cli run build`: exit 0, compiled 171 files with SWC before declaration/template output. +- Command split lint: `npm --workspace packages/cli run lint`: exit 0, with the same 5 pre-existing warnings outside the agent-groups changes. +- Command split feature docs lint: `npx ai-devkit@latest lint --feature agent-groups`: exit 0. +- Second review polling test fix: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent.service.test.ts`: exit 0, 29 tests passed after widening the remaining timing-sensitive polling test timeout. +- Second review feature regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 114 tests passed. +- Second review full regression: `npm --workspace packages/cli test`: exit 0, 67 test files passed, 807 tests passed. +- API naming cleanup focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent.service.test.ts`: exit 0, 29 tests passed after removing the `waitForResponse` alias. +- API naming cleanup feature regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 114 tests passed. +- API naming cleanup full regression: `npm --workspace packages/cli test`: exit 0, 67 test files passed, 807 tests passed. +- API naming cleanup build: `npm --workspace packages/cli run build`: exit 0, compiled 171 files with SWC before declaration/template output. +- API naming cleanup lint: `npm --workspace packages/cli run lint`: exit 0, with the same 5 pre-existing warnings outside the agent-groups changes. +- Test cleanup command regression: `npm --workspace packages/cli test -- --run src/__tests__/commands/agent.test.ts`: exit 0, 66 tests passed after removing stale wait-response mock scaffolding. +- Test cleanup feature regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 114 tests passed. +- Test cleanup full regression: `npm --workspace packages/cli test`: exit 0, 67 test files passed, 807 tests passed. +- Test cleanup build: `npm --workspace packages/cli run build`: exit 0, compiled 171 files with SWC before declaration/template output. +- Test cleanup lint: `npm --workspace packages/cli run lint`: exit 0, with the same 5 pre-existing warnings outside the agent-groups changes. + +## Integration Tests + +- [x] Commander-driven CLI tests cover group create, list, detail, update, add, remove-agent, remove, and send. +- [x] Mocked live-agent tests verify group send uses the same `TerminalFocusManager.findTerminal()` and `TtyWriter.send()` path as single-target send. +- [x] Regression tests confirm representative existing `agent send --id` tests still pass. + +## End-to-End Tests + +- [ ] Manual smoke: start two tmux-managed agents, create a group with both names, run `agent send "say ready" --group `, and confirm both terminals receive the prompt. Pending because no controlled live tmux-managed test agents were available during implementation. +- [ ] Manual smoke: delete the group and confirm `agent send --group ` reports not found. Pending with the live-agent smoke. + +## Test Data + +- Temporary directory for the group JSON file. +- Mock groups: `backend-team`, `reviewers`, and `docs-team`. +- Mock agents with names, slugs, statuses, PIDs, and terminal locations. +- Ambiguous resolution fixture with two live agents matching one member identifier. + +## Test Reporting & Coverage + +- Focused feature tests: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 114 tests passed. +- Simplification focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 114 tests passed. +- Naming simplification focused regression: `npm --workspace packages/cli test -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0, 114 tests passed. +- Command split focused regression: `npm --workspace packages/cli test -- --run src/__tests__/commands/agent.test.ts src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts`: exit 0, 114 tests passed. +- Full CLI regression: `npm --workspace packages/cli test`: exit 0, 67 test files passed, 807 tests passed after the second review polling-test fix. +- Feature-scoped coverage: `npm --workspace packages/cli exec -- vitest run --coverage --coverage.include=src/services/agent/agent-group.service.ts --coverage.include=src/services/agent/agent.service.ts --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts`: exit 0; statements 94.75%, branches 88.23%, functions 97.01%, lines 95.07%. +- Broad focused coverage note: `npm --workspace packages/cli run test:coverage -- --run src/__tests__/services/agent/agent-group.service.test.ts src/__tests__/services/agent/agent.service.test.ts src/__tests__/commands/agent.test.ts` ran the same 86 tests successfully but exited 1 because the package coverage threshold measures all `src/**/*.ts` while only the feature suites were selected. +- Feature lint: `npx ai-devkit@latest lint --feature agent-groups`: exit 0. + +## Manual Testing + +- Validate human-readable command output for group management. +- Validate group send output when all targets succeed. +- Validate group send output when one target fails delivery. +- Validate no prompt is sent when group resolution is ambiguous. + +## Performance Testing + +- No dedicated load testing required for v1. +- Unit tests should cover at least a group with 10 members to catch accidental quadratic or output formatting issues. + +## Bug Tracking + +- Bugs found during implementation should be added to the planning doc as follow-up tasks or blockers. diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index ccb105e9..3837cb0d 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -31,10 +31,19 @@ const mockSpinner: any = { const mockSelect: any = vi.fn(); const mockTtyWriterSend = vi.fn<(location: any, message: string) => Promise>().mockResolvedValue(undefined); -const mockWaitForAgentResponse = vi.fn<(...args: any[]) => Promise>(); const mockKillAgent = vi.fn<(...args: any[]) => Promise>(); let restoreStdin: (() => void) | undefined; +const mockGroupStore: any = { + list: vi.fn(), + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + addMember: vi.fn(), + removeMember: vi.fn(), + remove: vi.fn(), +}; + const mockRegistry: any = { prune: vi.fn(), lookup: vi.fn().mockReturnValue(null), @@ -120,11 +129,67 @@ vi.mock('../../util/terminal-ui.js', () => ({ }, })); -vi.mock('../../services/agent/agent.service.js', () => ({ - waitForAgentResponse: (...args: any[]) => mockWaitForAgentResponse(...args), - killAgent: (...args: any[]) => mockKillAgent(...args), +vi.mock('../../services/agent/agent.service.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + killAgent: (...args: any[]) => mockKillAgent(...args), + }; +}); + +vi.mock('../../tui/console/ConsoleApp.js', () => ({ + ConsoleApp: () => null, })); +vi.mock('../../services/agent/agent-group.service.js', () => { + class AgentGroupNotFoundError extends Error { + constructor(public groupName: string) { + super(`Agent group "${groupName}" not found.`); + this.name = 'AgentGroupNotFoundError'; + } + } + class AgentGroupConflictError extends Error { + constructor(public groupName: string) { + super(`Agent group "${groupName}" already exists.`); + this.name = 'AgentGroupConflictError'; + } + } + class AgentGroupInvalidNameError extends Error { + constructor(public groupName: string) { + super(`Invalid agent group name "${groupName}".`); + this.name = 'AgentGroupInvalidNameError'; + } + } + class AgentGroupInvalidMemberError extends Error { + constructor(public member: string) { + super(`Invalid agent group member "${member}".`); + this.name = 'AgentGroupInvalidMemberError'; + } + } + class AgentGroupEmptyMembersError extends Error { + constructor() { + super('Agent group must contain at least one member.'); + this.name = 'AgentGroupEmptyMembersError'; + } + } + class AgentGroupStorageError extends Error { + constructor(public filePath: string, message: string) { + super(`Failed to read agent groups from "${filePath}": ${message}`); + this.name = 'AgentGroupStorageError'; + } + } + + return { + createDefaultAgentGroupService: vi.fn(() => mockGroupStore), + AgentGroupNotFoundError, + AgentGroupConflictError, + AgentGroupInvalidNameError, + AgentGroupInvalidMemberError, + AgentGroupEmptyMembersError, + AgentGroupStorageError, + }; +}); + describe('agent command', () => { let logSpy: ReturnType; let stdoutSpy: ReturnType; @@ -133,6 +198,19 @@ describe('agent command', () => { restoreStdin?.(); restoreStdin = undefined; vi.clearAllMocks(); + mockManager.registerAdapter.mockReset(); + mockManager.listAgents.mockReset(); + mockManager.listSessions.mockReset(); + mockManager.resolveAgent.mockReset(); + mockManager.getAdapter.mockReset(); + mockAgentAdapter.getConversation.mockReset(); + mockFocusManager.findTerminal.mockReset(); + mockFocusManager.focusTerminal.mockReset(); + mockTtyWriterSend.mockReset().mockResolvedValue(undefined); + mockKillAgent.mockReset(); + Object.values(mockGroupStore).forEach((method) => method.mockReset()); + mockGroupStore.list.mockReturnValue([]); + process.exitCode = undefined; logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); @@ -295,7 +373,7 @@ Waiting on user input`, it('focuses selected agent when open succeeds', async () => { const agent = { name: 'repo-a', - status: AgentStatus.RUNNING, + status: AgentStatus.WAITING, summary: 'A', lastActive: new Date(), pid: 10, @@ -378,10 +456,136 @@ Waiting on user input`, expect(mockKillAgent).not.toHaveBeenCalled(); }); + it('creates an agent group with multiple members', async () => { + mockGroupStore.create.mockReturnValue({ + name: 'backend-team', + members: ['api', 'worker'], + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'group', 'create', 'backend-team', '--agent', 'api', '--agent', 'worker']); + + expect(mockGroupStore.create).toHaveBeenCalledWith('backend-team', ['api', 'worker']); + expect(ui.success).toHaveBeenCalledWith('Created agent group "backend-team" with 2 member(s).'); + }); + + it('updates an agent group by replacing members', async () => { + mockGroupStore.update.mockReturnValue({ + name: 'backend-team', + members: ['api-v2', 'worker-v2'], + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'group', 'update', 'backend-team', '--agent', 'api-v2', '--agent', 'worker-v2']); + + expect(mockGroupStore.update).toHaveBeenCalledWith('backend-team', ['api-v2', 'worker-v2']); + expect(ui.success).toHaveBeenCalledWith('Updated agent group "backend-team" with 2 member(s).'); + }); + + it('adds a member to an agent group', async () => { + mockGroupStore.addMember.mockReturnValue({ + name: 'backend-team', + members: ['api', 'docs'], + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'group', 'add', 'backend-team', 'docs']); + + expect(mockGroupStore.addMember).toHaveBeenCalledWith('backend-team', 'docs'); + expect(ui.success).toHaveBeenCalledWith('Agent group "backend-team" now has 2 member(s).'); + }); + + it('removes a member from an agent group', async () => { + mockGroupStore.removeMember.mockReturnValue({ + name: 'backend-team', + members: ['worker'], + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'group', 'remove-agent', 'backend-team', 'api']); + + expect(mockGroupStore.removeMember).toHaveBeenCalledWith('backend-team', 'api'); + expect(ui.success).toHaveBeenCalledWith('Agent group "backend-team" now has 1 member(s).'); + }); + + it('removes an agent group', async () => { + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'group', 'remove', 'backend-team']); + + expect(mockGroupStore.remove).toHaveBeenCalledWith('backend-team'); + expect(ui.success).toHaveBeenCalledWith('Removed agent group "backend-team".'); + }); + + it('lists configured agent groups', async () => { + mockGroupStore.list.mockReturnValue([ + { name: 'backend-team', members: ['api', 'worker'] }, + { name: 'docs-team', members: ['docs'] }, + ]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'group', 'list']); + + expect(ui.table).toHaveBeenCalledWith(expect.objectContaining({ + headers: ['Group', 'Members'], + rows: [ + ['backend-team', 'api, worker'], + ['docs-team', 'docs'], + ], + })); + }); + + it('shows agent group detail', async () => { + mockGroupStore.get.mockReturnValue({ + name: 'backend-team', + members: ['api', 'worker'], + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'group', 'detail', 'backend-team']); + + expect(mockGroupStore.get).toHaveBeenCalledWith('backend-team'); + expect(ui.text).toHaveBeenCalledWith('Agent Group: backend-team', { breakline: true }); + expect(ui.text).toHaveBeenCalledWith(' - api'); + expect(ui.text).toHaveBeenCalledWith(' - worker'); + }); + + it('reports invalid group names for detail', async () => { + mockGroupStore.get.mockImplementation(() => { + throw new Error('Invalid agent group name "Bad_Name".'); + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'group', 'detail', 'Bad_Name']); + + expect(ui.error).toHaveBeenCalledWith('Failed to manage agent group: Invalid agent group name "Bad_Name".'); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('prints a user-readable group error and exits non-zero', async () => { + mockGroupStore.remove.mockImplementation(() => { + throw new Error('Agent group "missing" not found.'); + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'group', 'remove', 'missing']); + + expect(ui.error).toHaveBeenCalledWith('Failed to manage agent group: Agent group "missing" not found.'); + expect(process.exit).toHaveBeenCalledWith(1); + }); + it('sends message to a resolved agent', async () => { const agent = { name: 'repo-a', - status: AgentStatus.WAITING, + status: AgentStatus.RUNNING, summary: 'Waiting', lastActive: new Date(), pid: 10, @@ -460,6 +664,148 @@ Waiting on user input`, expect(mockTtyWriterSend).not.toHaveBeenCalled(); }); + it('fails before sending when no send target selector is provided', async () => { + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'hello']); + + expect(ui.error).toHaveBeenCalledWith('Failed to send message: Use exactly one of --id or --group.'); + expect(process.exit).toHaveBeenCalledWith(1); + expect(mockManager.listAgents).not.toHaveBeenCalled(); + expect(mockTtyWriterSend).not.toHaveBeenCalled(); + }); + + it('fails before sending when both --id and --group are provided', async () => { + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'hello', '--id', 'repo-a', '--group', 'backend-team']); + + expect(ui.error).toHaveBeenCalledWith('Failed to send message: Use exactly one of --id or --group.'); + expect(process.exit).toHaveBeenCalledWith(1); + expect(mockManager.listAgents).not.toHaveBeenCalled(); + expect(mockTtyWriterSend).not.toHaveBeenCalled(); + }); + + it('rejects --wait with --group before sending', async () => { + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'hello', '--group', 'backend-team', '--wait']); + + expect(ui.error).toHaveBeenCalledWith('Failed to send message: Use --wait only with --id; group wait mode is not supported.'); + expect(process.exit).toHaveBeenCalledWith(1); + expect(mockTtyWriterSend).not.toHaveBeenCalled(); + }); + + it('rejects --json with --group before sending', async () => { + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'hello', '--group', 'backend-team', '--json']); + + expect(ui.error).toHaveBeenCalledWith('Failed to send message: Use --json only with --id --wait; group JSON output is not supported.'); + expect(process.exit).toHaveBeenCalledWith(1); + expect(mockTtyWriterSend).not.toHaveBeenCalled(); + }); + + it('reports invalid group names for send --group before delivery', async () => { + mockGroupStore.get.mockImplementation(() => { + throw new Error('Invalid agent group name "Bad_Name".'); + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'hello', '--group', 'Bad_Name']); + + expect(ui.error).toHaveBeenCalledWith('Failed to send message: Invalid agent group name "Bad_Name".'); + expect(process.exit).toHaveBeenCalledWith(1); + expect(mockManager.listAgents).not.toHaveBeenCalled(); + expect(mockTtyWriterSend).not.toHaveBeenCalled(); + }); + + it('sends a message to every distinct resolved group target', async () => { + const api = { name: 'api', status: AgentStatus.WAITING, summary: 'Waiting', lastActive: new Date(), pid: 10 }; + const worker = { name: 'worker', status: AgentStatus.IDLE, summary: 'Idle', lastActive: new Date(), pid: 11 }; + const duplicateApi = { ...api }; + const apiLocation = { type: 'tmux', identifier: '0:1.0', tty: '/dev/ttys030' }; + const workerLocation = { type: 'tmux', identifier: '0:1.1', tty: '/dev/ttys031' }; + const agents = [api, worker]; + mockGroupStore.get.mockReturnValue({ name: 'backend-team', members: ['api', 'worker', 'api-alias'] }); + mockManager.listAgents.mockResolvedValue(agents); + mockManager.resolveAgent + .mockReturnValueOnce(api) + .mockReturnValueOnce(worker) + .mockReturnValueOnce(duplicateApi); + mockFocusManager.findTerminal + .mockResolvedValueOnce(apiLocation) + .mockResolvedValueOnce(workerLocation); + mockTtyWriterSend.mockResolvedValue(undefined); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'status update', '--group', 'backend-team']); + + expect(mockManager.resolveAgent).toHaveBeenNthCalledWith(1, 'api', agents); + expect(mockManager.resolveAgent).toHaveBeenNthCalledWith(2, 'worker', agents); + expect(mockManager.resolveAgent).toHaveBeenNthCalledWith(3, 'api-alias', agents); + expect(mockFocusManager.findTerminal).toHaveBeenCalledTimes(2); + expect(mockTtyWriterSend).toHaveBeenNthCalledWith(1, apiLocation, 'status update'); + expect(mockTtyWriterSend).toHaveBeenNthCalledWith(2, workerLocation, 'status update'); + expect(ui.info).toHaveBeenCalledWith('Skipped duplicate target "api" from group member "api-alias".'); + expect(ui.success).toHaveBeenCalledWith('Sent message to 2 agent(s) in group "backend-team".'); + }); + + it('fails before delivery when any group member is missing or ambiguous', async () => { + const agents = [ + { name: 'api', status: AgentStatus.WAITING, summary: 'Waiting', lastActive: new Date(), pid: 10 }, + { name: 'worker-a', status: AgentStatus.WAITING, summary: 'Waiting', lastActive: new Date(), pid: 11 }, + { name: 'worker-b', status: AgentStatus.WAITING, summary: 'Waiting', lastActive: new Date(), pid: 12 }, + ]; + mockGroupStore.get.mockReturnValue({ name: 'backend-team', members: ['api', 'missing', 'worker'] }); + mockManager.listAgents.mockResolvedValue(agents); + mockManager.resolveAgent + .mockReturnValueOnce(agents[0]) + .mockReturnValueOnce(null) + .mockReturnValueOnce([agents[1], agents[2]]); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'hello', '--group', 'backend-team']); + + expect(ui.error).toHaveBeenCalledWith('Cannot send to group "backend-team" because some members could not be resolved.'); + expect(ui.error).toHaveBeenCalledWith(' - missing: no running agent matched'); + expect(ui.error).toHaveBeenCalledWith(' - worker: matched multiple agents (worker-a, worker-b)'); + expect(mockFocusManager.findTerminal).not.toHaveBeenCalled(); + expect(mockTtyWriterSend).not.toHaveBeenCalled(); + }); + + it('continues group delivery after one target fails and exits non-zero', async () => { + const api = { name: 'api', status: AgentStatus.RUNNING, summary: 'Running', lastActive: new Date(), pid: 10 }; + const worker = { name: 'worker', status: AgentStatus.WAITING, summary: 'Waiting', lastActive: new Date(), pid: 11 }; + const apiLocation = { type: 'tmux', identifier: '0:1.0', tty: '/dev/ttys030' }; + const workerLocation = { type: 'tmux', identifier: '0:1.1', tty: '/dev/ttys031' }; + mockGroupStore.get.mockReturnValue({ name: 'backend-team', members: ['api', 'worker'] }); + mockManager.listAgents.mockResolvedValue([api, worker]); + mockManager.resolveAgent + .mockReturnValueOnce(api) + .mockReturnValueOnce(worker); + mockFocusManager.findTerminal + .mockResolvedValueOnce(apiLocation) + .mockResolvedValueOnce(workerLocation); + mockTtyWriterSend + .mockRejectedValueOnce(new Error('send failed')) + .mockResolvedValueOnce(undefined); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'hello', '--group', 'backend-team']); + + expect(ui.warning).toHaveBeenCalledWith('Agent "api" is not waiting for input (status: running). Sending anyway.'); + expect(mockTtyWriterSend).toHaveBeenCalledTimes(2); + expect(ui.error).toHaveBeenCalledWith('Failed to send to api: send failed'); + expect(ui.success).toHaveBeenCalledWith('Sent message to worker.'); + expect(ui.error).toHaveBeenCalledWith('Sent message to 1 agent(s), failed for 1 agent(s) in group "backend-team".'); + expect(process.exitCode).toBe(1); + }); + it('sends message with --wait, seeds transcript before delivery, and prints assistant output only to stdout', async () => { const agent = { name: 'repo-a', @@ -473,25 +819,15 @@ Waiting on user input`, }; const location = { type: 'tmux', identifier: '0:1.0', tty: '/dev/ttys030' }; const historical = [{ role: 'assistant', content: 'old response' }]; + const withNewResponse = [...historical, { role: 'assistant', content: 'new response' }]; mockManager.listAgents.mockResolvedValue([agent]); mockManager.resolveAgent.mockReturnValue(agent); mockManager.getAdapter.mockReturnValue(mockAgentAdapter); - mockAgentAdapter.getConversation.mockReturnValue(historical); + mockAgentAdapter.getConversation + .mockReturnValueOnce(historical) + .mockReturnValueOnce(withNewResponse); mockFocusManager.findTerminal.mockResolvedValue(location); mockTtyWriterSend.mockResolvedValue(undefined); - mockWaitForAgentResponse.mockImplementation(async (params) => { - params.onAssistantMessage({ role: 'assistant', content: 'new response' }); - return { - agentName: 'repo-a', - agentType: 'claude', - pid: 10, - sessionId: 'session-1', - sessionFilePath: '/tmp/session.jsonl', - messages: [{ role: 'assistant', content: 'new response' }], - finalStatus: AgentStatus.WAITING, - elapsedMs: 10, - }; - }); const program = new Command(); registerAgentCommand(program); @@ -501,19 +837,6 @@ Waiting on user input`, expect(mockAgentAdapter.getConversation).toHaveBeenCalledWith('/tmp/session.jsonl', { verbose: false }); expect(mockAgentAdapter.getConversation.mock.invocationCallOrder[0]) .toBeLessThan(mockTtyWriterSend.mock.invocationCallOrder[0]); - expect(mockWaitForAgentResponse).toHaveBeenCalledWith(expect.objectContaining({ - manager: mockManager, - adapter: mockAgentAdapter, - initialMessageCount: 1, - target: expect.objectContaining({ - id: 'repo-a', - name: 'repo-a', - type: 'claude', - pid: 10, - sessionId: 'session-1', - sessionFilePath: '/tmp/session.jsonl', - }), - })); expect(stdoutSpy).toHaveBeenCalledWith('new response\n'); expect(ui.success).not.toHaveBeenCalled(); expect(stderrSpy).not.toHaveBeenCalled(); @@ -537,72 +860,41 @@ Waiting on user input`, mockAgentAdapter.getConversation.mockReturnValue([]); mockFocusManager.findTerminal.mockResolvedValue(location); mockTtyWriterSend.mockResolvedValue(undefined); - mockWaitForAgentResponse.mockResolvedValue({ - agentName: 'repo-a', - agentType: 'claude', - pid: 10, - sessionId: 'session-1', - sessionFilePath: '/tmp/session.jsonl', - messages: [], - finalStatus: AgentStatus.WAITING, - elapsedMs: 1500, - }); const program = new Command(); registerAgentCommand(program); await program.parseAsync(['node', 'test', 'agent', 'send', 'continue', '--id', 'repo-a', '--wait', '--timeout', '1500']); - expect(mockWaitForAgentResponse).toHaveBeenCalledWith(expect.objectContaining({ - options: expect.objectContaining({ - pollIntervalMs: expect.any(Number), - maxWaitMs: 1500, - }), - })); + expect(mockTtyWriterSend).toHaveBeenCalledWith(location, 'continue'); + expect(ui.error).not.toHaveBeenCalled(); }); - it('exits non-zero with a clear error when send --wait reaches the timeout', async () => { - const agent = { - name: 'repo-a', - type: 'claude', - status: AgentStatus.WAITING, - summary: 'Waiting', - lastActive: new Date(), - pid: 10, - sessionId: 'session-1', - sessionFilePath: '/tmp/session.jsonl', - }; - const location = { type: 'tmux', identifier: '0:1.0', tty: '/dev/ttys030' }; - mockManager.listAgents.mockResolvedValue([agent]); - mockManager.resolveAgent.mockReturnValue(agent); - mockManager.getAdapter.mockReturnValue(mockAgentAdapter); - mockAgentAdapter.getConversation.mockReturnValue([]); - mockFocusManager.findTerminal.mockResolvedValue(location); - mockTtyWriterSend.mockResolvedValue(undefined); - mockWaitForAgentResponse.mockRejectedValue(new Error('Timed out waiting for agent "repo-a" after 1500ms.')); - + it('fails before sending when --timeout is used without --wait', async () => { const program = new Command(); registerAgentCommand(program); - await program.parseAsync(['node', 'test', 'agent', 'send', 'continue', '--id', 'repo-a', '--wait', '--timeout', '1500']); + await program.parseAsync(['node', 'test', 'agent', 'send', 'continue', '--id', 'repo-a', '--timeout', '30000']); - expect(ui.error).toHaveBeenCalledWith('Failed to send message: Timed out waiting for agent "repo-a" after 1500ms.'); + expect(ui.error).toHaveBeenCalledWith('Failed to send message: Use --timeout only with --wait.'); expect(process.exit).toHaveBeenCalledWith(1); + expect(mockManager.listAgents).not.toHaveBeenCalled(); + expect(mockTtyWriterSend).not.toHaveBeenCalled(); }); - it('fails before sending when --timeout is used without --wait', async () => { + it('fails before sending when --timeout is invalid', async () => { const program = new Command(); registerAgentCommand(program); - await program.parseAsync(['node', 'test', 'agent', 'send', 'continue', '--id', 'repo-a', '--timeout', '30000']); + await program.parseAsync(['node', 'test', 'agent', 'send', 'continue', '--id', 'repo-a', '--wait', '--timeout', '1.5s']); - expect(ui.error).toHaveBeenCalledWith('Failed to send message: Use --timeout only with --wait.'); + expect(ui.error).toHaveBeenCalledWith('Failed to send message: Invalid --timeout. Expected positive integer milliseconds. Example: 30000.'); expect(process.exit).toHaveBeenCalledWith(1); expect(mockManager.listAgents).not.toHaveBeenCalled(); expect(mockTtyWriterSend).not.toHaveBeenCalled(); }); - it('fails before sending when --timeout is invalid', async () => { + it('validates invalid --timeout before resolving the message source', async () => { const program = new Command(); registerAgentCommand(program); - await program.parseAsync(['node', 'test', 'agent', 'send', 'continue', '--id', 'repo-a', '--wait', '--timeout', '1.5s']); + await program.parseAsync(['node', 'test', 'agent', 'send', '--id', 'repo-a', '--wait', '--timeout', '1.5s']); expect(ui.error).toHaveBeenCalledWith('Failed to send message: Invalid --timeout. Expected positive integer milliseconds. Example: 30000.'); expect(process.exit).toHaveBeenCalledWith(1); @@ -631,23 +923,11 @@ Waiting on user input`, mockManager.listAgents.mockResolvedValue([agent]); mockManager.resolveAgent.mockReturnValue(agent); mockManager.getAdapter.mockReturnValue(mockAgentAdapter); - mockAgentAdapter.getConversation.mockReturnValue([]); + mockAgentAdapter.getConversation + .mockReturnValueOnce([]) + .mockReturnValueOnce(messages); mockFocusManager.findTerminal.mockResolvedValue(location); mockTtyWriterSend.mockResolvedValue(undefined); - mockWaitForAgentResponse.mockImplementation(async (params) => { - params.onAssistantMessage(messages[0]); - params.onAssistantMessage(messages[1]); - return { - agentName: 'repo-a', - agentType: 'claude', - pid: 10, - sessionId: 'session-1', - sessionFilePath: '/tmp/session.jsonl', - messages, - finalStatus: AgentStatus.WAITING, - elapsedMs: 42, - }; - }); const program = new Command(); registerAgentCommand(program); @@ -670,7 +950,7 @@ Waiting on user input`, }, prompt: 'continue', responseMessages: messages, - elapsedMs: 42, + elapsedMs: expect.any(Number), finalStatus: AgentStatus.WAITING, }); }); @@ -695,7 +975,6 @@ Waiting on user input`, expect(ui.error).toHaveBeenCalledWith('Failed to send message: No session file found for agent "repo-a"; cannot wait for response.'); expect(process.exit).toHaveBeenCalledWith(1); expect(mockTtyWriterSend).not.toHaveBeenCalled(); - expect(mockWaitForAgentResponse).not.toHaveBeenCalled(); }); it('fails and does not send when --wait target has no adapter', async () => { @@ -720,7 +999,6 @@ Waiting on user input`, expect(ui.error).toHaveBeenCalledWith('Failed to send message: Unsupported agent type: claude'); expect(process.exit).toHaveBeenCalledWith(1); expect(mockTtyWriterSend).not.toHaveBeenCalled(); - expect(mockWaitForAgentResponse).not.toHaveBeenCalled(); }); it('fails when --wait terminal cannot be found', async () => { @@ -737,6 +1015,7 @@ Waiting on user input`, mockManager.listAgents.mockResolvedValue([agent]); mockManager.resolveAgent.mockReturnValue(agent); mockManager.getAdapter.mockReturnValue(mockAgentAdapter); + mockAgentAdapter.getConversation.mockReturnValue([]); mockFocusManager.findTerminal.mockResolvedValue(null); const program = new Command(); @@ -837,22 +1116,14 @@ Waiting on user input`, sessionFilePath: '/tmp/session.jsonl', }; const location = { type: 'tmux', identifier: '0:1.0', tty: '/dev/ttys030' }; - mockManager.listAgents.mockResolvedValue([agent]); + mockManager.listAgents + .mockResolvedValueOnce([agent]) + .mockResolvedValueOnce([{ ...agent, status: AgentStatus.WAITING }]); mockManager.resolveAgent.mockReturnValue(agent); mockManager.getAdapter.mockReturnValue(mockAgentAdapter); mockAgentAdapter.getConversation.mockReturnValue([]); mockFocusManager.findTerminal.mockResolvedValue(location); mockTtyWriterSend.mockResolvedValue(undefined); - mockWaitForAgentResponse.mockResolvedValue({ - agentName: 'repo-a', - agentType: 'claude', - pid: 10, - sessionId: 'session-1', - sessionFilePath: '/tmp/session.jsonl', - messages: [], - finalStatus: AgentStatus.WAITING, - elapsedMs: 10, - }); const program = new Command(); registerAgentCommand(program); @@ -877,25 +1148,14 @@ Waiting on user input`, sessionFilePath: '/tmp/session.jsonl', }; const location = { type: 'tmux', identifier: '0:1.0', tty: '/dev/ttys030' }; - mockManager.listAgents.mockResolvedValue([agent]); + mockManager.listAgents + .mockResolvedValueOnce([agent]) + .mockResolvedValueOnce([{ ...agent, status: AgentStatus.WAITING }]); mockManager.resolveAgent.mockReturnValue(agent); mockManager.getAdapter.mockReturnValue(mockAgentAdapter); mockAgentAdapter.getConversation.mockReturnValue([]); mockFocusManager.findTerminal.mockResolvedValue(location); mockTtyWriterSend.mockResolvedValue(undefined); - mockWaitForAgentResponse.mockImplementation(async (params) => { - params.onStatus('Status for \x1b[31mrepo-a\x1b[0m'); - return { - agentName: agent.name, - agentType: 'claude', - pid: 10, - sessionId: 'session-1', - sessionFilePath: '/tmp/session.jsonl', - messages: [], - finalStatus: AgentStatus.WAITING, - elapsedMs: 10, - }; - }); const program = new Command(); registerAgentCommand(program); @@ -904,7 +1164,7 @@ Waiting on user input`, expect(stderrSpy).toHaveBeenCalledWith( 'Agent "repo-a" is not waiting for input (status: running). Sending anyway.\n' ); - expect(stderrSpy).toHaveBeenCalledWith('Status for repo-a\n'); + expect(stderrSpy).toHaveBeenCalledWith('Agent "repo-a" returned to waiting without assistant output.\n'); }); it('shows error when terminal cannot be found', async () => { diff --git a/packages/cli/src/__tests__/services/agent/agent-group.service.test.ts b/packages/cli/src/__tests__/services/agent/agent-group.service.test.ts new file mode 100644 index 00000000..f7a0db14 --- /dev/null +++ b/packages/cli/src/__tests__/services/agent/agent-group.service.test.ts @@ -0,0 +1,148 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + AgentGroupConflictError, + AgentGroupEmptyMembersError, + AgentGroupInvalidMemberError, + AgentGroupInvalidNameError, + AgentGroupNotFoundError, + AgentGroupStorageError, + AgentGroupService, +} from '../../../services/agent/agent-group.service.js'; + +describe('AgentGroupService', () => { + let tmpDir: string; + let groupsPath: string; + let service: AgentGroupService; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-group-service-')); + groupsPath = path.join(tmpDir, 'nested', 'agent-groups.json'); + service = new AgentGroupService(groupsPath); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function readRaw(): any { + return JSON.parse(fs.readFileSync(groupsPath, 'utf8')); + } + + it('returns an empty list when the file is missing', () => { + expect(service.list()).toEqual([]); + }); + + it('creates the file and parent directory when creating a group', () => { + const group = service.create('backend-team', ['api', 'worker']); + + expect(group.name).toBe('backend-team'); + expect(group.members).toEqual(['api', 'worker']); + expect(fs.existsSync(groupsPath)).toBe(true); + expect(readRaw()).toMatchObject({ + version: 1, + groups: [{ name: 'backend-team', members: ['api', 'worker'] }], + }); + }); + + it('writes atomically without leaving a temp file on success', () => { + service.create('backend-team', ['api']); + + expect(fs.existsSync(`${groupsPath}.tmp`)).toBe(false); + }); + + it('gets a group by name', () => { + service.create('backend-team', ['api']); + + expect(service.get('backend-team')?.members).toEqual(['api']); + expect(service.get('missing')).toBeUndefined(); + }); + + it('rejects invalid group names when getting a group', () => { + expect(() => service.get('Bad_Name')).toThrow(AgentGroupInvalidNameError); + }); + + it('rejects invalid group names', () => { + expect(() => service.create('Bad_Name', ['api'])).toThrow(AgentGroupInvalidNameError); + }); + + it('rejects empty member lists', () => { + expect(() => service.create('backend-team', [])).toThrow(AgentGroupEmptyMembersError); + }); + + it('rejects blank members', () => { + expect(() => service.create('backend-team', ['api', ' '])).toThrow(AgentGroupInvalidMemberError); + }); + + it('rejects duplicate members after trimming', () => { + expect(() => service.create('backend-team', ['api', ' api '])).toThrow(AgentGroupInvalidMemberError); + }); + + it('rejects creating an existing group', () => { + service.create('backend-team', ['api']); + + expect(() => service.create('backend-team', ['worker'])).toThrow(AgentGroupConflictError); + }); + + it('updates a group by replacing all members', () => { + service.create('backend-team', ['api']); + const updated = service.update('backend-team', ['worker', 'docs']); + + expect(updated.members).toEqual(['worker', 'docs']); + expect(service.get('backend-team')?.members).toEqual(['worker', 'docs']); + }); + + it('throws when updating a missing group', () => { + expect(() => service.update('missing', ['api'])).toThrow(AgentGroupNotFoundError); + }); + + it('adds a new member to an existing group', () => { + service.create('backend-team', ['api']); + const updated = service.addMember('backend-team', 'worker'); + + expect(updated.members).toEqual(['api', 'worker']); + }); + + it('treats adding an existing member as idempotent', () => { + service.create('backend-team', ['api']); + const updated = service.addMember('backend-team', ' api '); + + expect(updated.members).toEqual(['api']); + }); + + it('removes one member from an existing group', () => { + service.create('backend-team', ['api', 'worker']); + const updated = service.removeMember('backend-team', 'api'); + + expect(updated.members).toEqual(['worker']); + }); + + it('rejects removing the last member', () => { + service.create('backend-team', ['api']); + + expect(() => service.removeMember('backend-team', 'api')).toThrow(AgentGroupEmptyMembersError); + expect(service.get('backend-team')?.members).toEqual(['api']); + }); + + it('removes a group', () => { + service.create('backend-team', ['api']); + service.remove('backend-team'); + + expect(service.list()).toEqual([]); + }); + + it('throws a storage error for malformed JSON', () => { + fs.mkdirSync(path.dirname(groupsPath), { recursive: true }); + fs.writeFileSync(groupsPath, 'not json', 'utf8'); + + expect(() => service.list()).toThrow(AgentGroupStorageError); + }); + + it('throws a storage error for unsupported file versions', () => { + fs.mkdirSync(path.dirname(groupsPath), { recursive: true }); + fs.writeFileSync(groupsPath, JSON.stringify({ version: 2, groups: [] }), 'utf8'); + + expect(() => service.list()).toThrow(AgentGroupStorageError); + }); +}); diff --git a/packages/cli/src/__tests__/services/agent/agent.service.test.ts b/packages/cli/src/__tests__/services/agent/agent.service.test.ts index 0328e260..cf9c6d1f 100644 --- a/packages/cli/src/__tests__/services/agent/agent.service.test.ts +++ b/packages/cli/src/__tests__/services/agent/agent.service.test.ts @@ -9,6 +9,8 @@ import { } from '@ai-devkit/agent-manager'; import { waitForAgentResponse, + assertSendTargetOptions, + sendToAgentGroup, startAgent, killAgent, AgentNameInUseError, @@ -585,7 +587,10 @@ describe('startAgent', () => { const tmux = makeTmux(); const registry = makeRegistry(); - const entry = await startAgent(startOpts, { tmux, registry }); + const entry = await startAgent( + { ...startOpts, pollTimeoutMs: 250 }, + { tmux, registry }, + ); expect(tmux.createSession).toHaveBeenCalledWith('agent1', '/work'); expect(tmux.sendKeys).toHaveBeenCalledWith('agent1', 'claude'); @@ -662,7 +667,10 @@ describe('startAgent', () => { const tmux = makeTmux({ findAgentPid } as Partial); const registry = makeRegistry(); - const entry = await startAgent(startOpts, { tmux, registry }); + const entry = await startAgent( + { ...startOpts, pollTimeoutMs: 250 }, + { tmux, registry }, + ); expect(findAgentPid).toHaveBeenCalledTimes(7); expect(entry.pid).toBe(42); @@ -720,3 +728,125 @@ describe('startAgent', () => { expect(order).toEqual(['prune', 'lookup']); }); }); + +describe('sendToAgentGroup', () => { + const reporter = { + info: vi.fn(), + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }; + const manager: any = { + listAgents: vi.fn(), + resolveAgent: vi.fn(), + }; + const focusManager: any = { + findTerminal: vi.fn(), + }; + const writer = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = undefined; + writer.mockResolvedValue(undefined); + }); + + it('rejects invalid send target option combinations', () => { + expect(() => assertSendTargetOptions({})).toThrow('Use exactly one of --id or --group.'); + expect(() => assertSendTargetOptions({ id: 'api', group: 'team' })).toThrow('Use exactly one of --id or --group.'); + expect(() => assertSendTargetOptions({ group: 'team', wait: true })).toThrow('Use --wait only with --id; group wait mode is not supported.'); + expect(() => assertSendTargetOptions({ group: 'team', json: true })).toThrow('Use --json only with --id --wait; group JSON output is not supported.'); + expect(() => assertSendTargetOptions({ id: 'api', wait: true, timeout: '1.5s' })).toThrow('Invalid --timeout. Expected positive integer milliseconds. Example: 30000.'); + }); + + it('fails before delivery when group members are missing or ambiguous', async () => { + const api = { name: 'api', status: AgentStatus.WAITING, pid: 10 }; + const workerA = { name: 'worker-a', status: AgentStatus.WAITING, pid: 11 }; + const workerB = { name: 'worker-b', status: AgentStatus.WAITING, pid: 12 }; + const agents = [api, workerA, workerB]; + manager.listAgents.mockResolvedValue(agents); + manager.resolveAgent + .mockReturnValueOnce(api) + .mockReturnValueOnce(null) + .mockReturnValueOnce([workerA, workerB]); + + await sendToAgentGroup({ + group: { name: 'backend-team', members: ['api', 'missing', 'worker'], createdAt: '', updatedAt: '' }, + prompt: 'hello', + manager, + focusManager, + writer, + reporter, + }); + + expect(reporter.error).toHaveBeenCalledWith('Cannot send to group "backend-team" because some members could not be resolved.'); + expect(reporter.error).toHaveBeenCalledWith(' - missing: no running agent matched'); + expect(reporter.error).toHaveBeenCalledWith(' - worker: matched multiple agents (worker-a, worker-b)'); + expect(focusManager.findTerminal).not.toHaveBeenCalled(); + expect(writer).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('deduplicates targets and sends sequentially', async () => { + const api = { name: 'api', status: AgentStatus.WAITING, pid: 10 }; + const worker = { name: 'worker', status: AgentStatus.IDLE, pid: 11 }; + const agents = [api, worker]; + const apiLocation = { type: 'tmux', identifier: 'api' }; + const workerLocation = { type: 'tmux', identifier: 'worker' }; + manager.listAgents.mockResolvedValue(agents); + manager.resolveAgent + .mockReturnValueOnce(api) + .mockReturnValueOnce(worker) + .mockReturnValueOnce({ ...api }); + focusManager.findTerminal + .mockResolvedValueOnce(apiLocation) + .mockResolvedValueOnce(workerLocation); + + await sendToAgentGroup({ + group: { name: 'backend-team', members: ['api', 'worker', 'api-alias'], createdAt: '', updatedAt: '' }, + prompt: 'status', + manager, + focusManager, + writer, + reporter, + }); + + expect(reporter.info).toHaveBeenCalledWith('Skipped duplicate target "api" from group member "api-alias".'); + expect(writer).toHaveBeenNthCalledWith(1, apiLocation, 'status'); + expect(writer).toHaveBeenNthCalledWith(2, workerLocation, 'status'); + expect(reporter.success).toHaveBeenCalledWith('Sent message to 2 agent(s) in group "backend-team".'); + }); + + it('continues after one target fails and sets a non-zero exit code', async () => { + const api = { name: 'api', status: AgentStatus.RUNNING, pid: 10 }; + const worker = { name: 'worker', status: AgentStatus.WAITING, pid: 11 }; + const apiLocation = { type: 'tmux', identifier: 'api' }; + const workerLocation = { type: 'tmux', identifier: 'worker' }; + manager.listAgents.mockResolvedValue([api, worker]); + manager.resolveAgent + .mockReturnValueOnce(api) + .mockReturnValueOnce(worker); + focusManager.findTerminal + .mockResolvedValueOnce(apiLocation) + .mockResolvedValueOnce(workerLocation); + writer + .mockRejectedValueOnce(new Error('send failed')) + .mockResolvedValueOnce(undefined); + + await sendToAgentGroup({ + group: { name: 'backend-team', members: ['api', 'worker'], createdAt: '', updatedAt: '' }, + prompt: 'hello', + manager, + focusManager, + writer, + reporter, + }); + + expect(reporter.warning).toHaveBeenCalledWith('Agent "api" is not waiting for input (status: running). Sending anyway.'); + expect(writer).toHaveBeenCalledTimes(2); + expect(reporter.error).toHaveBeenCalledWith('Failed to send to api: send failed'); + expect(reporter.success).toHaveBeenCalledWith('Sent message to worker.'); + expect(reporter.error).toHaveBeenCalledWith('Sent message to 1 agent(s), failed for 1 agent(s) in group "backend-team".'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 25fc2508..c02bf8cc 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -7,7 +7,6 @@ import chalk from 'chalk'; import { render } from 'ink'; import { AgentManager, - type AgentAdapter, ClaudeCodeAdapter, CodexAdapter, CopilotAdapter, @@ -16,7 +15,6 @@ import { PiAdapter, AgentStatus, TerminalFocusManager, - TtyWriter, AgentRegistry, RenameNotFoundError, RenameConflictError, @@ -37,20 +35,25 @@ import { toJsonSession, } from '../util/sessions.js'; import { - waitForAgentResponse, startAgent, killAgent, + assertSendTargetOptions, + type SendReporter, + sendToAgent, + sendToAgentGroup, TmuxUnavailableError, AgentNameInUseError, AgentPidPollTimeoutError, } from '../services/agent/agent.service.js'; -import { parseMilliseconds } from '../util/time.js'; +import { + AgentGroupNotFoundError, + createDefaultAgentGroupService, +} from '../services/agent/agent-group.service.js'; +import { registerAgentGroupCommand } from './agent/group.command.js'; import { ConsoleApp } from '../tui/console/ConsoleApp.js'; import { generateAgentName } from '../util/agent.js'; import { select } from '@inquirer/prompts'; -const AGENT_SEND_WAIT_POLL_INTERVAL_MS = 2000; -const AGENT_SEND_WAIT_MAX_WAIT_MS = 10 * 60 * 1000; // eslint-disable-next-line no-control-regex const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; @@ -178,15 +181,6 @@ function writeWaitStatus(message: string): void { process.stderr.write(`${message.replace(ANSI_ESCAPE_PATTERN, '')}\n`); } -function parseSendWaitTimeout(value: string | undefined): { maxWaitMs: number; label?: string } { - try { - const parsed = parseMilliseconds(value, AGENT_SEND_WAIT_MAX_WAIT_MS); - return { maxWaitMs: parsed.milliseconds, label: parsed.label }; - } catch (error) { - throw new Error(`Invalid --timeout. ${(error as Error).message} Example: 30000.`); - } -} - function readStdin(): Promise { return new Promise((resolve, reject) => { let input = ''; @@ -231,45 +225,12 @@ async function resolveSendMessage(message: string | undefined, options: { stdin? return message; } -function prepareWaitMode(manager: AgentManager, agent: AgentInfo): { - adapter: AgentAdapter; - sessionFilePath: string; - initialMessageCount: number; -} { - if (!agent.sessionFilePath) { - throw new Error(`No session file found for agent "${agent.name}"; cannot wait for response.`); - } - - const adapter = manager.getAdapter(agent.type); - if (!adapter) { - throw new Error(`Unsupported agent type: ${agent.type}`); - } - +function createCommandSendReporter(): SendReporter { return { - adapter, - sessionFilePath: agent.sessionFilePath, - initialMessageCount: adapter.getConversation(agent.sessionFilePath, { verbose: false }).length, - }; -} - -function toAgentSendWaitJson(result: Awaited>, agent: AgentInfo, prompt: string, targetId: string): object { - return { - target: { - id: targetId, - name: agent.name, - type: agent.type, - pid: agent.pid, - status: agent.status, - summary: agent.summary, - projectPath: agent.projectPath, - sessionId: agent.sessionId, - sessionFilePath: result.sessionFilePath, - lastActive: agent.lastActive, - }, - prompt, - responseMessages: result.messages, - elapsedMs: result.elapsedMs, - finalStatus: result.finalStatus, + info: (text) => text.startsWith(' - ') ? ui.text(text) : ui.info(text), + warning: (text) => ui.warning(text), + success: (text) => ui.success(text), + error: (text) => ui.error(text), }; } @@ -438,6 +399,8 @@ export function registerAgentCommand(program: Command): void { }); })); + registerAgentGroupCommand(agentCommand); + const sessionCommand = agentCommand .command('session') .description('Manage historical AI agent sessions'); @@ -573,102 +536,38 @@ export function registerAgentCommand(program: Command): void { agentCommand .command('send [message]') .description('Send a message to a running agent') - .requiredOption('--id ', 'Agent name or partial match') + .option('--id ', 'Agent name or partial match') + .option('--group ', 'Agent group name') .option('--stdin', 'Read the message from stdin') .option('--wait', 'Wait for and print the agent response') .option('--timeout ', 'Maximum time to wait with --wait, in milliseconds') .option('-j, --json', 'Output wait result as JSON') .action(withErrorHandler('send message', async (message, options) => { - if (options.timeout !== undefined && !options.wait) { - throw new Error('Use --timeout only with --wait.'); - } - const waitTimeout = parseSendWaitTimeout(options.timeout); + assertSendTargetOptions(options); const prompt = await resolveSendMessage(message, options); const manager = createAgentManager(); - - const agents = await manager.listAgents(); - if (agents.length === 0) { - ui.error('No running agents found.'); - return; - } - - const resolved = manager.resolveAgent(options.id, agents); - - if (!resolved) { - ui.error(`No agent found matching "${options.id}".`); - ui.info('Available agents:'); - agents.forEach(a => ui.text(` - ${a.name}`)); - return; - } - - if (Array.isArray(resolved)) { - ui.error(`Multiple agents match "${options.id}":`); - resolved.forEach(a => ui.text(` - ${a.name} (${formatStatus(a.status)})`)); - ui.info('Please use a more specific identifier.'); - return; - } - - const agent = resolved as AgentInfo; - - if (![AgentStatus.WAITING, AgentStatus.IDLE].includes(agent.status)) { - const warning = `Agent "${agent.name}" is not waiting for input (status: ${agent.status}). Sending anyway.`; - if (options.wait) { - writeWaitStatus(warning); - } else { - ui.warning(warning); - } - } - - const waitContext = options.wait ? prepareWaitMode(manager, agent) : undefined; - const focusManager = new TerminalFocusManager(); - const location = await focusManager.findTerminal(agent.pid); - if (!location) { - if (options.wait) { - throw new Error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); - } - ui.error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); - return; - } - await TtyWriter.send(location, prompt); - - if (!options.wait) { - ui.success(`Sent message to ${agent.name}.`); + if (options.group) { + const group = createDefaultAgentGroupService().get(options.group); + if (!group) { + throw new AgentGroupNotFoundError(options.group); + } + await sendToAgentGroup({ group, prompt, manager, focusManager }); return; } - if (!waitContext) { - throw new Error('Wait mode was not prepared.'); - } - - const waitResult = await waitForAgentResponse({ + await sendToAgent({ + id: options.id, + prompt, manager, - adapter: waitContext.adapter, - target: { - id: options.id, - name: agent.name, - type: agent.type, - pid: agent.pid, - sessionId: agent.sessionId, - sessionFilePath: waitContext.sessionFilePath, - }, - initialMessageCount: waitContext.initialMessageCount, - options: { - pollIntervalMs: AGENT_SEND_WAIT_POLL_INTERVAL_MS, - maxWaitMs: waitTimeout.maxWaitMs, - timeoutLabel: waitTimeout.label, - }, - onAssistantMessage: (msg) => { - if (options.json) return; - process.stdout.write(`${msg.content}\n`); - }, - onStatus: writeWaitStatus, + focusManager, + wait: options.wait, + timeout: options.timeout, + json: options.json, + reporter: createCommandSendReporter(), + writeWaitStatus, }); - - if (options.json) { - console.log(JSON.stringify(toAgentSendWaitJson(waitResult, agent, prompt, options.id), null, 2)); - } })); agentCommand diff --git a/packages/cli/src/commands/agent/group.command.ts b/packages/cli/src/commands/agent/group.command.ts new file mode 100644 index 00000000..8b02a5b5 --- /dev/null +++ b/packages/cli/src/commands/agent/group.command.ts @@ -0,0 +1,93 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { ui } from '../../util/terminal-ui.js'; +import { withErrorHandler } from '../../util/errors.js'; +import { + AgentGroupNotFoundError, + createDefaultAgentGroupService, +} from '../../services/agent/agent-group.service.js'; + +function collectAgentMember(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +export function registerAgentGroupCommand(agentCommand: Command): void { + const groupCommand = agentCommand + .command('group') + .description('Manage named groups of agents'); + + groupCommand + .command('create ') + .description('Create an agent group') + .option('--agent ', 'Agent identifier to include in the group', collectAgentMember, [] as string[]) + .action(withErrorHandler('manage agent group', async (name: string, options: { agent?: string[] }) => { + const group = createDefaultAgentGroupService().create(name, options.agent ?? []); + ui.success(`Created agent group "${group.name}" with ${group.members.length} member(s).`); + })); + + groupCommand + .command('update ') + .description('Replace all members in an agent group') + .option('--agent ', 'Agent identifier to include in the group', collectAgentMember, [] as string[]) + .action(withErrorHandler('manage agent group', async (name: string, options: { agent?: string[] }) => { + const group = createDefaultAgentGroupService().update(name, options.agent ?? []); + ui.success(`Updated agent group "${group.name}" with ${group.members.length} member(s).`); + })); + + groupCommand + .command('add ') + .description('Add an agent identifier to a group') + .action(withErrorHandler('manage agent group', async (name: string, identifier: string) => { + const group = createDefaultAgentGroupService().addMember(name, identifier); + ui.success(`Agent group "${group.name}" now has ${group.members.length} member(s).`); + })); + + groupCommand + .command('remove-agent ') + .description('Remove an agent identifier from a group') + .action(withErrorHandler('manage agent group', async (name: string, identifier: string) => { + const group = createDefaultAgentGroupService().removeMember(name, identifier); + ui.success(`Agent group "${group.name}" now has ${group.members.length} member(s).`); + })); + + groupCommand + .command('remove ') + .description('Remove an agent group') + .action(withErrorHandler('manage agent group', async (name: string) => { + createDefaultAgentGroupService().remove(name); + ui.success(`Removed agent group "${name}".`); + })); + + groupCommand + .command('list') + .description('List configured agent groups') + .action(withErrorHandler('manage agent group', async () => { + const groups = createDefaultAgentGroupService().list(); + if (groups.length === 0) { + ui.info('No agent groups configured.'); + return; + } + ui.table({ + headers: ['Group', 'Members'], + rows: groups.map((group) => [group.name, group.members.join(', ')]), + columnStyles: [ + (text) => chalk.cyan(text), + (text) => text, + ], + }); + })); + + groupCommand + .command('detail ') + .description('Show one configured agent group') + .action(withErrorHandler('manage agent group', async (name: string) => { + const group = createDefaultAgentGroupService().get(name); + if (!group) { + throw new AgentGroupNotFoundError(name); + } + ui.text(`Agent Group: ${group.name}`, { breakline: true }); + for (const member of group.members) { + ui.text(` - ${member}`); + } + })); +} diff --git a/packages/cli/src/services/agent/agent-group.service.ts b/packages/cli/src/services/agent/agent-group.service.ts new file mode 100644 index 00000000..0cf50423 --- /dev/null +++ b/packages/cli/src/services/agent/agent-group.service.ts @@ -0,0 +1,240 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +export interface AgentGroup { + name: string; + members: string[]; + createdAt: string; + updatedAt: string; +} + +interface AgentGroupFile { + version: 1; + groups: AgentGroup[]; +} + +const DEFAULT_GROUPS_PATH = path.join(os.homedir(), '.ai-devkit', 'agent-groups.json'); +const GROUP_NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; + +let defaultInstance: AgentGroupService | null = null; + +export class AgentGroupNotFoundError extends Error { + constructor(public groupName: string) { + super(`Agent group "${groupName}" not found.`); + this.name = 'AgentGroupNotFoundError'; + } +} + +export class AgentGroupConflictError extends Error { + constructor(public groupName: string) { + super(`Agent group "${groupName}" already exists.`); + this.name = 'AgentGroupConflictError'; + } +} + +export class AgentGroupInvalidNameError extends Error { + constructor(public groupName: string) { + super(`Invalid agent group name "${groupName}".`); + this.name = 'AgentGroupInvalidNameError'; + } +} + +export class AgentGroupInvalidMemberError extends Error { + constructor(public member: string, message?: string) { + super(message ?? `Invalid agent group member "${member}".`); + this.name = 'AgentGroupInvalidMemberError'; + } +} + +export class AgentGroupEmptyMembersError extends Error { + constructor() { + super('Agent group must contain at least one member.'); + this.name = 'AgentGroupEmptyMembersError'; + } +} + +export class AgentGroupStorageError extends Error { + constructor(public filePath: string, message: string) { + super(`Failed to read agent groups from "${filePath}": ${message}`); + this.name = 'AgentGroupStorageError'; + } +} + +export class AgentGroupService { + constructor(private filePath: string = DEFAULT_GROUPS_PATH) {} + + list(): AgentGroup[] { + return this.readFile().groups; + } + + get(name: string): AgentGroup | undefined { + const validName = this.validateName(name); + return this.readFile().groups.find((group) => group.name === validName); + } + + create(name: string, members: string[]): AgentGroup { + const validName = this.validateName(name); + const validMembers = this.normalizeMembers(members); + const data = this.readFile(); + if (data.groups.some((group) => group.name === validName)) { + throw new AgentGroupConflictError(validName); + } + + const now = new Date().toISOString(); + const group: AgentGroup = { + name: validName, + members: validMembers, + createdAt: now, + updatedAt: now, + }; + this.writeFile({ version: 1, groups: [...data.groups, group] }); + return group; + } + + update(name: string, members: string[]): AgentGroup { + const validName = this.validateName(name); + const validMembers = this.normalizeMembers(members); + const data = this.readFile(); + const index = data.groups.findIndex((group) => group.name === validName); + if (index < 0) { + throw new AgentGroupNotFoundError(validName); + } + + const group: AgentGroup = { + ...data.groups[index], + members: validMembers, + updatedAt: new Date().toISOString(), + }; + const groups = [...data.groups]; + groups[index] = group; + this.writeFile({ version: 1, groups }); + return group; + } + + addMember(name: string, member: string): AgentGroup { + const group = this.requireGroup(name); + const normalizedMember = this.normalizeMember(member); + if (group.members.includes(normalizedMember)) { + return group; + } + return this.update(group.name, [...group.members, normalizedMember]); + } + + removeMember(name: string, member: string): AgentGroup { + const group = this.requireGroup(name); + const normalizedMember = this.normalizeMember(member); + const members = group.members.filter((entry) => entry !== normalizedMember); + if (members.length === group.members.length) { + throw new AgentGroupInvalidMemberError(normalizedMember, `Agent group "${group.name}" does not contain member "${normalizedMember}".`); + } + return this.update(group.name, members); + } + + remove(name: string): void { + const validName = this.validateName(name); + const data = this.readFile(); + const groups = data.groups.filter((group) => group.name !== validName); + if (groups.length === data.groups.length) { + throw new AgentGroupNotFoundError(validName); + } + this.writeFile({ version: 1, groups }); + } + + private requireGroup(name: string): AgentGroup { + const validName = this.validateName(name); + const group = this.get(validName); + if (!group) { + throw new AgentGroupNotFoundError(validName); + } + return group; + } + + private readFile(): AgentGroupFile { + if (!fs.existsSync(this.filePath)) { + return { version: 1, groups: [] }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')); + } catch (error) { + throw new AgentGroupStorageError(this.filePath, (error as Error).message); + } + + if (!this.isGroupFile(parsed)) { + throw new AgentGroupStorageError(this.filePath, 'unsupported or malformed agent group file'); + } + + return { + version: 1, + groups: parsed.groups.map((group) => ({ + name: group.name, + members: [...group.members], + createdAt: group.createdAt, + updatedAt: group.updatedAt, + })), + }; + } + + private writeFile(data: AgentGroupFile): void { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + const tmp = `${this.filePath}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8'); + fs.renameSync(tmp, this.filePath); + } + + private validateName(name: string): string { + const normalized = name.trim(); + if (!GROUP_NAME_REGEX.test(normalized)) { + throw new AgentGroupInvalidNameError(name); + } + return normalized; + } + + private normalizeMembers(members: string[]): string[] { + if (members.length === 0) { + throw new AgentGroupEmptyMembersError(); + } + + const normalized = members.map((member) => this.normalizeMember(member)); + const seen = new Set(); + for (const member of normalized) { + if (seen.has(member)) { + throw new AgentGroupInvalidMemberError(member, `Duplicate agent group member "${member}".`); + } + seen.add(member); + } + return normalized; + } + + private normalizeMember(member: string): string { + const normalized = member.trim(); + if (!normalized) { + throw new AgentGroupInvalidMemberError(member, 'Agent group member cannot be blank.'); + } + return normalized; + } + + private isGroupFile(value: unknown): value is AgentGroupFile { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + return candidate.version === 1 + && Array.isArray(candidate.groups) + && candidate.groups.every((group) => ( + group + && typeof group.name === 'string' + && Array.isArray(group.members) + && group.members.every((member) => typeof member === 'string') + && typeof group.createdAt === 'string' + && typeof group.updatedAt === 'string' + )); + } +} + +export function createDefaultAgentGroupService(): AgentGroupService { + if (!defaultInstance) { + defaultInstance = new AgentGroupService(); + } + return defaultInstance; +} diff --git a/packages/cli/src/services/agent/agent.service.ts b/packages/cli/src/services/agent/agent.service.ts index bdbd484d..b28eed42 100644 --- a/packages/cli/src/services/agent/agent.service.ts +++ b/packages/cli/src/services/agent/agent.service.ts @@ -1,17 +1,22 @@ import { AGENTS, AgentStatus, + TtyWriter, type AgentAdapter, type AgentInfo, type AgentManager, type AgentRegistry, + type TerminalFocusManager, + type TerminalLocation, type AgentType, type ConversationMessage, type RegistryEntry, type StartableAgentType, type TmuxManager, } from '@ai-devkit/agent-manager'; -import { sleep } from '../../util/time.js'; +import { parseMilliseconds, sleep } from '../../util/time.js'; +import { ui } from '../../util/terminal-ui.js'; +import type { AgentGroup } from './agent-group.service.js'; export interface AgentSendWaitTarget { id: string; @@ -49,6 +54,64 @@ export interface WaitForAgentResponseParams { onStatus?: (message: string) => void; } +export interface SendReporter { + info(message: string): void; + warning(message: string): void; + success(message: string): void; + error(message: string): void; +} + +interface GroupTarget { + member: string; + agent: AgentInfo; +} + +export interface SendToAgentOptions { + id: string; + prompt: string; + manager: Pick; + focusManager: Pick; + wait?: boolean; + timeout?: string; + json?: boolean; + reporter?: SendReporter; + writer?: typeof TtyWriter.send; + writeWaitStatus?: (message: string) => void; + writeAssistantMessage?: (message: ConversationMessage) => void; + writeJson?: (value: object) => void; +} + +export interface SendToAgentGroupOptions { + group: AgentGroup; + prompt: string; + manager: Pick; + focusManager: Pick; + reporter?: SendReporter; + writer?: typeof TtyWriter.send; +} + +export function assertSendTargetOptions(options: { id?: string; group?: string; wait?: boolean; timeout?: string; json?: boolean }): void { + const targetCount = Number(Boolean(options.id)) + Number(Boolean(options.group)); + if (targetCount !== 1) { + throw new Error('Use exactly one of --id or --group.'); + } + if (options.group && options.wait) { + throw new Error('Use --wait only with --id; group wait mode is not supported.'); + } + if (options.group && options.timeout !== undefined) { + throw new Error('Use --timeout only with --id --wait; group wait mode is not supported.'); + } + if (options.group && options.json) { + throw new Error('Use --json only with --id --wait; group JSON output is not supported.'); + } + if (options.timeout !== undefined && !options.wait) { + throw new Error('Use --timeout only with --wait.'); + } + if (options.timeout !== undefined) { + parseSendWaitTimeout(options.timeout); + } +} + function findSameAgent(target: AgentSendWaitTarget, agents: AgentInfo[]): AgentInfo | undefined { return agents.find((agent) => agent.pid === target.pid) ?? agents.find((agent) => agent.sessionId === target.sessionId && agent.type === target.type); @@ -129,6 +192,310 @@ export async function waitForAgentResponse(params: WaitForAgentResponseParams): throw new Error(`Timed out waiting for agent "${target.name}" after ${options.timeoutLabel ?? `${options.maxWaitMs}ms`}.`); } +export async function sendToAgent({ + id, + prompt, + manager, + focusManager, + wait = false, + timeout, + json = false, + reporter = ui, + writer = TtyWriter.send, + writeWaitStatus = (message) => process.stderr.write(`${message}\n`), + writeAssistantMessage = (message) => process.stdout.write(`${message.content}\n`), + writeJson = (value) => console.log(JSON.stringify(value, null, 2)), +}: SendToAgentOptions): Promise { + const waitTimeout = parseSendWaitTimeout(timeout); + const agents = await manager.listAgents(); + if (agents.length === 0) { + reporter.error('No running agents found.'); + return; + } + + const resolved = manager.resolveAgent(id, agents); + if (!resolved) { + reporter.error(`No agent found matching "${id}".`); + reporter.info('Available agents:'); + agents.forEach((agent) => reporter.info(` - ${agent.name}`)); + return; + } + + if (Array.isArray(resolved)) { + reporter.error(`Multiple agents match "${id}":`); + resolved.forEach((agent) => reporter.info(` - ${agent.name} (${formatStatus(agent.status)})`)); + reporter.info('Please use a more specific identifier.'); + return; + } + + const agent = resolved; + if (![AgentStatus.WAITING, AgentStatus.IDLE].includes(agent.status)) { + const warning = `Agent "${agent.name}" is not waiting for input (status: ${agent.status}). Sending anyway.`; + if (wait) { + writeWaitStatus(warning); + } else { + reporter.warning(warning); + } + } + + const waitContext = wait ? prepareWaitMode(manager, agent) : undefined; + const location = await focusManager.findTerminal(agent.pid); + if (!location) { + if (wait) { + throw new Error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); + } + reporter.error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); + return; + } + + await writer(location, prompt); + + if (!wait) { + reporter.success(`Sent message to ${agent.name}.`); + return; + } + + if (!waitContext) { + throw new Error('Wait mode was not prepared.'); + } + + const waitResult = await waitForAgentResponse({ + manager, + adapter: waitContext.adapter, + target: { + id, + name: agent.name, + type: agent.type, + pid: agent.pid, + sessionId: agent.sessionId, + sessionFilePath: waitContext.sessionFilePath, + }, + initialMessageCount: waitContext.initialMessageCount, + options: { + pollIntervalMs: AGENT_SEND_WAIT_POLL_INTERVAL_MS, + maxWaitMs: waitTimeout.maxWaitMs, + timeoutLabel: waitTimeout.label, + }, + onAssistantMessage: (message) => { + if (!json) writeAssistantMessage(message); + }, + onStatus: writeWaitStatus, + }); + + if (json) { + writeJson(toAgentSendWaitJson(waitResult, agent, prompt, id)); + } +} + +export async function sendToAgentGroup({ + group, + prompt, + manager, + focusManager, + reporter = ui, + writer = TtyWriter.send, +}: SendToAgentGroupOptions): Promise { + if (group.members.length === 0) { + throw new Error(`Agent group "${group.name}" has no members.`); + } + + const agents = await manager.listAgents(); + if (agents.length === 0) { + reporter.error('No running agents found.'); + process.exitCode = 1; + return; + } + + const resolution = resolveGroupTargets(group, agents, manager); + if (resolution.errors.length > 0) { + reportResolutionErrors(group.name, resolution.errors, reporter); + process.exitCode = 1; + return; + } + + const targets = dedupeTargets(resolution.targets, reporter); + await deliverGroupMessage({ + groupName: group.name, + targets, + prompt, + focusManager, + reporter, + writer, + }); +} + +function parseSendWaitTimeout(value: string | undefined): { maxWaitMs: number; label?: string } { + try { + const parsed = parseMilliseconds(value, AGENT_SEND_WAIT_MAX_WAIT_MS); + return { maxWaitMs: parsed.milliseconds, label: parsed.label }; + } catch (error) { + throw new Error(`Invalid --timeout. ${(error as Error).message} Example: 30000.`); + } +} + +function prepareWaitMode(manager: Pick, agent: AgentInfo): { + adapter: AgentAdapter; + sessionFilePath: string; + initialMessageCount: number; +} { + if (!agent.sessionFilePath) { + throw new Error(`No session file found for agent "${agent.name}"; cannot wait for response.`); + } + + const adapter = manager.getAdapter(agent.type); + if (!adapter) { + throw new Error(`Unsupported agent type: ${agent.type}`); + } + + return { + adapter, + sessionFilePath: agent.sessionFilePath, + initialMessageCount: adapter.getConversation(agent.sessionFilePath, { verbose: false }).length, + }; +} + +function toAgentSendWaitJson(result: AgentSendWaitResult, agent: AgentInfo, prompt: string, targetId: string): object { + return { + target: { + id: targetId, + name: agent.name, + type: agent.type, + pid: agent.pid, + status: agent.status, + summary: agent.summary, + projectPath: agent.projectPath, + sessionId: agent.sessionId, + sessionFilePath: result.sessionFilePath, + lastActive: agent.lastActive, + }, + prompt, + responseMessages: result.messages, + elapsedMs: result.elapsedMs, + finalStatus: result.finalStatus, + }; +} + +function formatStatus(status: AgentStatus): string { + const label = { + [AgentStatus.RUNNING]: 'run', + [AgentStatus.WAITING]: 'wait', + [AgentStatus.IDLE]: 'idle', + [AgentStatus.UNKNOWN]: 'unknown', + }[status] ?? 'unknown'; + return `${statusEmoji(status)} ${label}`; +} + +function statusEmoji(status: AgentStatus): string { + return { + [AgentStatus.RUNNING]: '\u{1F7E2}', + [AgentStatus.WAITING]: '\u{1F7E1}', + [AgentStatus.IDLE]: '\u{26AA}', + [AgentStatus.UNKNOWN]: '\u{2753}', + }[status] ?? '\u{2753}'; +} + +function resolveGroupTargets( + group: AgentGroup, + agents: AgentInfo[], + manager: Pick, +): { targets: GroupTarget[]; errors: string[] } { + const targets: GroupTarget[] = []; + const errors: string[] = []; + + for (const member of group.members) { + const resolved = manager.resolveAgent(member, agents); + if (!resolved) { + errors.push(` - ${member}: no running agent matched`); + continue; + } + if (Array.isArray(resolved)) { + errors.push(` - ${member}: matched multiple agents (${resolved.map((agent) => agent.name).join(', ')})`); + continue; + } + targets.push({ member, agent: resolved }); + } + + return { targets, errors }; +} + +function reportResolutionErrors(groupName: string, errors: string[], reporter: SendReporter): void { + reporter.error(`Cannot send to group "${groupName}" because some members could not be resolved.`); + for (const error of errors) { + reporter.error(error); + } +} + +function dedupeTargets(targets: GroupTarget[], reporter: SendReporter): GroupTarget[] { + const uniqueTargets: GroupTarget[] = []; + const seen = new Set(); + + for (const target of targets) { + const key = targetKey(target.agent); + if (seen.has(key)) { + reporter.info(`Skipped duplicate target "${target.agent.name}" from group member "${target.member}".`); + continue; + } + seen.add(key); + uniqueTargets.push(target); + } + + return uniqueTargets; +} + +async function deliverGroupMessage(options: { + groupName: string; + targets: GroupTarget[]; + prompt: string; + focusManager: Pick; + reporter: SendReporter; + writer: (location: TerminalLocation, message: string) => Promise; +}): Promise { + let successCount = 0; + let failureCount = 0; + + for (const { agent } of options.targets) { + warnIfAgentIsBusy(agent, options.reporter); + + try { + const location = await options.focusManager.findTerminal(agent.pid); + if (!location) { + throw new Error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); + } + await options.writer(location, options.prompt); + successCount += 1; + options.reporter.success(`Sent message to ${agent.name}.`); + } catch (error) { + failureCount += 1; + options.reporter.error(`Failed to send to ${agent.name}: ${(error as Error).message}`); + } + } + + reportDeliverySummary(options.groupName, successCount, failureCount, options.reporter); +} + +function warnIfAgentIsBusy(agent: AgentInfo, reporter: SendReporter): void { + if (![AgentStatus.WAITING, AgentStatus.IDLE].includes(agent.status)) { + reporter.warning(`Agent "${agent.name}" is not waiting for input (status: ${agent.status}). Sending anyway.`); + } +} + +function reportDeliverySummary(groupName: string, successCount: number, failureCount: number, reporter: SendReporter): void { + if (failureCount > 0) { + reporter.error(`Sent message to ${successCount} agent(s), failed for ${failureCount} agent(s) in group "${groupName}".`); + process.exitCode = 1; + return; + } + + reporter.success(`Sent message to ${successCount} agent(s) in group "${groupName}".`); +} + +function targetKey(agent: AgentInfo): string { + return agent.pid ? `pid:${agent.pid}` : `name:${agent.name}`; +} + +const AGENT_SEND_WAIT_POLL_INTERVAL_MS = 2000; +const AGENT_SEND_WAIT_MAX_WAIT_MS = 10 * 60 * 1000; + export const DEFAULT_PID_POLL_INTERVAL_MS = 500; export const DEFAULT_PID_POLL_TIMEOUT_MS = 5_000; const REQUIRED_STABLE_PID_POLLS = 5;