From 745e6f7958e3d38c3c62e4b69c3d80fb12b13f33 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 14 May 2026 19:23:55 +0200 Subject: [PATCH 1/3] 0.30.0 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- CHANGELOG.md | 2 +- package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7c35c91..cfe67a4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.29.0", + "version": "0.30.0", "description": "Structured AI-assisted development with phase workflows, persistent memory, and reusable skills", "author": { "name": "Hoang Nguyen", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 4477a1b..6fc6d31 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.29.0", + "version": "0.30.0", "description": "AI-assisted development toolkit for Codex with structured workflows, reusable commands, persistent memory, and skills", "author": { "name": "Hoang Nguyen", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 2fd017d..2f3f15b 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.29.0", + "version": "0.30.0", "description": "AI-assisted development toolkit with structured SDLC workflows, persistent memory, and reusable skills", "author": { "name": "Hoang Nguyen", diff --git a/CHANGELOG.md b/CHANGELOG.md index 589dfaa..78ec4f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.30.0] - 2026-05-14 ### Added diff --git a/package.json b/package.json index 51e1018..4bdf020 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.29.0", + "version": "0.30.0", "private": true, "description": "A CLI toolkit for AI-assisted software development with phase templates and environment setup", "scripts": { From 753790ef42bd81f3c13403871cad8866583c3507 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 14 May 2026 19:27:56 +0200 Subject: [PATCH 2/3] Update CHANGELOG for version 0.30.0 --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ec4f6..0340bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **OpenCode Agent Adapter** - Added `OpenCodeAdapter` to `agent-manager` so `ai-devkit agent` commands can discover, inspect, and control running OpenCode sessions (#82). -- **HTML Artifact in `document-code` Skill** - The `document-code` skill now offers an HTML artifact alongside its markdown output (ff9d3bb). +- **OpenCode Agent Adapter** - Added `OpenCodeAdapter` to `agent-manager` so `ai-devkit agent` commands can discover, inspect, and control running OpenCode sessions. +- **HTML Artifact in `document-code` Skill** - The `document-code` skill now offers an HTML artifact alongside its markdown output. ### Fixed -- **Claude Code PID-File Live Status** - `agent-manager` now prefers the PID-file live status when resolving Claude Code agent state, improving accuracy of `agent list` output (f4e189d). -- **Claude Code Lossy Project Dir Encoding** - `agent-manager` now matches Claude Code's lossy project directory encoding when resolving session paths, fixing session discovery for paths that Claude Code re-encodes (a98e7ac). +- **Claude Code PID-File Live Status** - `agent-manager` now prefers the PID-file live status when resolving Claude Code agent state, improving accuracy of `agent list` output. +- **Claude Code Lossy Project Dir Encoding** - `agent-manager` now matches Claude Code's lossy project directory encoding when resolving session paths, fixing session discovery for paths that Claude Code re-encodes. ## [0.29.0] - 2026-05-11 From ee05c7964ece14dfe0d254a547443f45ccd8b153 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 15 May 2026 07:24:28 +0200 Subject: [PATCH 3/3] feat(cli): add wait mode to agent send (#83) * feat(cli): add wait mode to agent send planning * feat(cli): add wait mode to agent send Add agent send --wait so scripts can send a prompt to an existing interactive agent session and capture the new assistant response from stdout. - seed transcript length before terminal delivery - poll the target session transcript for new assistant messages - complete when the agent returns to waiting, or idle after assistant output - keep status and warning output on stderr in wait mode - validate missing session files and unsupported adapters before sending - add focused wait-loop and command coverage * feat(cli): refactor the service --- docs/ai/design/feature-agent-send-wait.md | 179 +++++++++ .../implementation/feature-agent-send-wait.md | 76 ++++ docs/ai/planning/feature-agent-send-wait.md | 88 +++++ .../requirements/feature-agent-send-wait.md | 95 +++++ docs/ai/testing/feature-agent-send-wait.md | 104 +++++ .../cli/src/__tests__/commands/agent.test.ts | 251 ++++++++++++ .../services/agent/agent.service.test.ts | 369 ++++++++++++++++++ packages/cli/src/commands/agent.ts | 79 +++- .../cli/src/services/agent/agent.service.ts | 122 ++++++ packages/cli/src/util/time.ts | 4 + 10 files changed, 1364 insertions(+), 3 deletions(-) create mode 100644 docs/ai/design/feature-agent-send-wait.md create mode 100644 docs/ai/implementation/feature-agent-send-wait.md create mode 100644 docs/ai/planning/feature-agent-send-wait.md create mode 100644 docs/ai/requirements/feature-agent-send-wait.md create mode 100644 docs/ai/testing/feature-agent-send-wait.md create mode 100644 packages/cli/src/__tests__/services/agent/agent.service.test.ts create mode 100644 packages/cli/src/services/agent/agent.service.ts create mode 100644 packages/cli/src/util/time.ts diff --git a/docs/ai/design/feature-agent-send-wait.md b/docs/ai/design/feature-agent-send-wait.md new file mode 100644 index 0000000..c5b7eb3 --- /dev/null +++ b/docs/ai/design/feature-agent-send-wait.md @@ -0,0 +1,179 @@ +--- +phase: design +title: Agent Send Wait Design +description: Technical design for waiting on and printing responses after agent send +--- + +# Agent Send Wait Design + +## Architecture Overview + +```mermaid +graph TD + CLI["agent send --id --wait"] + Manager["AgentManager"] + Resolve["resolveAgent(id, agents)"] + Terminal["TerminalFocusManager.findTerminal(pid)"] + Writer["TtyWriter.send(location, message)"] + Waiter["AgentResponseWaiter"] + Adapter["AgentAdapter.getConversation(sessionFilePath)"] + Status["AgentManager.listAgents()"] + Stderr["stderr: status/errors"] + Output["stdout: assistant response"] + + CLI --> Manager + Manager --> Resolve + Resolve --> Terminal + Terminal --> Writer + CLI --> Waiter + Waiter --> Adapter + Waiter --> Status + Adapter --> Waiter + Status --> Waiter + Waiter --> Stderr + Waiter --> Output +``` + +The command flow is: + +1. Resolve the target agent using the existing `--id` logic. +2. Find the terminal and validate `sessionFilePath` when `--wait` is enabled. +3. Seed the transcript cursor from the current conversation length before sending. +4. Send the message with `TtyWriter.send()`. +5. Poll the conversation for new messages and print assistant text content that appears after the seed cursor. +6. Poll agent status until the original target returns to `waiting`, disappears, or the 10-minute safety cap is reached. + +## Data Models + +No persistent data model is required. + +Internal wait result: + +```typescript +interface AgentSendWaitResult { + agentName: string; + agentType: AgentType; + pid: number; + sessionId: string; + sessionFilePath: string; + messages: ConversationMessage[]; + finalStatus: AgentStatus; + elapsedMs: number; +} +``` + +Internal wait options: + +```typescript +interface AgentSendWaitOptions { + pollIntervalMs: number; + maxWaitMs: number; +} +``` + +For this feature, `maxWaitMs` is fixed at 10 minutes. The later `--timeout` backlog item can make it configurable without changing the wait-loop contract. + +## API Design + +CLI interface: + +```bash +npx ai-devkit agent send --id --wait +``` + +Behavior: + +- `--wait` is optional and defaults to `false`. +- Without `--wait`, the command keeps the current fire-and-forget path. +- With `--wait`, stdout is reserved for assistant response text. +- With `--wait`, status/progress/warnings/errors must go to stderr. Current `ui.info`, `ui.warning`, and `ui.success` write to stdout, so the wait path should use a small stderr writer or direct `process.stderr.write()` for non-response messages. +- With `--wait`, do not print the existing success line (`Sent message to ...`) to stdout after delivery. + +Internal helper: + +```typescript +async function waitForAgentResponse(params: { + manager: AgentManager; + adapter: AgentAdapter; + target: { + id: string; + name: string; + type: AgentType; + pid: number; + sessionId: string; + sessionFilePath: string; + }; + initialMessageCount: number; + options: AgentSendWaitOptions; + onAssistantMessage: (message: ConversationMessage) => void; + onStatus?: (message: string) => void; +}): Promise; +``` + +`target.id` is the original `--id` value for user-facing context, but the wait loop should prefer `pid` and `sessionId` when finding the same running agent on later polls. This avoids accidentally switching to a different process if a partial name becomes ambiguous while waiting. + +## Component Breakdown + +### CLI command + +File: `packages/cli/src/commands/agent.ts` + +- Add `.option('--wait', 'Wait for and print the agent response')`. +- Before sending, validate `agent.sessionFilePath` when `options.wait` is true. +- Find the owning adapter with existing `manager.getAdapter(agent.type)`. +- Seed `initialMessageCount` from `adapter.getConversation(agent.sessionFilePath, { verbose: false }).length`. +- Send via `TtyWriter.send()`. +- If `--wait`, call the wait helper and print assistant messages as complete transcript messages are detected. +- If `--wait`, use stderr for pre-send warnings and wait status messages so stdout remains response-only. + +### Wait helper + +Preferred location: `packages/cli/src/services/agent/agent.service.ts` + +Responsibilities: + +- Poll conversation and status. +- Track already emitted transcript indexes. +- Emit only messages where `role === 'assistant'` and `content` is non-empty. +- Stop when the resolved target returns to `AgentStatus.WAITING`. +- Also stop on `AgentStatus.IDLE` after at least one new assistant message has been emitted for this send; Claude Code can move from busy to idle after writing the response instead of reporting waiting. +- Do not stop on `AgentStatus.WAITING` until a transcript read succeeds for the current loop; this avoids missing a final response when the transcript is observed mid-write. +- Fail if the target disappears. +- Fail if the 10-minute safety cap is reached. +- Write a no-response status note to stderr if the target returns to waiting without new assistant text. +- Return structured result for tests and future JSON output. + +### Tests + +Primary test file: `packages/cli/src/__tests__/commands/agent.test.ts` + +Add focused service tests if the wait helper is extracted. + +## Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Response source | Transcript via `getConversation()` | Existing adapter contract already normalizes Claude, Codex, and Gemini transcripts. | +| Completion signal | Agent returns to `waiting` | Matches existing agent status model and user expectation that the turn is done when input is accepted again. | +| Transcript cursor | Seed by current conversation length before send | Prevents historical messages from being printed. | +| Output default | Assistant text content only on stdout | Keeps the command useful in scripts and close to `claude -p` ergonomics. | +| Status output | stderr for wait mode | Existing `ui.info`, `ui.warning`, and `ui.success` write to stdout, so wait mode needs a response-safe status path. | +| Polling | Interval polling | Existing channel bridge already uses polling; no new daemon or file watcher is needed. | +| Target tracking | Prefer original PID and session ID | Avoids changing targets if a partial name resolves differently during the wait. | +| Safety cap | Fixed 10 minutes | Prevents indefinite hangs while keeping configurable `--timeout` as a later backlog item. | +| Scope | `--wait` only | Keeps item 1 small and leaves timeout/json/stdin/ask for separate backlog items. | + +Alternatives considered: + +- **Block on terminal output:** rejected because terminal screen capture is brittle and terminal-specific. +- **Use Claude Agent SDK / `claude -p`:** rejected because the feature goal is to use interactive Claude Code sessions. +- **Add `agent ask` first:** rejected because `agent ask` should be a wrapper once `agent send --wait` is reliable. + +## Non-Functional Requirements + +- Polling must avoid tight loops; use an interval around 1-2 seconds. +- The command must not mutate transcript files. +- The command must not introduce shell interpolation. Message delivery remains handled by `TtyWriter`. +- The command must exit non-zero for delivery failure, missing transcript support, target termination, or safety-cap timeout. +- The command must keep stdout response-only in wait mode. +- Implementation should keep response waiting testable without real terminals or real Claude Code sessions. diff --git a/docs/ai/implementation/feature-agent-send-wait.md b/docs/ai/implementation/feature-agent-send-wait.md new file mode 100644 index 0000000..fc7b3e1 --- /dev/null +++ b/docs/ai/implementation/feature-agent-send-wait.md @@ -0,0 +1,76 @@ +--- +phase: implementation +title: Agent Send Wait Implementation +description: Technical implementation notes for adding --wait to agent send +--- + +# Agent Send Wait Implementation + +## Development Setup + +- Worktree: `.worktrees/feature-agent-send-wait` +- Branch: `feature-agent-send-wait` +- Required Node: use Node 20-25. Local bootstrap succeeded with `/opt/homebrew/Cellar/node/24.3.0/bin` first in `PATH`. +- Build command used during setup: `PATH=/opt/homebrew/Cellar/node/24.3.0/bin:$PATH npm run build` +- Lint command used during setup: `PATH=/opt/homebrew/Cellar/node/24.3.0/bin:$PATH node packages/cli/dist/cli.js lint` + +## Code Structure + +Likely files: + +- `packages/cli/src/commands/agent.ts`: add `--wait` option and connect command flow. +- `packages/cli/src/services/agent/agent.service.ts`: new agent service with the wait helper. +- `packages/cli/src/__tests__/commands/agent.test.ts`: command-level regression tests. +- `packages/cli/src/__tests__/services/agent/agent.service.test.ts`: helper-level tests for the agent service. + +## Implementation Notes + +### Core Features + +- Seed transcript length before sending: + ```typescript + const initialMessageCount = adapter.getConversation(agent.sessionFilePath, { verbose: false }).length; + await TtyWriter.send(location, message); + ``` +- Poll conversation and emit `conversation.slice(lastSeenCount)` entries where `role !== 'user'` and `content` is non-empty. +- Poll agent status by listing agents again and resolving the original `--id`. +- Stop when the resolved target status is `AgentStatus.WAITING`. +- Also stop on `AgentStatus.IDLE` after new assistant output has been observed for the current send. +- If a transcript read fails during a poll, do not complete even if status is already `WAITING`; retry until a read succeeds, the agent disappears, or the safety cap is reached. +- Return non-zero for missing transcript path, terminated target, delivery failure, and defensive timeout. + +### Patterns & Best Practices + +- Keep terminal delivery in `TtyWriter`; do not add new terminal-specific send logic. +- Keep transcript parsing inside adapters. +- Keep stdout focused on assistant content so scripts can consume it. +- Keep fixed wait defaults local to this feature so `--timeout` can replace them cleanly later. + +## Integration Points + +- `AgentManager.listAgents()` for target detection and status refresh. +- `AgentManager.resolveAgent()` for target consistency. +- `AgentAdapter.getConversation()` for transcript polling. +- `TerminalFocusManager.findTerminal()` and `TtyWriter.send()` for existing delivery flow. + +## Error Handling + +- Missing target: existing command behavior. +- Ambiguous target: existing command behavior. +- Unsupported terminal or terminal not found: existing command behavior. +- Missing `sessionFilePath` in wait mode: print clear error and exit non-zero. +- Transcript read error: continue polling for transient reads; fail after defensive cap or termination. +- Target disappears: fail non-zero with a clear termination message. + +## Performance Considerations + +- Poll around every 1-2 seconds. +- Read only the normalized conversation array through the adapter. +- Track `lastSeenCount` to avoid repeated output work. +- Avoid long-running busy loops. + +## Security Notes + +- Continue using `TtyWriter.send()` and its `execFile`-based delivery. +- Do not introduce shell execution for prompts. +- Do not write prompt or response content to persistent storage for this feature. diff --git a/docs/ai/planning/feature-agent-send-wait.md b/docs/ai/planning/feature-agent-send-wait.md new file mode 100644 index 0000000..869b5b8 --- /dev/null +++ b/docs/ai/planning/feature-agent-send-wait.md @@ -0,0 +1,88 @@ +--- +phase: planning +title: Agent Send Wait Planning +description: Task breakdown for adding --wait to agent send +--- + +# Agent Send Wait Planning + +## Milestones + +- [x] Milestone 1: CLI option and transcript seed +- [x] Milestone 2: Wait helper and response output +- [x] Milestone 3: Failure handling and tests +- [x] Milestone 4: Documentation and verification + +## Task Breakdown + +### Phase 1: CLI foundation + +- [x] Task 1.1: Add `--wait` option to `agent send`. +- [x] Task 1.2: Preserve existing non-wait behavior and tests. +- [x] Task 1.3: Resolve the target adapter for the selected agent type. +- [x] Task 1.4: Validate `sessionFilePath` before waiting and return a clear error when unavailable. +- [x] Task 1.5: Seed transcript length before `TtyWriter.send()`. + +### Phase 2: Wait helper + +- [x] Task 2.1: Add `waitForAgentResponse()` helper under CLI services. +- [x] Task 2.2: Poll `getConversation()` and emit only new assistant messages. +- [x] Task 2.3: Poll `AgentManager.listAgents()` and resolve the same target to detect `waiting`. +- [x] Task 2.4: Stop successfully when the agent returns to `AgentStatus.WAITING`. +- [x] Task 2.5: Return structured wait results for future `--json` support. + +### Phase 3: Failure modes + +- [x] Task 3.1: Handle agent termination while waiting with a non-zero exit. +- [x] Task 3.2: Handle transcript read errors without crashing on the first transient failure. +- [x] Task 3.3: Add a fixed defensive max wait duration until the separate `--timeout` item is implemented. +- [x] Task 3.4: Ensure status/progress does not pollute stdout response output. + +### Phase 4: Tests and docs + +- [x] Task 4.1: Add tests for historical transcript seeding. +- [x] Task 4.2: Add tests for assistant-only output filtering. +- [x] Task 4.3: Add tests for missing `sessionFilePath`. +- [x] Task 4.4: Add tests for target termination and defensive timeout. +- [x] Task 4.5: Update user-facing docs/help text if command documentation exists. + +## Progress Summary + +Phase 4 implementation completed the first backlog item for `agent send --wait`. The CLI now seeds transcript length before sending, validates wait-mode transcript support, resolves the target adapter, suppresses the normal success line in wait mode, writes assistant response text to stdout, and uses a dedicated wait helper to poll transcript/status until the original agent returns to `waiting`, returns to `idle` after new assistant output, disappears, or reaches the fixed 10-minute safety cap. Phase 6 review tightened the wait loop so it does not complete on `waiting` status until a transcript read succeeds, completes on `idle` only after response output, treats initial idle agents as safe to send without a busy warning, and sanitizes wait-mode stderr status messages. Focused command and wait-helper tests cover historical transcript seeding, assistant-only output, missing session files, target termination, transient transcript read errors, session-ID target fallback, no-response status reporting, sanitized stderr status output, idle-after-response completion, idle-before-output timeout, and defensive timeout. + +## Dependencies + +- Existing `AgentManager.resolveAgent()` behavior. +- Existing `AgentAdapter.getConversation()` implementations. +- Existing `TtyWriter.send()` terminal delivery. +- Existing `AgentStatus.WAITING` detection. +- Node 20-25 for local dependency installation because `better-sqlite3@12.6.2` does not build under Node 26. + +## Timeline & Estimates + +- CLI option and seed logic: 0.5 day. +- Wait helper: 1 day. +- Failure handling: 0.5 day. +- Unit tests and docs: 1 day. + +Estimated total: 2-3 engineering days. + +## Risks & Mitigation + +- **Risk:** Agent status may lag transcript writes. + **Mitigation:** Continue polling until both new messages are captured and status returns to waiting. + +- **Risk:** Some adapters may not provide reliable `sessionFilePath`. + **Mitigation:** Fail clearly in wait mode and keep fire-and-forget send available. + +- **Risk:** Transcript parsing can throw while the agent is writing. + **Mitigation:** Treat occasional read errors as transient during the wait loop. + +- **Risk:** Command can hang without configurable timeout. + **Mitigation:** Add a fixed defensive cap in item 1; implement user-facing `--timeout` as the next backlog item. + +## Resources Needed + +- Unit-test fixtures for conversation messages. +- Mock `AgentManager`, `AgentAdapter`, and `TtyWriter` behavior. +- Existing agent command tests as regression coverage. diff --git a/docs/ai/requirements/feature-agent-send-wait.md b/docs/ai/requirements/feature-agent-send-wait.md new file mode 100644 index 0000000..9fa9559 --- /dev/null +++ b/docs/ai/requirements/feature-agent-send-wait.md @@ -0,0 +1,95 @@ +--- +phase: requirements +title: Agent Send Wait +description: Add a --wait mode to agent send so scripts can receive the response from an interactive agent session +--- + +# Agent Send Wait + +## Problem Statement + +`npx ai-devkit agent send` can deliver input to a running agent terminal, but it exits immediately after sending. This is useful for simple confirmations, but it is not enough for users who want a scriptable `claude -p` style workflow backed by an existing interactive Claude Code subscription session. + +Affected users are developers who already run Claude Code interactively and want to automate short prompts from shells, scripts, editor tasks, or CI-like local workflows without switching terminals manually. + +The current workaround is to run `agent send`, then separately inspect the terminal or run `agent detail` to see what happened. That breaks scriptability because the caller cannot know when the response is ready or capture it from stdout. + +## Goals & Objectives + +Primary goals: + +- Add `--wait` to `npx ai-devkit agent send --id `. +- After sending, poll the target agent transcript and print new assistant output produced after the send. +- Exit after the target agent is ready for user input again. +- Avoid replaying historical conversation messages. +- Preserve current fire-and-forget behavior when `--wait` is not provided. + +Secondary goals: + +- Reuse existing agent transcript parsing via `AgentAdapter.getConversation()`. +- Reuse existing status detection via `AgentManager.listAgents()`. +- Keep the implementation agent-type aware through existing adapters, with Claude Code as the primary target. +- Structure the implementation so later backlog items can add timeout, JSON output, stdin prompts, and `agent ask`. + +Non-goals: + +- Do not add `agent ask`. +- Do not add `--timeout`, `--json`, `--stdin`, `--stream`, or queueing in this feature. +- Do not start or manage Claude Code sessions automatically. +- Do not use Claude Agent SDK / `claude -p`. +- Do not capture terminal screen output directly. +- Do not change subscription or billing behavior. + +## User Stories & Use Cases + +- As a developer, I want `agent send --wait` to print the assistant response so I can call it from a shell script. +- As a developer, I want `agent send --wait` to wait until the agent is ready again so I know the turn has completed. +- As a developer, I want historical transcript content excluded so the command output contains only the response to my new prompt. +- As a developer, I want the existing `agent send` behavior unchanged when I do not pass `--wait`. + +Primary workflow: + +1. User has a running Claude Code session. +2. User runs `npx ai-devkit agent send "summarize current git diff" --id ai-devkit --wait`. +3. CLI resolves the agent and terminal, seeds the current transcript position, sends the prompt, then polls for new transcript messages. +4. CLI prints new assistant messages generated after the send. +5. CLI exits when the agent status returns to waiting. + +Edge cases: + +- Target agent has no `sessionFilePath`: send succeeds, but wait mode fails with a clear message because transcript polling is unavailable. +- Target agent cannot be resolved: existing not-found and ambiguous-match behavior remains. +- Target terminal cannot be found: existing delivery error behavior remains. +- Agent exits while waiting: command exits non-zero with a clear message. +- Transcript parsing throws temporarily: command keeps polling for a bounded default period or until agent state proves failure. +- Agent produces no assistant messages before becoming waiting: command exits successfully with no assistant output and a status note on stderr. +- Agent is not waiting before send: existing warning remains; wait mode still works by seeding transcript before send. + +## Success Criteria + +- `agent send --id --wait` sends the message and prints only new assistant output. +- `agent send --id ` without `--wait` behaves as it does today. +- Existing not-found, ambiguous target, unsupported terminal, and terminal-not-found errors still work. +- Historical transcript messages are not printed in wait mode. +- The command exits when the agent returns to `AgentStatus.WAITING`. +- The command also exits when Claude Code reports `AgentStatus.IDLE` after new assistant output has been printed for the current send. +- Unit tests cover transcript seeding, output filtering, missing transcript path, agent termination, and unchanged non-wait behavior. + +## Constraints & Assumptions + +- This feature depends on agent adapters that expose `sessionFilePath` and implement `getConversation()`. +- Claude Code is the main target because the opportunity comes from interactive Claude Code subscription usage. +- Waiting is transcript-based, not terminal-screen-based. +- Agent status is detected by re-running `AgentManager.listAgents()` and resolving the same target. +- Polling should use conservative intervals similar to the existing channel connector polling loop. +- The first implementation uses a fixed 10-minute safety cap to avoid hanging forever. A configurable `--timeout` flag is a separate backlog item. + +## Resolved Decisions + +- **Default maximum wait:** Use a fixed 10-minute safety cap until the dedicated `--timeout` backlog item is implemented. +- **Output streams:** Write assistant response content to stdout. Write status, progress, warnings, and errors to stderr through existing UI/error helpers where practical. +- **Default message filtering:** Print assistant text content only. Do not include user messages, tool calls, tool results, or verbose transcript details by default. + +## Questions & Open Items + +- None blocking for this feature. diff --git a/docs/ai/testing/feature-agent-send-wait.md b/docs/ai/testing/feature-agent-send-wait.md new file mode 100644 index 0000000..2bb6225 --- /dev/null +++ b/docs/ai/testing/feature-agent-send-wait.md @@ -0,0 +1,104 @@ +--- +phase: testing +title: Agent Send Wait Testing +description: Testing strategy for adding --wait to agent send +--- + +# Agent Send Wait Testing + +## Test Coverage Goals + +- Target 100% coverage for new wait helper branches. +- Preserve existing `agent send` tests. +- Cover success, no-op, and failure modes without requiring a real Claude Code process. + +## Unit Tests + +### `waitForAgentResponse` + +- [x] Emits only assistant messages added after the seeded transcript count. +- [x] Skips historical assistant messages. +- [x] Skips user messages and empty assistant messages. +- [x] Stops when the resolved agent reaches `AgentStatus.WAITING`. +- [x] Stops when the resolved agent reaches `AgentStatus.IDLE` after new assistant output. +- [x] Does not stop on `AgentStatus.IDLE` before new assistant output. +- [x] Tracks the same target by session ID when PID is not matched. +- [x] Fails when the target agent disappears. +- [x] Handles transient `getConversation()` errors without crashing immediately. +- [x] Does not complete on `AgentStatus.WAITING` until a transcript read succeeds. +- [x] Reports a stderr status message when the agent finishes without assistant text. +- [x] Waits for the configured poll interval before polling again. +- [x] Fails after the defensive max wait duration. + +### `agent send` command + +- [x] Existing non-wait send flow still calls `TtyWriter.send()` and prints success. +- [x] Initial idle agents send without busy-agent warning. +- [x] `--wait` validates `sessionFilePath`. +- [x] `--wait` validates target adapter support. +- [x] `--wait` seeds transcript before sending. +- [x] `--wait` calls the wait helper after sending. +- [x] `--wait` keeps assistant response text on stdout. +- [x] `--wait` sends busy-agent warnings to stderr. +- [x] `--wait` sanitizes status messages before writing to stderr. +- [x] Delivery failure does not enter wait mode. +- [x] Not-found and ambiguous agent behavior remains unchanged. + +## Integration Tests + +- [x] Command-level mocked integration: resolved agent plus mocked terminal plus mocked transcript returns assistant output. +- [x] Service-level mocked integration: agent returns waiting with no new assistant output. +- [x] Service-level mocked integration: target terminates during wait. + +## End-to-End Tests + +Manual E2E after implementation: + +- [ ] Start a Claude Code session in tmux. +- [ ] Run `npx ai-devkit agent list` and identify the target. +- [ ] Run `npx ai-devkit agent send "say pong" --id --wait`. +- [ ] Confirm stdout contains only the new assistant response. +- [ ] Confirm the command exits after Claude Code returns to the prompt. +- [ ] Run existing `npx ai-devkit agent send "continue" --id ` without `--wait` and confirm it remains fire-and-forget. + +## Test Data + +- Mock `ConversationMessage[]` arrays with historical messages and newly appended messages. +- Mock `AgentInfo` records with statuses `WAITING`, `IDLE`, active/non-waiting statuses, and missing `sessionFilePath`. +- Mock adapter read failures to simulate transcript writes in progress. + +## Test Reporting & Coverage + +Suggested verification commands: + +```bash +npm run build +npx nx test cli --runInBand +npx ai-devkit lint --feature agent-send-wait +``` + +Phase 7 verification results: + +- `npm run test -- --runInBand` from `packages/cli`: passed, 33 suites and 513 tests. +- `npm run test -- --runInBand src/__tests__/commands/agent.test.ts src/__tests__/services/agent/agent.service.test.ts` from `packages/cli`: passed, 38 tests. +- `npm run test -- --runInBand --coverage --coverageThreshold='{}' --collectCoverageFrom='src/services/agent/agent.service.ts' src/__tests__/services/agent/agent.service.test.ts` from `packages/cli`: passed, 100% statements/branches/functions/lines for the new wait helper. +- Regression check: with the `IDLE` completion fix temporarily reverted, `stops when the agent becomes idle after assistant output` failed with the original timeout. +- `npm run build`: passed. +- `npm run lint --workspace packages/cli`: passed with 0 errors and 4 pre-existing warnings outside this feature. +- `npx ai-devkit lint --feature agent-send-wait`: passed via the local built CLI in this workspace. + +## Manual Testing + +- Validate stdout/stderr separation from a shell pipeline. +- Validate behavior when the target session has no transcript path. +- Validate behavior when Claude Code is busy before the prompt is sent. + +## Performance Testing + +- Confirm polling interval does not create high CPU usage during a multi-minute wait. +- Confirm repeated transcript reads remain acceptable for normal Claude Code transcript sizes. + +## Bug Tracking + +- Add regressions to `agent.test.ts` for command-level bugs. +- Add focused helper tests for wait-loop state bugs. diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 250b37d..adfcd92 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -9,6 +9,11 @@ const mockManager: any = { listAgents: jest.fn(), listSessions: jest.fn(), resolveAgent: jest.fn(), + getAdapter: jest.fn(), +}; + +const mockAgentAdapter: any = { + getConversation: jest.fn(), }; const mockFocusManager: any = { @@ -25,6 +30,7 @@ const mockSpinner: any = { const mockPrompt: any = jest.fn(); const mockTtyWriterSend = jest.fn<(location: any, message: string) => Promise>().mockResolvedValue(undefined); +const mockWaitForAgentResponse = jest.fn<(...args: any[]) => Promise>(); jest.mock('@ai-devkit/agent-manager', () => ({ AgentManager: jest.fn(() => mockManager), @@ -62,11 +68,19 @@ jest.mock('../../util/terminal-ui', () => ({ }, })); +jest.mock('../../services/agent/agent.service', () => ({ + waitForAgentResponse: (...args: any[]) => mockWaitForAgentResponse(...args), +})); + describe('agent command', () => { let logSpy: ReturnType; + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; beforeEach(() => { jest.clearAllMocks(); logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); jest.spyOn(process, 'exit').mockImplementation((() => {}) as any); }); @@ -246,6 +260,138 @@ Waiting on user input`, expect(ui.success).toHaveBeenCalledWith('Sent message to repo-a.'); }); + it('sends message with --wait, seeds transcript before delivery, and prints assistant output only to stdout', 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' }; + const historical = [{ role: 'assistant', content: 'old response' }]; + mockManager.listAgents.mockResolvedValue([agent]); + mockManager.resolveAgent.mockReturnValue(agent); + mockManager.getAdapter.mockReturnValue(mockAgentAdapter); + mockAgentAdapter.getConversation.mockReturnValue(historical); + 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); + await program.parseAsync(['node', 'test', 'agent', 'send', 'continue', '--id', 'repo-a', '--wait']); + + expect(mockManager.getAdapter).toHaveBeenCalledWith('claude'); + 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(); + }); + + it('fails and does not send when --wait target has no session file', async () => { + const agent = { + name: 'repo-a', + type: 'claude', + status: AgentStatus.WAITING, + summary: 'Waiting', + lastActive: new Date(), + pid: 10, + sessionId: 'session-1', + }; + mockManager.listAgents.mockResolvedValue([agent]); + mockManager.resolveAgent.mockReturnValue(agent); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'hello', '--id', 'repo-a', '--wait']); + + 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 () => { + const agent = { + name: 'repo-a', + type: 'claude', + status: AgentStatus.WAITING, + summary: 'Waiting', + lastActive: new Date(), + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }; + mockManager.listAgents.mockResolvedValue([agent]); + mockManager.resolveAgent.mockReturnValue(agent); + mockManager.getAdapter.mockReturnValue(undefined); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'hello', '--id', 'repo-a', '--wait']); + + 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 () => { + const agent = { + name: 'repo-a', + type: 'claude', + status: AgentStatus.WAITING, + summary: 'Waiting', + lastActive: new Date(), + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }; + mockManager.listAgents.mockResolvedValue([agent]); + mockManager.resolveAgent.mockReturnValue(agent); + mockManager.getAdapter.mockReturnValue(mockAgentAdapter); + mockFocusManager.findTerminal.mockResolvedValue(null); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'hello', '--id', 'repo-a', '--wait']); + + expect(ui.error).toHaveBeenCalledWith('Failed to send message: Cannot find terminal for agent "repo-a" (PID: 10).'); + expect(process.exit).toHaveBeenCalledWith(1); + expect(mockTtyWriterSend).not.toHaveBeenCalled(); + }); + it('shows error when send target agent is not found', async () => { mockManager.listAgents.mockResolvedValue([ { name: 'repo-a', status: AgentStatus.RUNNING, summary: 'A', lastActive: new Date(), pid: 1 }, @@ -300,6 +446,111 @@ Waiting on user input`, expect(ui.success).toHaveBeenCalledWith('Sent message to repo-a.'); }); + it('does not warn when agent is idle and still sends', async () => { + const agent = { + name: 'repo-a', + status: AgentStatus.IDLE, + summary: 'Idle', + lastActive: new Date(), + pid: 10, + }; + const location = { type: 'tmux', identifier: '0:1.0', tty: '/dev/ttys030' }; + mockManager.listAgents.mockResolvedValue([agent]); + mockManager.resolveAgent.mockReturnValue(agent); + mockFocusManager.findTerminal.mockResolvedValue(location); + mockTtyWriterSend.mockResolvedValue(undefined); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'continue', '--id', 'repo-a']); + + expect(ui.warning).not.toHaveBeenCalled(); + expect(mockTtyWriterSend).toHaveBeenCalled(); + expect(ui.success).toHaveBeenCalledWith('Sent message to repo-a.'); + }); + + it('writes busy-agent warning to stderr in --wait mode', async () => { + const agent = { + name: 'repo-a', + type: 'claude', + status: AgentStatus.RUNNING, + summary: 'Running', + 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.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); + await program.parseAsync(['node', 'test', 'agent', 'send', 'continue', '--id', 'repo-a', '--wait']); + + expect(stderrSpy).toHaveBeenCalledWith( + 'Agent "repo-a" is not waiting for input (status: running). Sending anyway.\n' + ); + expect(ui.warning).not.toHaveBeenCalled(); + expect(mockTtyWriterSend).toHaveBeenCalledWith(location, 'continue'); + }); + + it('sanitizes wait-mode status messages before writing to stderr', async () => { + const agent = { + name: '\x1b[31mrepo-a\x1b[0m', + type: 'claude', + status: AgentStatus.RUNNING, + summary: 'Running', + 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.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); + await program.parseAsync(['node', 'test', 'agent', 'send', 'continue', '--id', 'repo-a', '--wait']); + + expect(stderrSpy).toHaveBeenCalledWith( + 'Agent "repo-a" is not waiting for input (status: running). Sending anyway.\n' + ); + expect(stderrSpy).toHaveBeenCalledWith('Status for repo-a\n'); + }); + it('shows error when terminal cannot be found', async () => { const agent = { name: 'repo-a', diff --git a/packages/cli/src/__tests__/services/agent/agent.service.test.ts b/packages/cli/src/__tests__/services/agent/agent.service.test.ts new file mode 100644 index 0000000..b8a4c40 --- /dev/null +++ b/packages/cli/src/__tests__/services/agent/agent.service.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { AgentStatus, type AgentInfo, type ConversationMessage } from '@ai-devkit/agent-manager'; +import { waitForAgentResponse } from '../../../services/agent/agent.service'; + +function makeAgent(overrides: Partial = {}): AgentInfo { + return { + name: 'repo-a', + type: 'claude', + status: AgentStatus.RUNNING, + summary: 'Working', + pid: 10, + projectPath: '/repo', + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + lastActive: new Date('2026-05-14T00:00:00.000Z'), + ...overrides, + }; +} + +function makeMessage(overrides: Partial = {}): ConversationMessage { + return { + role: 'assistant', + content: 'response', + timestamp: '2026-05-14T00:00:01.000Z', + ...overrides, + }; +} + +describe('waitForAgentResponse', () => { + it('emits only new non-empty assistant messages and stops when the same agent is waiting', async () => { + const running = makeAgent({ status: AgentStatus.RUNNING }); + const waiting = makeAgent({ status: AgentStatus.WAITING }); + const conversation = [ + makeMessage({ role: 'user', content: 'historical prompt' }), + makeMessage({ role: 'assistant', content: 'historical response' }), + makeMessage({ role: 'user', content: 'new prompt' }), + makeMessage({ role: 'assistant', content: '' }), + makeMessage({ role: 'system', content: 'system note' }), + makeMessage({ role: 'assistant', content: 'new response' }), + ]; + const manager = { + listAgents: jest.fn<() => Promise>() + .mockResolvedValueOnce([running]) + .mockResolvedValueOnce([waiting]), + }; + const adapter = { + getConversation: jest.fn<() => ConversationMessage[]>().mockReturnValue(conversation), + }; + const emitted: ConversationMessage[] = []; + + const result = await waitForAgentResponse({ + manager, + adapter, + target: { + id: 'repo-a', + name: 'repo-a', + type: 'claude', + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }, + initialMessageCount: 2, + options: { pollIntervalMs: 0, maxWaitMs: 1000 }, + onAssistantMessage: (message) => emitted.push(message), + }); + + expect(emitted.map((message) => message.content)).toEqual(['new response']); + expect(result.finalStatus).toBe(AgentStatus.WAITING); + expect(result.messages.map((message) => message.content)).toEqual(['new response']); + }); + + it('fails when the target agent disappears while waiting', async () => { + const manager = { + listAgents: jest.fn<() => Promise>().mockResolvedValue([]), + }; + const adapter = { + getConversation: jest.fn<() => ConversationMessage[]>().mockReturnValue([]), + }; + + await expect(waitForAgentResponse({ + manager, + adapter, + target: { + id: 'repo-a', + name: 'repo-a', + type: 'claude', + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }, + initialMessageCount: 0, + options: { pollIntervalMs: 0, maxWaitMs: 1000 }, + onAssistantMessage: jest.fn(), + })).rejects.toThrow('Agent "repo-a" is no longer running.'); + }); + + it('tracks the same target by session id when pid is not matched', async () => { + const waiting = makeAgent({ + pid: 99, + sessionId: 'session-1', + status: AgentStatus.WAITING, + }); + const manager = { + listAgents: jest.fn<() => Promise>().mockResolvedValue([waiting]), + }; + const adapter = { + getConversation: jest.fn<() => ConversationMessage[]>().mockReturnValue([ + makeMessage({ role: 'assistant', content: 'session response' }), + ]), + }; + const emitted: ConversationMessage[] = []; + + const result = await waitForAgentResponse({ + manager, + adapter, + target: { + id: 'repo-a', + name: 'repo-a', + type: 'claude', + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }, + initialMessageCount: 0, + options: { pollIntervalMs: 0, maxWaitMs: 1000 }, + onAssistantMessage: (message) => emitted.push(message), + }); + + expect(result.finalStatus).toBe(AgentStatus.WAITING); + expect(emitted.map((message) => message.content)).toEqual(['session response']); + }); + + it('continues after transient transcript read errors', async () => { + const running = makeAgent({ status: AgentStatus.RUNNING }); + const waiting = makeAgent({ status: AgentStatus.WAITING }); + const manager = { + listAgents: jest.fn<() => Promise>() + .mockResolvedValueOnce([running]) + .mockResolvedValueOnce([waiting]), + }; + const adapter = { + getConversation: jest.fn<() => ConversationMessage[]>() + .mockImplementationOnce(() => { + throw new Error('partial write'); + }) + .mockReturnValueOnce([makeMessage({ role: 'assistant', content: 'ok' })]), + }; + const emitted: ConversationMessage[] = []; + + const result = await waitForAgentResponse({ + manager, + adapter, + target: { + id: 'repo-a', + name: 'repo-a', + type: 'claude', + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }, + initialMessageCount: 0, + options: { pollIntervalMs: 0, maxWaitMs: 1000 }, + onAssistantMessage: (message) => emitted.push(message), + }); + + expect(result.finalStatus).toBe(AgentStatus.WAITING); + expect(emitted.map((message) => message.content)).toEqual(['ok']); + }); + + it('does not finish on waiting status until transcript read succeeds', async () => { + const waiting = makeAgent({ status: AgentStatus.WAITING }); + const manager = { + listAgents: jest.fn<() => Promise>().mockResolvedValue([waiting]), + }; + const adapter = { + getConversation: jest.fn<() => ConversationMessage[]>() + .mockImplementationOnce(() => { + throw new Error('partial write'); + }) + .mockReturnValueOnce([makeMessage({ role: 'assistant', content: 'final response' })]), + }; + const emitted: ConversationMessage[] = []; + + const result = await waitForAgentResponse({ + manager, + adapter, + target: { + id: 'repo-a', + name: 'repo-a', + type: 'claude', + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }, + initialMessageCount: 0, + options: { pollIntervalMs: 0, maxWaitMs: 1000 }, + onAssistantMessage: (message) => emitted.push(message), + }); + + expect(adapter.getConversation).toHaveBeenCalledTimes(2); + expect(result.finalStatus).toBe(AgentStatus.WAITING); + expect(emitted.map((message) => message.content)).toEqual(['final response']); + }); + + it('stops when the agent becomes idle after assistant output', async () => { + const idle = makeAgent({ status: AgentStatus.IDLE }); + const manager = { + listAgents: jest.fn<() => Promise>().mockResolvedValue([idle]), + }; + const adapter = { + getConversation: jest.fn<() => ConversationMessage[]>().mockReturnValue([ + makeMessage({ role: 'assistant', content: 'idle response' }), + ]), + }; + const emitted: ConversationMessage[] = []; + + const result = await waitForAgentResponse({ + manager, + adapter, + target: { + id: 'repo-a', + name: 'repo-a', + type: 'claude', + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }, + initialMessageCount: 0, + options: { pollIntervalMs: 1, maxWaitMs: 10 }, + onAssistantMessage: (message) => emitted.push(message), + }); + + expect(result.finalStatus as AgentStatus).toBe(AgentStatus.IDLE); + expect(emitted.map((message) => message.content)).toEqual(['idle response']); + }); + + it('does not stop on idle status before assistant output', async () => { + const idle = makeAgent({ status: AgentStatus.IDLE }); + const manager = { + listAgents: jest.fn<() => Promise>().mockResolvedValue([idle]), + }; + const adapter = { + getConversation: jest.fn<() => ConversationMessage[]>().mockReturnValue([ + makeMessage({ role: 'user', content: 'new prompt' }), + ]), + }; + + await expect(waitForAgentResponse({ + manager, + adapter, + target: { + id: 'repo-a', + name: 'repo-a', + type: 'claude', + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }, + initialMessageCount: 0, + options: { pollIntervalMs: 1, maxWaitMs: 5 }, + onAssistantMessage: jest.fn(), + })).rejects.toThrow('Timed out waiting for agent "repo-a" after 5ms.'); + }); + + it('reports status when the agent finishes without assistant text', async () => { + const waiting = makeAgent({ status: AgentStatus.WAITING }); + const manager = { + listAgents: jest.fn<() => Promise>().mockResolvedValue([waiting]), + }; + const adapter = { + getConversation: jest.fn<() => ConversationMessage[]>().mockReturnValue([ + makeMessage({ role: 'user', content: 'new prompt' }), + ]), + }; + const onStatus = jest.fn<(message: string) => void>(); + + const result = await waitForAgentResponse({ + manager, + adapter, + target: { + id: 'repo-a', + name: 'repo-a', + type: 'claude', + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }, + initialMessageCount: 0, + options: { pollIntervalMs: 0, maxWaitMs: 1000 }, + onAssistantMessage: jest.fn(), + onStatus, + }); + + expect(result.messages).toEqual([]); + expect(onStatus).toHaveBeenCalledWith('Agent "repo-a" returned to waiting without assistant output.'); + }); + + it('waits for the configured poll interval before polling again', async () => { + jest.useFakeTimers(); + try { + const running = makeAgent({ status: AgentStatus.RUNNING }); + const waiting = makeAgent({ status: AgentStatus.WAITING }); + const manager = { + listAgents: jest.fn<() => Promise>() + .mockResolvedValueOnce([running]) + .mockResolvedValueOnce([waiting]), + }; + const adapter = { + getConversation: jest.fn<() => ConversationMessage[]>().mockReturnValue([ + makeMessage({ role: 'assistant', content: 'delayed response' }), + ]), + }; + + const promise = waitForAgentResponse({ + manager, + adapter, + target: { + id: 'repo-a', + name: 'repo-a', + type: 'claude', + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }, + initialMessageCount: 0, + options: { pollIntervalMs: 25, maxWaitMs: 1000 }, + onAssistantMessage: jest.fn(), + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(manager.listAgents).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(25); + const result = await promise; + + expect(result.finalStatus).toBe(AgentStatus.WAITING); + expect(manager.listAgents).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('fails after the defensive timeout is reached', async () => { + const running = makeAgent({ status: AgentStatus.RUNNING }); + const manager = { + listAgents: jest.fn<() => Promise>().mockResolvedValue([running]), + }; + const adapter = { + getConversation: jest.fn<() => ConversationMessage[]>().mockReturnValue([]), + }; + + await expect(waitForAgentResponse({ + manager, + adapter, + target: { + id: 'repo-a', + name: 'repo-a', + type: 'claude', + pid: 10, + sessionId: 'session-1', + sessionFilePath: '/tmp/session.jsonl', + }, + initialMessageCount: 0, + options: { pollIntervalMs: 0, maxWaitMs: 0 }, + onAssistantMessage: jest.fn(), + })).rejects.toThrow('Timed out waiting for agent "repo-a" after 0ms.'); + }); +}); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 50a3495..3298edc 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -4,6 +4,7 @@ import chalk from 'chalk'; import inquirer from 'inquirer'; import { AgentManager, + type AgentAdapter, ClaudeCodeAdapter, CodexAdapter, GeminiCliAdapter, @@ -22,6 +23,12 @@ import { resolveListSessionsOptions, toJsonSession, } from '../util/sessions'; +import { waitForAgentResponse } from '../services/agent/agent.service'; + +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; const STATUS_DISPLAY: Record = { [AgentStatus.RUNNING]: { emoji: '🟢', label: 'run' }, @@ -84,6 +91,31 @@ function createAgentManager(): AgentManager { return manager; } +function writeWaitStatus(message: string): void { + process.stderr.write(`${message.replace(ANSI_ESCAPE_PATTERN, '')}\n`); +} + +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}`); + } + + return { + adapter, + sessionFilePath: agent.sessionFilePath, + initialMessageCount: adapter.getConversation(agent.sessionFilePath, { verbose: false }).length, + }; +} + export function registerAgentCommand(program: Command): void { const agentCommand = program .command('agent') @@ -262,6 +294,7 @@ export function registerAgentCommand(program: Command): void { .command('send ') .description('Send a message to a running agent') .requiredOption('--id ', 'Agent name or partial match') + .option('--wait', 'Wait for and print the agent response') .action(withErrorHandler('send message', async (message, options) => { const manager = createAgentManager(); @@ -289,19 +322,59 @@ export function registerAgentCommand(program: Command): void { const agent = resolved as AgentInfo; - if (agent.status !== AgentStatus.WAITING) { - ui.warning(`Agent "${agent.name}" is not waiting for input (status: ${agent.status}). Sending anyway.`); + 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, message); - ui.success(`Sent message to ${agent.name}.`); + + if (!options.wait) { + ui.success(`Sent message to ${agent.name}.`); + return; + } + + if (!waitContext) { + throw new Error('Wait mode was not prepared.'); + } + + await waitForAgentResponse({ + 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: AGENT_SEND_WAIT_MAX_WAIT_MS, + }, + onAssistantMessage: (msg) => { + process.stdout.write(`${msg.content}\n`); + }, + onStatus: writeWaitStatus, + }); })); agentCommand diff --git a/packages/cli/src/services/agent/agent.service.ts b/packages/cli/src/services/agent/agent.service.ts new file mode 100644 index 0000000..91dd48a --- /dev/null +++ b/packages/cli/src/services/agent/agent.service.ts @@ -0,0 +1,122 @@ +import { + AgentStatus, + type AgentAdapter, + type AgentInfo, + type AgentManager, + type AgentType, + type ConversationMessage, +} from '@ai-devkit/agent-manager'; +import { sleep } from '../../util/time'; + +export interface AgentSendWaitTarget { + id: string; + name: string; + type: AgentType; + pid: number; + sessionId: string; + sessionFilePath: string; +} + +export interface AgentSendWaitOptions { + pollIntervalMs: number; + maxWaitMs: number; +} + +export interface AgentSendWaitResult { + agentName: string; + agentType: AgentType; + pid: number; + sessionId: string; + sessionFilePath: string; + messages: ConversationMessage[]; + finalStatus: AgentStatus; + elapsedMs: number; +} + +export interface WaitForAgentResponseParams { + manager: Pick; + adapter: Pick; + target: AgentSendWaitTarget; + initialMessageCount: number; + options: AgentSendWaitOptions; + onAssistantMessage: (message: ConversationMessage) => void; + onStatus?: (message: string) => void; +} + +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); +} + +function readNewAssistantMessages( + adapter: Pick, + sessionFilePath: string, + lastSeenCount: number, +): { messages: ConversationMessage[]; nextSeenCount: number } { + const conversation = adapter.getConversation(sessionFilePath, { verbose: false }); + const newMessages = conversation.slice(lastSeenCount); + const assistantMessages = newMessages.filter((message) => ( + message.role === 'assistant' && Boolean(message.content) + )); + + return { + messages: assistantMessages, + nextSeenCount: conversation.length, + }; +} + +export async function waitForAgentResponse(params: WaitForAgentResponseParams): Promise { + const { manager, adapter, target, initialMessageCount, options, onAssistantMessage, onStatus } = params; + const startedAt = Date.now(); + let lastSeenCount = initialMessageCount; + const messages: ConversationMessage[] = []; + + while (Date.now() - startedAt < options.maxWaitMs) { + let transcriptReadSucceeded = false; + try { + const read = readNewAssistantMessages(adapter, target.sessionFilePath, lastSeenCount); + lastSeenCount = read.nextSeenCount; + transcriptReadSucceeded = true; + + for (const message of read.messages) { + messages.push(message); + onAssistantMessage(message); + } + } catch { + // Transcript files can be observed mid-write. Treat read failures as + // transient while the status loop still has time to prove completion. + } + + const agents = await manager.listAgents(); + const agent = findSameAgent(target, agents); + if (!agent) { + throw new Error(`Agent "${target.name}" is no longer running.`); + } + + const hasAssistantOutput = messages.length > 0; + const canCompleteOnStatus = + agent.status === AgentStatus.WAITING || + (agent.status === AgentStatus.IDLE && hasAssistantOutput); + + if (canCompleteOnStatus && transcriptReadSucceeded) { + if (messages.length === 0) { + onStatus?.(`Agent "${target.name}" returned to waiting without assistant output.`); + } + + return { + agentName: target.name, + agentType: target.type, + pid: target.pid, + sessionId: target.sessionId, + sessionFilePath: target.sessionFilePath, + messages, + finalStatus: agent.status, + elapsedMs: Date.now() - startedAt, + }; + } + + await sleep(options.pollIntervalMs); + } + + throw new Error(`Timed out waiting for agent "${target.name}" after ${options.maxWaitMs}ms.`); +} diff --git a/packages/cli/src/util/time.ts b/packages/cli/src/util/time.ts new file mode 100644 index 0000000..f78f119 --- /dev/null +++ b/packages/cli/src/util/time.ts @@ -0,0 +1,4 @@ +export function sleep(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); +}