From e9a3c1cd12311af35281c7148e88f5a3fad7f89d Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 1 Apr 2026 09:52:33 +0200 Subject: [PATCH 1/4] feat: add agent detail command planning --- docs/ai/design/feature-agent-detail.md | 164 ++++++++++++++++++ .../ai/implementation/feature-agent-detail.md | 50 ++++++ docs/ai/planning/feature-agent-detail.md | 54 ++++++ docs/ai/requirements/feature-agent-detail.md | 83 +++++++++ docs/ai/testing/feature-agent-detail.md | 59 +++++++ 5 files changed, 410 insertions(+) create mode 100644 docs/ai/design/feature-agent-detail.md create mode 100644 docs/ai/implementation/feature-agent-detail.md create mode 100644 docs/ai/planning/feature-agent-detail.md create mode 100644 docs/ai/requirements/feature-agent-detail.md create mode 100644 docs/ai/testing/feature-agent-detail.md diff --git a/docs/ai/design/feature-agent-detail.md b/docs/ai/design/feature-agent-detail.md new file mode 100644 index 00000000..218b89bd --- /dev/null +++ b/docs/ai/design/feature-agent-detail.md @@ -0,0 +1,164 @@ +--- +phase: design +title: Agent Detail Command - System Design +description: Technical design for the agent detail CLI command +--- + +# System Design & Architecture + +## Architecture Overview + +The `agent detail` command follows the existing adapter pattern. It reuses `AgentManager` for agent discovery and resolution, then adds a conversation-reading layer. + +```mermaid +graph TD + CLI["CLI: agent detail --id abc"] + AM["AgentManager"] + CCA["ClaudeCodeAdapter"] + CA["CodexAdapter"] + SR["Session Reader"] + SF["Session JSONL Files"] + + CLI -->|resolve agent| AM + AM --> CCA + AM --> CA + CLI -->|read conversation| SR + SR -->|parse JSONL| SF + CCA -->|AgentInfo + sessionFilePath| CLI + CA -->|AgentInfo + sessionFilePath| CLI +``` + +**Key insight:** The existing adapters already read session files to extract status/summary. For `detail`, we need the full conversation, which requires a second pass that reads and parses all JSONL entries (not just the last few lines). + +## Data Models + +### AgentDetail (extends AgentInfo conceptually) + +```typescript +interface AgentDetail { + sessionId: string; + cwd: string; + startTime: Date; + status: AgentStatus; + type: AgentType; + name: string; + slug?: string; + conversation: ConversationMessage[]; +} + +interface ConversationMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp?: string; +} +``` + +### Conversation parsing rules + +**Claude Code JSONL:** +- Each line is `{ type, timestamp, message, ... }` +- `type: "user"` → role=user, extract text from `message.content` +- `type: "assistant"` → role=assistant, extract text from `message.content` +- `type: "system"` → role=system +- Skip metadata types: `file-history-snapshot`, `last-prompt`, `progress`, `thinking` +- **Default mode:** Extract only text blocks from `message.content` arrays +- **Verbose mode:** Also include tool_use blocks (name + input summary) and tool_result blocks + +**Codex JSONL:** +- First line: `session_meta` → skip (metadata only) +- Subsequent lines have `type`, `timestamp`, and `payload` with `payload.message` (plain string) and `payload.type` +- Map `payload.type` to roles: `user_message` → user, `agent_message` → assistant, others → system +- Default mode: extract `payload.message` strings +- Verbose mode: include additional payload details if present + +## API Design + +### CLI Interface + +``` +ai-devkit agent detail --id [--json] [--full] [--tail ] [--verbose] +``` + +**Options:** +- `--id ` (required): Agent name (as shown in `agent list` output) +- `--json` (optional): Output as JSON +- `--full` (optional): Show entire conversation history (default: last 20 messages) +- `--tail ` (optional): Show last N messages (default: 20) +- `--verbose` (optional): Include tool call/result details in messages + +**Default output format (human-readable, text only, last 20 messages):** +``` +Agent Detail +──────────────────────────────── + Session ID: a6ce7023-6ac4-40b7-a8a5-dde50645bed5 + CWD: ~/Code/ai-devkit + Start Time: 2026-03-27 10:30:00 + Status: 🟢 run + Type: Claude Code + +Conversation (last 20 messages) +──────────────────────────────── +[10:30:05] user: + Fix the login bug in auth.ts + +[10:30:12] assistant: + I'll look at the auth.ts file... + ... +``` + +**Verbose mode adds tool details:** +``` +[10:30:12] assistant: + I'll look at the auth.ts file... + [Tool: Read] auth.ts + [Tool: Edit] auth.ts (lines 15-20) +``` + +### Internal API + +Add `getConversation()` as a **required** method on the `AgentAdapter` interface: + +```typescript +interface AgentAdapter { + // existing... + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[]; +} +``` + +Each adapter implements its own parsing logic (Claude: structured content blocks, Codex: `payload.message` strings) but all return the same `ConversationMessage[]` output. + +## Component Breakdown + +1. **CLI command handler** (`packages/cli/src/commands/agent.ts`): + - New `detail` subcommand under the existing `agent` command + - Uses `AgentManager` to list + resolve agent + - Calls conversation reader + - Formats and displays output + +2. **Conversation reader** (`packages/agent-manager/src/`): + - New method or utility to parse full conversation from JSONL + - Claude-specific parsing in `ClaudeCodeAdapter` + - Codex-specific parsing in `CodexAdapter` + - Returns `ConversationMessage[]` + +3. **AgentInfo extension**: + - Add `sessionFilePath?: string` to `AgentInfo` — already known at detection time, avoids re-discovery + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Where to add conversation parsing | Required method on `AgentAdapter` interface | Each adapter has its own JSONL format but unified output | +| How to pass session file path | Add to `AgentInfo` | Already known at detection, avoids re-discovery | +| Output format | Table header + message list | Consistent with `agent list` style | +| Message filtering | Skip progress/thinking/metadata | Show only meaningful conversation turns | +| Default message count | Last 20 | Practical default; `--full`/`--tail` for overrides | +| Tool display | Text-only default, `--verbose` for tools | Keeps output clean; tools are verbose | +| Identifier type | Name only | Consistent, simple; users copy from `agent list` | +| Non-running agents | Not supported | Scoped to running processes only | + +## Non-Functional Requirements + +- **Performance:** Session files can be large. Read the file once, parse line-by-line. No concern for very large files since we're already reading them in `readSession()`. +- **Reliability:** Graceful handling of corrupted JSONL lines (skip and continue). +- **Compatibility:** Works on macOS and Linux (same as existing commands). diff --git a/docs/ai/implementation/feature-agent-detail.md b/docs/ai/implementation/feature-agent-detail.md new file mode 100644 index 00000000..759a491a --- /dev/null +++ b/docs/ai/implementation/feature-agent-detail.md @@ -0,0 +1,50 @@ +--- +phase: implementation +title: Agent Detail Command - Implementation Guide +description: Technical implementation notes for the agent detail command +--- + +# Implementation Guide + +## Development Setup + +- Worktree: `.worktrees/feature-agent-detail` +- Branch: `feature-agent-detail` +- Dependencies: already bootstrapped via `npm ci` + +## Code Structure + +**Files to modify:** +- `packages/agent-manager/src/adapters/AgentAdapter.ts` — add `sessionFilePath` to `AgentInfo`, add `ConversationMessage` type +- `packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts` — populate `sessionFilePath`, add `getConversation()` +- `packages/agent-manager/src/adapters/CodexAdapter.ts` — populate `sessionFilePath`, add `getConversation()` +- `packages/agent-manager/src/index.ts` — export new types +- `packages/cli/src/commands/agent.ts` — add `detail` subcommand + +## Implementation Notes + +### ConversationMessage type +```typescript +export interface ConversationMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp?: string; +} +``` + +### Claude conversation parsing +- Read file once, split by newlines +- For each line: parse JSON, check `type` field +- Include `user`, `assistant`, `system` types +- Skip `progress`, `thinking`, `file-history-snapshot`, `last-prompt` +- Extract text content using existing `extractUserMessageText` logic for user messages +- For assistant messages, concatenate text blocks from `message.content` array + +### Codex conversation parsing +- Skip `session_meta` first line +- Map event types to roles based on Codex format + +### CLI output formatting +- Use `ui.text()` for section headers +- Use chalk for coloring roles +- Truncate very long messages with `--full` flag (future consideration) diff --git a/docs/ai/planning/feature-agent-detail.md b/docs/ai/planning/feature-agent-detail.md new file mode 100644 index 00000000..816a8c84 --- /dev/null +++ b/docs/ai/planning/feature-agent-detail.md @@ -0,0 +1,54 @@ +--- +phase: planning +title: Agent Detail Command - Planning +description: Task breakdown and implementation plan for the agent detail command +--- + +# Project Planning & Task Breakdown + +## Milestones + +- [x] Milestone 1: Core infrastructure (AgentInfo extension + conversation reader) +- [x] Milestone 2: CLI command implementation +- [x] Milestone 3: Tests and polish + +## Task Breakdown + +### Phase 1: Core Infrastructure + +- [x] Task 1.1: Add `sessionFilePath` field to `AgentInfo` interface in `AgentAdapter.ts` +- [x] Task 1.2: Populate `sessionFilePath` in `ClaudeCodeAdapter.mapSessionToAgent()` and `CodexAdapter` +- [x] Task 1.3: Add `ConversationMessage` type and `getConversation(filePath: string): ConversationMessage[]` method to `ClaudeCodeAdapter` +- [x] Task 1.4: Add `getConversation(filePath: string): ConversationMessage[]` method to `CodexAdapter` +- [x] Task 1.5: Export new types and methods from `@ai-devkit/agent-manager` package index + +### Phase 2: CLI Command + +- [x] Task 2.1: Add `agent detail` subcommand in `agent.ts` with `--id`, `--json`, `--full`, `--tail`, `--verbose` options +- [x] Task 2.2: Implement agent resolution logic (reuse `resolveAgent` by name + handle ambiguity) +- [x] Task 2.3: Implement human-readable output formatting (metadata header + tail-limited conversation, text-only default) +- [x] Task 2.4: Implement `--verbose` mode (include tool call/result details) +- [x] Task 2.5: Implement JSON output mode + +### Phase 3: Testing & Polish + +- [x] Task 3.1: Unit tests for `ClaudeCodeAdapter.getConversation()` — 11 tests +- [x] Task 3.2: Unit tests for `CodexAdapter.getConversation()` — 9 tests +- [ ] Task 3.3: Integration test for `agent detail` CLI command (deferred — requires running agents) +- [ ] Task 3.4: Manual testing with real running agents (deferred — requires running agents) + +## Dependencies + +- Task 1.1 must complete before Task 1.2 +- Task 1.3/1.4 can run in parallel +- Task 1.5 depends on 1.1–1.4 +- Phase 2 depends on Phase 1 completion +- Phase 3 depends on Phase 2 completion + +## Risks & Mitigation + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Large session files slow down detail display | Medium | Stream/parse line by line (already done in readSession) | +| Codex JSONL format has undocumented fields | Low | Graceful fallback for unknown entry types | +| Adding field to AgentInfo breaks existing consumers | Low | Field is optional (`sessionFilePath?: string`) | diff --git a/docs/ai/requirements/feature-agent-detail.md b/docs/ai/requirements/feature-agent-detail.md new file mode 100644 index 00000000..3dc89b8d --- /dev/null +++ b/docs/ai/requirements/feature-agent-detail.md @@ -0,0 +1,83 @@ +--- +phase: requirements +title: Agent Detail Command +description: CLI command to fetch detailed information about a running agent including session data and conversation history +--- + +# Requirements & Problem Understanding + +## Problem Statement + +Users of `ai-devkit` can list running agents via `agent list`, but there is no way to inspect the full details of a specific agent. When debugging, monitoring, or reviewing an agent's work, users need to see: + +- The session ID and metadata (cwd, start time) +- Current status +- The full conversation history (all messages) + +Currently, users must manually navigate to `~/.claude/projects/` or `~/.codex/sessions/`, find the correct JSONL file, and parse it themselves. + +## Goals & Objectives + +**Primary goals:** +- Provide a `ai-devkit agent detail --id ` command that displays comprehensive agent information +- Show session metadata: session ID, cwd, start time, current status +- Show the last N conversation messages by default, with `--full` to show all and `--tail ` to control count +- Show text content by default; `--verbose` to include tool call/result details + +**Non-goals:** +- Editing or modifying session data +- Real-time streaming/tailing of the conversation +- Exporting conversation to external formats (PDF, HTML, etc.) +- Supporting terminated/non-running agents (only agents visible in `agent list`) + +## User Stories & Use Cases + +- **As a developer**, I want to run `ai-devkit agent detail --id "abc"` so that I can see what a specific agent has been doing, including its recent conversation. +- **As a team lead**, I want to inspect an agent's session details (start time, status, cwd) to understand its context and current state. +- **As a developer**, I want JSON output (`--json` flag) so I can pipe agent details into other tools for analysis. +- **As a developer**, I want `--verbose` to see tool call details when debugging what an agent actually executed. +- **As a developer**, I want `--full` or `--tail ` to control how much conversation history is shown. + +**Key workflows:** +1. User runs `agent list` to see all running agents +2. User picks an agent name from the list +3. User runs `agent detail --id ` to see details + recent conversation +4. Output shows metadata header + last N conversation messages (text only by default) + +**Edge cases:** +- Agent name matches multiple agents → show error with available matches +- Agent name matches no agent → show error with available agents +- Session file is missing or corrupted → graceful error message +- Agent is from Codex (not just Claude) → adapter-agnostic detail fetching + +## Success Criteria + +- `ai-devkit agent detail --id ` resolves the agent by name and displays: + - Session ID + - CWD (working directory) + - Start time + - Current status + - Last 20 conversation messages (text only) by default +- `--full` shows entire conversation history +- `--tail ` shows last N messages +- `--verbose` includes tool call/result details in messages +- `--json` flag outputs structured JSON +- Works for both Claude Code and Codex agents +- Only works for running agents (those visible in `agent list`) +- Handles ambiguous/missing names gracefully with helpful messages +- Uses existing `AgentManager.resolveAgent()` for name resolution + +## Constraints & Assumptions + +- Reuses the existing adapter architecture (`AgentManager`, `ClaudeCodeAdapter`, `CodexAdapter`) +- Session data is read from JSONL files already discovered during agent detection +- The conversation reader needs access to the session file path, which is available via the matched `SessionFile` +- Conversation parsing must handle both Claude and Codex JSONL formats +- Must not break existing `agent list`, `agent open`, or `agent send` commands + +## Resolved Questions + +- **Identifier type:** Accept agent name only (from `agent list` output), not session IDs or slugs +- **Conversation length:** Show last 20 messages by default; `--full` for all, `--tail ` for custom count +- **Tool use display:** Text-only by default; `--verbose` includes tool call/result details +- **Non-running agents:** Not supported — only agents with a running process (visible in `agent list`) diff --git a/docs/ai/testing/feature-agent-detail.md b/docs/ai/testing/feature-agent-detail.md new file mode 100644 index 00000000..fd64b2fa --- /dev/null +++ b/docs/ai/testing/feature-agent-detail.md @@ -0,0 +1,59 @@ +--- +phase: testing +title: Agent Detail Command - Testing Strategy +description: Test plan for the agent detail command +--- + +# Testing Strategy + +## Test Coverage Goals + +- 100% coverage for new conversation parsing methods +- Integration tests for CLI command output +- Edge case coverage for malformed JSONL + +## Unit Tests + +### ClaudeCodeAdapter.getConversation() +- [x] Parses valid JSONL with user and assistant text messages +- [x] Skips metadata entries (file-history-snapshot, last-prompt) +- [x] Skips progress and thinking entries +- [x] Includes system messages +- [x] Skips tool_use and tool_result in default mode +- [x] Includes tool_use and tool_result in verbose mode +- [x] Handles tool_result errors in verbose mode +- [x] Handles malformed JSON lines gracefully (skip and continue) +- [x] Returns empty array for missing file +- [x] Returns empty array for empty file +- [x] Filters noise messages (interrupted, Tool loaded., etc.) + +### CodexAdapter.getConversation() +- [x] Parses valid Codex JSONL with user and agent messages +- [x] Skips session_meta entry +- [x] Maps task_complete to assistant role +- [x] Skips non-conversation types in default mode +- [x] Includes non-conversation types as system in verbose mode +- [x] Handles malformed lines gracefully +- [x] Returns empty array for missing file +- [x] Returns empty array for empty file +- [x] Skips entries with empty payload message + +### AgentManager (existing tests updated) +- [x] MockAdapter implements getConversation() interface + +## Test Files + +- `packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts` — 11 new tests in `getConversation` describe block +- `packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts` — 9 new tests in `getConversation` describe block +- `packages/agent-manager/src/__tests__/AgentManager.test.ts` — MockAdapter updated + +## Test Results + +- **178 tests passing** (20 new + 158 existing) +- **Coverage threshold met** (80% minimum) +- All tests use real temp files (no fs mocking) following existing patterns + +## Test Data + +- Temp JSONL files created in `beforeEach`, cleaned in `afterEach` +- Inline fixture objects per test for clarity From 26eefc48bda9c68f8bad761fc90d597e06062799 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 1 Apr 2026 09:53:06 +0200 Subject: [PATCH 2/4] feat: add agent detail implementation --- .../src/__tests__/AgentManager.test.ts | 6 +- .../adapters/ClaudeCodeAdapter.test.ts | 174 ++++++++++++++++++ .../__tests__/adapters/CodexAdapter.test.ts | 115 ++++++++++++ .../src/adapters/AgentAdapter.ts | 21 ++- .../src/adapters/ClaudeCodeAdapter.ts | 117 +++++++++++- .../src/adapters/CodexAdapter.ts | 62 ++++++- packages/agent-manager/src/index.ts | 2 +- 7 files changed, 485 insertions(+), 12 deletions(-) diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index fa1dc8aa..99ea3c2b 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; import { AgentManager } from '../AgentManager'; -import type { AgentAdapter, AgentInfo, AgentType } from '../adapters/AgentAdapter'; +import type { AgentAdapter, AgentInfo, AgentType, ConversationMessage } from '../adapters/AgentAdapter'; import { AgentStatus } from '../adapters/AgentAdapter'; // Mock adapter for testing @@ -26,6 +26,10 @@ class MockAdapter implements AgentAdapter { return true; } + getConversation(): ConversationMessage[] { + return []; + } + setAgents(agents: AgentInfo[]): void { this.mockAgents = agents; } diff --git a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index 909310d9..27dc2368 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -1117,4 +1117,178 @@ describe('ClaudeCodeAdapter', () => { }); }); }); + + describe('getConversation', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-conv-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeJsonl(lines: object[]): string { + const filePath = path.join(tmpDir, 'session.jsonl'); + fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n')); + return filePath; + } + + it('should parse user and assistant text messages', () => { + const filePath = writeJsonl([ + { type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } }, + { type: 'assistant', timestamp: '2026-03-27T10:00:05Z', message: { content: [{ type: 'text', text: 'Hi there!' }] } }, + ]); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual({ role: 'user', content: 'Hello', timestamp: '2026-03-27T10:00:00Z' }); + expect(messages[1]).toEqual({ role: 'assistant', content: 'Hi there!', timestamp: '2026-03-27T10:00:05Z' }); + }); + + it('should skip metadata entry types', () => { + const filePath = writeJsonl([ + { type: 'file-history-snapshot', timestamp: '2026-03-27T10:00:00Z', snapshot: {} }, + { type: 'last-prompt', timestamp: '2026-03-27T10:00:00Z' }, + { type: 'user', timestamp: '2026-03-27T10:00:01Z', message: { content: 'Fix bug' } }, + ]); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('Fix bug'); + }); + + it('should skip progress and thinking entries', () => { + const filePath = writeJsonl([ + { type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } }, + { type: 'progress', timestamp: '2026-03-27T10:00:01Z', data: {} }, + { type: 'thinking', timestamp: '2026-03-27T10:00:02Z' }, + { type: 'assistant', timestamp: '2026-03-27T10:00:03Z', message: { content: [{ type: 'text', text: 'Done' }] } }, + ]); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(2); + expect(messages[0].role).toBe('user'); + expect(messages[1].role).toBe('assistant'); + }); + + it('should include system messages', () => { + const filePath = writeJsonl([ + { type: 'system', timestamp: '2026-03-27T10:00:00Z', message: { content: 'System initialized' } }, + ]); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(1); + expect(messages[0]).toEqual({ role: 'system', content: 'System initialized', timestamp: '2026-03-27T10:00:00Z' }); + }); + + it('should skip tool_use and tool_result blocks in default mode', () => { + const filePath = writeJsonl([ + { + type: 'assistant', timestamp: '2026-03-27T10:00:00Z', + message: { + content: [ + { type: 'text', text: 'Let me read the file.' }, + { type: 'tool_use', name: 'Read', input: { file_path: '/src/app.ts' } }, + ], + }, + }, + { + type: 'user', timestamp: '2026-03-27T10:00:01Z', + message: { + content: [ + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'file contents here' }, + ], + }, + }, + ]); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('Let me read the file.'); + }); + + it('should include tool_use and tool_result blocks in verbose mode', () => { + const filePath = writeJsonl([ + { + type: 'assistant', timestamp: '2026-03-27T10:00:00Z', + message: { + content: [ + { type: 'text', text: 'Let me read the file.' }, + { type: 'tool_use', name: 'Read', input: { file_path: '/src/app.ts' } }, + ], + }, + }, + { + type: 'user', timestamp: '2026-03-27T10:00:01Z', + message: { + content: [ + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'file contents here' }, + ], + }, + }, + ]); + + const messages = adapter.getConversation(filePath, { verbose: true }); + expect(messages).toHaveLength(2); + expect(messages[0].content).toContain('[Tool: Read]'); + expect(messages[0].content).toContain('/src/app.ts'); + expect(messages[1].content).toContain('[Tool Result]'); + }); + + it('should handle tool_result errors in verbose mode', () => { + const filePath = writeJsonl([ + { + type: 'user', timestamp: '2026-03-27T10:00:00Z', + message: { + content: [ + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'Something went wrong', is_error: true }, + ], + }, + }, + ]); + + const messages = adapter.getConversation(filePath, { verbose: true }); + expect(messages).toHaveLength(1); + expect(messages[0].content).toContain('[Tool Error]'); + }); + + it('should handle malformed JSON lines gracefully', () => { + const filePath = path.join(tmpDir, 'malformed.jsonl'); + fs.writeFileSync(filePath, [ + JSON.stringify({ type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: 'Hello' } }), + 'this is not valid json', + JSON.stringify({ type: 'assistant', timestamp: '2026-03-27T10:00:01Z', message: { content: [{ type: 'text', text: 'World' }] } }), + ].join('\n')); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(2); + }); + + it('should return empty array for missing file', () => { + const messages = adapter.getConversation('/nonexistent/path.jsonl'); + expect(messages).toEqual([]); + }); + + it('should return empty array for empty file', () => { + const filePath = path.join(tmpDir, 'empty.jsonl'); + fs.writeFileSync(filePath, ''); + + const messages = adapter.getConversation(filePath); + expect(messages).toEqual([]); + }); + + it('should filter noise messages from user entries', () => { + const filePath = writeJsonl([ + { type: 'user', timestamp: '2026-03-27T10:00:00Z', message: { content: [{ type: 'text', text: '[Request interrupted by user]' }] } }, + { type: 'user', timestamp: '2026-03-27T10:00:01Z', message: { content: 'Tool loaded.' } }, + { type: 'user', timestamp: '2026-03-27T10:00:02Z', message: { content: 'Real question' } }, + ]); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('Real question'); + }); + }); }); diff --git a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts index 3491fd5d..e8cc46be 100644 --- a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts @@ -515,4 +515,119 @@ describe('CodexAdapter', () => { }); }); }); + + describe('getConversation', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'codex-conv-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeJsonl(lines: object[]): string { + const filePath = path.join(tmpDir, 'session.jsonl'); + fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n')); + return filePath; + } + + it('should parse user and agent messages', () => { + const filePath = writeJsonl([ + { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } }, + { type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Fix the bug' } }, + { type: 'event', timestamp: '2026-03-27T10:00:05Z', payload: { type: 'agent_message', message: 'I found the issue' } }, + ]); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual({ role: 'user', content: 'Fix the bug', timestamp: '2026-03-27T10:00:01Z' }); + expect(messages[1]).toEqual({ role: 'assistant', content: 'I found the issue', timestamp: '2026-03-27T10:00:05Z' }); + }); + + it('should skip session_meta entry', () => { + const filePath = writeJsonl([ + { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } }, + { type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Hello' } }, + ]); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe('user'); + }); + + it('should map task_complete to assistant role', () => { + const filePath = writeJsonl([ + { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } }, + { type: 'event', timestamp: '2026-03-27T10:00:05Z', payload: { type: 'task_complete', message: 'Task finished successfully' } }, + ]); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe('assistant'); + expect(messages[0].content).toBe('Task finished successfully'); + }); + + it('should skip non-conversation types in default mode', () => { + const filePath = writeJsonl([ + { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } }, + { type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Hello' } }, + { type: 'event', timestamp: '2026-03-27T10:00:02Z', payload: { type: 'exec_command', message: 'Running npm test' } }, + { type: 'event', timestamp: '2026-03-27T10:00:03Z', payload: { type: 'agent_message', message: 'Done' } }, + ]); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(2); + }); + + it('should include non-conversation types as system in verbose mode', () => { + const filePath = writeJsonl([ + { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } }, + { type: 'event', timestamp: '2026-03-27T10:00:02Z', payload: { type: 'exec_command', message: 'Running npm test' } }, + ]); + + const messages = adapter.getConversation(filePath, { verbose: true }); + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe('system'); + expect(messages[0].content).toBe('Running npm test'); + }); + + it('should handle malformed JSON lines gracefully', () => { + const filePath = path.join(tmpDir, 'malformed.jsonl'); + fs.writeFileSync(filePath, [ + JSON.stringify({ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } }), + 'invalid json line', + JSON.stringify({ type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: 'Hello' } }), + ].join('\n')); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(1); + }); + + it('should return empty array for missing file', () => { + const messages = adapter.getConversation('/nonexistent/path.jsonl'); + expect(messages).toEqual([]); + }); + + it('should return empty array for empty file', () => { + const filePath = path.join(tmpDir, 'empty.jsonl'); + fs.writeFileSync(filePath, ''); + + const messages = adapter.getConversation(filePath); + expect(messages).toEqual([]); + }); + + it('should skip entries with empty payload message', () => { + const filePath = writeJsonl([ + { type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } }, + { type: 'event', timestamp: '2026-03-27T10:00:01Z', payload: { type: 'user_message', message: '' } }, + { type: 'event', timestamp: '2026-03-27T10:00:02Z', payload: { type: 'agent_message', message: 'Response' } }, + ]); + + const messages = adapter.getConversation(filePath); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('Response'); + }); + }); }); diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index be6d5547..b1489dd3 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -51,6 +51,8 @@ export interface AgentInfo { /** Timestamp of last activity */ lastActive: Date; + /** Path to the session JSONL file on disk */ + sessionFilePath?: string; } /** @@ -73,9 +75,18 @@ export interface ProcessInfo { startTime?: Date; } +/** + * A single message in a conversation + */ +export interface ConversationMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp?: string; +} + /** * Agent Adapter Interface - * + * * Implementations must provide detection logic for a specific agent type. */ export interface AgentAdapter { @@ -94,4 +105,12 @@ export interface AgentAdapter { * @returns True if this adapter can handle the process */ canHandle(processInfo: ProcessInfo): boolean; + + /** + * Read the full conversation from a session file + * @param sessionFilePath Path to the session JSONL file + * @param options.verbose Include tool call/result details + * @returns Array of conversation messages + */ + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[]; } diff --git a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index 5baa8f17..f93548bf 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter'; +import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter'; import { AgentStatus } from './AgentAdapter'; import { listAgentProcesses, enrichProcesses } from '../utils/process'; import { batchGetSessionFileBirthtimes } from '../utils/session'; @@ -9,17 +9,23 @@ import { matchProcessesToSessions, generateAgentName } from '../utils/matching'; /** * Entry in session JSONL file */ +interface ContentBlock { + type?: string; + text?: string; + content?: string; + name?: string; + input?: Record; + tool_use_id?: string; + is_error?: boolean; +} + interface SessionEntry { type?: string; timestamp?: string; slug?: string; cwd?: string; message?: { - content?: string | Array<{ - type?: string; - text?: string; - content?: string; - }>; + content?: string | ContentBlock[]; }; } @@ -277,6 +283,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { sessionId: sessionFile.sessionId, slug: session.slug, lastActive: session.lastActive, + sessionFilePath: sessionFile.filePath, }; } @@ -517,4 +524,102 @@ export class ClaudeCodeAdapter implements AgentAdapter { return type === 'last-prompt' || type === 'file-history-snapshot'; } + /** + * Read the full conversation from a Claude Code session JSONL file. + * + * Default mode returns only text content from user/assistant/system messages. + * Verbose mode also includes tool_use and tool_result blocks. + */ + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + const verbose = options?.verbose ?? false; + + let content: string; + try { + content = fs.readFileSync(sessionFilePath, 'utf-8'); + } catch { + return []; + } + + const lines = content.trim().split('\n'); + const messages: ConversationMessage[] = []; + + for (const line of lines) { + let entry: SessionEntry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + + const entryType = entry.type; + if (!entryType || this.isMetadataEntryType(entryType)) continue; + if (entryType === 'progress' || entryType === 'thinking') continue; + + let role: ConversationMessage['role']; + if (entryType === 'user') { + role = 'user'; + } else if (entryType === 'assistant') { + role = 'assistant'; + } else if (entryType === 'system') { + role = 'system'; + } else { + continue; + } + + const text = this.extractConversationContent(entry.message?.content, role, verbose); + if (!text) continue; + + messages.push({ + role, + content: text, + timestamp: entry.timestamp, + }); + } + + return messages; + } + + /** + * Extract displayable content from a message content field. + */ + private extractConversationContent( + content: string | ContentBlock[] | undefined, + role: ConversationMessage['role'], + verbose: boolean, + ): string | undefined { + if (!content) return undefined; + + if (typeof content === 'string') { + const trimmed = content.trim(); + if (role === 'user' && this.isNoiseMessage(trimmed)) return undefined; + return trimmed || undefined; + } + + if (!Array.isArray(content)) return undefined; + + const parts: string[] = []; + + for (const block of content) { + if (block.type === 'text' && block.text?.trim()) { + if (role === 'user' && this.isNoiseMessage(block.text.trim())) continue; + parts.push(block.text.trim()); + } else if (block.type === 'tool_use' && verbose) { + const inputSummary = block.input?.file_path || block.input?.pattern || block.input?.command || ''; + parts.push(`[Tool: ${block.name}]${inputSummary ? ' ' + inputSummary : ''}`); + } else if (block.type === 'tool_result' && verbose) { + const truncated = this.truncateToolResult(block.content || ''); + const prefix = block.is_error ? '[Tool Error]' : '[Tool Result]'; + parts.push(`${prefix} ${truncated}`); + } + } + + return parts.length > 0 ? parts.join('\n') : undefined; + } + + private truncateToolResult(content: string, maxLength = 200): string { + const firstLine = content.split('\n')[0] || ''; + if (firstLine.length <= maxLength) return firstLine; + return firstLine.slice(0, maxLength - 3) + '...'; + } + } diff --git a/packages/agent-manager/src/adapters/CodexAdapter.ts b/packages/agent-manager/src/adapters/CodexAdapter.ts index 44c3696d..7a85fbde 100644 --- a/packages/agent-manager/src/adapters/CodexAdapter.ts +++ b/packages/agent-manager/src/adapters/CodexAdapter.ts @@ -12,7 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter'; +import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter'; import { AgentStatus } from './AgentAdapter'; import { listAgentProcesses, enrichProcesses } from '../utils/process'; import { batchGetSessionFileBirthtimes } from '../utils/session'; @@ -78,7 +78,7 @@ export class CodexAdapter implements AgentAdapter { const cachedContent = contentCache.get(match.session.filePath); const sessionData = this.parseSession(cachedContent, match.session.filePath); if (sessionData) { - agents.push(this.mapSessionToAgent(sessionData, match.process)); + agents.push(this.mapSessionToAgent(sessionData, match.process, match.session.filePath)); } else { matchedPids.delete(match.process.pid); } @@ -234,7 +234,7 @@ export class CodexAdapter implements AgentAdapter { }; } - private mapSessionToAgent(session: CodexSession, processInfo: ProcessInfo): AgentInfo { + private mapSessionToAgent(session: CodexSession, processInfo: ProcessInfo, filePath: string): AgentInfo { return { name: generateAgentName(session.projectPath || processInfo.cwd || '', processInfo.pid), type: this.type, @@ -244,6 +244,7 @@ export class CodexAdapter implements AgentAdapter { projectPath: session.projectPath || processInfo.cwd || '', sessionId: session.sessionId, lastActive: session.lastActive, + sessionFilePath: filePath, }; } @@ -316,4 +317,59 @@ export class CodexAdapter implements AgentAdapter { const base = path.basename(executable).toLowerCase(); return base === 'codex' || base === 'codex.exe'; } + + /** + * Read the full conversation from a Codex session JSONL file. + * + * Codex entries use payload.type to indicate message role and payload.message for content. + */ + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + const verbose = options?.verbose ?? false; + + let content: string; + try { + content = fs.readFileSync(sessionFilePath, 'utf-8'); + } catch { + return []; + } + + const lines = content.trim().split('\n'); + const messages: ConversationMessage[] = []; + + for (const line of lines) { + let entry: CodexEventEntry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + + if (entry.type === 'session_meta') continue; + + const payloadType = entry.payload?.type; + if (!payloadType) continue; + + let role: ConversationMessage['role']; + if (payloadType === 'user_message') { + role = 'user'; + } else if (payloadType === 'agent_message' || payloadType === 'task_complete') { + role = 'assistant'; + } else if (verbose) { + role = 'system'; + } else { + continue; + } + + const text = entry.payload?.message?.trim(); + if (!text) continue; + + messages.push({ + role, + content: text, + timestamp: entry.timestamp, + }); + } + + return messages; + } } diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index c70021fa..53e9ed9e 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -3,7 +3,7 @@ export { AgentManager } from './AgentManager'; export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; export { CodexAdapter } from './adapters/CodexAdapter'; export { AgentStatus } from './adapters/AgentAdapter'; -export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './adapters/AgentAdapter'; +export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo, ConversationMessage } from './adapters/AgentAdapter'; export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager'; export type { TerminalLocation } from './terminal/TerminalFocusManager'; From 24937ec4a4ebe1a9f5b8dc032a3527c2a65302f6 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 1 Apr 2026 09:53:20 +0200 Subject: [PATCH 3/4] feat: add agent detail command in cli --- packages/cli/src/commands/agent.ts | 136 +++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index dc40c878..47c6ac84 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -9,8 +9,10 @@ import { AgentStatus, TerminalFocusManager, TtyWriter, + type AgentAdapter, type AgentInfo, type AgentType, + type ConversationMessage, } from '@ai-devkit/agent-manager'; import { ui } from '../util/terminal-ui'; @@ -265,4 +267,138 @@ export function registerAgentCommand(program: Command): void { process.exit(1); } }); + + agentCommand + .command('detail') + .description('Show detailed information about a running agent') + .requiredOption('--id ', 'Agent name (as shown in agent list)') + .option('-j, --json', 'Output as JSON') + .option('--full', 'Show entire conversation history') + .option('--tail ', 'Show last N messages (default: 20)', '20') + .option('--verbose', 'Include tool call/result details') + .action(async (options) => { + try { + const claudeAdapter = new ClaudeCodeAdapter(); + const codexAdapter = new CodexAdapter(); + + const manager = new AgentManager(); + manager.registerAdapter(claudeAdapter); + manager.registerAdapter(codexAdapter); + + 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 => console.log(` - ${a.name}`)); + return; + } + + if (Array.isArray(resolved)) { + ui.error(`Multiple agents match "${options.id}":`); + resolved.forEach(a => console.log(` - ${a.name} (${formatStatus(a.status)})`)); + ui.info('Please use a more specific name.'); + return; + } + + const agent = resolved as AgentInfo; + + if (!agent.sessionFilePath) { + ui.error(`No session file found for agent "${agent.name}".`); + return; + } + + // Pick the right adapter for conversation reading + const adapters: Record = { + claude: claudeAdapter, + codex: codexAdapter, + }; + const adapter = adapters[agent.type]; + if (!adapter) { + ui.error(`Unsupported agent type: ${agent.type}`); + return; + } + + const conversation = adapter.getConversation(agent.sessionFilePath, { + verbose: options.verbose, + }); + + // Apply tail/full limit + const tailCount = options.full ? conversation.length : parseInt(options.tail, 10) || 20; + const displayMessages = conversation.slice(-tailCount); + const isTruncated = displayMessages.length < conversation.length; + + // Derive start time from first conversation message, fall back to lastActive + const startTime = conversation.length > 0 && conversation[0].timestamp + ? new Date(conversation[0].timestamp) + : agent.lastActive; + + if (options.json) { + const output = { + sessionId: agent.sessionId, + cwd: agent.projectPath, + startTime, + status: agent.status, + type: agent.type, + name: agent.name, + slug: agent.slug, + lastActive: agent.lastActive, + conversation: displayMessages, + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + // Human-readable output + ui.text('Agent Detail', { breakline: true }); + console.log(chalk.dim('─'.repeat(40))); + console.log(` ${chalk.bold('Session ID:')} ${agent.sessionId}`); + console.log(` ${chalk.bold('CWD:')} ${formatCwd(agent.projectPath)}`); + console.log(` ${chalk.bold('Start Time:')} ${new Date(startTime).toLocaleString()}`); + console.log(` ${chalk.bold('Last Active:')} ${formatRelativeTime(agent.lastActive)}`); + console.log(` ${chalk.bold('Status:')} ${formatStatus(agent.status)}`); + console.log(` ${chalk.bold('Type:')} ${formatType(agent.type)}`); + if (agent.slug) { + console.log(` ${chalk.bold('Slug:')} ${agent.slug}`); + } + + ui.breakline(); + const label = isTruncated + ? `Conversation (last ${displayMessages.length} of ${conversation.length} messages)` + : `Conversation (${displayMessages.length} messages)`; + ui.text(label, { breakline: false }); + console.log(chalk.dim('─'.repeat(40))); + + for (const msg of displayMessages) { + const time = msg.timestamp + ? chalk.dim(`[${new Date(msg.timestamp).toLocaleTimeString()}]`) + : ''; + const roleColor = msg.role === 'user' + ? chalk.green + : msg.role === 'assistant' + ? chalk.cyan + : chalk.yellow; + console.log(`${time} ${roleColor(msg.role + ':')}`); + const lines = msg.content.split('\n'); + for (const line of lines) { + console.log(` ${line}`); + } + console.log(); + } + + if (isTruncated) { + ui.info(`Showing last ${displayMessages.length} of ${conversation.length} messages. Use --full to see all.`); + } + + } catch (error: any) { + ui.error(`Failed to get agent detail: ${error.message}`); + process.exit(1); + } + }); } From b6b575015bdf35fe56653eb9abb37a042dfe3cd8 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 1 Apr 2026 16:31:40 +0200 Subject: [PATCH 4/4] fix: lint error --- packages/cli/src/commands/agent.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 47c6ac84..41df9ce1 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -12,7 +12,6 @@ import { type AgentAdapter, type AgentInfo, type AgentType, - type ConversationMessage, } from '@ai-devkit/agent-manager'; import { ui } from '../util/terminal-ui';