diff --git a/AGENTS.md b/AGENTS.md index 764cc05..03d3e0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,8 @@ Auto-generated from all feature plans. Last updated: 2026-04-01 ## Active Technologies +- TypeScript 5.x with strict mode enabled, running on Node.js 20+ + Commander.js (CLI framework), Node.js built-ins (`fs`, `path`, `readline`) (008-agent-session-linking) +- Local Claude Code JSONL session files under `~/.claude/projects/`, including both flat `agent-*.jsonl` files and nested `/subagents/agent-*.jsonl` files (008-agent-session-linking) - TypeScript 5.x with strict mode enabled + Commander.js (CLI framework), Node.js built-ins (`fs`, `path`, `readline`) (007-support-progress-messages) @@ -22,6 +24,7 @@ npm test && npm run lint TypeScript 5.x with strict mode enabled: Follow standard conventions ## Recent Changes +- 008-agent-session-linking: Added TypeScript 5.x with strict mode enabled, running on Node.js 20+ + Commander.js (CLI framework), Node.js built-ins (`fs`, `path`, `readline`) - 007-support-progress-messages: Added TypeScript 5.x with strict mode enabled + Commander.js (CLI framework), Node.js built-ins (`fs`, `path`, `readline`) diff --git a/README.md b/README.md index 3064b74..e468b16 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A POSIX-style CLI tool that does one thing well: access your Claude Code chat hi - **List Sessions** - View all your Claude Code sessions with summaries, timestamps, and message counts - **View Conversations** - Read full session content with formatted messages and tool calls +- **Linked Agent Sessions** - Discover nested subagent transcripts, expose linked agent IDs on parent sessions, and open agent transcripts directly by bare or prefixed agent ID - **Progress Messages** - Search, view, filter, and export `progress` session entries without falling back to raw JSONL files - **Token Statistics** - View complete token usage breakdown (input, output, cache read, cache creation) per session or aggregated across sessions - **Search** - Find specific content across all sessions or within a single session @@ -58,6 +59,8 @@ cch list --stats cch list --json ``` +Human-readable `cch list` output remains main-session-only. `cch list --json` includes `agentIds` for discoverable child transcripts and `unresolvedAgentIds` for referenced child agents whose transcripts are not currently discoverable. + **Output:** ``` @@ -96,13 +99,20 @@ cch view 0 # View by UUID cch view abc123-def456-... +# View a linked agent transcript directly +cch view linked123 +cch view agent-linked123 + # View only progress messages cch view 0 --only progress # Output as JSON cch view 0 --json +cch view linked123 --json ``` +`cch view` accepts a session index, full or partial main-session UUID, bare agent ID, or `agent-`. Direct agent lookup is safe by default: ambiguous bare agent IDs fail instead of guessing. In JSON mode, ambiguous agent lookups return an `ambiguous-agent-id` error payload, and missing direct agent lookups return `session-not-found`. + **Output:** ```` @@ -210,7 +220,11 @@ cch export --all --output all-sessions.json cch export --all --format markdown --output all-sessions.md ``` -Progress messages are preserved in JSON and Markdown exports, and `cch view --json` retains `type: "progress"` entries in the returned message list. +Progress messages are preserved in JSON and Markdown exports, `cch view --json` retains `type: "progress"` entries in the returned message list, and exported session metadata preserves both `agentIds` and `unresolvedAgentIds`. + +## Platform Notes + +Nested subagent discovery is supported on macOS, Linux, and Windows Claude directory layouts. One existing Claude path caveat still applies: encoded project directory names do not round-trip perfectly when original workspace path segments contain literal hyphens, so decoded paths in those edge cases can normalize hyphens into path separators. ### Migrate Sessions diff --git a/package-lock.json b/package-lock.json index 5863e5a..456cdbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-history", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-history", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "dependencies": { "commander": "^14.0.2" diff --git a/package.json b/package.json index 40cdfec..6835727 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-history", - "version": "0.6.0", + "version": "0.7.0", "description": "CLI and library for browsing, searching, exporting, and managing Claude Code chat history", "type": "module", "main": "./dist/lib/index.js", diff --git a/specs/008-agent-session-linking/checklists/requirements.md b/specs/008-agent-session-linking/checklists/requirements.md new file mode 100644 index 0000000..c1bec06 --- /dev/null +++ b/specs/008-agent-session-linking/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Agent Session Linking + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-02 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validated against `spec.md` on 2026-04-02. +- No clarification markers remain; the spec is ready for `/speckit.plan`. diff --git a/specs/008-agent-session-linking/contracts/cli-interface.md b/specs/008-agent-session-linking/contracts/cli-interface.md new file mode 100644 index 0000000..ec0040a --- /dev/null +++ b/specs/008-agent-session-linking/contracts/cli-interface.md @@ -0,0 +1,158 @@ +# CLI Interface Contract: Agent Session Linking + +**Feature**: 008-agent-session-linking +**Date**: 2026-04-02 + +## Overview + +This contract defines the user-facing CLI behavior changes needed so `cch` can navigate directly from main-session metadata to nested or flat agent transcripts without polluting top-level session lists. + +## Command: `list` + +### Syntax + +```bash +cch list [options] +``` + +No new flags are introduced. + +### Updated Result Semantics + +- Human-readable list output continues to show only main sessions as top-level rows. +- JSON list output includes both `agentIds` and `unresolvedAgentIds` for each main-session summary. +- Linked agent IDs are derived from explicit main-session evidence first and nested fallback path ownership second. + +### JSON Example + +```json +{ + "success": true, + "data": [ + { + "index": 0, + "id": "11111111-1111-1111-1111-111111111111", + "projectPath": "/tmp/project", + "summary": "Nested agent example", + "agentIds": ["abc123"], + "unresolvedAgentIds": ["missing456"] + } + ] +} +``` + +## Command: `view` + +### Updated Syntax + +```bash +cch view +``` + +### Accepted Identifier Forms + +- Numeric session index +- Main-session UUID +- Partial main-session UUID prefix +- Bare linked agent identifier +- `agent-` form of a linked agent identifier + +### Updated Usage Text + +```text +Usage: cch view + +Provide a session index, session UUID, or agent ID. +``` + +## View Output Behavior + +### Main Session Detail + +- Human-readable output may show: + - linked agent IDs that are directly usable in follow-up lookup + - unresolved referenced agent IDs that are not currently retrievable +- JSON output includes both `agentIds` and `unresolvedAgentIds`. + +### Direct Agent Transcript View + +- `cch view abc123` opens the matching agent transcript when exactly one discoverable transcript matches. +- `cch view agent-abc123` behaves the same as the bare form. +- The agent transcript remains a separate session view rather than being merged into the parent main-session transcript. + +### Ambiguity Handling + +```bash +$ cch view abc123 +``` + +```text +Error: Agent ID is ambiguous: abc123 +Multiple matching agent transcripts were found. Use a more specific session identifier. +``` + +Exit code: non-zero + +### JSON Ambiguity Handling + +```bash +$ cch view abc123 --json +``` + +```json +{ + "success": false, + "error": { + "type": "ambiguous-agent-id", + "agentId": "abc123", + "message": "Agent ID is ambiguous: abc123" + } +} +``` + +### Missing Agent Handling + +```bash +$ cch view missing456 +``` + +```text +Error: Session not found: missing456 +Try 'cch list --json' or inspect the parent session's unresolved agent references. +``` + +Exit code: non-zero + +### JSON Missing Agent Handling + +```bash +$ cch view missing456 --json +``` + +```json +{ + "success": false, + "error": { + "type": "session-not-found", + "agentId": "missing456", + "message": "Session not found: missing456" + } +} +``` + +## Option Interactions + +| Command | Behavior | +|---------|----------| +| `cch list` | Shows only main-session rows | +| `cch list --json` | Includes `agentIds` and `unresolvedAgentIds` in each summary | +| `cch view ` | Shows main-session detail with linked and unresolved agent metadata | +| `cch view ` | Opens matching child-agent transcript when unique | +| `cch view agent-` | Same lookup behavior as bare agent ID | + +## Help Text Impact + +- `cch view --help` MUST describe accepted identifier forms beyond index/UUID only. +- User-facing ambiguity errors MUST be distinct from not-found errors. +- `cch view --json` MUST return structured error results for ambiguity and not-found outcomes instead of plain strings. +- No new flags are required for `list` or `view`. diff --git a/specs/008-agent-session-linking/contracts/library-api.md b/specs/008-agent-session-linking/contracts/library-api.md new file mode 100644 index 0000000..1b62bbe --- /dev/null +++ b/specs/008-agent-session-linking/contracts/library-api.md @@ -0,0 +1,156 @@ +# Library API Contract: Agent Session Linking + +**Feature**: 008-agent-session-linking +**Date**: 2026-04-02 + +## Overview + +This contract defines the additive library API changes required so `claude-code-history` can discover nested agent transcripts, expose accurate main-session links, surface unresolved references separately, and resolve direct agent lookups safely. + +## Updated Public Types + +### SessionSummary + +```typescript +export interface SessionSummary { + id: string; + projectPath: string; + gitBranch: string | null; + summary: string | null; + timestamp: Date; + lastActivityAt: Date; + messageCount: number; + agentIds: string[]; + unresolvedAgentIds: string[]; +} +``` + +**Behavioral Contract**: +- `agentIds` contains only discoverable child-agent identifiers for that main session. +- `unresolvedAgentIds` contains referenced child-agent identifiers whose transcripts are not discoverable. +- Both arrays are scoped to the requested main session only. + +### Session + +```typescript +export interface Session extends SessionSummary { + encodedPath: string; + version: string; + messages: Message[]; +} +``` + +### AmbiguousAgentSessionError + +```typescript +export class AmbiguousAgentSessionError extends Error { + readonly name: 'AmbiguousAgentSessionError'; + readonly agentId: string; + readonly matchingSessionPaths: string[]; +} +``` + +```typescript +export function isAmbiguousAgentSessionError( + error: unknown +): error is AmbiguousAgentSessionError; +``` + +**Behavioral Contract**: +- Raised when direct agent lookup by agent identifier matches more than one discoverable transcript. +- Distinct from `SessionNotFoundError`, which still represents zero matches. + +## Updated Functions and Behaviors + +### listSessions + +```typescript +export async function listSessions( + config?: LibraryConfig +): Promise>; +``` + +**Behavioral Contract**: +- Top-level list results continue to include only main sessions. +- Nested `subagents/agent-*.jsonl` transcripts participate in link resolution even though they do not appear as top-level list rows. +- `agentIds` are resolved from explicit main-session evidence first, then from nested path fallback only when explicit evidence is missing or incomplete. +- `unresolvedAgentIds` surface referenced child-agent identifiers that cannot be resolved to a retrievable transcript. + +### getSession + +```typescript +export async function getSession( + identifier: number | string, + config?: LibraryConfig +): Promise; +``` + +**Accepted Identifier Forms**: +- Numeric session index +- Full main-session UUID +- Partial main-session UUID prefix +- Bare agent identifier from `agentIds` +- `agent-` form of an agent identifier + +**Behavioral Contract**: +- Main-session retrieval returns accurate `agentIds` and `unresolvedAgentIds` for that session only. +- Bare or prefixed agent identifiers resolve the matching agent transcript when exactly one discoverable transcript matches. +- When a bare or prefixed agent identifier matches more than one transcript, `getSession()` throws `AmbiguousAgentSessionError`. +- When an identifier matches no session, `getSession()` throws `SessionNotFoundError`. +- CLI JSON workflows that wrap `getSession()` must distinguish these two failures as structured `ambiguous-agent-id` and `session-not-found` results. + +### getAgentSession + +```typescript +export async function getAgentSession( + agentId: string, + config?: LibraryConfig +): Promise; +``` + +**Behavioral Contract**: +- Accepts both bare agent identifiers and `agent-` form. +- Discovers and returns nested and flat agent transcripts. +- Throws `AmbiguousAgentSessionError` when more than one discoverable transcript matches the requested agent identifier. +- Throws `SessionNotFoundError` when no transcript matches the requested agent identifier. + +### exportSessionToJson / exportSessionToMarkdown + +```typescript +export async function exportSessionToJson( + sessionId: string | number, + config?: LibraryConfig +): Promise; + +export async function exportSessionToMarkdown( + sessionId: string | number, + config?: LibraryConfig +): Promise; +``` + +**Behavioral Contract**: +- JSON export preserves both `agentIds` and `unresolvedAgentIds` through the serialized session object. +- Exported `agentIds` and `unresolvedAgentIds` remain stable under round-trip validation when the serialized JSON is read back into a session-shaped object. +- Markdown export surfaces both discoverable linked agents and unresolved referenced agent IDs in session metadata. + +## Public Re-Exports + +The library index MUST re-export: + +- Updated `SessionSummary` +- Updated `Session` +- `AmbiguousAgentSessionError` +- `isAmbiguousAgentSessionError` + +## Backward Compatibility + +- Flat project-level `agent-*.jsonl` transcripts remain discoverable and retrievable. +- Main-session listing behavior remains unchanged at the top level; agent sessions still do not appear as list rows. +- Existing exact main-session UUID and partial-UUID lookup behavior remains intact. +- The new `unresolvedAgentIds` field is additive. + +## Defensive Behavior + +- Explicit main-session reference evidence remains authoritative when it conflicts with nested path evidence. +- Nested path ownership is used only when explicit reference data is missing or incomplete. +- Project co-location alone is never enough to link an agent transcript to a main session. diff --git a/specs/008-agent-session-linking/data-model.md b/specs/008-agent-session-linking/data-model.md new file mode 100644 index 0000000..1be0c05 --- /dev/null +++ b/specs/008-agent-session-linking/data-model.md @@ -0,0 +1,189 @@ +# Data Model: Agent Session Linking + +**Feature**: 008-agent-session-linking +**Date**: 2026-04-02 + +## Overview + +This feature extends the session discovery and retrieval model so Claude main sessions can expose accurate child-agent relationships, unresolved agent references, and safe direct lookup behavior for nested and flat agent transcripts. + +## New and Updated Types + +### SessionSummary + +Extend session summaries with separately surfaced unresolved agent references. + +```typescript +interface SessionSummary { + id: string; + projectPath: string; + gitBranch: string | null; + summary: string | null; + timestamp: Date; + lastActivityAt: Date; + messageCount: number; + agentIds: string[]; + unresolvedAgentIds: string[]; +} +``` + +**Validation Rules**: +- `agentIds` contains only discoverable child agent identifiers that are directly usable in a follow-up lookup. +- `unresolvedAgentIds` contains referenced child agent identifiers whose transcripts are not discoverable. +- `agentIds` and `unresolvedAgentIds` are unique within a summary and must not overlap. +- Top-level session listings still exclude agent sessions as rows, even when summaries contain linked-agent metadata. + +### Session + +Full session payloads inherit the same linked-agent metadata split. + +```typescript +interface Session extends SessionSummary { + encodedPath: string; + version: string; + messages: Message[]; +} +``` + +**Validation Rules**: +- Main-session retrieval returns `agentIds` and `unresolvedAgentIds` scoped only to that main session. +- Agent-session retrieval returns empty linked-agent metadata arrays. + +### Internal SessionInfo + +Discovery records need enough context to distinguish flat and nested agent storage. + +```typescript +interface SessionInfo { + id: string; + filePath: string; + projectPath: string; + encodedPath: string; + isAgent: boolean; + agentId: string | null; + storageLayout: 'flat' | 'nested'; + nestedOwnerSessionId: string | null; + modifiedTime: Date; +} +``` + +**Validation Rules**: +- Flat project-level files use `storageLayout: 'flat'` and `nestedOwnerSessionId: null`. +- Nested `subagents/agent-*.jsonl` files use `storageLayout: 'nested'` and record the owning main-session identifier from the path. +- Workspace filtering continues to apply at the project directory level. + +### AgentLinkResolution + +Derived link state for a single main session. + +```typescript +interface AgentLinkResolution { + agentIds: string[]; + unresolvedAgentIds: string[]; + source: 'explicit' | 'fallback-path' | 'mixed'; +} +``` + +**Validation Rules**: +- Explicit main-session reference data is authoritative whenever available and internally consistent. +- Fallback path ownership is used only when explicit reference data is missing or incomplete. +- Conflicting path evidence never overrides a valid explicit reference. + +### AmbiguousAgentSessionError + +Direct lookup must distinguish duplicate matches from not-found results. + +```typescript +class AmbiguousAgentSessionError extends Error { + name: 'AmbiguousAgentSessionError'; + agentId: string; + matchingSessionPaths: string[]; +} +``` + +**Validation Rules**: +- Raised when a direct lookup by bare or prefixed agent identifier matches more than one discoverable transcript. +- Not used for missing agent transcripts; missing lookups remain not-found failures. + +### JsonLookupErrorResult + +Structured CLI JSON output must distinguish ambiguous direct lookup from missing direct lookup. + +```typescript +interface JsonLookupErrorResult { + type: 'ambiguous-agent-id' | 'session-not-found'; + agentId: string; + message: string; +} +``` + +**Validation Rules**: +- Returned only for JSON-oriented direct lookup failures. +- `type: 'ambiguous-agent-id'` is used when more than one discoverable transcript matches the requested agent ID. +- `type: 'session-not-found'` is used when no discoverable transcript matches the requested agent ID. + +## Type Relationships + +```text +Main Session +├── has many Agent Link References +├── resolves to many discoverable Agent Sessions via agentIds +└── may retain many Unresolved Agent References via unresolvedAgentIds + +Agent Session +├── belongs to zero or one Main Session via explicit reference evidence +└── may be recoverably linked by nested fallback path ownership when explicit evidence is missing/incomplete + +Direct Agent Lookup +├── returns Agent Session when exactly one match exists +├── returns not found when zero matches exist +└── returns AmbiguousAgentSessionError when multiple matches exist +``` + +## Linkage Source Precedence + +| Evidence Source | Usage | Priority | +|-----------------|-------|----------| +| Explicit main-session agent reference | Primary parent-child linkage | 1 | +| Nested `subagents/` path ownership | Fallback when explicit reference is missing/incomplete | 2 | +| Project co-location only | Never sufficient on its own | Not allowed | + +## Identifier Semantics + +### Linked Agent Identifier + +- Exported `agentIds` remain the direct navigation target for follow-up lookup. +- Lookup accepts both bare agent IDs and `agent-` forms. +- Duplicate matches across discovered transcripts become ambiguous rather than silently selecting one. + +### Unresolved Agent Identifier + +- Exported through `unresolvedAgentIds`. +- Visible for fidelity and debugging. +- Not guaranteed to resolve to a retrievable transcript. + +## Lifecycle / State Transitions + +1. Discover session files recursively under each Claude project directory. +2. Normalize each discovered file into `SessionInfo`, including storage-layout context. +3. Parse main-session evidence to collect explicit agent references. +4. Resolve links for each main session: + - use explicit references first, + - add unresolved references for missing transcripts, + - use nested path ownership only when explicit evidence is missing/incomplete. +5. Surface resolved links in `agentIds` and unresolved references in `unresolvedAgentIds`. +6. Perform direct agent lookup: + - unique match → return agent session, + - zero matches → not found, + - multiple matches → ambiguity error. + +## Validation Requirements + +1. Nested `subagents/agent-*.jsonl` transcripts are discoverable. +2. Flat `agent-*.jsonl` transcripts remain discoverable. +3. Linked child agents for one main session never include agents from another main session in the same project. +4. `agentIds` never include unresolved or missing child references. +5. `unresolvedAgentIds` never include discoverable linked child agents. +6. Explicit reference evidence overrides conflicting nested path evidence. +7. Duplicate direct agent lookups never return an arbitrary transcript. +8. Exported `agentIds` and `unresolvedAgentIds` survive round-trip validation without changing value or association. diff --git a/specs/008-agent-session-linking/plan.md b/specs/008-agent-session-linking/plan.md new file mode 100644 index 0000000..7172934 --- /dev/null +++ b/specs/008-agent-session-linking/plan.md @@ -0,0 +1,121 @@ +# Implementation Plan: Agent Session Linking + +**Branch**: `008-agent-session-linking` | **Date**: 2026-04-02 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/008-agent-session-linking/spec.md` + +## Summary + +Add accurate Claude agent-session discovery and lookup support by recursively discovering nested subagent transcripts, deriving parent-to-agent links from main-session evidence with a path-based fallback, surfacing unresolved references separately, and making direct agent lookup explicit and safe when duplicate agent IDs exist. The implementation stays library-first: session discovery, link resolution, round-trip-safe export metadata, and ambiguity handling live in `src/lib/`, while CLI surfaces update lookup messaging, structured JSON errors, and detail rendering. + +## Technical Context + +**Language/Version**: TypeScript 5.x with strict mode enabled, running on Node.js 20+ +**Primary Dependencies**: Commander.js (CLI framework), Node.js built-ins (`fs`, `path`, `readline`) +**Storage**: Local Claude Code JSONL session files under `~/.claude/projects/`, including both flat `agent-*.jsonl` files and nested `/subagents/agent-*.jsonl` files +**Testing**: Vitest unit and integration tests, CLI integration coverage against generated fixture trees, contract validation against anonymized Claude JSONL samples, cross-platform path-semantic coverage, and repeatable performance validation +**Target Platform**: Node.js 20+ on macOS, Linux, and Windows +**Project Type**: Single TypeScript project with library and CLI layers +**Performance Goals**: Session listing and direct session lookup over 100+ fixture sessions complete in under 1 second during acceptance validation, with no user-visible slowdown from recursive nested-agent discovery +**Constraints**: Non-destructive read operations only; no new runtime dependencies; preserve existing flat agent-session support; keep main-session list results free of agent rows; explicit main-session references remain authoritative over fallback path evidence; duplicate direct agent lookups must fail as ambiguous rather than guessing +**Scale/Scope**: One session-discovery and lookup enhancement spanning recursive file discovery, per-session agent-link resolution, additive summary/session metadata, direct lookup behavior, structured JSON lookup failures, CLI view/list messaging, export round-trip fidelity, and regression fixtures for nested, missing, conflicting, duplicate, contract, and performance scenarios + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +### Pre-Research Gate + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. CLI-First Design | ✅ Pass | Feature improves existing `cch list` and `cch view` navigation and keeps human-readable and JSON workflows aligned. | +| II. Non-Destructive Operations | ✅ Pass | Reads existing JSONL history only; no mutation of source Claude data. | +| III. Cross-Platform Compatibility | ✅ Pass | Uses Node path/file abstractions, must validate nested discovery on macOS, Linux, and Windows path layouts, and must document any known limitations. | +| IV. Library-First Architecture | ✅ Pass | Discovery, link resolution, lookup, and error handling live in `src/lib/`; CLI only adjusts command/help/output behavior. | +| V. Data Fidelity | ✅ Pass | Preserves agent/subagent relationships more accurately than the current project-wide guess, surfaces unresolved references instead of silently dropping them, and requires round-trip validation for exported linked metadata. | + +**Technical Standards Compliance**: +- TypeScript strict mode remains in force ✅ +- No new external runtime dependencies are required ✅ +- Vitest unit, integration, contract, round-trip, and performance coverage will be expanded for nested discovery, direct lookup, unresolved references, ambiguity handling, cross-platform path handling, and legacy compatibility ✅ +- Public library API additions and new error behavior will be documented in contracts and re-exported through the library entrypoint ✅ + +### Post-Design Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. CLI-First Design | ✅ Pass | Design keeps top-level list behavior stable and clarifies accepted session identifiers for `view`, including ambiguity handling. | +| II. Non-Destructive Operations | ✅ Pass | Design remains entirely read-only across discovery, lookup, export, and CLI rendering flows. | +| III. Cross-Platform Compatibility | ✅ Pass | Design adds path-semantic validation for macOS, Linux, and Windows layouts and documents platform-specific caveats instead of assuming helper coverage is sufficient. | +| IV. Library-First Architecture | ✅ Pass | Recursive discovery, link resolution, unresolved-reference tracking, and ambiguity errors remain library concerns consumed by CLI formatters/commands. | +| V. Data Fidelity | ✅ Pass | Design distinguishes discoverable child agents from unresolved references, preserves both flat and nested storage layouts, adds structured JSON failure outcomes, and validates linked metadata through export round-trip checks. | + +No constitution violations require justification. + +## Project Structure + +### Documentation (this feature) + +```text +specs/008-agent-session-linking/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ ├── cli-interface.md +│ └── library-api.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +src/ +├── lib/ +│ ├── types.ts # Add unresolved linked-reference metadata to session types +│ ├── errors.ts # Add ambiguity error for duplicate direct agent lookups +│ ├── parser.ts # Extract explicit agent-link reference data from raw main-session entries +│ ├── platform.ts # Add recursive/nested session path helpers +│ ├── session.ts # Recursive discovery, precise agent linking, unresolved references, direct lookup +│ ├── export.ts # Surface linked and unresolved agent metadata in exports +│ └── index.ts # Re-export new metadata/error types +└── cli/ + ├── commands/ + │ └── view.ts # Accept/document direct agent identifiers and ambiguity errors + ├── utils/ + │ └── config.ts # Normalize CLI JSON error payloads for direct lookup failures + └── formatters/ + ├── session.ts # Surface linked and unresolved agent metadata in session detail output + └── table.ts # Keep list rows main-session-only while preserving additive metadata + +tests/ +├── fixtures/ +│ ├── contracts/ # Anonymized Claude JSONL samples for parser/discovery contract coverage +│ └── performance/ # 100+ session fixture sets for repeatable lookup/list validation +├── unit/ +│ ├── platform.test.ts +│ ├── parser.test.ts +│ ├── session.test.ts +│ └── cli/ +│ ├── commands/ +│ │ └── view.test.ts +│ └── formatters/ +│ └── session.test.ts +└── integration/ + ├── contract/ + │ └── claude-session-contract.test.ts + ├── get-session.test.ts + ├── list-sessions.test.ts + ├── export-sessions.test.ts + ├── performance/ + │ └── session-lookup.test.ts + └── cli/ + ├── list.test.ts + └── view.test.ts +``` + +**Structure Decision**: Keep the existing single-project structure. The core change belongs in library discovery and lookup code, with CLI updates limited to clearer identifier handling and user-facing metadata. Tests will extend the existing fixture-based library/CLI split instead of introducing new subsystems. + +## Complexity Tracking + +No constitutional or architectural exceptions required. The feature is an additive refinement of the existing discovery and lookup model and fits the current lib/cli split without new packages or new storage layers. diff --git a/specs/008-agent-session-linking/quickstart.md b/specs/008-agent-session-linking/quickstart.md new file mode 100644 index 0000000..2021f1b --- /dev/null +++ b/specs/008-agent-session-linking/quickstart.md @@ -0,0 +1,156 @@ +# Quickstart: Agent Session Linking + +**Feature**: 008-agent-session-linking +**Date**: 2026-04-02 + +## Overview + +This guide maps the agent-session-linking feature onto the existing Claude Code History codebase so implementation can proceed without re-discovering the current discovery and lookup limitations. + +## What We’re Building + +Support for nested Claude child-agent transcripts so they: + +- are discovered even when stored under `/subagents/`, +- link back only to the main session that actually spawned them, +- surface unresolved referenced agent IDs separately from usable linked agent IDs, +- open through direct library and CLI lookup by agent identifier, +- and fail safely when duplicate agent IDs make direct lookup ambiguous. + +## Key Files To Modify + +| File | Purpose | Planned Change | +|------|---------|----------------| +| `src/lib/types.ts` | Public session types | Add `unresolvedAgentIds` to summary/session metadata | +| `src/lib/errors.ts` | Library error model | Add ambiguity error and type guard for duplicate agent lookups | +| `src/lib/platform.ts` | Path helpers | Add recursive discovery helpers and nested-owner path parsing | +| `src/lib/parser.ts` | Raw JSONL inspection | Extract explicit child-agent reference evidence from main-session entries | +| `src/lib/session.ts` | Discovery and lookup | Replace flat discovery with recursive discovery, resolve per-session links, surface unresolved refs, and support direct agent lookup | +| `src/lib/export.ts` | Export fidelity | Include unresolved referenced agent IDs in Markdown/JSON export metadata | +| `src/lib/index.ts` | Public API surface | Re-export additive types and ambiguity error helpers | +| `src/cli/commands/view.ts` | View command | Accept/document agent identifiers and handle ambiguity errors distinctly | +| `src/cli/utils/config.ts` | CLI JSON behavior | Normalize structured JSON errors for ambiguous and missing agent lookups | +| `src/cli/formatters/session.ts` | Detail rendering | Show linked and unresolved agent metadata in human-readable session views | +| `src/cli/formatters/table.ts` | List rendering | Keep top-level rows focused on main sessions while preserving additive summary metadata | +| `tests/integration/list-sessions.test.ts` | Session summaries | Verify nested linking, fallback behavior, and main-session-only list rows | +| `tests/integration/get-session.test.ts` | Session retrieval | Verify direct lookup by bare/prefixed agent ID, nested discovery, unresolved refs, and ambiguity handling | +| `tests/integration/export-sessions.test.ts` | Export surfaces | Verify linked and unresolved agent metadata are preserved | +| `tests/integration/contract/claude-session-contract.test.ts` | Contract validation | Verify parser/discovery behavior against anonymized Claude JSONL samples | +| `tests/integration/performance/session-lookup.test.ts` | Performance validation | Verify `list` and direct lookup stay under the feature budget with 100+ sessions | +| `tests/integration/cli/list.test.ts` | CLI list flow | Verify main-session-only rows while JSON/export metadata stays additive | +| `tests/integration/cli/view.test.ts` | CLI view flow | Verify direct child-agent lookup, usage/help text, and ambiguity messaging | +| `tests/unit/platform.test.ts` | Path parsing | Verify nested owner extraction and recursive session file handling | +| `tests/unit/parser.test.ts` | Raw reference parsing | Verify explicit agent reference extraction and fallback conditions | +| `tests/unit/session.test.ts` | Resolution rules | Verify precedence, unresolved references, and duplicate-ID behavior | +| `tests/unit/cli/formatters/session.test.ts` | Session detail formatting | Verify linked and unresolved metadata remain readable without changing list scope | + +## Suggested Implementation Order + +### Step 1: Extend Discovery Metadata + +- Add internal discovery context for flat vs nested agent transcripts. +- Implement recursive file discovery beneath each Claude project directory. +- Keep existing flat `agent-*.jsonl` handling intact. + +### Step 2: Extract Link Evidence + +- Parse main-session raw entries for explicit child-agent identifiers. +- Separate discoverable linked child agents from unresolved referenced IDs. +- Apply fallback path ownership only when explicit data is missing or incomplete. + +### Step 3: Update Direct Lookup Semantics + +- Let direct lookup accept both bare agent IDs and `agent-` form. +- Return the matching child transcript when unique. +- Raise a dedicated ambiguity error when duplicate agent IDs exist. + +### Step 4: Surface Metadata in CLI and Exports + +- Keep `cch list` human-readable output focused on main-session rows. +- Expose `agentIds` and `unresolvedAgentIds` in JSON summaries and session detail/export metadata. +- Update `cch view` usage/help text and structured JSON ambiguity/not-found handling. + +### Step 5: Lock Behavior With Regression Tests + +- Add nested fixture trees under `tests` with: + - explicit main-session references, + - missing child transcripts, + - fallback-only nested ownership, + - explicit-vs-path conflicts, + - duplicate agent IDs, + - legacy flat agent files, + - anonymized real Claude JSONL contract samples, + - macOS/Linux/Windows path variants, + - and 100+ session performance fixtures. + +## Example Synthetic Fixture Tree + +```text +projects/ +└── -test-project/ + ├── 11111111-1111-1111-1111-111111111111.jsonl + ├── agent-flat123.jsonl + └── 11111111-1111-1111-1111-111111111111/ + └── subagents/ + ├── agent-nested456.jsonl + └── agent-duplicate999.jsonl +``` + +## Example Main Session Evidence + +Use a main-session raw entry set that includes: + +- an explicit child-agent reference for `nested456`, +- a missing referenced child-agent ID such as `missing777`, +- and, in a separate conflict fixture, explicit evidence that disagrees with a nested path so the explicit reference remains authoritative. + +## Verification Commands + +```bash +npm run typecheck +npm test +node dist/cli/index.js --data-path list --json +node dist/cli/index.js --data-path view linked123 --json +node dist/cli/index.js --data-path view duplicate777 --json +``` + +## Structured JSON Lookup Errors + +Direct agent lookup through `cch view --json` returns structured failures so automation can distinguish duplicate IDs from missing transcripts: + +```json +{ + "success": false, + "error": { + "type": "ambiguous-agent-id", + "agentId": "duplicate999", + "message": "Agent ID is ambiguous: duplicate999" + } +} +``` + +```json +{ + "success": false, + "error": { + "type": "session-not-found", + "agentId": "missing777", + "message": "Session not found: missing777" + } +} +``` + +## Platform Caveat + +Nested discovery is validated against macOS, Linux, and Windows path layouts, but Claude's encoded project directory naming is still lossy when the original workspace path contains literal hyphens. In those edge cases, decoded workspace paths can normalize hyphens into path separators. + +## Expected Outcomes + +- Nested child-agent transcripts become discoverable through the library. +- Main sessions expose only their true child-agent links. +- Missing child transcripts appear as unresolved references instead of broken linked IDs. +- Direct child-agent lookup works through both library and CLI when unique. +- Duplicate and missing agent-ID JSON lookups return structured error payloads instead of arbitrary transcript content. +- Exported linked and unresolved metadata survive round-trip validation. +- Path-sensitive discovery remains correct across macOS, Linux, and Windows fixture cases, with any limitations documented. +- Legacy flat agent-session behavior continues to pass regression tests. diff --git a/specs/008-agent-session-linking/research.md b/specs/008-agent-session-linking/research.md new file mode 100644 index 0000000..48b4d2b --- /dev/null +++ b/specs/008-agent-session-linking/research.md @@ -0,0 +1,149 @@ +# Research: Agent Session Linking + +**Feature**: 008-agent-session-linking +**Date**: 2026-04-02 + +## Overview + +This document captures the decisions needed to support nested Claude subagent transcripts, accurate main-session linking, and safe direct agent lookup without breaking the current library-first CLI architecture. + +## Research Areas + +### 1. Session Discovery Strategy + +**Decision**: Recursively discover session files within each Claude project directory and retain storage-layout context for each discovered agent transcript. + +**Rationale**: +- Current discovery only scans the top level of each project directory, so nested `/subagents/agent-*.jsonl` files are invisible. +- Recursive discovery is required to bring nested subagent transcripts into the supported lookup surface. +- Retaining path context allows the system to distinguish flat project-level agent files from nested child-agent files and use nested ownership as a fallback relationship signal. + +**Alternatives Considered**: +- Continue scanning only the project root: Rejected because nested subagents remain undiscoverable. +- Recursively discover files without recording layout context: Rejected because fallback linkage and conflict handling need to know where the agent transcript was found. + +### 2. Parent-to-Agent Link Resolution + +**Decision**: Use explicit agent references recorded in the main session as the authoritative link source, and use nested subagent path ownership only when the explicit reference data is missing or incomplete. + +**Rationale**: +- The clarified spec requires real `toolUseResult.agentId`-style evidence to replace the current project-wide guess. +- Explicit reference data best reflects the true conversation relationship and must win when it conflicts with path evidence. +- Nested storage remains valuable as a recovery path for incomplete histories or older variants that still preserve the parent-child folder shape. + +**Alternatives Considered**: +- Treat nested path ownership as the primary source: Rejected because it can be wrong when explicit main-session evidence disagrees. +- Require explicit references in every case: Rejected because the clarified spec allows fallback linking when the explicit data is incomplete. + +### 3. Representation of Missing Child Transcripts + +**Decision**: Separate unresolved referenced agent IDs from discoverable linked agent IDs in session metadata. + +**Rationale**: +- The clarified spec requires missing child references to remain visible to users instead of being silently discarded. +- Mixing broken references into `agentIds` would violate the promise that linked identifiers are immediately usable in a follow-up lookup. +- A dedicated unresolved-reference field preserves fidelity while keeping successful navigation targets reliable. + +**Alternatives Considered**: +- Drop missing references entirely: Rejected because it hides incomplete linkage evidence from users. +- Include missing references inside the normal linked-agent list: Rejected because it turns broken IDs into misleading navigation targets. + +### 4. Direct Agent Lookup Semantics + +**Decision**: Allow direct lookup by both bare agent ID and `agent-` form, but fail with an ambiguity error if more than one discoverable transcript matches that agent ID. + +**Rationale**: +- The clarified spec explicitly prefers direct lookup by agent ID alone, without requiring parent-session context. +- Recursive nested discovery makes duplicate agent IDs possible across different transcript files. +- Returning an ambiguity error is safer than choosing an arbitrary transcript and silently misrouting the user. + +**Alternatives Considered**: +- Assume agent IDs are globally unique: Rejected because the clarified spec already anticipates duplicate-ID scenarios. +- Require parent-session context for every direct agent lookup: Rejected by clarification in favor of direct ID-based lookup. + +### 5. Lookup Surface and CLI Contract + +**Decision**: Keep `list` focused on main sessions, but expand library and CLI contracts so exported linked identifiers are directly usable and ambiguity and not-found lookup failures are surfaced as structured JSON outcomes. + +**Rationale**: +- The current public contract documents `getSession()`/`view` mainly as index-or-UUID lookups, while the feature requires direct navigation by linked agent ID. +- Top-level list behavior should remain stable; the richer agent metadata belongs in JSON/list summaries, session detail views, and exports. +- Distinguishing ambiguity from “not found” keeps troubleshooting actionable for users and testable for the CLI. +- JSON workflows need a structured error payload so automation can reliably tell ambiguity apart from a missing transcript. + +**Alternatives Considered**: +- Add agent sessions as top-level rows in `list`: Rejected because the spec keeps top-level session lists focused on main conversations. +- Treat ambiguity as “not found”: Rejected because it hides a materially different failure mode. + +### 6. Backward Compatibility and Test Strategy + +**Decision**: Preserve flat project-level `agent-*.jsonl` support while combining synthetic fixtures, anonymized real Claude JSONL contract samples, and cross-platform path cases for nested subagents, fallback linkage, missing transcripts, conflict precedence, and duplicate-ID ambiguity. + +**Rationale**: +- Existing tests cover only flat agent sessions and simple `getAgentSession()` success/failure, so they do not protect the new discovery and linkage behavior. +- The feature must support both older flat storage and newer nested storage without regressing current workflows. +- Synthetic fixture trees remain useful for targeted edge cases, but contract validation against anonymized real Claude JSONL structures is required to protect parser and discovery behavior. +- Cross-platform path cases must be exercised explicitly because recursive nested discovery is path-sensitive. + +**Alternatives Considered**: +- Replace flat support with nested-only behavior: Rejected because the spec requires backward compatibility. +- Cover only library paths: Rejected because direct lookup and ambiguity messaging must also work through `cch view`. + +### 7. Fidelity and Performance Validation + +**Decision**: Add explicit round-trip export validation for linked and unresolved metadata, and repeatable performance validation for session listing and direct lookup over fixture sets containing at least 100 sessions. + +**Rationale**: +- The constitution requires exported metadata and relationships to survive round-trip validation. +- The clarified spec adds a measurable under-1-second target for both list and direct lookup workflows. +- Recursive discovery broadens the search surface, so performance regression must be measured rather than assumed. + +**Alternatives Considered**: +- Validate only correctness and skip round-trip/performance checks: Rejected because fidelity and speed are explicit acceptance criteria. +- Benchmark only `list`: Rejected because direct agent lookup is also part of the promised user workflow. + +## Integration Points + +### Files to Modify + +1. `src/lib/types.ts` + - Add additive session metadata for unresolved linked agent references. + +2. `src/lib/errors.ts` + - Add a dedicated ambiguity error for duplicate direct agent lookups. + +3. `src/lib/parser.ts` + - Extract explicit agent-link evidence from main-session raw entries. + +4. `src/lib/platform.ts` + - Add helpers for recursive discovery and nested-owner path parsing. + +5. `src/lib/session.ts` + - Implement recursive session discovery, precise per-session link resolution, unresolved-reference tracking, and ambiguity-safe direct lookup. + +6. `src/lib/export.ts` + - Include unresolved linked references in exported session metadata. + +7. `src/lib/index.ts` + - Re-export new metadata/error types. + +8. `src/cli/commands/view.ts` + - Clarify accepted identifiers and surface ambiguity errors distinctly from not-found errors. + +9. `src/cli/formatters/session.ts` + - Display linked and unresolved agent metadata in human-readable session detail output. + +10. `tests/...` + - Add nested, conflicting, unresolved, duplicate-ID, contract, round-trip, and performance coverage while retaining flat-agent regression tests. + +## Performance Considerations + +- Recursive discovery adds directory traversal depth but remains bounded by local project trees under `~/.claude/projects/`. +- Link resolution remains linear in the number of entries in the target main session file and the number of discovered agent transcripts in the same project. +- The acceptance target is under 1 second for both session listing and direct session lookup over fixture sets containing at least 100 sessions. + +## Open Uncertainty Managed by Design + +- Claude session histories may represent agent-link evidence in more than one raw entry shape. +- The design mitigates this by preferring explicit agent references when available, then falling back to nested ownership only when reference data is missing or incomplete. +- If additional Claude raw formats are observed later, they can extend the explicit-reference extractor without changing the higher-level linkage contract. diff --git a/specs/008-agent-session-linking/spec.md b/specs/008-agent-session-linking/spec.md new file mode 100644 index 0000000..bbfe23f --- /dev/null +++ b/specs/008-agent-session-linking/spec.md @@ -0,0 +1,158 @@ +# Feature Specification: Agent Session Linking + +**Feature Branch**: `008-agent-session-linking` +**Created**: 2026-04-02 +**Status**: Draft +**Input**: User description: "Recursively discover nested subagents/agent-*.jsonl files, link them to each main session by parsing real toolUseResult.agentId references instead of guessing, and make view/lib lookups resolve those agent IDs directly." + +## Clarifications + +### Session 2026-04-02 + +- Q: When a main session references agent IDs whose transcript files are missing, should those references still be surfaced to users? → A: Return discoverable agent IDs and separately flag unresolved referenced agent IDs. +- Q: If the same agent ID appears in more than one transcript, how should direct agent lookup behave? → A: Resolve by agent ID alone, but fail with an ambiguity result if duplicate agent IDs are found. +- Q: When explicit agent references are missing or incomplete, should nested subagent paths be allowed as a fallback source of linkage? → A: Use explicit agent references first, then use the nested subagent path as a fallback when needed. +- Q: If explicit agent references and nested subagent path evidence conflict, which source should win? → A: Trust the explicit agent reference and ignore the conflicting nested path evidence. +- Q: Should linked and unresolved agent metadata survive export and re-import validation? → A: Yes, both linked and unresolved agent metadata must survive a full round-trip validation. +- Q: How should platform-sensitive path discovery be validated? → A: Validate macOS, Linux, and Windows path semantics and document any known platform-specific limitations. +- Q: What fixture quality is required for parser and discovery validation? → A: Use real or anonymized Claude JSONL samples that cover nested, missing, fallback, and conflicting linkage scenarios. +- Q: How should performance validation be scoped? → A: Verify both session listing and direct session lookup complete in under 1 second over fixture sets containing at least 100 sessions. +- Q: What should JSON-oriented direct lookup return for ambiguous or missing agent IDs? → A: Return a structured JSON error result for both ambiguity and not-found outcomes. +- Q: How should missing child transcript terminology be normalized? → A: Distinguish unresolved agent references on main-session metadata from not-found direct lookup failures. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Show Correct Linked Agent Sessions (Priority: P1) + +As a developer reviewing a Claude Code session, I want each main session to expose only the agent sessions that were actually spawned from that conversation so I can trust the linked agent IDs without manually inspecting raw history files. + +**Why this priority**: Incorrect agent linking makes the product unreliable for one of the core Claude session relationships and can send users to unrelated agent transcripts from the same project. + +**Independent Test**: Can be fully tested by listing or retrieving a main session from fixture data that contains multiple main sessions and nested agent histories, then verifying that only the truly linked agent IDs are returned. + +**Acceptance Scenarios**: + +1. **Given** a main session with one or more nested agent histories that were actually spawned from that conversation, **When** the user lists sessions or retrieves that main session, **Then** the returned linked agent IDs include every true child agent session for that main session. +2. **Given** multiple main sessions in the same project, each with different agent histories, **When** the user retrieves one main session, **Then** the linked agent IDs exclude agent sessions that belong to the other main sessions. +3. **Given** a main session with no linked agent histories, **When** the user lists sessions or retrieves that session, **Then** the session is returned successfully with no linked agent IDs rather than guessed or unrelated ones. +4. **Given** a main session references both discoverable and missing child agent transcripts, **When** the user lists sessions or retrieves that session, **Then** the discoverable linked agent IDs are returned and the unresolved referenced agent IDs are surfaced separately. +5. **Given** a child agent transcript is stored under a main session's nested subagent location but the explicit reference data is missing or incomplete, **When** the user lists sessions or retrieves that main session, **Then** the child agent transcript can still be linked through the fallback path-based relationship. +6. **Given** explicit agent reference data conflicts with the nested storage path for a child transcript, **When** the user lists sessions or retrieves the main session, **Then** the explicit reference determines the link and the conflicting path evidence does not override it. + +--- + +### User Story 2 - Open Agent Transcript From Exported ID (Priority: P1) + +As a developer navigating from a main session to an agent session, I want an exported linked agent ID to be directly retrievable through the library and CLI so I can inspect the child transcript without manually locating the underlying file. + +**Why this priority**: Linked agent IDs are only useful if they can immediately resolve to the corresponding agent transcript in a second step. + +**Independent Test**: Can be fully tested by retrieving a main session, taking one of its linked agent IDs, and using both library and CLI session lookup paths to open the corresponding agent transcript. + +**Acceptance Scenarios**: + +1. **Given** a main session that exposes a linked agent ID, **When** the user requests that agent transcript through the library, **Then** the full agent session is returned successfully. +2. **Given** a main session that exposes a linked agent ID, **When** the user requests that agent transcript through the session-viewing workflow, **Then** the transcript opens successfully without requiring manual file-path discovery. +3. **Given** a linked agent ID is provided in either a bare or display-ready form, **When** the user performs a lookup, **Then** the lookup resolves to the same agent transcript. +4. **Given** the same agent ID appears in more than one transcript, **When** the user performs a direct lookup by that agent ID, **Then** the system reports an ambiguity result instead of returning one of the matching transcripts arbitrarily. +5. **Given** the user performs a JSON-oriented direct lookup for an agent ID that matches more than one transcript, **When** the lookup completes, **Then** the response is a structured error result that identifies the requested agent ID and the ambiguity outcome without returning transcript content. +6. **Given** the user performs a JSON-oriented direct lookup for an agent ID that matches no discoverable transcript, **When** the lookup completes, **Then** the response is a structured error result that identifies the requested agent ID and the not-found outcome without returning unrelated transcript content. + +--- + +### User Story 3 - Keep Session Lists Focused on Main Conversations (Priority: P2) + +As a developer scanning available history, I want session lists to remain focused on main conversations while still preserving navigable links to child agent sessions so that list output stays readable without hiding agent details from follow-up inspection. + +**Why this priority**: Agent sessions should remain discoverable through their parent conversation without overwhelming the primary session list. + +**Independent Test**: Can be fully tested by listing sessions from fixture data containing both main sessions and nested agent sessions and verifying that only main sessions appear as top-level list results while linked agent IDs remain available on relevant sessions. + +**Acceptance Scenarios**: + +1. **Given** history data that contains both main sessions and linked agent sessions, **When** the user lists sessions, **Then** only main sessions appear as list entries. +2. **Given** a listed main session that has linked agent sessions, **When** the user inspects its metadata or exported representation, **Then** the linked agent IDs are present and correspond only to that session's actual child agents. +3. **Given** a project with unrelated agent sessions that are not linked to a listed main session, **When** the user views that main session's exported metadata, **Then** those unrelated agent sessions are not surfaced as links. + +### Edge Cases + +- What happens when a main session references an agent session whose transcript file is missing? The system surfaces an unresolved agent reference on the main session metadata without substituting unrelated transcript data. +- What happens when a user attempts to directly open an agent ID whose transcript cannot be discovered? The system returns a not-found result for the requested agent ID. +- What happens when nested agent session files exist in the project but are not referenced by a main session? The system does not attach those agent sessions to unrelated main sessions. +- What happens when a project contains a mix of nested agent session storage and older flat agent session storage? Users can still retrieve valid linked agent transcripts from either format without changing the list behavior for main sessions. +- What happens when a user attempts to open a linked agent session using either the raw linked ID or its display-ready agent session form? Both inputs resolve to the same agent transcript. +- What happens when the same agent ID exists in more than one transcript? The system reports an ambiguity result and requires disambiguation instead of guessing which transcript the user intended. +- What happens when explicit child-agent references are missing or incomplete but the nested storage path still implies the parent-child relationship? The system can recover the link from the nested path, but only as a fallback after checking for explicit references. +- What happens when explicit child-agent references disagree with nested path evidence? The system trusts the explicit reference and does not let the conflicting path evidence override that link. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST discover agent session histories stored inside nested subagent locations as part of session discovery. +- **FR-002**: The system MUST determine linked agent sessions for each main session from agent references recorded by that main session, rather than by assuming all agent sessions in the same project are related. +- **FR-002a**: When explicit agent references for a child transcript are missing or incomplete, the system MUST be able to use the nested subagent storage relationship as a fallback source of linkage. +- **FR-002b**: When explicit agent references conflict with nested subagent storage evidence, the system MUST treat the explicit reference as authoritative for linkage. +- **FR-003**: The session-listing workflow MUST return only main sessions as top-level list results. +- **FR-004**: Each main-session summary returned by the session-listing workflow MUST include only the linked agent IDs that actually belong to that main session. +- **FR-005**: Main-session retrieval MUST return only the linked agent IDs that actually belong to the requested main session. +- **FR-005a**: When a main session references child agent IDs whose transcripts cannot be discovered, the system MUST surface those unresolved referenced agent IDs separately from the discoverable linked agent IDs. +- **FR-006**: Direct agent-session retrieval by agent identifier MUST retrieve agent transcripts that were discovered from nested subagent storage. +- **FR-006a**: When the same agent identifier matches more than one discoverable agent transcript, direct agent-session retrieval MUST return an ambiguity result instead of selecting one transcript arbitrarily. +- **FR-006b**: JSON-oriented direct lookup workflows MUST return a structured error result that identifies the requested agent identifier and the ambiguity outcome whenever duplicate agent transcripts match the request. +- **FR-007**: Session lookup workflows used by the CLI and library MUST resolve a linked agent ID directly, without requiring the caller to discover the underlying storage path manually. +- **FR-008**: The system MUST treat different main sessions in the same project as separate agent-linking scopes so that one main session cannot inherit another session's child agents. +- **FR-009**: When a requested linked agent transcript cannot be found, the system MUST fail with a session-not-found result for that requested agent ID rather than returning unrelated transcript data. +- **FR-009a**: JSON-oriented direct lookup workflows MUST return a structured error result that identifies the requested agent identifier and the not-found outcome whenever no discoverable transcript matches the request. +- **FR-010**: Exported session metadata that surfaces linked agent IDs MUST use values that are directly usable in a follow-up agent lookup. +- **FR-011**: Existing retrieval behavior for already supported agent session layouts MUST remain available after nested agent discovery is added. + +### Non-Functional Requirements + +- **NFR-001**: Path discovery and direct lookup MUST validate macOS, Linux, and Windows path semantics for both flat and nested agent session layouts, and any known unsupported platform-specific behavior MUST be documented. +- **NFR-002**: Session listing and direct session lookup over fixture sets containing at least 100 sessions MUST complete in under 1 second during acceptance validation on supported development platforms. +- **NFR-003**: Exported session metadata for `agentIds` and `unresolvedAgentIds` MUST survive export and re-import validation without losing linkage meaning or changing session association. +- **NFR-004**: JSONL parsing and agent-link discovery MUST be validated against real or anonymized Claude history samples that cover flat layouts, nested layouts, unresolved references, fallback linkage, and conflicting evidence. + +### Key Entities *(include if feature involves data)* + +- **Main Session**: The primary Claude Code conversation that users list and inspect as the top-level unit of history. +- **Agent Session**: A child conversation spawned from a main session that has its own transcript and can be opened independently. +- **Agent Link Reference**: The recorded relationship in a main session that identifies which child agent sessions belong to that conversation. +- **Fallback Path Relationship**: A recoverable parent-child relationship inferred from nested subagent storage when explicit agent reference data is missing or incomplete. +- **Unresolved Agent Reference**: A child agent identifier recorded by a main session for which no retrievable agent transcript is currently discoverable. +- **Ambiguous Agent Match**: A direct agent lookup outcome in which more than one discoverable transcript matches the requested agent identifier. +- **Structured Lookup Error Result**: A machine-readable direct-lookup failure response that identifies the requested agent identifier and whether the outcome was ambiguous or not found. +- **Session Identifier**: The user-facing value used to retrieve either a main session or an agent session in a later lookup. + +## Assumptions + +- Agent session transcripts remain separate from the main session transcript and are accessed through linking rather than by merging child messages into the parent message flow. +- Users want linked agent IDs to serve as immediate navigation targets for a second lookup step in either the library or CLI. +- Session lists should stay focused on main conversations even when agent transcripts are available. +- When a main session references a missing child transcript, users still benefit from seeing that unresolved reference as separate metadata rather than having it silently dropped. +- Explicit agent references are the preferred source of truth for linkage, with nested path relationships used only when that reference evidence is missing or incomplete. +- If explicit reference evidence and nested path evidence disagree, the explicit reference remains authoritative. + +## Dependencies + +- Claude session history continues to record child-agent relationships in the main session history. +- Users rely on session listing, session viewing, and direct session retrieval workflows as the primary ways to navigate session history. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In acceptance testing with fixture data that contains nested child-agent histories, 100% of main sessions return all of their true linked agent IDs and 0 unrelated agent IDs. +- **SC-001a**: In acceptance testing, child agent transcripts that lack complete explicit reference data but have a valid nested parent-child storage relationship are still linked to the correct main session. +- **SC-001b**: In acceptance testing, 100% of cases where explicit reference data conflicts with nested path evidence follow the explicit reference and 0 are reassigned based on the conflicting path alone. +- **SC-002**: In acceptance testing, 100% of linked agent IDs returned from a main session can be used to retrieve the correct child transcript through both library lookup and session-viewing workflows. +- **SC-002a**: In acceptance testing, 100% of direct lookups involving duplicate agent IDs return an ambiguity result and 0 return an arbitrary transcript. +- **SC-002b**: In acceptance testing, 100% of JSON-oriented direct lookups involving duplicate agent IDs return a structured ambiguity result that identifies the requested agent identifier and 0 return transcript content. +- **SC-002c**: In acceptance testing, 100% of JSON-oriented direct lookups involving missing agent IDs return a structured not-found result that identifies the requested agent identifier and 0 return unrelated transcript content. +- **SC-003**: In acceptance testing, session-list output continues to show only main sessions as top-level results even when nested child-agent histories are present. +- **SC-004**: In regression testing, 100% of pre-existing supported agent-session retrieval scenarios continue to pass after nested child-agent discovery is added. +- **SC-005**: In acceptance validation, 100% of supported macOS, Linux, and Windows path-layout cases for flat and nested agent discovery resolve correctly, and any unsupported platform-specific caveats are documented before release. +- **SC-006**: In repeatable acceptance validation with fixture sets containing at least 100 sessions, both session listing and direct session lookup complete in under 1 second. +- **SC-007**: In round-trip validation, 100% of exported `agentIds` and `unresolvedAgentIds` survive export and re-import without changing value, meaning, or parent-session association. +- **SC-008**: In contract validation with real or anonymized Claude JSONL samples, 100% of supported nested, unresolved, fallback, conflicting, and flat-layout linkage scenarios pass. diff --git a/specs/008-agent-session-linking/tasks.md b/specs/008-agent-session-linking/tasks.md new file mode 100644 index 0000000..757115e --- /dev/null +++ b/specs/008-agent-session-linking/tasks.md @@ -0,0 +1,217 @@ +# Tasks: Agent Session Linking + +**Input**: Design documents from `/specs/008-agent-session-linking/` +**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), [data-model.md](./data-model.md), [contracts/](./contracts/) + +**Tests**: The feature specification requires independent validation for each user story, and the plan/constitution require regression coverage. Tests are included in each story phase, but this is not a TDD-only plan, so implementation may precede the validation tasks within a phase. + +**Organization**: Tasks are grouped by user story to enable independent implementation and validation of each increment. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (`[US1]`, `[US2]`, `[US3]`) +- Every task includes an exact file path + +## Path Conventions + +- Single project layout at repository root: `src/`, `tests/`, `specs/` +- Library-first implementation in `src/lib/` +- CLI consumption and formatting in `src/cli/` + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare reusable synthetic, anonymized contract, and scale fixtures that all stories can share. + +- [X] T001 Create reusable nested-agent, missing-reference, and conflict JSONL fixtures in `tests/fixtures/nested-main-session.jsonl`, `tests/fixtures/nested-agent-session.jsonl`, and `tests/fixtures/nested-agent-conflict-session.jsonl` +- [X] T002 [P] Add anonymized Claude contract fixtures and 100+-session performance fixtures in `tests/fixtures/contracts/claude-main-session.jsonl`, `tests/fixtures/contracts/claude-main-session/subagents/agent-linked.jsonl`, `tests/fixtures/performance/perf-main-session-001.jsonl`, and `tests/fixtures/performance/perf-main-session-101.jsonl` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the shared discovery, metadata, and error model required by every user story. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T003 [P] Add `unresolvedAgentIds` summary/session metadata definitions in `src/lib/types.ts` +- [X] T004 [P] Add `AmbiguousAgentSessionError` and `isAmbiguousAgentSessionError` in `src/lib/errors.ts` +- [X] T005 [P] Add recursive session-file discovery helpers and macOS/Linux/Windows nested-owner path parsing in `src/lib/platform.ts` +- [X] T006 [P] Add explicit agent-reference extraction helpers for raw main-session entries in `src/lib/parser.ts` +- [X] T007 Implement shared `SessionInfo` discovery context and link-resolution scaffolding in `src/lib/session.ts` +- [X] T008 Re-export unresolved-link metadata and ambiguity helpers in `src/lib/index.ts` +- [X] T009 Add cross-platform flat and nested path coverage for macOS, Linux, and Windows variants in `tests/unit/platform.test.ts` +- [X] T010 Add foundational raw-reference and shared-resolution coverage in `tests/unit/parser.test.ts` and `tests/unit/session.test.ts` +- [X] T011 Add anonymized Claude JSONL contract parsing and linkage coverage in `tests/integration/contract/claude-session-contract.test.ts` + +**Checkpoint**: Recursive discovery inputs, additive metadata, and ambiguity primitives are ready for story work. + +--- + +## Phase 3: User Story 1 - Show Correct Linked Agent Sessions (Priority: P1) 🎯 MVP + +**Goal**: Ensure each main session exposes only its true child agent sessions, with unresolved references surfaced separately. + +**Independent Test**: Retrieve or list a main session from fixture data containing multiple main sessions, nested agents, missing agents, fallback-only links, and path conflicts, then verify that only true child agent IDs appear in `agentIds` and missing references appear only in `unresolvedAgentIds`. + +### Implementation for User Story 1 + +- [X] T012 [US1] Replace project-wide agent guessing with explicit-reference-first per-session link resolution in `src/lib/session.ts` +- [X] T013 [P] [US1] Add main-session retrieval coverage for nested, unresolved, fallback, and conflicting links in `tests/integration/get-session.test.ts` +- [X] T014 [P] [US1] Add list-session coverage for true-child-only linking and unresolved references in `tests/integration/list-sessions.test.ts` + +**Checkpoint**: User Story 1 is complete when main-session metadata is accurate and independently testable through library list/retrieval flows. + +--- + +## Phase 4: User Story 2 - Open Agent Transcript From Exported ID (Priority: P1) + +**Goal**: Allow linked agent IDs to open the correct child transcript through both library and CLI lookup flows. + +**Independent Test**: Retrieve a main session, take one of its exported linked agent IDs, and verify that library and CLI lookups open the correct child transcript; verify duplicate agent IDs fail as ambiguous and missing IDs fail as not found. + +### Implementation for User Story 2 + +- [X] T015 [US2] Extend `getSession()` and `getAgentSession()` to resolve bare and prefixed agent IDs with ambiguity detection in `src/lib/session.ts` +- [X] T016 [P] [US2] Implement structured JSON ambiguity/not-found responses and accepted agent identifier forms in `src/cli/commands/view.ts` and `src/cli/utils/config.ts` +- [X] T017 [P] [US2] Add library lookup coverage for unique, missing, and duplicate agent IDs in `tests/integration/get-session.test.ts` +- [X] T018 [P] [US2] Add CLI view coverage for direct agent lookup, JSON error payloads, and ambiguity messaging in `tests/integration/cli/view.test.ts` and `tests/unit/cli/commands/view.test.ts` + +**Checkpoint**: User Story 2 is complete when exported linked agent IDs are directly usable in follow-up library and CLI lookups. + +--- + +## Phase 5: User Story 3 - Keep Session Lists Focused on Main Conversations (Priority: P2) + +**Goal**: Preserve main-session-only list output while surfacing navigable linked-agent metadata and unresolved references in the right places. + +**Independent Test**: Run `cch list` and `cch list --json` against mixed main-session and nested-agent fixtures, then confirm only main sessions appear as top-level rows while linked and unresolved agent metadata remain available in JSON, exports, and session detail output. + +### Implementation for User Story 3 + +- [X] T019 [P] [US3] Surface linked and unresolved agent metadata in human-readable session detail output in `src/cli/formatters/session.ts` +- [X] T020 [P] [US3] Preserve linked and unresolved agent metadata in JSON and Markdown export headers in `src/lib/export.ts` +- [X] T021 [US3] Keep `cch list` human-readable rows main-session-only while exposing additive summary fields in `src/cli/formatters/table.ts` +- [X] T022 [P] [US3] Add formatter, list, and export round-trip re-import coverage for main-session-only output and linked/unresolved metadata in `tests/unit/cli/formatters/session.test.ts`, `tests/integration/cli/list.test.ts`, and `tests/integration/export-sessions.test.ts` + +**Checkpoint**: User Story 3 is complete when navigation metadata is visible without promoting agent transcripts to top-level list rows. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Lock in backward compatibility, documentation, and end-to-end verification. + +- [X] T023 [P] Add legacy flat-agent regression coverage across `tests/unit/session.test.ts`, `tests/integration/get-session.test.ts`, and `tests/integration/list-sessions.test.ts` +- [X] T024 [P] Add repeatable 100+-session performance validation for `listSessions()`, `getSession()`, and `cch view` in `tests/integration/performance/session-lookup.test.ts` +- [X] T025 [P] Update linked-agent lookup examples, structured JSON error behavior, and platform-specific caveats in `README.md` and `specs/008-agent-session-linking/quickstart.md` +- [X] T026 Run the verification steps documented in `specs/008-agent-session-linking/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1: Setup**: No dependencies +- **Phase 2: Foundational**: Depends on Phase 1 and blocks all user stories +- **Phase 3: User Story 1**: Depends on Phase 2 only +- **Phase 4: User Story 2**: Depends on Phase 2 only +- **Phase 5: User Story 3**: Depends on Phase 3 and Phase 4 because it surfaces metadata produced by User Story 1 and relies on the direct-lookup contract finalized by User Story 2 +- **Phase 6: Polish**: Depends on completion of all desired user stories + +### User Story Dependencies + +- **US1**: Independent after Foundational; recommended MVP starting point +- **US2**: Independent after Foundational from a product perspective, but shares `src/lib/session.ts` with US1 and needs coordination if worked in parallel +- **US3**: Depends on accurate linked-agent metadata from US1 and the finalized direct-lookup/error contract from US2 + +### Dependency Graph + +```text +Phase 1 Setup + ↓ +Phase 2 Foundational + ├── Phase 3 US1 ──┐ + └── Phase 4 US2 ──┴── Phase 5 US3 + ↓ + Phase 6 Polish +``` + +--- + +## Parallel Opportunities + +- `T003`, `T004`, `T005`, and `T006` can run in parallel during Phase 2 because they target different foundational files. +- After foundational work lands, `T013` and `T014` can run in parallel for US1 validation. +- In US2, `T016`, `T017`, and `T018` can run in parallel once `T015` establishes the lookup semantics. +- In US3, `T019` and `T020` can run in parallel because session detail rendering and export metadata touch different files. +- `T023`, `T024`, and `T025` can run in parallel during polish while final quickstart validation is prepared. + +## Parallel Example: User Story 1 + +```bash +# After T012 is complete: +Task: "Add main-session retrieval coverage for nested, unresolved, fallback, and conflicting links in tests/integration/get-session.test.ts" +Task: "Add list-session coverage for true-child-only linking and unresolved references in tests/integration/list-sessions.test.ts" +``` + +## Parallel Example: User Story 2 + +```bash +# After T015 is complete: +Task: "Implement structured JSON ambiguity/not-found responses and accepted agent identifier forms in src/cli/commands/view.ts and src/cli/utils/config.ts" +Task: "Add CLI view coverage for direct agent lookup, JSON error payloads, and ambiguity messaging in tests/integration/cli/view.test.ts and tests/unit/cli/commands/view.test.ts" +``` + +## Parallel Example: User Story 3 + +```bash +# After US1 and US2 contracts are stable: +Task: "Surface linked and unresolved agent metadata in human-readable session detail output in src/cli/formatters/session.ts" +Task: "Preserve linked and unresolved agent metadata in JSON and Markdown export headers in src/lib/export.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1 and Phase 2 +2. Complete Phase 3 (US1) +3. Validate main-session linking accuracy against nested, missing, fallback, and conflicting fixtures +4. Stop and demo the MVP if correct main-session linkage is the immediate need + +### Incremental Delivery + +1. Land recursive discovery, additive metadata, and ambiguity primitives +2. Deliver US1 so main-session linking becomes accurate +3. Deliver US2 so linked agent IDs become directly navigable +4. Deliver US3 so CLI/detail/export surfaces present the richer metadata without changing top-level list scope or breaking round-trip fidelity +5. Finish with flat-layout regression coverage, contract fixtures, performance validation, docs, and quickstart validation + +### Suggested MVP Scope + +- **MVP**: Phase 1, Phase 2, and Phase 3 (User Story 1) +- **Why**: It removes the core correctness bug by replacing guessed project-wide agent links with per-session child-agent resolution + +### Parallel Team Strategy + +With multiple developers: + +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 2 +3. After User Story 1 and User Story 2 stabilize shared metadata and lookup contracts: + - Developer C: User Story 3 +4. Finish with shared regression/documentation polish + +--- + +## Notes + +- Tasks marked `[P]` are safe parallel candidates because they target different files and do not depend on incomplete same-phase work. +- User story phases are intentionally scoped so each story can be validated independently after shared foundational work lands. +- Exact file paths are included in every task so the list can be executed without re-discovering the plan documents. diff --git a/src/cli/commands/view.ts b/src/cli/commands/view.ts index 7d83fb8..50e192a 100644 --- a/src/cli/commands/view.ts +++ b/src/cli/commands/view.ts @@ -7,6 +7,7 @@ import type { Command } from 'commander'; import { getSession, + isAmbiguousAgentSessionError, isSessionNotFoundError, isDataNotFoundError, filterMessages, @@ -19,6 +20,8 @@ import { resolveConfig, toLibraryConfig, parseSessionRef, + createJsonLookupErrorResult, + isDirectAgentLookupInput, } from '../utils/config.js'; import { notFoundError, ioError, usageError } from '../utils/errors.js'; import { successResult, output, handleError } from '../utils/output.js'; @@ -55,6 +58,22 @@ function parseFilterTypes(input: string): FilterableMessageType[] { return types.filter((t) => t !== '') as FilterableMessageType[]; } +function outputLookupJsonError( + type: 'ambiguous-agent-id' | 'session-not-found', + sessionArg: string +): void { + process.stdout.write( + JSON.stringify( + { + success: false, + error: createJsonLookupErrorResult(type, sessionArg), + }, + null, + 2 + ) + '\n' + ); +} + /** * Execute the view command */ @@ -63,7 +82,7 @@ async function executeView(sessionArg: string, options: ViewOptions): Promise\n\nProvide a session index (e.g., '0') or UUID." + 'Usage: cch view \n\nProvide a session index, session UUID, or agent ID.' ), options.json ); @@ -150,7 +169,28 @@ async function executeView(sessionArg: string, options: ViewOptions): Promise') - .description("View a session's contents") + .description('View a session by index, UUID, or agent ID') .option( '-o, --only ', 'Filter by message type (user,assistant,tool,thinking,error,progress)' diff --git a/src/cli/formatters/session.ts b/src/cli/formatters/session.ts index 86d1509..4eeda2b 100644 --- a/src/cli/formatters/session.ts +++ b/src/cli/formatters/session.ts @@ -294,6 +294,14 @@ function formatSessionHeader(session: Session, options?: SessionFormatOptions): lines.push(`Summary: ${session.summary}`); } + if (session.agentIds.length > 0) { + lines.push(`Linked Agent Sessions: ${session.agentIds.join(', ')}`); + } + + if (session.unresolvedAgentIds.length > 0) { + lines.push(`Unresolved Agent References: ${session.unresolvedAgentIds.join(', ')}`); + } + return lines.join('\n'); } diff --git a/src/cli/utils/config.ts b/src/cli/utils/config.ts index c139c60..7c4717a 100644 --- a/src/cli/utils/config.ts +++ b/src/cli/utils/config.ts @@ -36,6 +36,15 @@ export interface ResolvedCliConfig { paginate: boolean; } +/** + * Structured JSON error payload for direct agent lookup failures. + */ +export interface JsonLookupErrorResult { + type: 'ambiguous-agent-id' | 'session-not-found'; + agentId: string; + message: string; +} + /** * Resolve CLI configuration from global options, environment variables, and defaults. * @@ -94,3 +103,47 @@ export function parseSessionRef(input: string): number | string { // Treat as UUID (lib layer will validate) return trimmed; } + +/** + * Normalize a direct agent lookup identifier to its bare agent ID form. + */ +export function normalizeAgentLookupId(input: string): string { + const trimmed = input.trim(); + return trimmed.startsWith('agent-') ? trimmed.slice(6) : trimmed; +} + +/** + * Best-effort check for whether CLI input should be treated as a direct agent lookup. + */ +export function isDirectAgentLookupInput(input: string): boolean { + const trimmed = input.trim(); + + if (trimmed.startsWith('agent-')) { + return true; + } + + if (/^\d+$/.test(trimmed)) { + return false; + } + + return !trimmed.includes('-'); +} + +/** + * Create a structured JSON error payload for direct agent lookup failures. + */ +export function createJsonLookupErrorResult( + type: JsonLookupErrorResult['type'], + input: string +): JsonLookupErrorResult { + const agentId = normalizeAgentLookupId(input); + + return { + type, + agentId, + message: + type === 'ambiguous-agent-id' + ? `Agent ID is ambiguous: ${agentId}` + : `Session not found: ${agentId}`, + }; +} diff --git a/src/cli/utils/errors.ts b/src/cli/utils/errors.ts index 61e8819..c9e8dfb 100644 --- a/src/cli/utils/errors.ts +++ b/src/cli/utils/errors.ts @@ -32,6 +32,8 @@ export interface CommandError { code: ErrorCode; message: string; details?: string; + type?: 'ambiguous-agent-id' | 'session-not-found'; + agentId?: string; } /** @@ -42,7 +44,8 @@ export class CliError extends Error { message: string, public readonly exitCode: ExitCode, public readonly errorCode: ErrorCode, - public readonly details?: string + public readonly details?: string, + public readonly metadata?: Pick ) { super(message); this.name = 'CliError'; @@ -56,6 +59,8 @@ export class CliError extends Error { code: this.errorCode, message: this.message, ...(this.details && { details: this.details }), + ...(this.metadata?.type && { type: this.metadata.type }), + ...(this.metadata?.agentId && { agentId: this.metadata.agentId }), }; } } @@ -74,6 +79,26 @@ export function notFoundError(message: string, details?: string): CliError { return new CliError(message, ExitCode.NOT_FOUND, 'NOT_FOUND', details); } +/** + * Create a structured not found error for direct agent lookup JSON output. + */ +export function lookupNotFoundError(message: string, agentId: string, details?: string): CliError { + return new CliError(message, ExitCode.NOT_FOUND, 'NOT_FOUND', details, { + type: 'session-not-found', + agentId, + }); +} + +/** + * Create an ambiguity error for direct agent lookup JSON output. + */ +export function ambiguousLookupError(message: string, agentId: string, details?: string): CliError { + return new CliError(message, ExitCode.USAGE_ERROR, 'USAGE_ERROR', details, { + type: 'ambiguous-agent-id', + agentId, + }); +} + /** * Create an I/O error (file system issues) */ diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 0870b16..5aba4aa 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -44,6 +44,22 @@ export class DataNotFoundError extends Error { } } +/** + * Error thrown when a bare or prefixed agent ID matches more than one transcript. + */ +export class AmbiguousAgentSessionError extends Error { + readonly name = 'AmbiguousAgentSessionError' as const; + readonly agentId: string; + readonly matchingSessionPaths: string[]; + + constructor(agentId: string, matchingSessionPaths: string[]) { + super(`Agent ID is ambiguous: ${agentId}`); + this.agentId = agentId; + this.matchingSessionPaths = matchingSessionPaths; + Object.setPrototypeOf(this, AmbiguousAgentSessionError.prototype); + } +} + // ============================================================================= // Type Guards // ============================================================================= @@ -68,3 +84,10 @@ export function isWorkspaceNotFoundError(error: unknown): error is WorkspaceNotF export function isDataNotFoundError(error: unknown): error is DataNotFoundError { return error instanceof DataNotFoundError; } + +/** + * Check if error is an AmbiguousAgentSessionError. + */ +export function isAmbiguousAgentSessionError(error: unknown): error is AmbiguousAgentSessionError { + return error instanceof AmbiguousAgentSessionError; +} diff --git a/src/lib/export.ts b/src/lib/export.ts index 0e1c31b..80ab99b 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -211,6 +211,10 @@ function formatSessionHeader(session: Session): string { lines.push(`| Agent Sessions | ${session.agentIds.join(', ')} |`); } + if (session.unresolvedAgentIds.length > 0) { + lines.push(`| Unresolved Agent References | ${session.unresolvedAgentIds.join(', ')} |`); + } + lines.push('', '---', ''); return lines.join('\n'); diff --git a/src/lib/index.ts b/src/lib/index.ts index f4db28f..63c49fd 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -72,8 +72,10 @@ export { VALID_FILTER_TYPES } from './types.js'; export { SessionNotFoundError, + AmbiguousAgentSessionError, WorkspaceNotFoundError, DataNotFoundError, + isAmbiguousAgentSessionError, isSessionNotFoundError, isWorkspaceNotFoundError, isDataNotFoundError, diff --git a/src/lib/parser.ts b/src/lib/parser.ts index 204fd03..303c72b 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -260,6 +260,10 @@ function asRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null ? (value as Record) : null; } +function asNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + function extractMessageContent(value: unknown): unknown { const record = asRecord(value); if (!record) { @@ -505,3 +509,47 @@ export async function parseSessionMetadata( const metadata = extractMetadata(entries); return { data: metadata, warnings }; } + +// ============================================================================= +// Agent Link Extraction +// ============================================================================= + +function collectToolUseResultAgentId(value: unknown, agentIds: Set): void { + const record = asRecord(value); + const agentId = asNonEmptyString(record?.agentId); + + if (agentId) { + agentIds.add(agentId); + } +} + +/** + * Extract explicit agent IDs referenced by a main session's raw entries. + * + * The canonical Claude shape records Task/subagent results on `toolUseResult.agentId`. + * Some nested progress shapes mirror the same payload under `data.toolUseResult`. + */ +export function extractExplicitAgentIds(entries: RawSessionEntry[]): string[] { + const agentIds = new Set(); + + for (const entry of entries) { + collectToolUseResultAgentId(entry.toolUseResult, agentIds); + + const data = asRecord(entry.data); + collectToolUseResultAgentId(data?.toolUseResult, agentIds); + + const nestedMessages = Array.isArray(entry.normalizedMessages) + ? entry.normalizedMessages + : Array.isArray(data?.normalizedMessages) + ? data.normalizedMessages + : []; + + for (const nestedMessage of nestedMessages) { + const messageRecord = asRecord(nestedMessage); + collectToolUseResultAgentId(messageRecord?.toolUseResult, agentIds); + collectToolUseResultAgentId(asRecord(messageRecord?.message)?.toolUseResult, agentIds); + } + } + + return [...agentIds]; +} diff --git a/src/lib/platform.ts b/src/lib/platform.ts index a0fee30..dc4b833 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -3,7 +3,8 @@ */ import { homedir } from 'os'; -import { join } from 'path'; +import { readdir } from 'fs/promises'; +import { basename, join } from 'path'; /** * Get the default Claude Code data directory path for the current platform. @@ -101,6 +102,74 @@ export function extractAgentId(filename: string): string | null { if (!isAgentSessionFile(filename)) { return null; } + // Remove 'agent-' prefix and '.jsonl' suffix return filename.slice(6, -6); } + +/** + * Normalize path separators so path matching works across platforms and tests. + */ +function normalizePathForMatching(value: string): string { + return value.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +/** + * Recursively discover session files beneath a Claude project directory. + * Includes both flat root files and nested subagent files. + */ +export async function discoverProjectSessionFiles(projectDir: string): Promise { + const sessionFiles: string[] = []; + + async function walk(currentDir: string): Promise { + const entries = await readdir(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(currentDir, entry.name); + + if (entry.isDirectory()) { + await walk(fullPath); + continue; + } + + if (extractSessionId(entry.name)) { + sessionFiles.push(fullPath); + } + } + } + + await walk(projectDir); + return sessionFiles; +} + +/** + * Determine whether a discovered agent session file lives under a nested subagent directory. + */ +export function getNestedOwnerSessionId(projectDir: string, filePath: string): string | null { + const normalizedProjectDir = normalizePathForMatching(projectDir); + const normalizedFilePath = normalizePathForMatching(filePath); + + if (!normalizedFilePath.startsWith(`${normalizedProjectDir}/`)) { + return null; + } + + const relativePath = normalizedFilePath.slice(normalizedProjectDir.length + 1); + const match = relativePath.match(/^([^/]+)\/subagents\/agent-[^/]+\.jsonl$/); + const ownerSessionId = match?.[1] ?? null; + + return ownerSessionId && isUUID(ownerSessionId) ? ownerSessionId : null; +} + +/** + * Determine how an agent session file is stored. + */ +export function getAgentStorageLayout(projectDir: string, filePath: string): 'flat' | 'nested' { + return getNestedOwnerSessionId(projectDir, filePath) ? 'nested' : 'flat'; +} + +/** + * Extract a session filename from an absolute path. + */ +export function extractSessionIdFromPath(filePath: string): string | null { + return extractSessionId(basename(filePath)); +} diff --git a/src/lib/session.ts b/src/lib/session.ts index 90998b5..82f695f 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -22,13 +22,21 @@ import { resolveConfig, paginate, createPagination, type ResolvedConfig } from ' import { getProjectsPath, decodeProjectPath, - extractSessionId, - isAgentSessionFile, - extractAgentId, + discoverProjectSessionFiles, + extractSessionIdFromPath, isUUID, + getNestedOwnerSessionId, + getAgentStorageLayout, } from './platform.js'; -import { parseSessionFile, parseSessionMetadata } from './parser.js'; -import { DataNotFoundError, SessionNotFoundError } from './errors.js'; +import { + extractExplicitAgentIds, + extractMetadata, + parseJsonlFile, + parseSessionFile, + parseSessionMetadata, + type SessionMetadata, +} from './parser.js'; +import { AmbiguousAgentSessionError, DataNotFoundError, SessionNotFoundError } from './errors.js'; // ============================================================================= // Types @@ -42,9 +50,23 @@ interface SessionInfo { encodedPath: string; isAgent: boolean; agentId: string | null; + storageLayout: 'flat' | 'nested'; + nestedOwnerSessionId: string | null; modifiedTime: Date; } +interface MainSessionAnalysis { + metadata: SessionMetadata; + explicitAgentIds: string[]; +} + +interface LinkContext { + analysisBySessionId: Map; + agentSessionsById: Map; + explicitAgentOwners: Map>; + nestedAgentIdsByOwner: Map>; +} + // ============================================================================= // Session Discovery // ============================================================================= @@ -77,48 +99,42 @@ async function discoverSessions(config: ResolvedConfig): Promise const projectsPath = getProjectsPath(config.dataPath); const sessions: SessionInfo[] = []; - // Check if projects directory exists try { await stat(projectsPath); } catch { - // No projects directory = no sessions return []; } - // Read all project directories const projectDirs = await readdir(projectsPath); for (const encodedPath of projectDirs) { const projectDir = join(projectsPath, encodedPath); - // Skip if not a directory try { const dirStats = await stat(projectDir); - if (!dirStats.isDirectory()) continue; + if (!dirStats.isDirectory()) { + continue; + } } catch { continue; } - // Decode project path const projectPath = decodeProjectPath(encodedPath); - - // Apply workspace filter if specified if (config.workspace && projectPath !== config.workspace) { continue; } - // Read session files in this project - const files = await readdir(projectDir); + const filePaths = await discoverProjectSessionFiles(projectDir); - for (const filename of files) { - const sessionId = extractSessionId(filename); - if (!sessionId) continue; // Skip non-session files + for (const filePath of filePaths) { + const sessionId = extractSessionIdFromPath(filePath); + if (!sessionId) { + continue; + } - const filePath = join(projectDir, filename); - const isAgent = isAgentSessionFile(filename); - const agentId = isAgent ? extractAgentId(filename) : null; + const isAgent = sessionId.startsWith('agent-'); + const agentId = isAgent ? sessionId.slice(6) : null; - // Get file modification time for sorting try { const fileStats = await stat(filePath); @@ -129,10 +145,11 @@ async function discoverSessions(config: ResolvedConfig): Promise encodedPath, isAgent, agentId, + storageLayout: isAgent ? getAgentStorageLayout(projectDir, filePath) : 'flat', + nestedOwnerSessionId: isAgent ? getNestedOwnerSessionId(projectDir, filePath) : null, modifiedTime: fileStats.mtime, }); } catch { - // Skip files we can't stat continue; } } @@ -141,36 +158,173 @@ async function discoverSessions(config: ResolvedConfig): Promise return sessions; } -/** - * Find agent sessions linked to a main session. - * @param _mainSessionId - The main session ID (reserved for future use) - * @param allSessions - All discovered sessions - * @returns Array of agent IDs linked to this session - */ -function findLinkedAgentIds(_mainSessionId: string, allSessions: SessionInfo[]): string[] { - // Agent sessions in the same project directory with matching patterns - // For now, we return agent sessions from the same project - // A more sophisticated approach would parse the main session to find Task tool results - return allSessions - .filter((s): s is SessionInfo & { agentId: string } => s.isAgent && s.agentId !== null) - .map((s) => s.agentId); +function pushToArrayMap(map: Map, key: K, value: V): void { + const existing = map.get(key); + if (existing) { + existing.push(value); + return; + } + + map.set(key, [value]); } -// ============================================================================= -// Session Listing -// ============================================================================= +function pushToSetMap(map: Map>, key: K, value: V): void { + const existing = map.get(key); + if (existing) { + existing.add(value); + return; + } + + map.set(key, new Set([value])); +} + +function uniqueSorted(values: Iterable): string[] { + return [...new Set(values)].sort(); +} + +function sortSessionsByModifiedTime(sessions: SessionInfo[]): SessionInfo[] { + return [...sessions].sort((a, b) => b.modifiedTime.getTime() - a.modifiedTime.getTime()); +} + +async function analyzeMainSessions( + mainSessions: SessionInfo[] +): Promise> { + const analysisBySessionId = new Map(); + + for (const session of mainSessions) { + const { data: entries } = await parseJsonlFile(session.filePath); + analysisBySessionId.set(session.id, { + metadata: extractMetadata(entries), + explicitAgentIds: extractExplicitAgentIds(entries), + }); + } + + return analysisBySessionId; +} + +function buildLinkContext( + projectSessions: SessionInfo[], + analysisBySessionId: Map +): LinkContext { + const agentSessionsById = new Map(); + const explicitAgentOwners = new Map>(); + const nestedAgentIdsByOwner = new Map>(); + + for (const session of projectSessions) { + if (!session.isAgent || !session.agentId) { + continue; + } + + pushToArrayMap(agentSessionsById, session.agentId, session); + + if (session.nestedOwnerSessionId) { + pushToSetMap(nestedAgentIdsByOwner, session.nestedOwnerSessionId, session.agentId); + } + } + + for (const [sessionId, analysis] of analysisBySessionId.entries()) { + for (const agentId of analysis.explicitAgentIds) { + pushToSetMap(explicitAgentOwners, agentId, sessionId); + } + } + + return { + analysisBySessionId, + agentSessionsById, + explicitAgentOwners, + nestedAgentIdsByOwner, + }; +} + +async function getLinkContextForProject( + projectPath: string, + allSessions: SessionInfo[], + cache: Map +): Promise { + const cached = cache.get(projectPath); + if (cached) { + return cached; + } + + const projectSessions = allSessions.filter((session) => session.projectPath === projectPath); + const mainSessions = projectSessions.filter((session) => !session.isAgent); + const analysisBySessionId = await analyzeMainSessions(mainSessions); + const context = buildLinkContext(projectSessions, analysisBySessionId); + cache.set(projectPath, context); + return context; +} + +function resolveAgentLinks( + info: SessionInfo, + context: LinkContext +): { + agentIds: string[]; + unresolvedAgentIds: string[]; +} { + if (info.isAgent) { + return { agentIds: [], unresolvedAgentIds: [] }; + } + + const explicitAgentIds = context.analysisBySessionId.get(info.id)?.explicitAgentIds ?? []; + const agentIds = new Set(); + const unresolvedAgentIds = new Set(); + + for (const agentId of explicitAgentIds) { + if ((context.agentSessionsById.get(agentId) ?? []).length > 0) { + agentIds.add(agentId); + continue; + } + + unresolvedAgentIds.add(agentId); + } + + for (const agentId of context.nestedAgentIdsByOwner.get(info.id) ?? []) { + const explicitOwners = context.explicitAgentOwners.get(agentId); + if (explicitOwners && explicitOwners.size > 0 && !explicitOwners.has(info.id)) { + continue; + } + + agentIds.add(agentId); + unresolvedAgentIds.delete(agentId); + } + + return { + agentIds: uniqueSorted(agentIds), + unresolvedAgentIds: uniqueSorted(unresolvedAgentIds), + }; +} + +function buildSessionRecord( + info: SessionInfo, + messages: Message[], + metadata: SessionMetadata, + links: { agentIds: string[]; unresolvedAgentIds: string[] } +): Session { + return { + id: info.id, + encodedPath: info.encodedPath, + projectPath: info.projectPath, + summary: metadata.summary, + timestamp: metadata.firstTimestamp ?? info.modifiedTime, + lastActivityAt: metadata.lastTimestamp ?? info.modifiedTime, + messageCount: metadata.messageCount, + version: metadata.version, + gitBranch: metadata.gitBranch, + agentIds: links.agentIds, + unresolvedAgentIds: links.unresolvedAgentIds, + messages, + }; +} -/** - * Build a SessionSummary from session info and metadata. - */ async function buildSessionSummary( info: SessionInfo, - allSessions: SessionInfo[] + allSessions: SessionInfo[], + linkContextCache: Map ): Promise { - const { data: metadata } = await parseSessionMetadata(info.filePath); - - // Find linked agent sessions (for main sessions only) - const agentIds = info.isAgent ? [] : findLinkedAgentIds(info.id, allSessions); + const context = await getLinkContextForProject(info.projectPath, allSessions, linkContextCache); + const analysis = context.analysisBySessionId.get(info.id); + const metadata = analysis?.metadata ?? (await parseSessionMetadata(info.filePath)).data; + const links = resolveAgentLinks(info, context); return { id: info.id, @@ -180,55 +334,63 @@ async function buildSessionSummary( timestamp: metadata.firstTimestamp ?? info.modifiedTime, lastActivityAt: metadata.lastTimestamp ?? info.modifiedTime, messageCount: metadata.messageCount, - agentIds, + agentIds: links.agentIds, + unresolvedAgentIds: links.unresolvedAgentIds, }; } +function normalizeAgentLookupId(agentId: string): string { + return agentId.startsWith('agent-') ? agentId.slice(6) : agentId; +} + +function findAgentSessionMatches(agentId: string, allSessions: SessionInfo[]): SessionInfo[] { + const normalizedAgentId = normalizeAgentLookupId(agentId); + return allSessions.filter((session) => session.isAgent && session.agentId === normalizedAgentId); +} + +async function loadSessionRecord(info: SessionInfo, allSessions: SessionInfo[]): Promise { + const [{ data: messages }, { data: metadata }] = await Promise.all([ + parseSessionFile(info.filePath), + parseSessionMetadata(info.filePath), + ]); + + if (info.isAgent) { + return buildSessionRecord(info, messages, metadata, { + agentIds: [], + unresolvedAgentIds: [], + }); + } + + const context = await getLinkContextForProject(info.projectPath, allSessions, new Map()); + return buildSessionRecord(info, messages, metadata, resolveAgentLinks(info, context)); +} + +// ============================================================================= +// Session Listing +// ============================================================================= + /** * List all sessions with pagination. * * Sessions are sorted by most recent activity first (descending timestamp). * Agent sessions are excluded from the main list (they're linked via agentIds). - * - * @param config - Optional configuration for filtering and pagination - * @returns Paginated list of session summaries - * - * @example - * ```typescript - * // List first 10 sessions - * const result = await listSessions({ limit: 10 }); - * - * // List sessions for a specific workspace - * const result = await listSessions({ workspace: '/path/to/project' }); - * - * // Paginate through results - * const page2 = await listSessions({ limit: 10, offset: 10 }); - * ``` */ export async function listSessions( config?: LibraryConfig ): Promise> { const resolved = resolveConfig(config); - - // Validate data path exists await validateDataPath(resolved.dataPath); - // Discover all sessions const allSessions = await discoverSessions(resolved); - - // Filter out agent sessions from main list (they're accessed via agentIds) - const mainSessions = allSessions.filter((s) => !s.isAgent); - - // Sort by modification time descending (most recent first) - mainSessions.sort((a, b) => b.modifiedTime.getTime() - a.modifiedTime.getTime()); - - // Build summaries for paginated subset + const mainSessions = sortSessionsByModifiedTime( + allSessions.filter((session) => !session.isAgent) + ); const paginatedInfos = paginate(mainSessions, resolved); + const linkContextCache = new Map(); const summaries: SessionSummary[] = []; for (const info of paginatedInfos) { - const summary = await buildSessionSummary(info, allSessions); - summaries.push(summary); + summaries.push(await buildSessionSummary(info, allSessions, linkContextCache)); } return { @@ -242,147 +404,48 @@ export async function listSessions( // ============================================================================= /** - * Get a session by index or UUID. - * - * @param identifier - Zero-based index or session UUID - * @param config - Optional configuration - * @returns Full session with all messages - * @throws SessionNotFoundError if session doesn't exist - * - * @example - * ```typescript - * // Get most recent session by index - * const session = await getSession(0); - * - * // Get session by UUID - * const session = await getSession('abc123-def456-...'); - * - * // Get session from specific workspace - * const session = await getSession(0, { workspace: '/path/to/project' }); - * ``` + * Get a session by index, UUID, partial UUID, or agent identifier. */ export async function getSession( identifier: number | string, config?: LibraryConfig ): Promise { const resolved = resolveConfig(config); - - // Validate data path exists await validateDataPath(resolved.dataPath); - // Discover all sessions const allSessions = await discoverSessions(resolved); + const mainSessions = sortSessionsByModifiedTime( + allSessions.filter((session) => !session.isAgent) + ); - // Filter out agent sessions - const mainSessions = allSessions.filter((s) => !s.isAgent); - - // Sort by modification time descending (most recent first) - mainSessions.sort((a, b) => b.modifiedTime.getTime() - a.modifiedTime.getTime()); + if (typeof identifier === 'string' && identifier.startsWith('agent-')) { + return getAgentSession(identifier, config); + } let targetSession: SessionInfo | undefined; if (typeof identifier === 'number') { - // Get by index if (identifier < 0 || identifier >= mainSessions.length) { throw new SessionNotFoundError(identifier); } targetSession = mainSessions[identifier]; + } else if (isUUID(identifier)) { + targetSession = mainSessions.find((session) => session.id === identifier); + if (!targetSession) { + throw new SessionNotFoundError(identifier); + } } else { - // Get by UUID - auto-detect if it's a UUID or agent ID - if (isUUID(identifier)) { - targetSession = mainSessions.find((s) => s.id === identifier); - } else if (identifier.startsWith('agent-')) { - // Looking for an agent session - targetSession = allSessions.find((s) => s.id === identifier); - } else { - // Try partial match on UUID - targetSession = mainSessions.find((s) => s.id.startsWith(identifier)); + targetSession = mainSessions.find((session) => session.id.startsWith(identifier)); + if (!targetSession) { + return getAgentSession(identifier, config); } } - // Check if session was found if (!targetSession) { throw new SessionNotFoundError(identifier); } - // Parse full session - const { data: messages } = await parseSessionFile(targetSession.filePath); - - // Extract metadata from messages - const metadata = extractSessionMetadataFromMessages(messages, targetSession); - - // Find linked agent IDs - const agentIds = targetSession.isAgent ? [] : findLinkedAgentIds(targetSession.id, allSessions); - - return { - id: targetSession.id, - encodedPath: targetSession.encodedPath, - projectPath: targetSession.projectPath, - summary: metadata.summary, - timestamp: metadata.timestamp, - lastActivityAt: metadata.lastActivityAt, - messageCount: messages.filter( - (m) => m.type === 'user' || m.type === 'assistant' || m.type === 'progress' - ).length, - version: metadata.version, - gitBranch: metadata.gitBranch, - agentIds, - messages, - }; -} - -/** - * Extract metadata from parsed messages. - */ -function extractSessionMetadataFromMessages( - messages: Message[], - info: SessionInfo -): { - summary: string | null; - timestamp: Date; - lastActivityAt: Date; - version: string; - gitBranch: string | null; -} { - let summary: string | null = null; - const version = ''; - let gitBranch: string | null = null; - let firstTimestamp: Date | null = null; - let lastTimestamp: Date | null = null; - - for (const msg of messages) { - // Extract summary - if (msg.type === 'summary') { - summary = msg.summary; - } - - // Track timestamps - if (msg.type === 'user' || msg.type === 'assistant' || msg.type === 'progress') { - if (!firstTimestamp || msg.timestamp < firstTimestamp) { - firstTimestamp = msg.timestamp; - } - if (!lastTimestamp || msg.timestamp > lastTimestamp) { - lastTimestamp = msg.timestamp; - } - - // Extract git branch from user messages - if (msg.type === 'user') { - gitBranch ??= msg.gitBranch; - } - - if (msg.type === 'progress') { - gitBranch ??= msg.gitBranch; - } - } - } - - return { - summary, - timestamp: firstTimestamp ?? info.modifiedTime, - lastActivityAt: lastTimestamp ?? info.modifiedTime, - version, - gitBranch, - }; + return loadSessionRecord(targetSession, allSessions); } /** @@ -392,40 +455,32 @@ function extractSessionMetadataFromMessages( * @param config - Optional configuration * @returns Full agent session with all messages * @throws SessionNotFoundError if agent session doesn't exist + * @throws AmbiguousAgentSessionError if more than one transcript matches the same agent ID */ export async function getAgentSession(agentId: string, config?: LibraryConfig): Promise { - // Normalize agent ID format - const normalizedId = agentId.startsWith('agent-') ? agentId : `agent-${agentId}`; - const resolved = resolveConfig(config); await validateDataPath(resolved.dataPath); const allSessions = await discoverSessions(resolved); - const agentSession = allSessions.find((s) => s.id === normalizedId); + const matches = findAgentSessionMatches(agentId, allSessions); - if (!agentSession) { + if (matches.length === 0) { throw new SessionNotFoundError(agentId); } - // Parse full session - const { data: messages } = await parseSessionFile(agentSession.filePath); - const metadata = extractSessionMetadataFromMessages(messages, agentSession); + if (matches.length > 1) { + throw new AmbiguousAgentSessionError( + normalizeAgentLookupId(agentId), + matches.map((session) => session.filePath) + ); + } + + const agentSession = matches[0]; + if (!agentSession) { + throw new SessionNotFoundError(agentId); + } - return { - id: agentSession.id, - encodedPath: agentSession.encodedPath, - projectPath: agentSession.projectPath, - summary: metadata.summary, - timestamp: metadata.timestamp, - lastActivityAt: metadata.lastActivityAt, - messageCount: messages.filter( - (m) => m.type === 'user' || m.type === 'assistant' || m.type === 'progress' - ).length, - version: metadata.version, - gitBranch: metadata.gitBranch, - agentIds: [], - messages, - }; + return loadSessionRecord(agentSession, allSessions); } // ============================================================================= diff --git a/src/lib/types.ts b/src/lib/types.ts index 8fff73e..f21d057 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -59,6 +59,9 @@ export interface SessionSummary { /** Linked agent session IDs */ agentIds: string[]; + + /** Referenced agent IDs whose transcripts are not discoverable */ + unresolvedAgentIds: string[]; } /** @@ -431,6 +434,7 @@ export interface RawSessionEntry { isSidechain?: boolean; isMeta?: boolean; message?: RawMessage; + toolUseResult?: unknown; data?: unknown; normalizedMessages?: unknown[]; summary?: string; diff --git a/tests/fixtures/contracts/claude-main-session.jsonl b/tests/fixtures/contracts/claude-main-session.jsonl new file mode 100644 index 0000000..a4d3cf4 --- /dev/null +++ b/tests/fixtures/contracts/claude-main-session.jsonl @@ -0,0 +1,4 @@ +{"type":"summary","summary":"Anonymized Claude nested agent contract fixture","leafUuid":"contract-msg-003"} +{"type":"assistant","uuid":"contract-msg-001","parentUuid":null,"timestamp":"2026-03-13T08:00:00.000Z","sessionId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","isSidechain":false,"message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TH867z","name":"Agent","input":{"description":"Research Claude storage","prompt":"Research how Claude Code stores nested subagents","subagent_type":"Explore"}}],"stop_reason":"tool_use","usage":{"input_tokens":120,"output_tokens":8,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} +{"type":"user","uuid":"contract-msg-002","parentUuid":"contract-msg-001","timestamp":"2026-03-13T08:00:10.000Z","sessionId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","cwd":"/tmp/claude-contract","gitBranch":"main","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01TH867z","content":"Agent's final response"}]},"toolUseResult":{"status":"completed","agentId":"contract123","prompt":"Research how Claude Code stores nested subagents","content":[{"type":"text","text":"Agent's final response"}],"totalDurationMs":170119,"totalTokens":76496,"totalToolUseCount":18}} +{"type":"assistant","uuid":"contract-msg-003","parentUuid":"contract-msg-002","timestamp":"2026-03-13T08:00:20.000Z","sessionId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","isSidechain":false,"message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"Main session continued after agent completion."}],"stop_reason":"end_turn","usage":{"input_tokens":15,"output_tokens":22,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} diff --git a/tests/fixtures/contracts/claude-main-session/subagents/agent-linked.jsonl b/tests/fixtures/contracts/claude-main-session/subagents/agent-linked.jsonl new file mode 100644 index 0000000..eda3edb --- /dev/null +++ b/tests/fixtures/contracts/claude-main-session/subagents/agent-linked.jsonl @@ -0,0 +1,3 @@ +{"type":"summary","summary":"Anonymized Claude linked agent contract fixture","leafUuid":"contract-agent-002"} +{"type":"user","uuid":"contract-agent-001","parentUuid":null,"timestamp":"2026-03-13T08:00:11.000Z","sessionId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","agentId":"contract123","cwd":"/tmp/claude-contract","gitBranch":"main","version":"2.0.55","isSidechain":true,"message":{"role":"user","content":"Research how Claude Code stores nested subagents"}} +{"type":"assistant","uuid":"contract-agent-002","parentUuid":"contract-agent-001","timestamp":"2026-03-13T08:00:30.000Z","sessionId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","agentId":"contract123","message":{"model":"claude-haiku-4-5-20251001","role":"assistant","content":[{"type":"thinking","thinking":"Inspecting nested subagent layout and toolUseResult linkage..."},{"type":"text","text":"Nested subagents are linked by toolUseResult.agentId and persisted separately."}],"stop_reason":"end_turn","usage":{"input_tokens":80,"output_tokens":120,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} diff --git a/tests/fixtures/nested-agent-conflict-session.jsonl b/tests/fixtures/nested-agent-conflict-session.jsonl new file mode 100644 index 0000000..770d66b --- /dev/null +++ b/tests/fixtures/nested-agent-conflict-session.jsonl @@ -0,0 +1,3 @@ +{"type":"summary","summary":"Nested conflicting agent session","leafUuid":"agent-conflict-002"} +{"type":"user","uuid":"agent-conflict-001","parentUuid":null,"timestamp":"2026-04-02T11:00:06.000Z","sessionId":"22222222-2222-2222-2222-222222222222","agentId":"conflict999","cwd":"/tmp/agent-linking","gitBranch":"main","version":"2.0.55","isSidechain":true,"message":{"role":"user","content":"Conflicting nested session"}} +{"type":"assistant","uuid":"agent-conflict-002","parentUuid":"agent-conflict-001","timestamp":"2026-04-02T11:00:20.000Z","sessionId":"22222222-2222-2222-2222-222222222222","agentId":"conflict999","message":{"model":"claude-haiku-4-5-20251001","role":"assistant","content":[{"type":"text","text":"Conflict agent findings."}],"stop_reason":"end_turn","usage":{"input_tokens":18,"output_tokens":28,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} diff --git a/tests/fixtures/nested-agent-session.jsonl b/tests/fixtures/nested-agent-session.jsonl new file mode 100644 index 0000000..a51a373 --- /dev/null +++ b/tests/fixtures/nested-agent-session.jsonl @@ -0,0 +1,3 @@ +{"type":"summary","summary":"Nested linked agent session","leafUuid":"agent-linked-002"} +{"type":"user","uuid":"agent-linked-001","parentUuid":null,"timestamp":"2026-04-02T10:00:06.000Z","sessionId":"11111111-1111-1111-1111-111111111111","agentId":"linked123","cwd":"/tmp/agent-linking","gitBranch":"main","version":"2.0.55","isSidechain":true,"message":{"role":"user","content":"Inspect nested subagents"}} +{"type":"assistant","uuid":"agent-linked-002","parentUuid":"agent-linked-001","timestamp":"2026-04-02T10:00:20.000Z","sessionId":"11111111-1111-1111-1111-111111111111","agentId":"linked123","message":{"model":"claude-haiku-4-5-20251001","role":"assistant","content":[{"type":"text","text":"Nested agent findings."}],"stop_reason":"end_turn","usage":{"input_tokens":20,"output_tokens":30,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} diff --git a/tests/fixtures/nested-main-session.jsonl b/tests/fixtures/nested-main-session.jsonl new file mode 100644 index 0000000..08c9589 --- /dev/null +++ b/tests/fixtures/nested-main-session.jsonl @@ -0,0 +1,5 @@ +{"type":"summary","summary":"Nested agent parent session","leafUuid":"msg-parent-004"} +{"type":"user","uuid":"msg-parent-001","parentUuid":null,"timestamp":"2026-04-02T10:00:00.000Z","sessionId":"11111111-1111-1111-1111-111111111111","cwd":"/tmp/agent-linking","gitBranch":"main","version":"2.0.55","isSidechain":false,"message":{"role":"user","content":"Run a nested research task"}} +{"type":"assistant","uuid":"msg-parent-002","parentUuid":"msg-parent-001","timestamp":"2026-04-02T10:00:05.000Z","sessionId":"11111111-1111-1111-1111-111111111111","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"tool_use","id":"toolu_nested_001","name":"Agent","input":{"description":"Research nested session linking","prompt":"Inspect nested subagents","subagent_type":"Explore"}}],"stop_reason":"tool_use","usage":{"input_tokens":12,"output_tokens":7,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} +{"type":"user","uuid":"msg-parent-003","parentUuid":"msg-parent-002","timestamp":"2026-04-02T10:00:15.000Z","sessionId":"11111111-1111-1111-1111-111111111111","cwd":"/tmp/agent-linking","gitBranch":"main","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_nested_001","content":"Nested agent completed."}]},"toolUseResult":{"status":"completed","agentId":"linked123","content":[{"type":"text","text":"Nested agent completed."}],"totalDurationMs":1000,"totalTokens":321,"totalToolUseCount":4}} +{"type":"user","uuid":"msg-parent-004","parentUuid":"msg-parent-003","timestamp":"2026-04-02T10:00:25.000Z","sessionId":"11111111-1111-1111-1111-111111111111","cwd":"/tmp/agent-linking","gitBranch":"main","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_nested_missing","content":"Missing agent transcript."}]},"toolUseResult":{"status":"completed","agentId":"missing456","content":[{"type":"text","text":"Missing agent transcript."}],"totalDurationMs":500,"totalTokens":100,"totalToolUseCount":1}} diff --git a/tests/fixtures/performance/perf-main-session-001.jsonl b/tests/fixtures/performance/perf-main-session-001.jsonl new file mode 100644 index 0000000..2ea2d2e --- /dev/null +++ b/tests/fixtures/performance/perf-main-session-001.jsonl @@ -0,0 +1,3 @@ +{"type":"summary","summary":"Performance session 001","leafUuid":"perf-001-assistant"} +{"type":"user","uuid":"perf-001-user","parentUuid":null,"timestamp":"2026-04-02T00:00:01.000Z","sessionId":"bbbbbbbb-cccc-dddd-eeee-000000000001","cwd":"/tmp/performance-project","gitBranch":"main","version":"2.0.55","message":{"role":"user","content":"Performance fixture session 001"}} +{"type":"assistant","uuid":"perf-001-assistant","parentUuid":"perf-001-user","timestamp":"2026-04-02T00:00:02.000Z","sessionId":"bbbbbbbb-cccc-dddd-eeee-000000000001","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"Performance response 001"}],"stop_reason":"end_turn","usage":{"input_tokens":2,"output_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} diff --git a/tests/fixtures/performance/perf-main-session-101.jsonl b/tests/fixtures/performance/perf-main-session-101.jsonl new file mode 100644 index 0000000..5298882 --- /dev/null +++ b/tests/fixtures/performance/perf-main-session-101.jsonl @@ -0,0 +1,3 @@ +{"type":"summary","summary":"Performance session 101","leafUuid":"perf-101-assistant"} +{"type":"user","uuid":"perf-101-user","parentUuid":null,"timestamp":"2026-04-02T00:01:41.000Z","sessionId":"bbbbbbbb-cccc-dddd-eeee-000000000101","cwd":"/tmp/performance-project","gitBranch":"main","version":"2.0.55","message":{"role":"user","content":"Performance fixture session 101"}} +{"type":"assistant","uuid":"perf-101-assistant","parentUuid":"perf-101-user","timestamp":"2026-04-02T00:01:42.000Z","sessionId":"bbbbbbbb-cccc-dddd-eeee-000000000101","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"Performance response 101"}],"stop_reason":"end_turn","usage":{"input_tokens":2,"output_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} diff --git a/tests/helpers/agent-linking.ts b/tests/helpers/agent-linking.ts new file mode 100644 index 0000000..1cd568e --- /dev/null +++ b/tests/helpers/agent-linking.ts @@ -0,0 +1,98 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { dirname, join, resolve } from 'path'; + +export const FIXTURES_DIR = resolve(process.cwd(), 'tests', 'fixtures'); + +export interface TempClaudeData { + dataPath: string; + projectsPath: string; +} + +export async function createTempClaudeData(prefix: string): Promise { + const dataPath = await mkdtemp(join(tmpdir(), prefix)); + const projectsPath = join(dataPath, 'projects'); + await mkdir(projectsPath, { recursive: true }); + return { dataPath, projectsPath }; +} + +export async function cleanupTempClaudeData(dataPath: string): Promise { + await rm(dataPath, { recursive: true, force: true }); +} + +export async function ensureProjectDir( + projectsPath: string, + encodedProjectPath: string +): Promise { + const projectDir = join(projectsPath, encodedProjectPath); + await mkdir(projectDir, { recursive: true }); + return projectDir; +} + +export async function writeProjectSessionFile( + projectsPath: string, + encodedProjectPath: string, + relativePath: string, + content: string +): Promise { + const targetPath = join(projectsPath, encodedProjectPath, relativePath); + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, content); + return targetPath; +} + +export async function readFixture(relativePath: string): Promise { + return readFile(join(FIXTURES_DIR, relativePath), 'utf-8'); +} + +export function encodeProjectPathForTests(projectPath: string): string { + return projectPath.replace(/\//g, '-'); +} + +export function buildSimpleSessionJson( + sessionId: string, + projectPath: string, + summary: string, + timestamp: string +): string { + return [ + JSON.stringify({ + type: 'summary', + summary, + leafUuid: `${sessionId}-assistant`, + }), + JSON.stringify({ + type: 'user', + uuid: `${sessionId}-user`, + parentUuid: null, + timestamp, + sessionId, + cwd: projectPath, + gitBranch: 'main', + version: '2.0.55', + message: { + role: 'user', + content: `Prompt for ${summary}`, + }, + }), + JSON.stringify({ + type: 'assistant', + uuid: `${sessionId}-assistant`, + parentUuid: `${sessionId}-user`, + timestamp, + sessionId, + message: { + role: 'assistant', + model: 'claude-opus-4-5-20251101', + content: [{ type: 'text', text: `Response for ${summary}` }], + stop_reason: 'end_turn', + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }), + ].join('\n'); +} diff --git a/tests/integration/cli/view.test.ts b/tests/integration/cli/view.test.ts index ce0f220..c80baef 100644 --- a/tests/integration/cli/view.test.ts +++ b/tests/integration/cli/view.test.ts @@ -124,6 +124,69 @@ function createRawSession( return sessionId; } +function createNestedAgentSession( + projectPath: string, + ownerSessionId: string, + agentId: string, + contentText: string +): void { + const encodedPath = projectPath.replace(/\//g, '-'); + const subagentsDir = join(TEST_PROJECTS_DIR, encodedPath, ownerSessionId, 'subagents'); + mkdirSync(subagentsDir, { recursive: true }); + + const entries = [ + { + type: 'summary', + uuid: `summary-agent-${agentId}`, + parentUuid: null, + timestamp: new Date().toISOString(), + summary: `Agent session: ${agentId}`, + leafUuid: `agent-${agentId}-assistant`, + }, + { + type: 'user', + uuid: `agent-${agentId}-user`, + parentUuid: null, + timestamp: new Date().toISOString(), + sessionId: ownerSessionId, + agentId, + cwd: projectPath, + gitBranch: 'main', + version: '2.0.0', + isSidechain: true, + message: { + role: 'user', + content: `Prompt for ${agentId}`, + }, + }, + { + type: 'assistant', + uuid: `agent-${agentId}-assistant`, + parentUuid: `agent-${agentId}-user`, + timestamp: new Date().toISOString(), + sessionId: ownerSessionId, + agentId, + message: { + role: 'assistant', + model: 'claude-3-haiku', + content: [{ type: 'text', text: contentText }], + stop_reason: 'end_turn', + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }, + ]; + + writeFileSync( + join(subagentsDir, `agent-${agentId}.jsonl`), + entries.map(JSON.stringify).join('\n') + ); +} + /** * Execute CLI command and return stdout/stderr */ @@ -494,6 +557,100 @@ describe('cch view', () => { }); }); + describe('direct agent lookup', () => { + it('should open a linked nested agent transcript by bare agent ID', () => { + const ownerSessionId = createRawSession('/Users/dev/agent-project', 'agent-parent', [ + { + type: 'assistant', + uuid: 'msg-agent-parent-001', + parentUuid: null, + timestamp: new Date().toISOString(), + message: { + role: 'assistant', + model: 'claude-3-sonnet', + content: [ + { + type: 'tool_use', + id: 'toolu_agent_001', + name: 'Agent', + input: { + description: 'Inspect nested agents', + prompt: 'Inspect nested agents', + subagent_type: 'Explore', + }, + }, + ], + stop_reason: 'tool_use', + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }, + { + type: 'user', + uuid: 'msg-agent-parent-002', + parentUuid: 'msg-agent-parent-001', + timestamp: new Date().toISOString(), + cwd: '/Users/dev/agent-project', + gitBranch: 'main', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_agent_001', content: 'Done' }], + }, + toolUseResult: { + status: 'completed', + agentId: 'agentlookup123', + }, + }, + ]); + createNestedAgentSession( + '/Users/dev/agent-project', + ownerSessionId, + 'agentlookup123', + 'Nested agent transcript content' + ); + + const { stdout, exitCode } = runCli('view agentlookup123 --full'); + + expect(exitCode).toBe(0); + expect(stdout).toContain('Nested agent transcript content'); + }); + + it('should return structured JSON ambiguity errors for duplicate agent IDs', () => { + const firstOwner = createTestSession('/Users/dev/duplicate-a', 'duplicate-a', [ + { type: 'user', content: 'First duplicate parent' }, + ]); + const secondOwner = createTestSession('/Users/dev/duplicate-b', 'duplicate-b', [ + { type: 'user', content: 'Second duplicate parent' }, + ]); + createNestedAgentSession('/Users/dev/duplicate-a', firstOwner, 'dupagent777', 'First dup'); + createNestedAgentSession('/Users/dev/duplicate-b', secondOwner, 'dupagent777', 'Second dup'); + + const { stdout, exitCode } = runCli('view dupagent777 --json'); + const json = JSON.parse(stdout); + + expect(exitCode).not.toBe(0); + expect(json.success).toBe(false); + expect(json.error.type).toBe('ambiguous-agent-id'); + expect(json.error.agentId).toBe('dupagent777'); + }); + + it('should return structured JSON not-found errors for missing agent IDs', () => { + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Test' }]); + + const { stdout, exitCode } = runCli('view missing456 --json'); + const json = JSON.parse(stdout); + + expect(exitCode).not.toBe(0); + expect(json.success).toBe(false); + expect(json.error.type).toBe('session-not-found'); + expect(json.error.agentId).toBe('missing456'); + }); + }); + describe('error handling for invalid session (T025)', () => { it('should return error for non-existent index', () => { createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Test' }]); diff --git a/tests/integration/contract/claude-session-contract.test.ts b/tests/integration/contract/claude-session-contract.test.ts new file mode 100644 index 0000000..7450aef --- /dev/null +++ b/tests/integration/contract/claude-session-contract.test.ts @@ -0,0 +1,67 @@ +/** + * Contract tests for anonymized Claude JSONL session structures. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getAgentSession, getSession, listSessions } from '../../../src/lib/session.js'; +import { + cleanupTempClaudeData, + createTempClaudeData, + readFixture, + writeProjectSessionFile, +} from '../../helpers/agent-linking.js'; + +describe('anonymized Claude contract fixtures', () => { + let testDataPath = ''; + + beforeAll(async () => { + const tempData = await createTempClaudeData('claude-contract-'); + testDataPath = tempData.dataPath; + + await writeProjectSessionFile( + tempData.projectsPath, + '-tmp-claude-contract', + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl', + await readFixture('contracts/claude-main-session.jsonl') + ); + await writeProjectSessionFile( + tempData.projectsPath, + '-tmp-claude-contract', + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/subagents/agent-contract123.jsonl', + await readFixture('contracts/claude-main-session/subagents/agent-linked.jsonl') + ); + }); + + afterAll(async () => { + await cleanupTempClaudeData(testDataPath); + }); + + it('should discover nested contract fixtures and link the main session to its child agent', async () => { + const result = await listSessions({ dataPath: testDataPath }); + + expect(result.data).toHaveLength(1); + expect(result.data[0]?.agentIds).toEqual(['contract123']); + expect(result.data[0]?.unresolvedAgentIds).toEqual([]); + }); + + it('should retrieve the main session with linked agent metadata from the contract fixture', async () => { + const session = await getSession('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', { + dataPath: testDataPath, + }); + + expect(session.summary).toContain('Anonymized Claude nested agent contract fixture'); + expect(session.agentIds).toEqual(['contract123']); + }); + + it('should retrieve the linked agent transcript from the anonymized contract fixture', async () => { + const session = await getAgentSession('contract123', { dataPath: testDataPath }); + + expect(session.id).toBe('agent-contract123'); + expect(session.messages.some((message) => message.type === 'assistant')).toBe(true); + const firstUserMessage = session.messages.find((message) => message.type === 'user'); + expect(firstUserMessage?.type).toBe('user'); + if (firstUserMessage?.type === 'user') { + expect(firstUserMessage.isSidechain).toBe(true); + } + }); +}); diff --git a/tests/integration/export-sessions.test.ts b/tests/integration/export-sessions.test.ts index 52065c4..6a3c690 100644 --- a/tests/integration/export-sessions.test.ts +++ b/tests/integration/export-sessions.test.ts @@ -14,6 +14,12 @@ import { exportSession, exportAllSessions, } from '../../src/lib/export.js'; +import { + cleanupTempClaudeData, + createTempClaudeData, + readFixture, + writeProjectSessionFile, +} from '../helpers/agent-linking.js'; function createLargeExportSessionId(index: number): string { return `cccccccc-dddd-eeee-ffff-${(index + 1).toString(16).padStart(12, '0')}`; @@ -239,13 +245,13 @@ describe('export functions', () => { expect(markdown).toContain('function identity'); }); - it('should include agent sessions if available', async () => { + it('should not link unrelated flat agent sessions by project co-location alone', async () => { const markdown = await exportSessionToMarkdown(sessionUuid3, { dataPath: testDataPath, }); - expect(markdown).toContain('| Agent Sessions |'); - expect(markdown).toContain('abc123'); + expect(markdown).not.toContain('| Agent Sessions |'); + expect(markdown).not.toContain('abc123'); }); it('should render progress messages and metadata in markdown export', async () => { @@ -389,3 +395,58 @@ describe('export all unlimited behavior', () => { expect(markdown.match(/^# Bulk export session /gm)).toHaveLength(totalSessions); }); }); + +describe('export round-trip agent metadata', () => { + let testDataPath = ''; + + beforeAll(async () => { + const tempData = await createTempClaudeData('claude-export-linking-'); + testDataPath = tempData.dataPath; + + await writeProjectSessionFile( + tempData.projectsPath, + '-tmp-agent-linking', + '11111111-1111-1111-1111-111111111111.jsonl', + await readFixture('nested-main-session.jsonl') + ); + await writeProjectSessionFile( + tempData.projectsPath, + '-tmp-agent-linking', + '11111111-1111-1111-1111-111111111111/subagents/agent-linked123.jsonl', + await readFixture('nested-agent-session.jsonl') + ); + }); + + afterAll(async () => { + await cleanupTempClaudeData(testDataPath); + }); + + it('should preserve linked and unresolved agent metadata in JSON export round-trips', async () => { + const json = await exportSessionToJson('11111111-1111-1111-1111-111111111111', { + dataPath: testDataPath, + }); + const session = JSON.parse(json) as { + agentIds: string[]; + unresolvedAgentIds: string[]; + }; + + expect(session.agentIds).toEqual(['linked123']); + expect(session.unresolvedAgentIds).toEqual(['missing456']); + + const roundTripped = JSON.parse(JSON.stringify(session)) as { + agentIds: string[]; + unresolvedAgentIds: string[]; + }; + expect(roundTripped.agentIds).toEqual(['linked123']); + expect(roundTripped.unresolvedAgentIds).toEqual(['missing456']); + }); + + it('should surface unresolved agent references in Markdown export metadata', async () => { + const markdown = await exportSessionToMarkdown('11111111-1111-1111-1111-111111111111', { + dataPath: testDataPath, + }); + + expect(markdown).toContain('| Agent Sessions | linked123 |'); + expect(markdown).toContain('| Unresolved Agent References | missing456 |'); + }); +}); diff --git a/tests/integration/get-session.test.ts b/tests/integration/get-session.test.ts index 4c6bec8..834dddf 100644 --- a/tests/integration/get-session.test.ts +++ b/tests/integration/get-session.test.ts @@ -7,7 +7,17 @@ import { mkdir, writeFile, rm } from 'fs/promises'; import { join } from 'path'; import { tmpdir } from 'os'; import { getSession, getAgentSession } from '../../src/lib/session.js'; -import { SessionNotFoundError, DataNotFoundError } from '../../src/lib/errors.js'; +import { + AmbiguousAgentSessionError, + SessionNotFoundError, + DataNotFoundError, +} from '../../src/lib/errors.js'; +import { + createTempClaudeData, + cleanupTempClaudeData, + readFixture, + writeProjectSessionFile, +} from '../helpers/agent-linking.js'; describe('getSession', () => { const testDataPath = join(tmpdir(), `claude-get-test-${Date.now()}`); @@ -251,3 +261,185 @@ describe('getAgentSession', () => { ); }); }); + +describe('nested agent linking scenarios', () => { + let testDataPath = ''; + const encodedProjectPath = '-tmp-agent-linking'; + + beforeAll(async () => { + const tempData = await createTempClaudeData('claude-agent-linking-'); + testDataPath = tempData.dataPath; + + const nestedMain = await readFixture('nested-main-session.jsonl'); + const nestedAgent = await readFixture('nested-agent-session.jsonl'); + const nestedConflictAgent = await readFixture('nested-agent-conflict-session.jsonl'); + + await writeProjectSessionFile( + tempData.projectsPath, + encodedProjectPath, + '11111111-1111-1111-1111-111111111111.jsonl', + nestedMain + ); + await writeProjectSessionFile( + tempData.projectsPath, + encodedProjectPath, + '11111111-1111-1111-1111-111111111111/subagents/agent-linked123.jsonl', + nestedAgent + ); + + const fallbackMain = [ + '{"type":"summary","summary":"Fallback-only parent","leafUuid":"fallback-msg-002"}', + '{"type":"user","uuid":"fallback-msg-001","parentUuid":null,"timestamp":"2026-04-02T12:00:00.000Z","sessionId":"22222222-2222-2222-2222-222222222222","cwd":"/tmp/agent-linking","gitBranch":"main","version":"2.0.55","isSidechain":false,"message":{"role":"user","content":"Fallback-only session"}}', + '{"type":"assistant","uuid":"fallback-msg-002","parentUuid":"fallback-msg-001","timestamp":"2026-04-02T12:00:10.000Z","sessionId":"22222222-2222-2222-2222-222222222222","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"No explicit agent reference here."}],"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}', + ].join('\n'); + + const fallbackAgent = [ + '{"type":"summary","summary":"Fallback nested agent","leafUuid":"fallback-agent-002"}', + '{"type":"user","uuid":"fallback-agent-001","parentUuid":null,"timestamp":"2026-04-02T12:00:11.000Z","sessionId":"22222222-2222-2222-2222-222222222222","agentId":"fallback789","cwd":"/tmp/agent-linking","gitBranch":"main","version":"2.0.55","isSidechain":true,"message":{"role":"user","content":"Fallback nested agent"}}', + '{"type":"assistant","uuid":"fallback-agent-002","parentUuid":"fallback-agent-001","timestamp":"2026-04-02T12:00:20.000Z","sessionId":"22222222-2222-2222-2222-222222222222","agentId":"fallback789","message":{"model":"claude-haiku-4-5-20251001","role":"assistant","content":[{"type":"text","text":"Fallback linked agent."}],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":6,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}', + ].join('\n'); + + const explicitConflictMain = [ + '{"type":"summary","summary":"Explicit conflict parent","leafUuid":"conflict-main-002"}', + '{"type":"assistant","uuid":"conflict-main-001","parentUuid":null,"timestamp":"2026-04-02T13:00:00.000Z","sessionId":"33333333-3333-3333-3333-333333333333","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"tool_use","id":"toolu_conflict","name":"Agent","input":{"description":"Conflict agent","prompt":"Conflict agent","subagent_type":"Explore"}}],"stop_reason":"tool_use","usage":{"input_tokens":20,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}', + '{"type":"user","uuid":"conflict-main-002","parentUuid":"conflict-main-001","timestamp":"2026-04-02T13:00:05.000Z","sessionId":"33333333-3333-3333-3333-333333333333","cwd":"/tmp/agent-linking","gitBranch":"main","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_conflict","content":"Explicit conflict agent."}]},"toolUseResult":{"status":"completed","agentId":"conflict999","content":[{"type":"text","text":"Explicit conflict agent."}]}}', + ].join('\n'); + + const conflictingNestedOwnerMain = [ + '{"type":"summary","summary":"Nested owner without explicit reference","leafUuid":"conflict-owner-002"}', + '{"type":"user","uuid":"conflict-owner-001","parentUuid":null,"timestamp":"2026-04-02T13:00:10.000Z","sessionId":"44444444-4444-4444-4444-444444444444","cwd":"/tmp/agent-linking","gitBranch":"main","version":"2.0.55","isSidechain":false,"message":{"role":"user","content":"Owner by path only"}}', + '{"type":"assistant","uuid":"conflict-owner-002","parentUuid":"conflict-owner-001","timestamp":"2026-04-02T13:00:20.000Z","sessionId":"44444444-4444-4444-4444-444444444444","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"Path-only owner."}],"stop_reason":"end_turn","usage":{"input_tokens":8,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}', + ].join('\n'); + + await writeProjectSessionFile( + tempData.projectsPath, + encodedProjectPath, + '22222222-2222-2222-2222-222222222222.jsonl', + fallbackMain + ); + await writeProjectSessionFile( + tempData.projectsPath, + encodedProjectPath, + '22222222-2222-2222-2222-222222222222/subagents/agent-fallback789.jsonl', + fallbackAgent + ); + await writeProjectSessionFile( + tempData.projectsPath, + encodedProjectPath, + '33333333-3333-3333-3333-333333333333.jsonl', + explicitConflictMain + ); + await writeProjectSessionFile( + tempData.projectsPath, + encodedProjectPath, + '44444444-4444-4444-4444-444444444444.jsonl', + conflictingNestedOwnerMain + ); + await writeProjectSessionFile( + tempData.projectsPath, + encodedProjectPath, + '44444444-4444-4444-4444-444444444444/subagents/agent-conflict999.jsonl', + nestedConflictAgent.replaceAll( + '22222222-2222-2222-2222-222222222222', + '44444444-4444-4444-4444-444444444444' + ) + ); + + const duplicateProjectA = '-tmp-duplicate-a'; + const duplicateProjectB = '-tmp-duplicate-b'; + const duplicateMainA = [ + '{"type":"summary","summary":"Duplicate parent A","leafUuid":"dup-a-002"}', + '{"type":"user","uuid":"dup-a-001","parentUuid":null,"timestamp":"2026-04-02T14:00:00.000Z","sessionId":"55555555-5555-5555-5555-555555555555","cwd":"/tmp/duplicate-a","gitBranch":"main","version":"2.0.55","isSidechain":false,"message":{"role":"user","content":"Duplicate A"}}', + '{"type":"assistant","uuid":"dup-a-002","parentUuid":"dup-a-001","timestamp":"2026-04-02T14:00:05.000Z","sessionId":"55555555-5555-5555-5555-555555555555","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"Duplicate A done."}],"stop_reason":"end_turn","usage":{"input_tokens":4,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}', + ].join('\n'); + const duplicateMainB = duplicateMainA + .replaceAll('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666') + .replaceAll('/tmp/duplicate-a', '/tmp/duplicate-b') + .replaceAll('Duplicate A', 'Duplicate B'); + const duplicateAgent = (sessionId: string, projectPath: string): string => + [ + '{"type":"summary","summary":"Duplicate agent session","leafUuid":"dup-agent-002"}', + `{"type":"user","uuid":"dup-agent-001","parentUuid":null,"timestamp":"2026-04-02T14:00:06.000Z","sessionId":"${sessionId}","agentId":"duplicate777","cwd":"${projectPath}","gitBranch":"main","version":"2.0.55","isSidechain":true,"message":{"role":"user","content":"Duplicate agent"}}`, + `{"type":"assistant","uuid":"dup-agent-002","parentUuid":"dup-agent-001","timestamp":"2026-04-02T14:00:10.000Z","sessionId":"${sessionId}","agentId":"duplicate777","message":{"model":"claude-haiku-4-5-20251001","role":"assistant","content":[{"type":"text","text":"Duplicate agent response"}],"stop_reason":"end_turn","usage":{"input_tokens":2,"output_tokens":3,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}`, + ].join('\n'); + + await writeProjectSessionFile( + tempData.projectsPath, + duplicateProjectA, + '55555555-5555-5555-5555-555555555555.jsonl', + duplicateMainA + ); + await writeProjectSessionFile( + tempData.projectsPath, + duplicateProjectA, + '55555555-5555-5555-5555-555555555555/subagents/agent-duplicate777.jsonl', + duplicateAgent('55555555-5555-5555-5555-555555555555', '/tmp/duplicate-a') + ); + await writeProjectSessionFile( + tempData.projectsPath, + duplicateProjectB, + '66666666-6666-6666-6666-666666666666.jsonl', + duplicateMainB + ); + await writeProjectSessionFile( + tempData.projectsPath, + duplicateProjectB, + '66666666-6666-6666-6666-666666666666/subagents/agent-duplicate777.jsonl', + duplicateAgent('66666666-6666-6666-6666-666666666666', '/tmp/duplicate-b') + ); + }); + + afterAll(async () => { + await cleanupTempClaudeData(testDataPath); + }); + + it('should return discoverable and unresolved agent IDs separately', async () => { + const session = await getSession('11111111-1111-1111-1111-111111111111', { + dataPath: testDataPath, + }); + + expect(session.agentIds).toEqual(['linked123']); + expect(session.unresolvedAgentIds).toEqual(['missing456']); + }); + + it('should fall back to nested ownership when explicit agent references are missing', async () => { + const session = await getSession('22222222-2222-2222-2222-222222222222', { + dataPath: testDataPath, + }); + + expect(session.agentIds).toEqual(['fallback789']); + expect(session.unresolvedAgentIds).toEqual([]); + }); + + it('should prefer explicit references over conflicting nested ownership', async () => { + const explicitSession = await getSession('33333333-3333-3333-3333-333333333333', { + dataPath: testDataPath, + }); + const nestedOwnerSession = await getSession('44444444-4444-4444-4444-444444444444', { + dataPath: testDataPath, + }); + + expect(explicitSession.agentIds).toContain('conflict999'); + expect(nestedOwnerSession.agentIds).not.toContain('conflict999'); + }); + + it('should resolve direct agent lookup from bare and prefixed IDs', async () => { + const bareLookup = await getSession('linked123', { dataPath: testDataPath }); + const prefixedLookup = await getAgentSession('agent-linked123', { dataPath: testDataPath }); + + expect(bareLookup.id).toBe('agent-linked123'); + expect(prefixedLookup.id).toBe('agent-linked123'); + }); + + it('should report missing direct agent lookups as not found', async () => { + await expect(getSession('missing456', { dataPath: testDataPath })).rejects.toThrow( + SessionNotFoundError + ); + }); + + it('should report duplicate bare agent lookups as ambiguous', async () => { + await expect(getSession('duplicate777', { dataPath: testDataPath })).rejects.toThrow( + AmbiguousAgentSessionError + ); + }); +}); diff --git a/tests/integration/list-sessions.test.ts b/tests/integration/list-sessions.test.ts index 3611922..d784c8e 100644 --- a/tests/integration/list-sessions.test.ts +++ b/tests/integration/list-sessions.test.ts @@ -9,6 +9,12 @@ import { tmpdir } from 'os'; import { listSessions } from '../../src/lib/session.js'; import { DEFAULT_CONFIG } from '../../src/lib/config.js'; import { DataNotFoundError } from '../../src/lib/errors.js'; +import { + createTempClaudeData, + cleanupTempClaudeData, + readFixture, + writeProjectSessionFile, +} from '../helpers/agent-linking.js'; function createBulkSessionJson( sessionId: string, @@ -301,3 +307,76 @@ describe('listSessions unlimited behavior', () => { expect(result.pagination.hasMore).toBe(false); }); }); + +describe('listSessions nested agent linking', () => { + let testDataPath = ''; + + beforeAll(async () => { + const tempData = await createTempClaudeData('claude-list-linking-'); + testDataPath = tempData.dataPath; + + const nestedMain = await readFixture('nested-main-session.jsonl'); + const nestedAgent = await readFixture('nested-agent-session.jsonl'); + + await writeProjectSessionFile( + tempData.projectsPath, + '-tmp-agent-linking', + '11111111-1111-1111-1111-111111111111.jsonl', + nestedMain + ); + await writeProjectSessionFile( + tempData.projectsPath, + '-tmp-agent-linking', + '11111111-1111-1111-1111-111111111111/subagents/agent-linked123.jsonl', + nestedAgent + ); + + const unrelatedMain = [ + '{"type":"summary","summary":"Unrelated parent session","leafUuid":"unrelated-msg-002"}', + '{"type":"user","uuid":"unrelated-msg-001","parentUuid":null,"timestamp":"2026-04-02T15:00:00.000Z","sessionId":"77777777-7777-7777-7777-777777777777","cwd":"/tmp/agent-linking","gitBranch":"main","version":"2.0.55","isSidechain":false,"message":{"role":"user","content":"No agent references here"}}', + '{"type":"assistant","uuid":"unrelated-msg-002","parentUuid":"unrelated-msg-001","timestamp":"2026-04-02T15:00:10.000Z","sessionId":"77777777-7777-7777-7777-777777777777","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"Standalone session"}],"stop_reason":"end_turn","usage":{"input_tokens":3,"output_tokens":4,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}', + ].join('\n'); + const unrelatedAgent = [ + '{"type":"summary","summary":"Unrelated nested agent","leafUuid":"unrelated-agent-002"}', + '{"type":"user","uuid":"unrelated-agent-001","parentUuid":null,"timestamp":"2026-04-02T15:00:11.000Z","sessionId":"77777777-7777-7777-7777-777777777777","agentId":"orphan111","cwd":"/tmp/agent-linking","gitBranch":"main","version":"2.0.55","isSidechain":true,"message":{"role":"user","content":"Orphan nested agent"}}', + '{"type":"assistant","uuid":"unrelated-agent-002","parentUuid":"unrelated-agent-001","timestamp":"2026-04-02T15:00:20.000Z","sessionId":"77777777-7777-7777-7777-777777777777","agentId":"orphan111","message":{"model":"claude-haiku-4-5-20251001","role":"assistant","content":[{"type":"text","text":"Not linked to the other session"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}', + ].join('\n'); + + await writeProjectSessionFile( + tempData.projectsPath, + '-tmp-agent-linking', + '77777777-7777-7777-7777-777777777777.jsonl', + unrelatedMain + ); + await writeProjectSessionFile( + tempData.projectsPath, + '-tmp-agent-linking', + '77777777-7777-7777-7777-777777777777/subagents/agent-orphan111.jsonl', + unrelatedAgent + ); + }); + + afterAll(async () => { + await cleanupTempClaudeData(testDataPath); + }); + + it('should keep nested agent files out of top-level list rows', async () => { + const result = await listSessions({ dataPath: testDataPath }); + + expect(result.data.every((session) => !session.id.startsWith('agent-'))).toBe(true); + }); + + it('should include linked and unresolved agent metadata only for the correct main session', async () => { + const result = await listSessions({ dataPath: testDataPath }); + const nestedParent = result.data.find( + (session) => session.id === '11111111-1111-1111-1111-111111111111' + ); + const unrelatedParent = result.data.find( + (session) => session.id === '77777777-7777-7777-7777-777777777777' + ); + + expect(nestedParent?.agentIds).toEqual(['linked123']); + expect(nestedParent?.unresolvedAgentIds).toEqual(['missing456']); + expect(unrelatedParent?.agentIds).toEqual(['orphan111']); + }); +}); diff --git a/tests/integration/performance/session-lookup.test.ts b/tests/integration/performance/session-lookup.test.ts new file mode 100644 index 0000000..4284536 --- /dev/null +++ b/tests/integration/performance/session-lookup.test.ts @@ -0,0 +1,148 @@ +import { execFileSync, execSync } from 'child_process'; +import { join } from 'path'; +import { performance } from 'perf_hooks'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getSession, listSessions } from '../../../src/lib/session.js'; +import { + buildSimpleSessionJson, + cleanupTempClaudeData, + createTempClaudeData, + readFixture, + writeProjectSessionFile, +} from '../../helpers/agent-linking.js'; + +const CLI_PATH = join(process.cwd(), 'dist', 'cli', 'index.js'); +const PERFORMANCE_PROJECT = '-tmp-performance-project'; +const PERFORMANCE_WORKSPACE = '/tmp/performance-project'; +const TOTAL_MAIN_SESSIONS = 101; + +function buildPerformanceSessionId(index: number): string { + return `bbbbbbbb-cccc-dddd-eeee-${index.toString().padStart(12, '0')}`; +} + +function buildPerformanceSummary(index: number): string { + return `Performance session ${index.toString().padStart(3, '0')}`; +} + +function buildPerformanceTimestamp(index: number): string { + return new Date(Date.UTC(2026, 3, 2, 0, Math.floor(index / 60), index % 60)).toISOString(); +} + +function measure(fn: () => T): { result: T; elapsedMs: number } { + const startedAt = performance.now(); + const result = fn(); + return { result, elapsedMs: performance.now() - startedAt }; +} + +async function measureAsync(fn: () => Promise): Promise<{ result: T; elapsedMs: number }> { + const startedAt = performance.now(); + const result = await fn(); + return { result, elapsedMs: performance.now() - startedAt }; +} + +describe('session lookup performance', () => { + let testDataPath = ''; + + beforeAll(async () => { + execSync('npm run build', { encoding: 'utf-8', timeout: 60000 }); + + const tempData = await createTempClaudeData('claude-performance-'); + testDataPath = tempData.dataPath; + + const firstFixture = await readFixture('performance/perf-main-session-001.jsonl'); + const lastFixture = await readFixture('performance/perf-main-session-101.jsonl'); + + await writeProjectSessionFile( + tempData.projectsPath, + PERFORMANCE_PROJECT, + `${buildPerformanceSessionId(1)}.jsonl`, + firstFixture + ); + + for (let index = 2; index < TOTAL_MAIN_SESSIONS; index++) { + await writeProjectSessionFile( + tempData.projectsPath, + PERFORMANCE_PROJECT, + `${buildPerformanceSessionId(index)}.jsonl`, + buildSimpleSessionJson( + buildPerformanceSessionId(index), + PERFORMANCE_WORKSPACE, + buildPerformanceSummary(index), + buildPerformanceTimestamp(index) + ) + ); + } + + await writeProjectSessionFile( + tempData.projectsPath, + PERFORMANCE_PROJECT, + `${buildPerformanceSessionId(TOTAL_MAIN_SESSIONS)}.jsonl`, + lastFixture + ); + + await writeProjectSessionFile( + tempData.projectsPath, + '-tmp-agent-linking', + '11111111-1111-1111-1111-111111111111.jsonl', + await readFixture('nested-main-session.jsonl') + ); + await writeProjectSessionFile( + tempData.projectsPath, + '-tmp-agent-linking', + '11111111-1111-1111-1111-111111111111/subagents/agent-linked123.jsonl', + await readFixture('nested-agent-session.jsonl') + ); + }); + + afterAll(async () => { + await cleanupTempClaudeData(testDataPath); + }); + + it('should list 100+ main sessions within the acceptance budget', async () => { + const { result: sessions, elapsedMs } = await measureAsync(() => + listSessions({ dataPath: testDataPath }) + ); + + expect(sessions.pagination.total).toBe(TOTAL_MAIN_SESSIONS + 1); + expect(sessions.data.length).toBe(TOTAL_MAIN_SESSIONS + 1); + expect(elapsedMs).toBeLessThan(1000); + }); + + it('should resolve main and direct agent lookups within the acceptance budget', async () => { + const mainLookup = await measureAsync(() => + getSession(buildPerformanceSessionId(TOTAL_MAIN_SESSIONS), { dataPath: testDataPath }) + ); + const mainSession = mainLookup.result; + + expect(mainSession.id).toBe(buildPerformanceSessionId(TOTAL_MAIN_SESSIONS)); + expect(mainLookup.elapsedMs).toBeLessThan(1000); + + const agentLookup = await measureAsync(() => + getSession('linked123', { dataPath: testDataPath }) + ); + const agentSession = agentLookup.result; + + expect(agentSession.id).toBe('agent-linked123'); + expect(agentLookup.elapsedMs).toBeLessThan(1000); + }); + + it('should keep direct CLI agent lookup under one second on a 100+ session fixture', () => { + const { result, elapsedMs } = measure(() => + execFileSync('node', [CLI_PATH, '--data-path', testDataPath, 'view', 'linked123', '--json'], { + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }) + ); + const parsed = JSON.parse(result) as { + success: boolean; + data?: { + id: string; + }; + }; + + expect(parsed.success).toBe(true); + expect(parsed.data?.id).toBe('agent-linked123'); + expect(elapsedMs).toBeLessThan(1000); + }); +}); diff --git a/tests/unit/cli/commands/view.test.ts b/tests/unit/cli/commands/view.test.ts index 7217e74..67572b4 100644 --- a/tests/unit/cli/commands/view.test.ts +++ b/tests/unit/cli/commands/view.test.ts @@ -12,6 +12,7 @@ vi.mock('../../../../src/lib/index.js', async (importOriginal) => { return { ...actual, getSession: vi.fn(), + isAmbiguousAgentSessionError: vi.fn(), isSessionNotFoundError: vi.fn(), isDataNotFoundError: vi.fn(), filterMessages: vi.fn(), @@ -26,6 +27,7 @@ vi.mock('../../../../src/cli/formatters/pager.js', () => ({ import { getSession, + isAmbiguousAgentSessionError, isSessionNotFoundError, isDataNotFoundError, filterMessages, @@ -41,9 +43,16 @@ describe('view command', () => { const mockSession = { id: 'test-session-123', + encodedPath: '-test-project', projectPath: '/test/project', + summary: 'Test session', timestamp: new Date('2024-01-15T10:00:00Z'), + lastActivityAt: new Date('2024-01-15T10:00:05Z'), messageCount: 2, + version: '2.0.55', + gitBranch: 'main', + agentIds: [], + unresolvedAgentIds: [], messages: [ { type: 'user', @@ -52,6 +61,8 @@ describe('view command', () => { timestamp: new Date('2024-01-15T10:00:00Z'), content: 'Hello', cwd: '/test', + gitBranch: 'main', + isSidechain: false, }, { type: 'assistant', @@ -79,6 +90,7 @@ describe('view command', () => { // Reset mocks vi.mocked(getSession).mockReset(); + vi.mocked(isAmbiguousAgentSessionError).mockReset(); vi.mocked(isSessionNotFoundError).mockReset(); vi.mocked(isDataNotFoundError).mockReset(); vi.mocked(filterMessages).mockReset(); @@ -158,23 +170,25 @@ describe('view command', () => { it('should handle SessionNotFoundError', async () => { const error = new Error('Session not found'); vi.mocked(getSession).mockRejectedValue(error); + vi.mocked(isAmbiguousAgentSessionError).mockReturnValue(false); vi.mocked(isSessionNotFoundError).mockReturnValue(true); vi.mocked(isDataNotFoundError).mockReturnValue(false); await program.parseAsync(['node', 'test', 'view', '999']); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(processExitSpy).toHaveBeenCalledWith(3); }); it('should handle DataNotFoundError', async () => { const error = new Error('Data not found'); vi.mocked(getSession).mockRejectedValue(error); + vi.mocked(isAmbiguousAgentSessionError).mockReturnValue(false); vi.mocked(isSessionNotFoundError).mockReturnValue(false); vi.mocked(isDataNotFoundError).mockReturnValue(true); await program.parseAsync(['node', 'test', 'view', '0']); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(processExitSpy).toHaveBeenCalledWith(4); }); it('should reject invalid filter type', async () => { @@ -227,4 +241,36 @@ describe('view command', () => { expect.objectContaining({ only: ['progress'] }) ); }); + + it('should emit structured JSON for ambiguous direct agent lookups', async () => { + const error = new Error('Agent ID is ambiguous: dupagent777'); + vi.mocked(getSession).mockRejectedValue(error); + vi.mocked(isAmbiguousAgentSessionError).mockReturnValue(true); + vi.mocked(isSessionNotFoundError).mockReturnValue(false); + vi.mocked(isDataNotFoundError).mockReturnValue(false); + + await program.parseAsync(['node', 'test', 'view', 'dupagent777', '--json']); + + const output = String(stdoutSpy.mock.calls[0]?.[0] ?? ''); + const json = JSON.parse(output); + expect(json.success).toBe(false); + expect(json.error.type).toBe('ambiguous-agent-id'); + expect(json.error.agentId).toBe('dupagent777'); + }); + + it('should emit structured JSON for missing direct agent lookups', async () => { + const error = new Error('Session not found'); + vi.mocked(getSession).mockRejectedValue(error); + vi.mocked(isAmbiguousAgentSessionError).mockReturnValue(false); + vi.mocked(isSessionNotFoundError).mockReturnValue(true); + vi.mocked(isDataNotFoundError).mockReturnValue(false); + + await program.parseAsync(['node', 'test', 'view', 'missing456', '--json']); + + const output = String(stdoutSpy.mock.calls[0]?.[0] ?? ''); + const json = JSON.parse(output); + expect(json.success).toBe(false); + expect(json.error.type).toBe('session-not-found'); + expect(json.error.agentId).toBe('missing456'); + }); }); diff --git a/tests/unit/cli/formatters/session.test.ts b/tests/unit/cli/formatters/session.test.ts index 44bdad6..4fa0c83 100644 --- a/tests/unit/cli/formatters/session.test.ts +++ b/tests/unit/cli/formatters/session.test.ts @@ -25,6 +25,8 @@ function createTestSession(overrides?: Partial): Session { projectPath: '/Users/dev/test-project', timestamp: new Date('2024-01-15T10:30:00Z'), messageCount: 2, + agentIds: [], + unresolvedAgentIds: [], messages: [ { type: 'user', @@ -113,6 +115,20 @@ describe('formatSession', () => { expect(output).toContain('feature/new-feature'); }); + it('should include linked agent IDs when present', () => { + const session = createTestSession({ agentIds: ['linked123'] }); + const output = formatSession(session); + + expect(output).toContain('Linked Agent Sessions: linked123'); + }); + + it('should include unresolved agent references when present', () => { + const session = createTestSession({ unresolvedAgentIds: ['missing456'] }); + const output = formatSession(session); + + expect(output).toContain('Unresolved Agent References: missing456'); + }); + it('should format user messages with USER label', () => { const session = createTestSession(); const output = formatSession(session); diff --git a/tests/unit/parser.test.ts b/tests/unit/parser.test.ts index 282680a..7abc2b5 100644 --- a/tests/unit/parser.test.ts +++ b/tests/unit/parser.test.ts @@ -11,6 +11,7 @@ import { parseSessionFile, parseSessionMetadata, extractMetadata, + extractExplicitAgentIds, } from '../../src/lib/parser.js'; const FIXTURES_DIR = join(process.cwd(), 'tests', 'fixtures'); @@ -823,6 +824,60 @@ describe('extractMetadata', () => { }); }); +describe('extractExplicitAgentIds', () => { + it('should extract agent IDs from top-level toolUseResult payloads', () => { + const entries = [ + { + type: 'user', + uuid: 'msg-001', + toolUseResult: { + status: 'completed', + agentId: 'linked123', + }, + }, + { + type: 'user', + uuid: 'msg-002', + toolUseResult: { + status: 'completed', + agentId: 'missing456', + }, + }, + ]; + + expect(extractExplicitAgentIds(entries)).toEqual(['linked123', 'missing456']); + }); + + it('should deduplicate repeated agent IDs', () => { + const entries = [ + { + type: 'user', + uuid: 'msg-001', + toolUseResult: { agentId: 'linked123' }, + }, + { + type: 'user', + uuid: 'msg-002', + toolUseResult: { agentId: 'linked123' }, + }, + ]; + + expect(extractExplicitAgentIds(entries)).toEqual(['linked123']); + }); + + it('should ignore entries without agent metadata', () => { + const entries = [ + { + type: 'user', + uuid: 'msg-001', + toolUseResult: { stdout: 'no agent' }, + }, + ]; + + expect(extractExplicitAgentIds(entries)).toEqual([]); + }); +}); + describe('parseSessionMetadata', () => { it('should extract metadata from session file', async () => { const filePath = join(FIXTURES_DIR, 'sample-session.jsonl'); @@ -853,4 +908,12 @@ describe('parseSessionMetadata', () => { expect(result.data.summary).toBe('Progress repro'); expect(result.data.messageCount).toBe(4); }); + + it('should extract contract metadata from nested main-session fixtures', async () => { + const filePath = join(FIXTURES_DIR, 'contracts', 'claude-main-session.jsonl'); + const result = await parseSessionMetadata(filePath); + + expect(result.data.summary).toContain('Anonymized Claude nested agent contract fixture'); + expect(result.data.sessionId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + }); }); diff --git a/tests/unit/platform.test.ts b/tests/unit/platform.test.ts index fd625d8..82e18c7 100644 --- a/tests/unit/platform.test.ts +++ b/tests/unit/platform.test.ts @@ -14,7 +14,12 @@ import { extractSessionId, isAgentSessionFile, extractAgentId, + discoverProjectSessionFiles, + getNestedOwnerSessionId, + getAgentStorageLayout, } from '../../src/lib/platform.js'; +import { mkdir, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; describe('getDefaultDataPath', () => { it('should return path to ~/.claude', () => { @@ -145,3 +150,71 @@ describe('extractAgentId', () => { expect(extractAgentId('random.jsonl')).toBe(null); }); }); + +describe('nested agent discovery helpers', () => { + it('should extract nested owner session IDs for macOS/Linux-style paths', () => { + const projectDir = '/Users/test/.claude/projects/-Users-test-project'; + const filePath = + '/Users/test/.claude/projects/-Users-test-project/11111111-1111-1111-1111-111111111111/subagents/agent-linked123.jsonl'; + + expect(getNestedOwnerSessionId(projectDir, filePath)).toBe( + '11111111-1111-1111-1111-111111111111' + ); + expect(getAgentStorageLayout(projectDir, filePath)).toBe('nested'); + }); + + it('should extract nested owner session IDs for Windows-style paths', () => { + const projectDir = 'C:\\Users\\test\\.claude\\projects\\C-Users-test-project'; + const filePath = + 'C:\\Users\\test\\.claude\\projects\\C-Users-test-project\\22222222-2222-2222-2222-222222222222\\subagents\\agent-linked123.jsonl'; + + expect(getNestedOwnerSessionId(projectDir, filePath)).toBe( + '22222222-2222-2222-2222-222222222222' + ); + expect(getAgentStorageLayout(projectDir, filePath)).toBe('nested'); + }); + + it('should treat flat agent files as flat layout', () => { + const projectDir = '/Users/test/.claude/projects/-Users-test-project'; + const filePath = '/Users/test/.claude/projects/-Users-test-project/agent-flat123.jsonl'; + + expect(getNestedOwnerSessionId(projectDir, filePath)).toBe(null); + expect(getAgentStorageLayout(projectDir, filePath)).toBe('flat'); + }); + + it('should recursively discover flat and nested session files', async () => { + const projectDir = join(tmpdir(), `claude-platform-${Date.now()}`); + + await mkdir(join(projectDir, '11111111-1111-1111-1111-111111111111', 'subagents'), { + recursive: true, + }); + await writeFile( + join(projectDir, 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl'), + '{"type":"summary","summary":"main","leafUuid":"msg-1"}' + ); + await writeFile( + join(projectDir, 'agent-flat123.jsonl'), + '{"type":"summary","summary":"flat","leafUuid":"msg-1"}' + ); + await writeFile( + join( + projectDir, + '11111111-1111-1111-1111-111111111111', + 'subagents', + 'agent-linked123.jsonl' + ), + '{"type":"summary","summary":"nested","leafUuid":"msg-1"}' + ); + + const files = await discoverProjectSessionFiles(projectDir); + + expect(files).toHaveLength(3); + expect(files.some((file) => file.endsWith('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl'))).toBe( + true + ); + expect(files.some((file) => file.endsWith('agent-flat123.jsonl'))).toBe(true); + expect(files.some((file) => file.endsWith('agent-linked123.jsonl'))).toBe(true); + + await rm(projectDir, { recursive: true, force: true }); + }); +});