diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..1aae345 --- /dev/null +++ b/.npmignore @@ -0,0 +1,20 @@ +src/ +tests/ +specs/ +docs/ +.specify/ +.claude/ +coverage/ +node_modules/ +*.log +.env* +.DS_Store +Thumbs.db +.idea/ +.vscode/ +tsconfig.json +vitest.config.ts +eslint.config.js +CLAUDE.md +CLAUDE_CODE_DATA_STRUCTURE.md +AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..764cc05 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# claude-code-history Development Guidelines + +Auto-generated from all feature plans. Last updated: 2026-04-01 + +## Active Technologies + +- TypeScript 5.x with strict mode enabled + Commander.js (CLI framework), Node.js built-ins (`fs`, `path`, `readline`) (007-support-progress-messages) + +## Project Structure + +```text +src/ +tests/ +``` + +## Commands + +npm test && npm run lint + +## Code Style + +TypeScript 5.x with strict mode enabled: Follow standard conventions + +## Recent Changes + +- 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 5797814..3064b74 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 +- **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 - **Export** - Save sessions to JSON or Markdown format @@ -58,6 +59,7 @@ cch list --json ``` **Output:** + ``` IDX TIMESTAMP PATH BRANCH SUMMARY MSGS ──── ──────────────────── ────────────────────────────── ─────────────── ────────────────────────────── ───── @@ -67,6 +69,7 @@ cch list --json ``` **With `--stats` flag:** + ``` IDX TIMESTAMP PATH BRANCH SUMMARY MSGS ──── ──────────────────── ────────────────────────────── ─────────────── ────────────────────────────── ───── @@ -93,12 +96,16 @@ cch view 0 # View by UUID cch view abc123-def456-... +# View only progress messages +cch view 0 --only progress + # Output as JSON cch view 0 --json ``` **Output:** -``` + +```` # Session: Implement user authentication | Property | Value | @@ -129,17 +136,18 @@ I'll help you implement JWT authentication. Let me start by... → Result: ```typescript export function authenticate() { ... } -``` +```` ──────────────────────────────────────────────────────────────────────────────── Token Usage Summary - Input tokens: 500 - Output tokens: 1,200 - Cache read tokens: 15,000 - Cache creation tokens: 3,500 - Total tokens: 20,200 +Input tokens: 500 +Output tokens: 1,200 +Cache read tokens: 15,000 +Cache creation tokens: 3,500 +Total tokens: 20,200 ──────────────────────────────────────────────────────────────────────────────── -``` + +```` ### Search Sessions @@ -155,14 +163,18 @@ cch search "error" --context 3 # Search within a specific session cch search "bug fix" --session 0 +# Search for content that only appears in progress messages +cch search "AGENT_TASK_SCHEMA" + # Limit results cch search "TODO" --limit 20 # Output as JSON cch search "query" --json -``` +```` **Output:** + ``` Found 3 matches in 2 sessions @@ -198,6 +210,8 @@ 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. + ### Migrate Sessions Copy or move sessions between workspaces: @@ -238,11 +252,11 @@ cch -f view 0 ### Environment Variables -| Variable | Description | -|----------|-------------| -| `CCH_DATA_PATH` | Custom Claude Code data directory (overridden by `--data-path`) | -| `PAGER` | Custom pager command (default: `less` on Unix, `more` on Windows) | -| `NO_COLOR` | Disable colored output | +| Variable | Description | +| --------------- | ----------------------------------------------------------------- | +| `CCH_DATA_PATH` | Custom Claude Code data directory (overridden by `--data-path`) | +| `PAGER` | Custom pager command (default: `less` on Unix, `more` on Windows) | +| `NO_COLOR` | Disable colored output | ## Library Usage @@ -428,6 +442,7 @@ Claude Code stores conversation history in `~/.claude/projects/` as JSONL files. ``` Each session file contains JSON entries for: + - **Summary entries** - Session title/summary - **User messages** - User inputs - **Assistant messages** - Claude's responses with tool calls diff --git a/package-lock.json b/package-lock.json index f6fc3e1..5863e5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-history", - "version": "0.3.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-history", - "version": "0.3.1", + "version": "0.6.0", "license": "MIT", "dependencies": { "commander": "^14.0.2" diff --git a/package.json b/package.json index ad7ed4d..40cdfec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-history", - "version": "0.5.0", + "version": "0.6.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/007-support-progress-messages/checklists/requirements.md b/specs/007-support-progress-messages/checklists/requirements.md new file mode 100644 index 0000000..d0b6075 --- /dev/null +++ b/specs/007-support-progress-messages/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Support Progress Messages + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-01 +**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-01. +- No clarification markers remain; the spec is ready for `/speckit.plan`. diff --git a/specs/007-support-progress-messages/contracts/cli-interface.md b/specs/007-support-progress-messages/contracts/cli-interface.md new file mode 100644 index 0000000..cdda8c5 --- /dev/null +++ b/specs/007-support-progress-messages/contracts/cli-interface.md @@ -0,0 +1,218 @@ +# CLI Interface Contract: Support Progress Messages + +**Feature**: 007-support-progress-messages +**Date**: 2026-04-01 + +## Overview + +This contract defines the user-facing CLI behavior changes required to surface `progress` session entries in `cch search` and `cch view`. + +## Command: `search` + +### Existing Syntax + +```bash +cch search [options] +``` + +No new flags are introduced. The behavior change is additive: matches from `progress` messages are now included. + +### Updated Result Semantics + +- Global search MUST include matches from progress messages. +- Session-scoped search (`--session`) MUST include matches from progress messages. +- Human-readable output MUST label progress matches as `PROGRESS`. +- JSON output MUST preserve `messageType: "progress"` for progress matches. + +### Human-Readable Example + +```text +Found 1 matches for "AGENT_TASK_SCHEMA": + +──────────────────────────────────────────────────────────────────────────────── + +[my-project] Progress repro + Session: 99999999-9999-9999-9999-999999999999 + PROGRESS message, line 1 + +> 1 │ AGENT_TASK_SCHEMA found in tool progress output + +──────────────────────────────────────────────────────────────────────────────── +``` + +### JSON Example + +```json +{ + "success": true, + "data": { + "matches": [ + { + "sessionId": "99999999-9999-9999-9999-999999999999", + "sessionSummary": "Progress repro", + "projectPath": "/tmp/project-progress", + "messageUuid": "msg-progress", + "messageType": "progress", + "match": "AGENT_TASK_SCHEMA", + "context": ["AGENT_TASK_SCHEMA found in tool progress output"], + "lineNumber": 1 + } + ], + "pagination": { + "total": 1, + "offset": 0, + "limit": 1, + "hasMore": false + } + } +} +``` + +## Command: `view` + +### Updated Option: `--only` + +```text +-o, --only Filter by message type (user,assistant,tool,thinking,error,progress) +``` + +### Syntax + +```bash +cch view --only [,...] +``` + +### Valid Filter Values + +- `user` +- `assistant` +- `tool` +- `thinking` +- `error` +- `progress` + +### Examples + +```bash +# Show the full transcript, including progress messages +cch view 0 + +# Show only progress messages +cch view 0 --only progress + +# Show progress messages alongside tool invocations +cch view 0 --only tool,progress + +# Include progress messages in JSON output +cch view 0 --only progress --json +``` + +## View Output Behavior + +### Human-Readable Transcript + +- Progress messages appear in chronological order with the rest of the transcript. +- Progress messages are visually distinct from `USER` and `ASSISTANT` blocks. +- Header message counts include progress messages when present in the displayed transcript. + +### Human-Readable Example + +```text +Session: 99999999-9999-9999-9999-999999999999 +Project: /tmp/project-progress +Started: 2026-04-01 08:00:00 +Messages: 3 +Branch: main +Summary: Progress repro + +──────────────────────────────────────────────────────────────────────────────── + +[08:00:00] USER +regular visible message + +──────────────────────────────────────────────────────────────────────────────── + +[08:00:01] PROGRESS +Tool is scanning project files... + +──────────────────────────────────────────────────────────────────────────────── + +[08:00:02] ASSISTANT (claude-test) [2 tokens] +final assistant message + +──────────────────────────────────────────────────────────────────────────────── +``` + +### JSON Output + +- Progress messages appear in `messages[]` with `type: "progress"`. +- Filter metadata continues to reflect the requested filter list. +- `messageCount` reflects the number of messages returned in the output payload. + +### JSON Example + +```json +{ + "success": true, + "data": { + "id": "99999999-9999-9999-9999-999999999999", + "messageCount": 1, + "totalMessageCount": 3, + "filter": ["progress"], + "messages": [ + { + "type": "progress", + "uuid": "msg-progress", + "timestamp": "2026-04-01T00:00:01.000Z", + "content": [ + { + "type": "text", + "text": "Tool is scanning project files..." + } + ] + } + ] + } +} +``` + +## Error and Empty-State Handling + +### Invalid Filter Type + +```bash +$ cch view 0 --only invalid +``` + +```text +Error: Invalid filter type 'invalid' +Valid types: user, assistant, tool, thinking, error, progress +``` + +Exit code: non-zero + +### No Matching Progress Messages + +```bash +$ cch view 0 --only progress +``` + +If the selected session contains no progress messages, the command succeeds and returns an informative empty-state message, consistent with existing filter behavior. + +### Search With No Progress Matches + +If the query does not match any searchable content, including progress messages, `cch search` continues to return the existing “No matches found” response. + +## Option Interactions + +| Option Combination | Behavior | +|--------------------|----------| +| `view --only progress` | Shows only progress messages | +| `view --only tool,progress` | Shows tool invocations and progress messages | +| `view --only progress --json` | Returns only progress messages in JSON | +| `search --session ` | Searches progress and existing searchable message types within the target session | +| `search --json` | Returns progress matches with `messageType: "progress"` | + +## Help Text Impact + +Help text for `cch view` MUST include `progress` in the filter value list. No new flags are required for `cch search`, but user-visible examples and documentation should reflect that progress content is now searchable. diff --git a/specs/007-support-progress-messages/contracts/library-api.md b/specs/007-support-progress-messages/contracts/library-api.md new file mode 100644 index 0000000..e0ce66c --- /dev/null +++ b/specs/007-support-progress-messages/contracts/library-api.md @@ -0,0 +1,182 @@ +# Library API Contract: Support Progress Messages + +**Feature**: 007-support-progress-messages +**Date**: 2026-04-01 + +## Overview + +This contract defines the additive library API changes required so the `claude-code-history` package can preserve, search, filter, and export `progress` messages. + +## Updated Public Types + +### MessageType + +```typescript +export type MessageType = + | 'user' + | 'assistant' + | 'progress' + | 'summary' + | 'file-history-snapshot'; +``` + +### ProgressMessage + +```typescript +export interface ProgressMessage extends BaseMessage { + type: 'progress'; + content: ProgressContent[]; + cwd: string; + gitBranch: string | null; + isSidechain: boolean; +} +``` + +### ProgressContent + +```typescript +export interface ProgressTextContent { + type: 'text'; + text: string; +} + +export type ProgressContent = ProgressTextContent; +``` + +### Message + +```typescript +export type Message = + | UserMessage + | AssistantMessage + | ProgressMessage + | SummaryMessage + | FileHistorySnapshotMessage; +``` + +### FilterableMessageType + +```typescript +export type FilterableMessageType = + | 'user' + | 'assistant' + | 'tool' + | 'thinking' + | 'error' + | 'progress'; +``` + +### SearchMatch + +```typescript +export interface SearchMatch { + sessionId: string; + sessionSummary: string | null; + projectPath: string; + messageUuid: string; + messageType: 'user' | 'assistant' | 'progress'; + match: string; + context: string[]; + lineNumber: number; +} +``` + +## Updated Functions and Behaviors + +### parseSessionFile + +```typescript +export async function parseSessionFile(filePath: string): Promise>; +``` + +**Behavioral Contract**: +- Raw session entries with `type: "progress"` are preserved as `ProgressMessage`. +- Unknown top-level message types other than supported ones remain ignored defensively. +- Progress entries with non-readable or missing text do not throw and do not corrupt the parse result. + +### getSession / getAgentSession + +```typescript +export async function getSession(identifier: number | string, config?: LibraryConfig): Promise; +export async function getAgentSession(agentId: string, config?: LibraryConfig): Promise; +``` + +**Behavioral Contract**: +- Returned `session.messages` may include `ProgressMessage` entries. +- `session.messageCount` counts displayable transcript messages, including progress messages. +- `timestamp` and `lastActivityAt` reflect progress entries when they are the earliest/latest displayable transcript messages. + +### searchSessions / searchInSession + +```typescript +export async function searchSessions( + query: string, + config?: LibraryConfig +): Promise>; + +export async function searchInSession( + sessionId: string | number, + query: string, + config?: LibraryConfig +): Promise; +``` + +**Behavioral Contract**: +- Search includes readable text from `ProgressMessage`. +- Matches produced from progress content set `messageType: "progress"`. +- Context extraction and match metadata behave the same as existing user and assistant matches. + +### classifyMessage / filterMessages + +```typescript +export function classifyMessage(message: Message): FilterableMessageType[]; + +export function filterMessages( + messages: Message[], + options?: MessageFilterOptions +): Message[]; +``` + +**Behavioral Contract**: +- `classifyMessage(progressMessage)` returns `['progress']`. +- `filterMessages(..., { only: ['progress'] })` returns only progress messages. +- When no filter is provided, displayable messages include `user`, `assistant`, and `progress`. + +### 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 progress messages through the serialized session model. +- Markdown export renders progress messages explicitly instead of skipping them. + +## Public Re-Exports + +The library index MUST re-export: + +- `ProgressMessage` +- `ProgressContent` +- Updated `MessageType` +- Updated `SearchMatch` +- Updated `FilterableMessageType` + +## Backward Compatibility + +- All changes are additive from the public API perspective. +- Existing consumers that already switch exhaustively on `MessageType` or `SearchMatch.messageType` will need to handle the new `progress` case. +- Existing search and session APIs keep their function signatures unchanged. + +## Defensive Behavior + +- If a progress entry contains no readable content, it remains safe to parse and serialize. +- Unsupported future top-level message types remain excluded until explicitly modeled. diff --git a/specs/007-support-progress-messages/data-model.md b/specs/007-support-progress-messages/data-model.md new file mode 100644 index 0000000..d77d6d4 --- /dev/null +++ b/specs/007-support-progress-messages/data-model.md @@ -0,0 +1,174 @@ +# Data Model: Support Progress Messages + +**Feature**: 007-support-progress-messages +**Date**: 2026-04-01 + +## Overview + +This feature extends the existing session message model with a new displayable and searchable message category: `progress`. The goal is to preserve progress entries as typed session data rather than dropping them during parsing. + +## New and Updated Types + +### MessageType + +Extend the top-level session message discriminator to include progress: + +```typescript +type MessageType = + | 'user' + | 'assistant' + | 'progress' + | 'summary' + | 'file-history-snapshot'; +``` + +**Validation Rules**: +- `progress` is a first-class typed message, not an alias for `assistant` or `tool`. +- `summary` and `file-history-snapshot` remain non-displayable metadata entries. + +### ProgressMessage + +A normalized session message representing user-visible tool or task progress. + +```typescript +interface ProgressMessage extends BaseMessage { + type: 'progress'; + content: ProgressContent[]; + cwd: string; + gitBranch: string | null; + isSidechain: boolean; +} +``` + +**Field Notes**: +- `uuid`, `parentUuid`, and `timestamp` preserve timeline ordering and message identity. +- `content` stores only readable progress blocks that should participate in search and display. +- `cwd`, `gitBranch`, and `isSidechain` follow the existing session-envelope metadata pattern when present. + +### ProgressContent + +Normalized readable content extracted from raw progress payloads. + +```typescript +interface ProgressTextContent { + type: 'text'; + text: string; +} + +type ProgressContent = ProgressTextContent; +``` + +**Validation Rules**: +- Empty or non-readable raw blocks are ignored safely. +- Progress content is ordered and preserved as it appeared in the raw entry. +- Only normalized readable text blocks are required for search and human-readable transcript output. + +### SearchMatch + +Extend search results to identify progress matches explicitly. + +```typescript +interface SearchMatch { + sessionId: string; + sessionSummary: string | null; + projectPath: string; + messageUuid: string; + messageType: 'user' | 'assistant' | 'progress'; + match: string; + context: string[]; + lineNumber: number; +} +``` + +### FilterableMessageType + +Extend view filtering to include progress. + +```typescript +type FilterableMessageType = + | 'user' + | 'assistant' + | 'tool' + | 'thinking' + | 'error' + | 'progress'; +``` + +## Type Relationships + +```text +Message (union) +├── UserMessage +├── AssistantMessage +├── ProgressMessage +├── SummaryMessage +└── FileHistorySnapshotMessage + +ProgressMessage +├── appears in Session.messages +├── is searchable through searchSessions/searchInSession +├── is displayable in cch view output +├── is exportable in JSON and Markdown outputs +└── classifies as FilterableMessageType 'progress' +``` + +## Classification Rules + +| Source Type | Displayable | Searchable | Filter Classification | +|-------------|-------------|------------|-----------------------| +| `user` | Yes | Yes | `user` or `error` | +| `assistant` | Yes | Yes | `assistant`, `tool`, `thinking` | +| `progress` | Yes | Yes | `progress` | +| `summary` | No | No | None | +| `file-history-snapshot` | No | No | None | + +## Count Semantics + +### Session Message Counts + +- `Session.messageCount` counts all displayable transcript messages. +- After this feature, displayable transcript messages are `user`, `assistant`, and `progress`. +- `summary` and `file-history-snapshot` remain excluded from message totals. + +### Filtered Message Counts + +- Filtered counts include progress entries when they are part of the filtered result. +- Progress-only views count only `progress` messages. + +## Data Flow + +```text +Raw JSONL Entry + └── type: "progress" + ↓ +Parser normalization + └── ProgressMessage { content: ProgressContent[] } + ↓ +Session model + ├── getSession() + ├── listSessions() metadata counts + └── exportSessionToJson() + ↓ +Consumer surfaces + ├── searchSessions() / searchInSession() + ├── filterMessages(..., { only: ['progress'] }) + ├── cch view + └── exportSessionToMarkdown() +``` + +## Lifecycle / State Transitions + +This feature does not introduce mutable state. Progress entries move through a read-only lifecycle: + +1. Raw progress entry is read from JSONL. +2. Parser normalizes readable content into `ProgressMessage`. +3. Library functions include it in session/search/filter/export flows. +4. CLI formatters render it as transcript or search output. + +## Validation Requirements + +1. Unknown top-level entry types other than `progress` continue to be ignored defensively. +2. Progress messages with readable text produce searchable text blocks. +3. Progress messages with no readable text still parse safely and do not crash search/view/export. +4. Progress messages preserve ordering relative to user and assistant messages. +5. View filtering recognizes `progress` as a dedicated filter value. diff --git a/specs/007-support-progress-messages/plan.md b/specs/007-support-progress-messages/plan.md new file mode 100644 index 0000000..86cc40c --- /dev/null +++ b/specs/007-support-progress-messages/plan.md @@ -0,0 +1,107 @@ +# Implementation Plan: Support Progress Messages + +**Branch**: `007-support-progress-messages` | **Date**: 2026-04-01 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/007-support-progress-messages/spec.md` + +## Summary + +Add first-class support for `progress` session entries so they survive parsing, appear in session data returned by the library, are searchable through `cch search`, are viewable through `cch view`, and can be isolated with a dedicated `--only progress` filter. The implementation follows the existing library-first split: normalize progress entries in the lib layer, then extend CLI rendering and result labeling to surface them consistently while preserving full progress-entry fidelity across every supported output that surfaces them. + +## Technical Context + +**Language/Version**: TypeScript 5.x with strict mode enabled +**Primary Dependencies**: Commander.js (CLI framework), Node.js built-ins (`fs`, `path`, `readline`) +**Storage**: Local Claude Code JSONL session files under `~/.claude/projects/` +**Testing**: Vitest unit and integration tests +**Target Platform**: Node.js 20+ on macOS, Linux, and Windows +**Project Type**: Single project with library and CLI layers +**Performance Goals**: Search, view, and filtering operations on 100+ message sessions complete in under 1 second during acceptance validation, with no user-visible regression from adding one additional message type +**Constraints**: Non-destructive read operations only; no new runtime dependencies; preserve existing JSON and human-readable behavior for already-supported content except where progress support is additive; preserve full progress-entry fields, metadata, and relationships in every supported output that surfaces progress messages +**Scale/Scope**: Single message-type expansion across parser, message model, search, session filtering, CLI formatting, export fidelity, and regression tests + +## 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 expands existing `cch search` and `cch view` behavior and keeps JSON output support. | +| II. Non-Destructive Operations | ✅ Pass | Reads existing JSONL data only; no source mutation. | +| III. Cross-Platform Compatibility | ✅ Pass | Uses existing path handling and local file parsing abstractions. | +| IV. Library-First Architecture | ✅ Pass | Parsing, search, filtering, and message typing remain in `src/lib/`; CLI only consumes them. | +| V. Data Fidelity | ✅ Pass | The design requires progress-entry fields, metadata, and relationships to remain available in session models and every supported output that surfaces them, with explicit regression coverage. | + +**Technical Standards Compliance**: +- TypeScript strict mode remains in effect ✅ +- No new external runtime dependencies required ✅ +- Vitest unit and integration coverage will be extended for fidelity, empty/non-readable progress content, and 100+ message performance validation ✅ +- Public library API changes remain documented and additive ✅ + +### Post-Design Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. CLI-First Design | ✅ Pass | Design adds progress labeling to search/view and a dedicated `progress` filter value. | +| II. Non-Destructive Operations | ✅ Pass | Design is read-only across parser, search, view, and export surfaces. | +| III. Cross-Platform Compatibility | ✅ Pass | Design uses existing Node/filepath abstractions only. | +| IV. Library-First Architecture | ✅ Pass | New message type, filtering, and search extraction live in lib; CLI formatters/commands only render and validate. | +| V. Data Fidelity | ✅ Pass | In-memory session models and every supported output that surfaces progress messages retain progress-entry fields, metadata, and relationships instead of silently discarding them. | + +No constitution violations require justification. + +## Project Structure + +### Documentation (this feature) + +```text +specs/007-support-progress-messages/ +├── 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 progress message types and filter/search type updates +│ ├── parser.ts # Parse raw progress entries and preserve full progress fidelity +│ ├── session.ts # Session counts, filtering, and displayable-message logic +│ ├── search.ts # Search extraction and match typing for progress messages +│ ├── export.ts # Preserve progress messages and metadata in export formatting +│ └── index.ts # Export new public types +└── cli/ + ├── commands/ + │ └── view.ts # Dedicated progress filter validation and counts + └── formatters/ + ├── session.ts # Human-readable rendering for progress messages + └── search.ts # Search result labels for progress matches + +tests/ +├── unit/ +│ ├── parser.test.ts +│ ├── session.test.ts +│ ├── cli/commands/view.test.ts +│ └── cli/formatters/session.test.ts +└── integration/ + ├── get-session.test.ts + ├── search-sessions.test.ts + ├── export-sessions.test.ts + └── cli/ + ├── search.test.ts + └── view.test.ts +``` + +**Structure Decision**: Keep the existing single-project structure. Extend the library’s canonical message model first, then update CLI formatting/validation and regression tests around the existing search/view/export surfaces. Explicitly validate full fidelity in session objects, CLI-visible outputs, and export surfaces, including empty or non-readable progress entries. + +## Complexity Tracking + +No constitutional or architectural exceptions required. This feature is additive and fits the existing lib/cli split without new packages or new subsystems. diff --git a/specs/007-support-progress-messages/quickstart.md b/specs/007-support-progress-messages/quickstart.md new file mode 100644 index 0000000..da757e3 --- /dev/null +++ b/specs/007-support-progress-messages/quickstart.md @@ -0,0 +1,102 @@ +# Quickstart: Support Progress Messages + +**Feature**: 007-support-progress-messages +**Date**: 2026-04-01 + +## Overview + +This guide maps the progress-message feature onto the existing Claude Code History codebase so implementation can proceed with minimal rediscovery. + +## What We’re Building + +Support for top-level session entries with `type: "progress"` so they: + +- survive parsing into the typed session model, +- appear in `getSession()` results and exported session data, +- are searchable via `cch search`, +- render in `cch view`, +- and can be isolated with `cch view --only progress`. + +## Key Files To Modify + +| File | Purpose | Planned Change | +|------|---------|----------------| +| `src/lib/types.ts` | Canonical type model | Add `ProgressMessage`, widen `MessageType`, `Message`, `SearchMatch`, and filter unions | +| `src/lib/parser.ts` | Raw JSONL normalization | Parse `type: "progress"` entries and include them in metadata counts | +| `src/lib/session.ts` | Session retrieval/filtering | Count progress as displayable, classify it for filters, and keep activity timestamps accurate | +| `src/lib/search.ts` | Search extraction | Include progress text and emit `messageType: "progress"` | +| `src/lib/export.ts` | Export fidelity | Render progress messages in Markdown export; JSON export inherits typed session data | +| `src/lib/index.ts` | Public API surface | Re-export new types | +| `src/cli/commands/view.ts` | View command | Accept `progress` in `--only` validation and filtered counts | +| `src/cli/formatters/session.ts` | Transcript formatting | Add a distinct progress renderer | +| `src/cli/formatters/search.ts` | Search output labels | Show `PROGRESS` for progress matches | +| `tests/unit/parser.test.ts` | Parser regression | Add progress transformation coverage | +| `tests/unit/session.test.ts` | Filter/count logic | Add progress classification and displayable-count tests | +| `tests/integration/search-sessions.test.ts` | Library search | Verify progress-only terms become searchable | +| `tests/integration/cli/view.test.ts` | CLI transcript | Verify progress rendering and `--only progress` | +| `tests/integration/export-sessions.test.ts` | Export fidelity | Verify progress survives JSON and Markdown export | + +## Suggested Implementation Order + +### Step 1: Extend the Core Message Model + +- Add `ProgressMessage` and supporting content types. +- Widen the message unions and filter/search type unions. +- Re-export the new public types. + +### Step 2: Parse and Count Progress Messages + +- Add a `progress` branch in `transformEntry()`. +- Reuse existing readable-content extraction patterns where possible. +- Update metadata helpers so displayable counts and activity timestamps include progress. + +### Step 3: Make Progress Searchable + +- Add progress text extraction to the library search path. +- Widen search result typing and human-readable labels. +- Verify both all-session and session-scoped search paths. + +### Step 4: Surface Progress In View and Filtering + +- Add a dedicated `progress` filter value. +- Update displayable-message logic so progress survives default transcript rendering. +- Add a distinct transcript label for progress output. + +### Step 5: Preserve Progress In Exports + +- Ensure JSON export includes progress automatically through the session object. +- Add Markdown rendering so progress entries are not skipped. + +### Step 6: Lock In Behavior With Tests + +- Add synthetic fixtures or inline sessions containing a top-level `progress` entry. +- Cover parser, search, filtering, view, and export regressions. +- Re-run typecheck, unit tests, and CLI integration tests. + +## Example Synthetic Fixture Shape + +Use a minimal session with one progress entry between a user and assistant message: + +```json +{"type":"summary","summary":"Progress repro","leafUuid":"msg-assistant"} +{"type":"user","uuid":"msg-user","parentUuid":null,"timestamp":"2026-04-01T00:00:00.000Z","sessionId":"session-1","cwd":"/tmp/project","gitBranch":"main","version":"2.0.0","message":{"role":"user","content":"regular visible message"}} +{"type":"progress","uuid":"msg-progress","parentUuid":"msg-user","timestamp":"2026-04-01T00:00:01.000Z","sessionId":"session-1","cwd":"/tmp/project","gitBranch":"main","version":"2.0.0","message":{"role":"assistant","content":[{"type":"text","text":"PROGRESS_ONLY_TOKEN"}]}} +{"type":"assistant","uuid":"msg-assistant","parentUuid":"msg-progress","timestamp":"2026-04-01T00:00:02.000Z","sessionId":"session-1","message":{"role":"assistant","model":"claude-test","content":[{"type":"text","text":"final assistant message"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} +``` + +## Verification Commands + +```bash +npm run typecheck +npm test +node dist/cli/index.js --data-path search "PROGRESS_ONLY_TOKEN" --full +node dist/cli/index.js --data-path view 0 --only progress --full +``` + +## Expected Outcomes + +- Progress-only search terms are returned by both library and CLI search flows. +- `cch view` shows progress entries in chronological order. +- `cch view --only progress` works as a dedicated filtered transcript. +- Session counts shown to users align with displayed progress entries. +- Exported sessions no longer omit progress from the typed session model. diff --git a/specs/007-support-progress-messages/research.md b/specs/007-support-progress-messages/research.md new file mode 100644 index 0000000..0fdd82b --- /dev/null +++ b/specs/007-support-progress-messages/research.md @@ -0,0 +1,153 @@ +# Research: Support Progress Messages + +**Feature**: 007-support-progress-messages +**Date**: 2026-04-01 + +## Overview + +This document captures the decisions needed to add first-class `progress` message support to Claude Code History while preserving the project’s existing library-first and CLI-first architecture. + +## Research Areas + +### 1. Canonical Representation of Progress Entries + +**Decision**: Represent `progress` as a distinct top-level session message type rather than coercing it into `assistant` or `tool`. + +**Rationale**: +- The feature spec requires progress entries to remain identifiable in search, view, JSON output, and filtering. +- The current bug exists because unknown entry types are discarded before later layers can reason about them. +- A dedicated message type avoids ambiguous behavior in filters, match labels, and counts. + +**Alternatives Considered**: +- Treat progress as `assistant`: Rejected because it hides the distinction the user explicitly asked to preserve. +- Treat progress as `tool`: Rejected because progress text is not a tool invocation and should not be conflated with tool-use blocks. +- Leave progress as untyped raw JSON: Rejected because search, filtering, and formatting rely on typed message unions. + +### 2. Raw Payload Handling Strategy + +**Decision**: Parse progress entries defensively using the same human-readable block extraction approach already used for assistant content, while ignoring unknown or non-readable blocks safely. + +**Rationale**: +- The repository does not include a real Claude Code fixture with `type: "progress"`, so payload variants cannot be exhaustively enumerated from local fixtures alone. +- The reported bug and local reproduction both show a top-level `type: "progress"` entry containing user-visible content that must be searchable and renderable. +- Defensive parsing lets the library preserve readable progress text now without breaking if progress payloads vary slightly across Claude Code versions. + +**Alternatives Considered**: +- Refuse support until a real fixture is checked in: Rejected because the feature can be designed safely with defensive parsing plus targeted regression tests. +- Serialize raw progress payloads directly into output: Rejected because it would degrade readability and search context quality. + +### 3. Filtering Semantics + +**Decision**: Add a dedicated `progress` filter value and include it in mixed filter combinations. + +**Rationale**: +- The clarification session resolved this explicitly. +- Dedicated filtering keeps acceptance tests straightforward and prevents users from guessing whether progress belongs to `assistant` or `tool`. +- The existing filter model already supports additive filter categories, so this is a natural extension. + +**Alternatives Considered**: +- Fold progress into `assistant`: Rejected because progress is operational state, not assistant prose. +- Fold progress into `tool`: Rejected because progress is not a tool invocation result category. +- Show progress by default but make it unfilterable: Rejected because troubleshooting workflows need isolation. + +### 4. Search Result Semantics + +**Decision**: Extend search matching and result labeling to recognize `progress` as a searchable message type with the same session/message/context metadata as existing matches. + +**Rationale**: +- The feature’s primary user value is that terms appearing only in progress entries become discoverable through normal search flows. +- Search results are already organized around message source and context; adding `progress` preserves that pattern with minimal conceptual change. +- Dedicated labeling avoids falsely reporting progress matches as user or assistant matches. + +**Alternatives Considered**: +- Search progress but label it as assistant: Rejected because it obscures where the match came from. +- Search progress only in session-scoped searches: Rejected because the bug affects both global and scoped search paths. + +### 5. Counts and Displayable Message Rules + +**Decision**: Treat progress messages as displayable session messages for transcript output, filtered counts, and session message totals shown to users. + +**Rationale**: +- Once progress becomes visible in the transcript, counts that exclude it would be misleading. +- Current message totals and filtered totals are hard-coded around `user` and `assistant`, which is one of the concrete places the bug persists. +- Summary and file-history entries remain non-displayable, so the existing distinction between transcript content and metadata-only entries remains intact. + +**Alternatives Considered**: +- Keep session totals limited to user and assistant: Rejected because it would undercount displayed transcript items. +- Count all raw entry types: Rejected because summary and file-history snapshot entries are still not transcript messages. + +### 6. Export Surface Behavior + +**Decision**: Preserve progress messages in exported session data, with JSON export including them automatically through the session model and Markdown export rendering them explicitly. + +**Rationale**: +- Data fidelity is a constitutional principle; once the parser preserves progress messages, exports should not silently drop them. +- JSON export already serializes `Session.messages`, so supporting progress there is a direct consequence of the corrected model. +- Markdown export currently formats messages by explicit type and would otherwise remain incomplete or become non-exhaustive. + +**Alternatives Considered**: +- Limit support to search and view only: Rejected because it would reintroduce silent omission on another user-facing surface. +- Exclude progress from Markdown export: Rejected because it would create inconsistent transcript fidelity across outputs. + +### 7. Test Strategy + +**Decision**: Add synthetic progress fixtures and regression coverage across parser, session retrieval, search, view, and export tests. + +**Rationale**: +- No current fixture covers `progress`, so the bug is untested today. +- A synthetic fixture is sufficient to lock in the intended behavior even before a sanitized real-world progress fixture is available. +- The existing test suite already separates library and CLI integration paths, which matches the architectural split of the feature. + +**Alternatives Considered**: +- Only add parser tests: Rejected because the bug spans multiple layers after parsing. +- Only add CLI tests: Rejected because the root cause begins in the typed message model and parser. + +## Integration Points + +### Files to Modify + +1. `src/lib/types.ts` + - Add a `ProgressMessage` type and extend relevant unions. + - Extend searchable/filterable message-type unions to include progress where appropriate. + +2. `src/lib/parser.ts` + - Transform raw `type: "progress"` entries into typed messages. + - Update metadata counting rules for displayable messages. + +3. `src/lib/session.ts` + - Update session message counts and displayable-message filtering. + - Extend classification logic for the dedicated `progress` filter. + +4. `src/lib/search.ts` + - Include progress text in search extraction and search match metadata. + +5. `src/lib/export.ts` + - Render progress entries in Markdown exports. + +6. `src/lib/index.ts` + - Export any new public types or constants added for progress support. + +7. `src/cli/commands/view.ts` + - Accept and validate `progress` in `--only`. + - Ensure filtered counts align with the updated displayable-message rules. + +8. `src/cli/formatters/session.ts` + - Format progress entries distinctly in human-readable view output. + +9. `src/cli/formatters/search.ts` + - Label progress search matches correctly. + +10. `tests/...` + - Add regression coverage across parser, session retrieval, search, view, and export. + +## Performance Considerations + +- The feature does not add new I/O passes; it only stops discarding an existing entry type. +- Search and filtering remain linear in the number of parsed messages. +- The main performance requirement is to avoid measurable user-visible regression when sessions contain progress entries. + +## Open Uncertainty Managed by Design + +- No checked-in fixture currently demonstrates the exact raw progress payload from Claude Code. +- The chosen mitigation is defensive parsing of readable content plus synthetic regression fixtures. +- If a real anonymized progress fixture becomes available later, it should be added as a follow-up test asset rather than blocking this feature. diff --git a/specs/007-support-progress-messages/spec.md b/specs/007-support-progress-messages/spec.md new file mode 100644 index 0000000..0f68959 --- /dev/null +++ b/specs/007-support-progress-messages/spec.md @@ -0,0 +1,119 @@ +# Feature Specification: Support Progress Messages + +**Feature Branch**: `007-support-progress-messages` +**Created**: 2026-04-01 +**Status**: Draft +**Input**: User description: "cch currently drops type: progress entries during parsing, so their content never reaches search or view, which is why the bug is real. Add first-class support for progress messages in the parser and message model, then include them in text extraction and display/filter logic so cch search and cch view can surface their content." + +## Clarifications + +### Session 2026-04-01 + +- Q: How should progress filtering work in `cch view`? → A: Add a dedicated `progress` filter value for `cch view --only progress`. +- Q: How strict should progress-message fidelity be? → A: Preserve full fidelity of progress messages in all outputs. +- Q: What performance target should the feature meet? → A: Search, view, and filter operations on 100+ message sessions complete in under 1 second. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Search Progress Content (Priority: P1) + +As a developer searching past Claude Code sessions, I want `cch search` to return matches that appear only in progress messages so I can find all relevant session content without falling back to raw file inspection. + +**Why this priority**: The current gap breaks the core promise of search and forces users to bypass the product when the missing content only appears in progress updates. + +**Independent Test**: Can be fully tested by searching for a unique term that appears only inside a progress message and verifying the term is returned in both all-session search and single-session search. + +**Acceptance Scenarios**: + +1. **Given** a session where a search term appears only in a progress message, **When** the user runs `cch search ""`, **Then** the command returns that session as a match with readable context from the progress message. +2. **Given** a session where a search term appears only in a progress message, **When** the user runs `cch search "" --session `, **Then** the command returns the match from that session instead of reporting no results. +3. **Given** a search term appears in both progress messages and other message types, **When** the user runs a search, **Then** the output includes matches from all relevant message types. +4. **Given** a progress message that contains no human-readable text, **When** the user runs a search, **Then** the command completes successfully and does not invent a false match from unreadable progress content. + +--- + +### User Story 2 - View Progress Messages in Session History (Priority: P1) + +As a developer reviewing a session transcript, I want `cch view` to show progress messages in the conversation timeline so I can understand what happened during tool execution without opening the raw session file. + +**Why this priority**: Session review is incomplete if visible session activity is omitted from the main transcript, especially when progress updates contain file names, status text, or intermediate tool output. + +**Independent Test**: Can be fully tested by viewing a session containing progress messages and verifying that those messages appear in order in both the human-readable transcript and JSON output. + +**Acceptance Scenarios**: + +1. **Given** a session with progress messages between user and assistant messages, **When** the user runs `cch view `, **Then** the progress messages appear in chronological order at the correct point in the transcript. +2. **Given** a session with progress messages, **When** the user runs `cch view --json`, **Then** the returned message list includes the progress messages with their type preserved. +3. **Given** a session where the only content between two conversational turns is a progress message, **When** the session is viewed, **Then** the progress message is still shown rather than silently skipped. +4. **Given** a progress message with fields, metadata, and parent-child relationships, **When** the user views or exports the session in any supported output format, **Then** those progress-entry details remain present without silent omission. + +--- + +### User Story 3 - Filter Progress Activity During Review (Priority: P2) + +As a developer troubleshooting a session, I want progress messages to participate in session filtering so I can isolate progress activity or keep it included when narrowing the transcript. + +**Why this priority**: Once progress messages are visible, users need the same control over them that they already expect for other visible message categories. + +**Independent Test**: Can be tested by filtering a session that contains progress messages and verifying that progress messages can be included in filtered views and isolated on their own. + +**Acceptance Scenarios**: + +1. **Given** a session with progress messages and other message types, **When** the user runs `cch view --only progress`, **Then** only progress messages are shown. +2. **Given** a session with progress messages and other message types, **When** the user applies a filter that includes progress messages alongside other types, **Then** progress messages remain present in the filtered output. +3. **Given** a session with no progress messages, **When** the user runs `cch view --only progress`, **Then** the command returns an informative empty result rather than failing. + +### Edge Cases + +- What happens when a progress message contains no human-readable text? The system omits non-readable content from search results and displays an understandable placeholder or empty-safe output rather than failing. +- What happens when a progress message contains metadata but no human-readable text? The system preserves the message with full fidelity in all outputs even when there is no searchable text to display inline. +- What happens when a search term exists only in progress messages across all matching sessions? The search still returns those sessions instead of reporting zero matches. +- What happens when progress messages are interleaved with user, assistant, or tool-related messages? The view preserves original session order so the transcript remains accurate. +- What happens when `--only progress` is requested for a session with no progress messages? The user receives the same kind of informative empty-state response used for other filters. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST preserve progress messages when reading session history instead of discarding them. +- **FR-002**: The system MUST treat progress messages as first-class session messages with their own identifiable message type, fields, metadata, and relationships preserved. +- **FR-003**: `cch search` MUST include searchable text from progress messages when searching across all sessions. +- **FR-004**: `cch search --session ` MUST include searchable text from progress messages when searching within a single session. +- **FR-005**: Search results sourced from progress messages MUST identify the originating session, message, and context in the same way other searchable messages do. +- **FR-006**: `cch view ` MUST display progress messages in the correct chronological position within the session transcript. +- **FR-007**: Human-readable session output MUST visually distinguish progress messages from user messages and assistant responses while preserving full fidelity of the progress messages it surfaces. +- **FR-008**: `cch view --json` MUST include progress messages in the returned message list with their message type, fields, metadata, and relationships preserved. +- **FR-009**: Session filtering MUST support a dedicated `progress` filter value. +- **FR-010**: Session filtering MUST support including progress messages in filtered output when the `progress` filter value is requested alone or alongside other filter values. +- **FR-011**: Session-level message counts and filtered message counts presented to users MUST include progress messages when those messages are part of the displayed result set. +- **FR-012**: The system MUST handle progress messages with missing or non-searchable text without crashing or corrupting output, and MUST preserve those messages with full fidelity even when no searchable text is available. +- **FR-013**: Existing visible search and view behavior for already-supported session content MUST remain unchanged except for the addition of progress-message support. +- **FR-014**: All supported output formats that surface progress messages, including human-readable view output, JSON view output, and JSON and Markdown exports, MUST preserve full progress-message fields, metadata, and relationships without silent omission. + +### Key Entities *(include if feature involves data)* + +- **Progress Message**: A time-stamped session entry representing intermediate tool or task progress that contains user-visible status or output text and belongs in the session transcript. +- **Search Match**: A searchable result that links a matched term to its source session, source message, message type, and surrounding context. +- **Session Transcript**: The ordered set of viewable session messages that users inspect through `cch view`, including conversational and progress activity. + +## Assumptions + +- Progress messages are part of the user-visible history and should be included by default in both search and session viewing. +- Users benefit from being able to identify progress messages explicitly rather than having them merged into another message category. +- Progress-only filtering should follow the same empty-state behavior already used for other filtered views. +- Only human-readable portions of progress messages need to participate in full-text search, but non-searchable progress fields and metadata must still be preserved in all outputs. + +## Dependencies + +- Session histories already contain progress entries that users need to inspect through existing `cch search` and `cch view` workflows. +- Search results and session views remain the primary product surfaces for discovering and reviewing session content. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In acceptance testing, 100% of terms that appear only in progress messages are returned by both global search and session-scoped search. +- **SC-002**: In acceptance testing, 100% of progress messages present in a session appear in `cch view` output and in JSON session output in their original order. +- **SC-003**: Search, view, and filter operations on a 100+ message session complete in under 1 second during acceptance testing. +- **SC-004**: Standard search and view behavior for sessions that do not contain progress messages remains unchanged in user-facing acceptance tests. +- **SC-005**: In acceptance testing, 100% of progress-message fields, metadata, and relationships present in fixture data remain available in every supported output that surfaces the progress message, with no silent omission. diff --git a/specs/007-support-progress-messages/tasks.md b/specs/007-support-progress-messages/tasks.md new file mode 100644 index 0000000..bd1c725 --- /dev/null +++ b/specs/007-support-progress-messages/tasks.md @@ -0,0 +1,200 @@ +# Tasks: Support Progress Messages + +**Input**: Design documents from `/specs/007-support-progress-messages/` +**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), [data-model.md](./data-model.md), [contracts/](./contracts/) + +**Tests**: The feature spec does not request TDD-style test-first implementation, so implementation can precede tests within a phase. Each user story phase still includes explicit validation tasks so every increment remains independently testable. + +**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 a reusable progress-entry fixture for implementation and verification across the feature. + +- [x] T001 Create a reusable top-level progress-entry fixture in `tests/fixtures/progress-session.jsonl` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the typed progress-message model and core session plumbing required by all user stories. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T002 Add `ProgressMessage`, `ProgressContent`, widened `MessageType`, and widened `SearchMatch` definitions in `src/lib/types.ts` +- [x] T003 Implement raw `type: "progress"` normalization and full-fidelity metadata preservation in `src/lib/parser.ts` +- [x] T004 Update session retrieval counts and activity timestamps to treat progress as displayable transcript content in `src/lib/session.ts` +- [x] T005 Re-export progress-related public types from `src/lib/index.ts` +- [x] T006 Add foundational parser regression coverage for readable, empty, and non-readable progress entries in `tests/unit/parser.test.ts` + +**Checkpoint**: The library can now preserve progress entries in parsed session data, with progress counted as displayable transcript content. + +--- + +## Phase 3: User Story 1 - Search Progress Content (Priority: P1) 🎯 MVP + +**Goal**: Make terms that appear only in progress messages discoverable through both global and session-scoped search. + +**Independent Test**: Search for a unique term that exists only inside a progress message and confirm it is returned by both `cch search ""` and `cch search "" --session `. + +### Implementation for User Story 1 + +- [x] T007 [US1] Extend searchable progress-text extraction and match typing in `src/lib/search.ts` +- [x] T008 [US1] Surface `PROGRESS` search result labels in `src/cli/formatters/search.ts` +- [x] T009 [US1] Validate progress-only search results, session-scoped search, empty/non-readable search handling, and the 100+ message performance target in `tests/integration/search-sessions.test.ts` and `tests/integration/cli/search.test.ts` + +**Checkpoint**: User Story 1 is complete when progress-only terms become searchable through the existing CLI and library search flows. + +--- + +## Phase 4: User Story 2 - View Progress Messages in Session History (Priority: P1) + +**Goal**: Show progress messages in chronological transcript order in both human-readable and JSON session views. + +**Independent Test**: View a session containing progress entries and confirm `cch view ` shows them in order and `cch view --json` preserves them as `type: "progress"` messages. + +### Implementation for User Story 2 + +- [x] T010 [US2] Render progress transcript blocks distinctly while surfacing preserved progress metadata in `src/cli/formatters/session.ts` +- [x] T011 [US2] Keep `cch view` message totals and JSON filtered counts aligned with progress displayability and fidelity in `src/cli/commands/view.ts` +- [x] T012 [US2] Preserve full-fidelity progress entries in JSON and Markdown export rendering in `src/lib/export.ts` +- [x] T013 [US2] Validate session retrieval, human-readable view rendering, JSON view output, export fidelity, and empty/non-readable progress handling in `tests/integration/get-session.test.ts`, `tests/unit/cli/formatters/session.test.ts`, `tests/integration/cli/view.test.ts`, and `tests/integration/export-sessions.test.ts` + +**Checkpoint**: User Story 2 is complete when session views and transcript-style export no longer omit progress messages. + +--- + +## Phase 5: User Story 3 - Filter Progress Activity During Review (Priority: P2) + +**Goal**: Let users isolate progress activity with a dedicated `progress` filter value and combine it with other view filters. + +**Independent Test**: Run `cch view --only progress` on sessions with and without progress entries and confirm the filtered output and empty-state behavior are correct. + +### Implementation for User Story 3 + +- [x] T014 [US3] Add `progress` to filter type definitions and valid filter values in `src/lib/types.ts` +- [x] T015 [US3] Classify and retain progress messages during filtered session views in `src/lib/session.ts` +- [x] T016 [US3] Accept `progress` in view filter parsing and help text in `src/cli/commands/view.ts` +- [x] T017 [US3] Show progress-only filtered states and empty-result messaging in `src/cli/formatters/session.ts` +- [x] T018 [US3] Validate dedicated progress filtering, filtered counts, and empty/non-readable filtered output behavior in `tests/unit/cli/commands/view.test.ts` and `tests/integration/cli/view.test.ts` + +**Checkpoint**: User Story 3 is complete when progress messages can be isolated or combined with other filter values without breaking transcript output. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Lock in shared regression coverage, update documentation, and validate the end-to-end feature. + +- [x] T019 [P] Add shared session-count and transcript-order regression coverage for progress messages in `tests/unit/session.test.ts` +- [x] T020 [P] Update progress-aware command examples, fidelity guarantees, and performance expectations in `README.md` +- [x] T021 Run the implementation verification steps documented in `specs/007-support-progress-messages/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 2 and is best applied after Phase 4 when working sequentially, because it extends the same view surfaces +- **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; can run in parallel with US1 if staffed carefully +- **US3**: Independent after Foundational from a product perspective, but shares `view`-related files with US2 and is safest after US2 in a single-developer flow + +### Dependency Graph + +```text +Phase 1 Setup + ↓ +Phase 2 Foundational + ├── Phase 3 US1 + └── Phase 4 US2 + ↓ + Phase 5 US3 + ↓ + Phase 6 Polish +``` + +--- + +## Parallel Opportunities + +- `T019` and `T020` can run in parallel after story work is complete because they touch different validation and documentation files. +- After Phase 2, US1 and US2 can be implemented in parallel if different contributors coordinate around shared type changes already landed in Phase 2. +- Documentation update `T020` can be done while final shared regression coverage is being completed, once the final CLI behavior is stable. + +## Parallel Example: User Story 1 + +```bash +# After foundational work is merged: +Task: "Extend searchable progress-text extraction and match typing in src/lib/search.ts" +Task: "Surface PROGRESS search result labels in src/cli/formatters/search.ts" +``` + +## Parallel Example: User Story 2 + +```bash +# After foundational work is merged: +Task: "Render progress transcript blocks distinctly in src/cli/formatters/session.ts" +Task: "Preserve full-fidelity progress entries in JSON and Markdown export rendering in src/lib/export.ts" +``` + +## Parallel Example: User Story 3 + +```bash +# After User Story 2 stabilizes the view surface: +Task: "Add progress to filter type definitions and valid filter values in src/lib/types.ts" +Task: "Accept progress in view filter parsing and help text in src/cli/commands/view.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1 and Phase 2 +2. Complete Phase 3 (US1) +3. Validate progress-only search behavior end to end +4. Stop and demo the MVP if search is the only required increment + +### Incremental Delivery + +1. Land foundational parsing and session-model support +2. Deliver US1 so search stops missing progress content +3. Deliver US2 so session transcripts become complete +4. Deliver US3 so progress can be isolated during review +5. Finish with shared regression coverage, docs, and quickstart validation + +### Suggested MVP Scope + +- **MVP**: Phase 1, Phase 2, and Phase 3 (User Story 1) +- **Why**: It resolves the core bug reported by users: progress-only terms become searchable without raw-file inspection + +--- + +## 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 the shared foundational work lands. +- This feature package is renumbered to `007-support-progress-messages`; use that explicit path in follow-up Specify commands if needed. diff --git a/src/cli/commands/export.ts b/src/cli/commands/export.ts index f753f85..711b9fc 100644 --- a/src/cli/commands/export.ts +++ b/src/cli/commands/export.ts @@ -74,7 +74,7 @@ async function executeExport( // No session specified and --all not set const exitCode = handleError( new Error( - "Session identifier required. Provide a session index/UUID or use --all.\n\nUsage: cch export [options]\n cch export --all [options]" + 'Session identifier required. Provide a session index/UUID or use --all.\n\nUsage: cch export [options]\n cch export --all [options]' ), options.json ); @@ -153,15 +153,20 @@ export function registerExportCommand(program: Command): void { .option('-F, --format ', 'Export format: json or markdown', 'json') .option('-o, --output ', 'Output file path (stdout if not specified)') .option('-a, --all', 'Export all sessions', false) - .action(async (sessionArg: string | undefined, cmdOptions: Omit) => { - const globalOptions = program.opts() as GlobalOptions; - const options: ExportOptions = { ...globalOptions, ...cmdOptions }; + .action( + async ( + sessionArg: string | undefined, + cmdOptions: Omit + ) => { + const globalOptions = program.opts() as GlobalOptions; + const options: ExportOptions = { ...globalOptions, ...cmdOptions }; - try { - await executeExport(sessionArg, options); - } catch (error) { - const exitCode = handleError(error, options.json); - process.exit(exitCode); + try { + await executeExport(sessionArg, options); + } catch (error) { + const exitCode = handleError(error, options.json); + process.exit(exitCode); + } } - }); + ); } diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 5808395..c91f20d 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -14,11 +14,7 @@ import { addStats, type AggregateTokenStats, } from '../../lib/index.js'; -import { - type GlobalOptions, - resolveConfig, - toLibraryConfig, -} from '../utils/config.js'; +import { type GlobalOptions, resolveConfig, toLibraryConfig } from '../utils/config.js'; import { ioError } from '../utils/errors.js'; import { successResult, @@ -93,10 +89,7 @@ async function executeList(options: ListOptions): Promise { } const tableOutput = formatSessionTable(result.data, offset); - const paginationHint = formatPaginationHint( - toPaginationInfo(result.pagination), - 'sessions' - ); + const paginationHint = formatPaginationHint(toPaginationInfo(result.pagination), 'sessions'); // Add stats summary if --stats flag is set const statsOutput = aggregateStats ? '\n' + formatAggregateStats(aggregateStats) : ''; diff --git a/src/cli/commands/migrate.ts b/src/cli/commands/migrate.ts index a818cd6..eab45c8 100644 --- a/src/cli/commands/migrate.ts +++ b/src/cli/commands/migrate.ts @@ -51,11 +51,15 @@ function formatMigrateResult(result: MigrateResult, mode: 'copy' | 'move'): stri const lines: string[] = []; if (result.successCount > 0) { - lines.push(`${action} ${result.successCount} session${result.successCount !== 1 ? 's' : ''} successfully.`); + lines.push( + `${action} ${result.successCount} session${result.successCount !== 1 ? 's' : ''} successfully.` + ); } if (result.failedCount > 0) { - lines.push(`Failed to migrate ${result.failedCount} session${result.failedCount !== 1 ? 's' : ''}:`); + lines.push( + `Failed to migrate ${result.failedCount} session${result.failedCount !== 1 ? 's' : ''}:` + ); for (const error of result.errors) { lines.push(` - ${error.sessionId}: ${error.error}`); } @@ -87,7 +91,7 @@ async function executeMigrate( if (!options.destination) { const exitCode = handleError( new Error( - "Destination required. Use --destination or -D to specify the target workspace.\n\nUsage: cch migrate --destination \n cch migrate --all --source --destination " + 'Destination required. Use --destination or -D to specify the target workspace.\n\nUsage: cch migrate --destination \n cch migrate --all --source --destination ' ), options.json ); @@ -102,7 +106,7 @@ async function executeMigrate( if (!options.source) { const exitCode = handleError( new Error( - "Source workspace required when using --all.\n\nUsage: cch migrate --all --source --destination " + 'Source workspace required when using --all.\n\nUsage: cch migrate --all --source --destination ' ), options.json ); @@ -134,7 +138,7 @@ async function executeMigrate( // No session specified and --all not set const exitCode = handleError( new Error( - "Session identifier required. Provide a session index/UUID or use --all.\n\nUsage: cch migrate --destination \n cch migrate --all --source --destination " + 'Session identifier required. Provide a session index/UUID or use --all.\n\nUsage: cch migrate --destination \n cch migrate --all --source --destination ' ), options.json ); @@ -175,7 +179,7 @@ async function executeMigrate( const exitCode = handleError( notFoundError( `Workspace not found: ${options.source}`, - "Make sure the source workspace path exists and contains sessions." + 'Make sure the source workspace path exists and contains sessions.' ), options.json ); @@ -208,15 +212,20 @@ export function registerMigrateCommand(program: Command): void { .option('-S, --source ', 'Source workspace path (required with --all)') .option('-m, --mode ', 'Migration mode: copy or move', 'copy') .option('-a, --all', 'Migrate all sessions from source workspace', false) - .action(async (sessionArg: string | undefined, cmdOptions: Omit) => { - const globalOptions = program.opts() as GlobalOptions; - const options: MigrateOptions = { ...globalOptions, ...cmdOptions }; - - try { - await executeMigrate(sessionArg, options); - } catch (error) { - const exitCode = handleError(error, options.json); - process.exit(exitCode); + .action( + async ( + sessionArg: string | undefined, + cmdOptions: Omit + ) => { + const globalOptions = program.opts() as GlobalOptions; + const options: MigrateOptions = { ...globalOptions, ...cmdOptions }; + + try { + await executeMigrate(sessionArg, options); + } catch (error) { + const exitCode = handleError(error, options.json); + process.exit(exitCode); + } } - }); + ); } diff --git a/src/cli/commands/search.ts b/src/cli/commands/search.ts index 01f13e6..ce4933e 100644 --- a/src/cli/commands/search.ts +++ b/src/cli/commands/search.ts @@ -5,11 +5,7 @@ */ import type { Command } from 'commander'; -import { - searchSessions, - searchInSession, - isDataNotFoundError, -} from '../../lib/index.js'; +import { searchSessions, searchInSession, isDataNotFoundError } from '../../lib/index.js'; import { type GlobalOptions, resolveConfig, @@ -18,10 +14,7 @@ import { } from '../utils/config.js'; import { ioError } from '../utils/errors.js'; import { successResult, output, handleError } from '../utils/output.js'; -import { - formatSearchResults, - formatSearchResultsForJson, -} from '../formatters/search.js'; +import { formatSearchResults, formatSearchResultsForJson } from '../formatters/search.js'; import { outputWithPager } from '../formatters/pager.js'; /** @@ -37,10 +30,7 @@ interface SearchOptions extends GlobalOptions { /** * Execute the search command */ -async function executeSearch( - query: string, - options: SearchOptions -): Promise { +async function executeSearch(query: string, options: SearchOptions): Promise { const config = resolveConfig(options); const contextLines = parseInt(options.context, 10) || 2; const limit = options.limit !== undefined ? parseInt(options.limit, 10) : undefined; @@ -74,12 +64,7 @@ async function executeSearch( const commandResult = successResult(jsonData); output(commandResult, true); } else { - const formatted = formatSearchResults( - paginatedMatches, - query, - contextLines, - pagination - ); + const formatted = formatSearchResults(paginatedMatches, query, contextLines, pagination); await outputWithPager(formatted, options.full); } } else { @@ -91,12 +76,7 @@ async function executeSearch( const commandResult = successResult(jsonData); output(commandResult, true); } else { - const formatted = formatSearchResults( - result.data, - query, - contextLines, - result.pagination - ); + const formatted = formatSearchResults(result.data, query, contextLines, result.pagination); await outputWithPager(formatted, options.full); } } diff --git a/src/cli/commands/view.ts b/src/cli/commands/view.ts index a423a00..7d83fb8 100644 --- a/src/cli/commands/view.ts +++ b/src/cli/commands/view.ts @@ -58,10 +58,7 @@ function parseFilterTypes(input: string): FilterableMessageType[] { /** * Execute the view command */ -async function executeView( - sessionArg: string, - options: ViewOptions -): Promise { +async function executeView(sessionArg: string, options: ViewOptions): Promise { if (!sessionArg) { const exitCode = handleError( usageError( @@ -96,9 +93,8 @@ async function executeView( } const session = await getSession(sessionRef, libConfig); - const totalMessageCount = session.messages.filter( - (m) => m.type === 'user' || m.type === 'assistant' - ).length; + const defaultMessages = filterMessages(session.messages); + const totalMessageCount = defaultMessages.length; // Compute token statistics for the session const tokenStats = computeTokenStats(session.messages); @@ -106,7 +102,7 @@ async function executeView( // Apply filter if specified const filteredMessages = filterTypes ? filterMessages(session.messages, { only: filterTypes }) - : session.messages; + : defaultMessages; // Check for empty results with filter if (filterTypes && filteredMessages.length === 0) { @@ -189,7 +185,7 @@ export function registerViewCommand(program: Command): void { .description("View a session's contents") .option( '-o, --only ', - 'Filter by message type (user,assistant,tool,thinking,error)' + 'Filter by message type (user,assistant,tool,thinking,error,progress)' ) .action(async (sessionArg: string, cmdOptions: { only?: string }) => { const globalOptions = program.opts() as GlobalOptions; diff --git a/src/cli/formatters/pager.ts b/src/cli/formatters/pager.ts index d14c1f3..302fece 100644 --- a/src/cli/formatters/pager.ts +++ b/src/cli/formatters/pager.ts @@ -44,10 +44,7 @@ function isInteractive(): boolean { * @param full - Whether to bypass pagination (--full flag) * @returns Promise that resolves when output is complete */ -export async function outputWithPager( - content: string, - full: boolean -): Promise { +export async function outputWithPager(content: string, full: boolean): Promise { // Direct output if --full flag or not interactive if (full || !isInteractive()) { process.stdout.write(content); diff --git a/src/cli/formatters/search.ts b/src/cli/formatters/search.ts index f1aa0e9..067b232 100644 --- a/src/cli/formatters/search.ts +++ b/src/cli/formatters/search.ts @@ -6,6 +6,17 @@ import type { SearchMatch } from '../../lib/index.js'; +function getMessageTypeLabel(messageType: SearchMatch['messageType']): string { + switch (messageType) { + case 'user': + return 'USER'; + case 'assistant': + return 'ASSISTANT'; + case 'progress': + return 'PROGRESS'; + } +} + /** * Get a short display name for the project path */ @@ -27,11 +38,7 @@ function truncate(str: string, maxLength: number): string { /** * Format context lines with line numbers and highlighting */ -function formatContext( - context: string[], - matchLineNumber: number, - contextLines: number -): string { +function formatContext(context: string[], matchLineNumber: number, contextLines: number): string { if (context.length === 0) { return ''; } @@ -62,10 +69,8 @@ function formatMatch(match: SearchMatch, contextLines: number): string { // Header: session info const project = getProjectName(match.projectPath); - const summary = match.sessionSummary - ? truncate(match.sessionSummary, 50) - : '(no summary)'; - const role = match.messageType.toUpperCase(); + const summary = match.sessionSummary ? truncate(match.sessionSummary, 50) : '(no summary)'; + const role = getMessageTypeLabel(match.messageType); lines.push(`[${project}] ${summary}`); lines.push(` Session: ${match.sessionId}`); @@ -129,9 +134,7 @@ export function formatSearchResults( // Pagination hint if (pagination.hasMore) { parts.push(''); - parts.push( - `Use --offset ${pagination.offset + pagination.limit} to see more results.` - ); + parts.push(`Use --offset ${pagination.offset + pagination.limit} to see more results.`); } return parts.join('\n'); diff --git a/src/cli/formatters/session.ts b/src/cli/formatters/session.ts index 520ed1e..86d1509 100644 --- a/src/cli/formatters/session.ts +++ b/src/cli/formatters/session.ts @@ -10,6 +10,7 @@ import type { Message, UserMessage, AssistantMessage, + ProgressMessage, AssistantContent, ToolUseContent, TextContent, @@ -61,10 +62,7 @@ function formatDate(date: Date): string { /** * Format token usage for display */ -function formatTokenUsage(usage: { - inputTokens: number; - outputTokens: number; -}): string { +function formatTokenUsage(usage: { inputTokens: number; outputTokens: number }): string { const total = usage.inputTokens + usage.outputTokens; return `[${total.toLocaleString()} tokens]`; } @@ -87,10 +85,16 @@ function formatToolResult(result: ToolResultContent): string { if (content.length > 500) { // Truncate long results but show more than before const truncated = content.slice(0, 497) + '...'; - const indented = truncated.split('\n').map(line => ' ' + line).join('\n'); + const indented = truncated + .split('\n') + .map((line) => ' ' + line) + .join('\n'); lines.push(indented); } else { - const indented = content.split('\n').map(line => ' ' + line).join('\n'); + const indented = content + .split('\n') + .map((line) => ' ' + line) + .join('\n'); lines.push(indented); } @@ -100,21 +104,20 @@ function formatToolResult(result: ToolResultContent): string { /** * Format tool use content with its result (if available) */ -function formatToolUseWithResult( - content: ToolUseContent, - toolResults: ToolResultMap -): string { +function formatToolUseWithResult(content: ToolUseContent, toolResults: ToolResultMap): string { const lines: string[] = []; // Format the tool call const inputStr = JSON.stringify(content.input, null, 2); - const truncatedInput = - inputStr.length > 300 ? inputStr.slice(0, 297) + '...' : inputStr; + const truncatedInput = inputStr.length > 300 ? inputStr.slice(0, 297) + '...' : inputStr; lines.push(`[Tool: ${content.name}]`); // Add input parameters with indentation - const indentedInput = truncatedInput.split('\n').map(line => ' ' + line).join('\n'); + const indentedInput = truncatedInput + .split('\n') + .map((line) => ' ' + line) + .join('\n'); lines.push(indentedInput); // Add the result if we have it @@ -130,10 +133,7 @@ function formatToolUseWithResult( /** * Format a single content item from assistant message */ -function formatContentItem( - item: AssistantContent, - toolResults: ToolResultMap -): string { +function formatContentItem(item: AssistantContent, toolResults: ToolResultMap): string { if (item.type === 'text') { return (item as TextContent).text; } @@ -144,8 +144,7 @@ function formatContentItem( if (item.type === 'thinking') { const thinking = (item as ThinkingContent).thinking; - const preview = - thinking.length > 100 ? thinking.slice(0, 97) + '...' : thinking; + const preview = thinking.length > 100 ? thinking.slice(0, 97) + '...' : thinking; return `[Thinking] ${preview}`; } @@ -164,11 +163,7 @@ function isToolResultMessage(msg: UserMessage): boolean { } // Check if all content items are tool results return msg.content.every( - (item) => - item && - typeof item === 'object' && - 'type' in item && - item.type === 'tool_result' + (item) => item && typeof item === 'object' && 'type' in item && item.type === 'tool_result' ); } @@ -188,9 +183,7 @@ function formatUserContent(content: string | ToolResultContent[]): string { if (item.type === 'tool_result') { // Fallback formatting if we somehow get here const preview = - item.content.length > 200 - ? item.content.slice(0, 197) + '...' - : item.content; + item.content.length > 200 ? item.content.slice(0, 197) + '...' : item.content; return `[Tool Result] ${preview}`; } return JSON.stringify(item); @@ -213,22 +206,38 @@ function formatUserMessage(msg: UserMessage): string { /** * Format an assistant message for display */ -function formatAssistantMessage( - msg: AssistantMessage, - toolResults: ToolResultMap -): string { +function formatAssistantMessage(msg: AssistantMessage, toolResults: ToolResultMap): string { const time = formatTime(msg.timestamp); const model = msg.model || 'assistant'; const tokens = formatTokenUsage(msg.usage); - const contentParts = msg.content.map((item) => - formatContentItem(item, toolResults) - ); + const contentParts = msg.content.map((item) => formatContentItem(item, toolResults)); const content = contentParts.join('\n\n'); return `[${time}] ASSISTANT (${model}) ${tokens}\n${content}`; } +/** + * Format a progress message for display. + */ +function formatProgressMessage(msg: ProgressMessage): string { + const time = formatTime(msg.timestamp); + const content = + msg.content.length > 0 + ? msg.content.map((block) => block.text).join('\n\n') + : '[No human-readable progress text captured]'; + + const metadata = [ + ` UUID: ${msg.uuid}`, + ` Parent: ${msg.parentUuid ?? 'null'}`, + ` CWD: ${msg.cwd || '(unknown)'}`, + ` Branch: ${msg.gitBranch ?? 'null'}`, + ` Sidechain: ${String(msg.isSidechain)}`, + ].join('\n'); + + return `[${time}] PROGRESS\n${content}\n\n${metadata}`; +} + /** * Build a map of tool_use_id to tool results from all messages */ @@ -261,10 +270,7 @@ function separator(): string { /** * Format session header */ -function formatSessionHeader( - session: Session, - options?: SessionFormatOptions -): string { +function formatSessionHeader(session: Session, options?: SessionFormatOptions): string { const lines = [ `Session: ${session.id}`, `Project: ${session.projectPath}`, @@ -272,8 +278,10 @@ function formatSessionHeader( ]; // Show filtered message count if filter is applied - if (options) { + if (options?.filter?.length) { lines.push(`Messages: ${options.messages.length} (filtered from ${options.totalMessageCount})`); + } else if (options) { + lines.push(`Messages: ${options.messages.length}`); } else { lines.push(`Messages: ${session.messageCount}`); } @@ -300,10 +308,7 @@ function formatSessionHeader( * @param options - Optional format options for filtered output * @returns Formatted session string */ -export function formatSession( - session: Session, - options?: SessionFormatOptions -): string { +export function formatSession(session: Session, options?: SessionFormatOptions): string { const parts: string[] = []; // Use filtered messages if provided, otherwise use session messages @@ -329,7 +334,7 @@ export function formatSession( // Messages for (const msg of messagesToFormat) { // Skip summary and file-history-snapshot messages - if (msg.type !== 'user' && msg.type !== 'assistant') { + if (msg.type !== 'user' && msg.type !== 'assistant' && msg.type !== 'progress') { continue; } @@ -341,8 +346,10 @@ export function formatSession( let formatted: string; if (msg.type === 'user') { formatted = formatUserMessage(msg as UserMessage); - } else { + } else if (msg.type === 'assistant') { formatted = formatAssistantMessage(msg as AssistantMessage, toolResults); + } else { + formatted = formatProgressMessage(msg as ProgressMessage); } parts.push(''); diff --git a/src/cli/formatters/table.ts b/src/cli/formatters/table.ts index fc6a1f6..365dad5 100644 --- a/src/cli/formatters/table.ts +++ b/src/cli/formatters/table.ts @@ -88,10 +88,7 @@ function getDisplaySummary(session: SessionSummary): string { * @param offset - Starting index offset for display * @returns Formatted table string */ -export function formatSessionTable( - sessions: SessionSummary[], - offset = 0 -): string { +export function formatSessionTable(sessions: SessionSummary[], offset = 0): string { if (sessions.length === 0) { return 'No sessions found.'; } @@ -129,18 +126,9 @@ export function formatSessionTable( const row = [ padLeft(String(displayIndex), COLUMN_WIDTHS.idx), padRight(formatDate(session.timestamp), COLUMN_WIDTHS.timestamp), - padRight( - truncatePath(session.projectPath, COLUMN_WIDTHS.path), - COLUMN_WIDTHS.path - ), - padRight( - truncate(session.gitBranch ?? '-', COLUMN_WIDTHS.branch), - COLUMN_WIDTHS.branch - ), - padRight( - truncate(getDisplaySummary(session), COLUMN_WIDTHS.summary), - COLUMN_WIDTHS.summary - ), + padRight(truncatePath(session.projectPath, COLUMN_WIDTHS.path), COLUMN_WIDTHS.path), + padRight(truncate(session.gitBranch ?? '-', COLUMN_WIDTHS.branch), COLUMN_WIDTHS.branch), + padRight(truncate(getDisplaySummary(session), COLUMN_WIDTHS.summary), COLUMN_WIDTHS.summary), padLeft(String(session.messageCount), COLUMN_WIDTHS.msgs), ].join(' '); diff --git a/src/cli/utils/config.ts b/src/cli/utils/config.ts index 672e53f..c139c60 100644 --- a/src/cli/utils/config.ts +++ b/src/cli/utils/config.ts @@ -48,8 +48,7 @@ export interface ResolvedCliConfig { * @returns Resolved configuration */ export function resolveConfig(opts: GlobalOptions): ResolvedCliConfig { - const dataPath = - opts.dataPath ?? process.env[ENV_VARS.DATA_PATH] ?? getDefaultDataPath(); + const dataPath = opts.dataPath ?? process.env[ENV_VARS.DATA_PATH] ?? getDefaultDataPath(); return { dataPath, diff --git a/src/cli/utils/errors.ts b/src/cli/utils/errors.ts index c58ab6c..61e8819 100644 --- a/src/cli/utils/errors.ts +++ b/src/cli/utils/errors.ts @@ -23,11 +23,7 @@ export enum ExitCode { /** * Error codes for structured error responses */ -export type ErrorCode = - | 'USAGE_ERROR' - | 'NOT_FOUND' - | 'IO_ERROR' - | 'INTERNAL_ERROR'; +export type ErrorCode = 'USAGE_ERROR' | 'NOT_FOUND' | 'IO_ERROR' | 'INTERNAL_ERROR'; /** * Structured command error for JSON output @@ -89,12 +85,7 @@ export function ioError(message: string, details?: string): CliError { * Create an internal error (unexpected issues) */ export function internalError(message: string, details?: string): CliError { - return new CliError( - message, - ExitCode.GENERAL_ERROR, - 'INTERNAL_ERROR', - details - ); + return new CliError(message, ExitCode.GENERAL_ERROR, 'INTERNAL_ERROR', details); } /** diff --git a/src/cli/utils/output.ts b/src/cli/utils/output.ts index 4f56af0..a54e2ef 100644 --- a/src/cli/utils/output.ts +++ b/src/cli/utils/output.ts @@ -48,10 +48,7 @@ export function toPaginationInfo(pagination: Pagination): PaginationInfo { /** * Create a successful command result */ -export function successResult( - data: T, - pagination?: Pagination -): CommandResult { +export function successResult(data: T, pagination?: Pagination): CommandResult { return { success: true, data, @@ -104,11 +101,7 @@ export function outputJson(result: CommandResult): void { * @param json - Whether to output as JSON * @param humanOutput - Human-readable string (used when json=false) */ -export function output( - result: CommandResult, - json: boolean, - humanOutput?: string -): void { +export function output(result: CommandResult, json: boolean, humanOutput?: string): void { if (json) { outputJson(result); } else if (humanOutput !== undefined) { @@ -145,10 +138,7 @@ export function handleError(error: unknown, json: boolean): ExitCode { * @param itemName - Name of items being paginated (e.g., "sessions", "matches") * @returns Human-readable pagination hint or empty string if not needed */ -export function formatPaginationHint( - pagination: PaginationInfo, - itemName: string -): string { +export function formatPaginationHint(pagination: PaginationInfo, itemName: string): string { if (!pagination.hasMore) { return ''; } diff --git a/src/lib/export.ts b/src/lib/export.ts index e5bcec9..0e1c31b 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -10,7 +10,9 @@ import type { Message, UserMessage, AssistantMessage, + ProgressMessage, AssistantContent, + ProgressContent, ToolResultContent, } from './types.js'; import { resolveConfig } from './config.js'; @@ -128,6 +130,17 @@ function formatAssistantContent(content: AssistantContent[]): string { return parts.join('\n\n'); } +/** + * Format progress content for markdown. + */ +function formatProgressContent(content: ProgressContent[]): string { + if (content.length === 0) { + return '_No human-readable progress text captured._'; + } + + return content.map((block) => block.text).join('\n\n'); +} + /** * Format a single message for markdown. */ @@ -146,6 +159,20 @@ function formatMessageMarkdown(message: Message): string | null { return `## 🤖 Assistant${model}\n\n${content}`; } + case 'progress': { + const progressMsg = message as ProgressMessage; + const content = formatProgressContent(progressMsg.content); + const metadata = [ + `- UUID: \`${progressMsg.uuid}\``, + `- Parent UUID: \`${progressMsg.parentUuid ?? 'null'}\``, + `- CWD: \`${progressMsg.cwd}\``, + `- Git Branch: \`${progressMsg.gitBranch ?? 'null'}\``, + `- Sidechain: \`${String(progressMsg.isSidechain)}\``, + ].join('\n'); + + return `## ⏳ Progress\n\n${content}\n\n${metadata}`; + } + case 'summary': // Skip summary in message output (it's in the header) return null; diff --git a/src/lib/index.ts b/src/lib/index.ts index 572073b..f4db28f 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -24,15 +24,18 @@ export type { BaseMessage, UserMessage, AssistantMessage, + ProgressMessage, SummaryMessage, FileHistorySnapshotMessage, Message, // Content types TextContent, + ProgressTextContent, ToolUseContent, ThinkingContent, AssistantContent, + ProgressContent, ToolResultContent, // Supporting types diff --git a/src/lib/parser.ts b/src/lib/parser.ts index b065fdf..204fd03 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -17,12 +17,15 @@ import type { Message, UserMessage, AssistantMessage, + ProgressMessage, SummaryMessage, FileHistorySnapshotMessage, TextContent, + ProgressTextContent, ToolUseContent, ThinkingContent, AssistantContent, + ProgressContent, ToolResultContent, TokenUsage, FileSnapshot, @@ -221,6 +224,88 @@ function parseUserContent(rawContent: unknown): string | ToolResultContent[] { return ''; } +/** + * Parse progress content from raw message. + */ +function parseProgressContent(rawContent: unknown): ProgressContent[] { + if (typeof rawContent === 'string') { + return rawContent.length > 0 ? [{ type: 'text', text: rawContent }] : []; + } + + if (!Array.isArray(rawContent)) { + return []; + } + + return rawContent + .map((item): ProgressContent | null => { + if (typeof item !== 'object' || item === null) { + return null; + } + + const typed = item as Record; + + if (typed.type !== 'text' || typeof typed.text !== 'string') { + return null; + } + + return { + type: 'text', + text: typed.text, + } as ProgressTextContent; + }) + .filter((item): item is ProgressContent => item !== null); +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null; +} + +function extractMessageContent(value: unknown): unknown { + const record = asRecord(value); + if (!record) { + return undefined; + } + + const nestedMessage = asRecord(record.message); + if (nestedMessage && 'content' in nestedMessage) { + return nestedMessage.content; + } + + if ('content' in record) { + return record.content; + } + + return undefined; +} + +function extractProgressContent(entry: RawSessionEntry): ProgressContent[] { + const directContent = parseProgressContent(entry.message?.content); + if (directContent.length > 0) { + return directContent; + } + + const data = asRecord(entry.data); + const nestedNormalizedMessages = Array.isArray(data?.normalizedMessages) + ? data.normalizedMessages + : []; + const normalizedMessages = Array.isArray(entry.normalizedMessages) + ? entry.normalizedMessages + : nestedNormalizedMessages; + const normalizedContent = normalizedMessages.flatMap((message) => + parseProgressContent(extractMessageContent(message)) + ); + if (normalizedContent.length > 0) { + return normalizedContent; + } + + const nestedContent = parseProgressContent(extractMessageContent(data?.message)); + if (nestedContent.length > 0) { + return nestedContent; + } + + return []; +} + /** * Transform a raw session entry into a typed Message. * @param entry - Raw parsed entry from JSONL @@ -262,6 +347,19 @@ export function transformEntry(entry: RawSessionEntry): Message | null { } as AssistantMessage; } + case 'progress': { + return { + type: 'progress', + uuid, + parentUuid, + timestamp, + content: extractProgressContent(entry), + cwd: entry.cwd ?? '', + gitBranch: entry.gitBranch ?? null, + isSidechain: entry.isSidechain ?? false, + } as ProgressMessage; + } + case 'summary': { return { type: 'summary', @@ -367,8 +465,8 @@ export function extractMetadata(entries: RawSessionEntry[]): SessionMetadata { agentId = entry.agentId; } - // Track timestamps for user/assistant messages only - if (entry.type === 'user' || entry.type === 'assistant') { + // Track timestamps for displayable transcript messages only + if (entry.type === 'user' || entry.type === 'assistant' || entry.type === 'progress') { messageCount++; if (entry.timestamp) { diff --git a/src/lib/search.ts b/src/lib/search.ts index c847db3..ed5253b 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -9,6 +9,7 @@ import type { SearchMatch, Message, AssistantContent, + ProgressContent, ToolResultContent, PaginatedResult, } from './types.js'; @@ -56,6 +57,13 @@ function extractUserText(content: string | ToolResultContent[]): string[] { return content.map((result) => result.content); } +/** + * Extract searchable text from progress content. + */ +function extractProgressText(content: ProgressContent[]): string[] { + return content.map((block) => block.text); +} + /** * Extract all searchable text from a message. */ @@ -65,6 +73,8 @@ function extractMessageText(message: Message): string[] { return extractUserText(message.content); case 'assistant': return extractAssistantText(message.content); + case 'progress': + return extractProgressText(message.content); case 'summary': return [message.summary]; case 'file-history-snapshot': @@ -133,8 +143,8 @@ function searchInMessage( const matches: SearchMatch[] = []; const lowerQuery = query.toLowerCase(); - // Only search user and assistant messages - if (message.type !== 'user' && message.type !== 'assistant') { + // Only search displayable/searchable transcript messages + if (message.type !== 'user' && message.type !== 'assistant' && message.type !== 'progress') { return matches; } diff --git a/src/lib/session.ts b/src/lib/session.ts index 3dbb0af..90998b5 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -321,7 +321,9 @@ export async function getSession( summary: metadata.summary, timestamp: metadata.timestamp, lastActivityAt: metadata.lastActivityAt, - messageCount: messages.filter((m) => m.type === 'user' || m.type === 'assistant').length, + messageCount: messages.filter( + (m) => m.type === 'user' || m.type === 'assistant' || m.type === 'progress' + ).length, version: metadata.version, gitBranch: metadata.gitBranch, agentIds, @@ -355,7 +357,7 @@ function extractSessionMetadataFromMessages( } // Track timestamps - if (msg.type === 'user' || msg.type === 'assistant') { + if (msg.type === 'user' || msg.type === 'assistant' || msg.type === 'progress') { if (!firstTimestamp || msg.timestamp < firstTimestamp) { firstTimestamp = msg.timestamp; } @@ -367,6 +369,10 @@ function extractSessionMetadataFromMessages( if (msg.type === 'user') { gitBranch ??= msg.gitBranch; } + + if (msg.type === 'progress') { + gitBranch ??= msg.gitBranch; + } } } @@ -412,7 +418,9 @@ export async function getAgentSession(agentId: string, config?: LibraryConfig): summary: metadata.summary, timestamp: metadata.timestamp, lastActivityAt: metadata.lastActivityAt, - messageCount: messages.filter((m) => m.type === 'user' || m.type === 'assistant').length, + messageCount: messages.filter( + (m) => m.type === 'user' || m.type === 'assistant' || m.type === 'progress' + ).length, version: metadata.version, gitBranch: metadata.gitBranch, agentIds: [], @@ -454,6 +462,11 @@ export function classifyMessage(message: Message): FilterableMessageType[] { return types; } + if (message.type === 'progress') { + types.push('progress'); + return types; + } + if (message.type === 'user') { const userMsg = message as UserMessage; @@ -530,13 +543,10 @@ export function classifyMessage(message: Message): FilterableMessageType[] { * const allMessages = filterMessages(session.messages, {}); * ``` */ -export function filterMessages( - messages: Message[], - options?: MessageFilterOptions -): Message[] { +export function filterMessages(messages: Message[], options?: MessageFilterOptions): Message[] { // First, filter out non-displayable messages (summary, file-history-snapshot) const displayableMessages = messages.filter( - (m) => m.type === 'user' || m.type === 'assistant' + (m) => m.type === 'user' || m.type === 'assistant' || m.type === 'progress' ); // If no filter specified or empty filter, return all displayable messages diff --git a/src/lib/stats.ts b/src/lib/stats.ts index 8a1efdd..2878f77 100644 --- a/src/lib/stats.ts +++ b/src/lib/stats.ts @@ -86,14 +86,10 @@ export function computeTokenStats(messages: Message[]): AggregateTokenStats { * const combined = addStats(session1Stats, session2Stats); * ``` */ -export function addStats( - a: AggregateTokenStats, - b: AggregateTokenStats -): AggregateTokenStats { +export function addStats(a: AggregateTokenStats, b: AggregateTokenStats): AggregateTokenStats { const inputTokens = a.inputTokens + b.inputTokens; const outputTokens = a.outputTokens + b.outputTokens; - const cacheCreationInputTokens = - a.cacheCreationInputTokens + b.cacheCreationInputTokens; + const cacheCreationInputTokens = a.cacheCreationInputTokens + b.cacheCreationInputTokens; const cacheReadInputTokens = a.cacheReadInputTokens + b.cacheReadInputTokens; return { @@ -101,7 +97,6 @@ export function addStats( outputTokens, cacheCreationInputTokens, cacheReadInputTokens, - totalTokens: - inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens, + totalTokens: inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 8a24798..8fff73e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -83,7 +83,7 @@ export interface Session extends SessionSummary { // ============================================================================= /** Message type discriminator */ -export type MessageType = 'user' | 'assistant' | 'summary' | 'file-history-snapshot'; +export type MessageType = 'user' | 'assistant' | 'progress' | 'summary' | 'file-history-snapshot'; /** * Base message structure. @@ -126,6 +126,17 @@ export interface AssistantMessage extends BaseMessage { usage: TokenUsage; } +/** + * Progress message emitted during tool or task execution. + */ +export interface ProgressMessage extends BaseMessage { + type: 'progress'; + content: ProgressContent[]; + cwd: string; + gitBranch: string | null; + isSidechain: boolean; +} + /** * Session summary entry. */ @@ -145,7 +156,12 @@ export interface FileHistorySnapshotMessage extends BaseMessage { } /** Union of all message types */ -export type Message = UserMessage | AssistantMessage | SummaryMessage | FileHistorySnapshotMessage; +export type Message = + | UserMessage + | AssistantMessage + | ProgressMessage + | SummaryMessage + | FileHistorySnapshotMessage; // ============================================================================= // Content Types @@ -157,6 +173,12 @@ export interface TextContent { text: string; } +/** Readable progress content block */ +export interface ProgressTextContent { + type: 'text'; + text: string; +} + /** Tool use (invocation) content */ export interface ToolUseContent { type: 'tool_use'; @@ -174,6 +196,9 @@ export interface ThinkingContent { /** Union of assistant content types */ export type AssistantContent = TextContent | ToolUseContent | ThinkingContent; +/** Union of progress content types */ +export type ProgressContent = ProgressTextContent; + /** Tool result content */ export interface ToolResultContent { type: 'tool_result'; @@ -196,7 +221,13 @@ export interface ToolResultContent { * - `thinking`: AI reasoning/thinking blocks * - `error`: Tool results with errors */ -export type FilterableMessageType = 'user' | 'assistant' | 'tool' | 'thinking' | 'error'; +export type FilterableMessageType = + | 'user' + | 'assistant' + | 'tool' + | 'thinking' + | 'error' + | 'progress'; /** * Options for filtering messages in a session. @@ -218,6 +249,7 @@ export const VALID_FILTER_TYPES: readonly FilterableMessageType[] = [ 'tool', 'thinking', 'error', + 'progress', ] as const; // ============================================================================= @@ -321,8 +353,8 @@ export interface SearchMatch { /** Message UUID containing match */ messageUuid: string; - /** Message type (user/assistant) */ - messageType: 'user' | 'assistant'; + /** Message type (user/assistant/progress) */ + messageType: 'user' | 'assistant' | 'progress'; /** Matched text */ match: string; @@ -399,6 +431,8 @@ export interface RawSessionEntry { isSidechain?: boolean; isMeta?: boolean; message?: RawMessage; + data?: unknown; + normalizedMessages?: unknown[]; summary?: string; leafUuid?: string; messageId?: string; diff --git a/tests/fixtures/progress-session.jsonl b/tests/fixtures/progress-session.jsonl new file mode 100644 index 0000000..0f45e06 --- /dev/null +++ b/tests/fixtures/progress-session.jsonl @@ -0,0 +1,5 @@ +{"type":"summary","summary":"Progress repro","leafUuid":"msg-assistant"} +{"type":"user","uuid":"msg-user","parentUuid":null,"timestamp":"2026-04-01T00:00:00.000Z","sessionId":"session-progress","cwd":"/tmp/project-progress","gitBranch":"main","version":"2.0.0","message":{"role":"user","content":"Start scanning the project"}} +{"type":"progress","uuid":"msg-progress-readable","parentUuid":"msg-user","timestamp":"2026-04-01T00:00:01.000Z","sessionId":"session-progress","cwd":"/tmp/project-progress","gitBranch":"main","version":"2.0.0","isSidechain":false,"message":{"role":"assistant","content":[{"type":"text","text":"PROGRESS_ONLY_TOKEN found while scanning src/lib/types.ts"}]}} +{"type":"progress","uuid":"msg-progress-empty","parentUuid":"msg-progress-readable","timestamp":"2026-04-01T00:00:01.500Z","sessionId":"session-progress","cwd":"/tmp/project-progress","gitBranch":"main","version":"2.0.0","isSidechain":false,"message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu-progress","name":"Glob","input":{"pattern":"src/**/*.ts"}}]}} +{"type":"assistant","uuid":"msg-assistant","parentUuid":"msg-progress-empty","timestamp":"2026-04-01T00:00:02.000Z","sessionId":"session-progress","message":{"model":"claude-test","role":"assistant","content":[{"type":"text","text":"Done scanning the project."}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} diff --git a/tests/integration/cli/config.test.ts b/tests/integration/cli/config.test.ts index 0181fb0..0f2c6b3 100644 --- a/tests/integration/cli/config.test.ts +++ b/tests/integration/cli/config.test.ts @@ -48,18 +48,19 @@ function createTestSession( sessionId: sessionId, cwd: projectPath, version: '2.0.0', - message: msg.type === 'user' - ? { - role: 'user', - content: msg.content, - } - : { - role: 'assistant', - model: 'claude-3-sonnet', - content: [{ type: 'text', text: msg.content }], - stop_reason: 'end_turn', - usage: { input_tokens: 100, output_tokens: 200 }, - }, + message: + msg.type === 'user' + ? { + role: 'user', + content: msg.content, + } + : { + role: 'assistant', + model: 'claude-3-sonnet', + content: [{ type: 'text', text: msg.content }], + stop_reason: 'end_turn', + usage: { input_tokens: 100, output_tokens: 200 }, + }, })); // Add summary entry @@ -109,7 +110,10 @@ function runCliWithEnv( /** * Execute CLI command with --data-path option */ -function runCli(args: string, dataPath?: string): { stdout: string; stderr: string; exitCode: number } { +function runCli( + args: string, + dataPath?: string +): { stdout: string; stderr: string; exitCode: number } { const dataPathArg = dataPath ? `--data-path "${dataPath}"` : ''; return runCliWithEnv(`${dataPathArg} ${args}`); } @@ -151,9 +155,9 @@ describe('custom data path configuration (T058-T060)', () => { describe('--data-path option (T058)', () => { it('should use custom data path from --data-path option', () => { - createTestSession('/Users/dev/project1', [ - { type: 'user', content: 'Custom path test' }, - ], { summary: 'Session from custom path' }); + createTestSession('/Users/dev/project1', [{ type: 'user', content: 'Custom path test' }], { + summary: 'Session from custom path', + }); const { stdout, exitCode } = runCli('list', TEST_DATA_DIR); @@ -162,9 +166,7 @@ describe('custom data path configuration (T058-T060)', () => { }); it('should work with list command', () => { - createTestSession('/Users/dev/project1', [ - { type: 'user', content: 'List test' }, - ]); + createTestSession('/Users/dev/project1', [{ type: 'user', content: 'List test' }]); const { stdout, exitCode } = runCli('list', TEST_DATA_DIR); @@ -173,9 +175,7 @@ describe('custom data path configuration (T058-T060)', () => { }); it('should work with view command', () => { - createTestSession('/Users/dev/project1', [ - { type: 'user', content: 'View test content' }, - ]); + createTestSession('/Users/dev/project1', [{ type: 'user', content: 'View test content' }]); const { stdout, exitCode } = runCli('view 0', TEST_DATA_DIR); @@ -195,9 +195,9 @@ describe('custom data path configuration (T058-T060)', () => { }); it('should work with export command', () => { - createTestSession('/Users/dev/project1', [ - { type: 'user', content: 'Export test' }, - ], { summary: 'Export session' }); + createTestSession('/Users/dev/project1', [{ type: 'user', content: 'Export test' }], { + summary: 'Export session', + }); const { stdout, exitCode } = runCli('export 0', TEST_DATA_DIR); @@ -207,9 +207,7 @@ describe('custom data path configuration (T058-T060)', () => { }); it('should support -d shorthand for --data-path', () => { - createTestSession('/Users/dev/project1', [ - { type: 'user', content: 'Shorthand test' }, - ]); + createTestSession('/Users/dev/project1', [{ type: 'user', content: 'Shorthand test' }]); const { stdout, exitCode } = runCliWithEnv(`-d "${TEST_DATA_DIR}" list`); @@ -220,9 +218,9 @@ describe('custom data path configuration (T058-T060)', () => { describe('CCH_DATA_PATH environment variable (T059)', () => { it('should use custom data path from environment variable', () => { - createTestSession('/Users/dev/project1', [ - { type: 'user', content: 'Env var test' }, - ], { summary: 'Session from env path' }); + createTestSession('/Users/dev/project1', [{ type: 'user', content: 'Env var test' }], { + summary: 'Session from env path', + }); const { stdout, exitCode } = runCliWithEnv('list', { CCH_DATA_PATH: TEST_DATA_DIR, @@ -233,9 +231,7 @@ describe('custom data path configuration (T058-T060)', () => { }); it('should work with all commands via env var', () => { - createTestSession('/Users/dev/project1', [ - { type: 'user', content: 'Env var content' }, - ]); + createTestSession('/Users/dev/project1', [{ type: 'user', content: 'Env var content' }]); // List command const listResult = runCliWithEnv('list', { CCH_DATA_PATH: TEST_DATA_DIR }); @@ -249,9 +245,11 @@ describe('custom data path configuration (T058-T060)', () => { it('should prefer --data-path over environment variable', () => { // Create session in test data dir - createTestSession('/Users/dev/project1', [ - { type: 'user', content: 'Data path priority test' }, - ], { summary: 'CLI option session' }); + createTestSession( + '/Users/dev/project1', + [{ type: 'user', content: 'Data path priority test' }], + { summary: 'CLI option session' } + ); // Set env var to non-existent path const { stdout, exitCode } = runCliWithEnv(`--data-path "${TEST_DATA_DIR}" list`, { diff --git a/tests/integration/cli/export.test.ts b/tests/integration/cli/export.test.ts index d65f193..c0bf040 100644 --- a/tests/integration/cli/export.test.ts +++ b/tests/integration/cli/export.test.ts @@ -50,18 +50,19 @@ function createTestSession( sessionId: sessionId, cwd: projectPath, version: '2.0.0', - message: msg.type === 'user' - ? { - role: 'user', - content: msg.content, - } - : { - role: 'assistant', - model: 'claude-3-sonnet', - content: [{ type: 'text', text: msg.content }], - stop_reason: 'end_turn', - usage: { input_tokens: 100, output_tokens: 200 }, - }, + message: + msg.type === 'user' + ? { + role: 'user', + content: msg.content, + } + : { + role: 'assistant', + model: 'claude-3-sonnet', + content: [{ type: 'text', text: msg.content }], + stop_reason: 'end_turn', + usage: { input_tokens: 100, output_tokens: 200 }, + }, })); // Add summary entry @@ -83,7 +84,10 @@ function createTestSession( /** * Execute CLI command and return stdout/stderr */ -function runCli(args: string, dataPath?: string): { stdout: string; stderr: string; exitCode: number } { +function runCli( + args: string, + dataPath?: string +): { stdout: string; stderr: string; exitCode: number } { const dataPathArg = dataPath ? `--data-path "${dataPath}"` : `--data-path "${TEST_DATA_DIR}"`; try { const stdout = execSync(`node ${CLI_PATH} ${dataPathArg} ${args}`, { @@ -150,10 +154,15 @@ describe('cch export', () => { describe('export to stdout JSON (T042)', () => { it('should export session to JSON format by default', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Hello' }, - { type: 'assistant', content: 'Hi there!' }, - ], { summary: 'Test export session' }); + createTestSession( + '/Users/dev/project1', + 'session-1', + [ + { type: 'user', content: 'Hello' }, + { type: 'assistant', content: 'Hi there!' }, + ], + { summary: 'Test export session' } + ); const { stdout, exitCode } = runCli('export 0'); @@ -165,9 +174,9 @@ describe('cch export', () => { }); it('should include session metadata in JSON', () => { - createTestSession('/Users/dev/myproject', 'session-1', [ - { type: 'user', content: 'Test' }, - ], { summary: 'Metadata test' }); + createTestSession('/Users/dev/myproject', 'session-1', [{ type: 'user', content: 'Test' }], { + summary: 'Metadata test', + }); const { stdout } = runCli('export 0 --format json'); const json = JSON.parse(stdout); @@ -194,10 +203,15 @@ describe('cch export', () => { describe('export to stdout Markdown (T043)', () => { it('should export session to Markdown format', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Hello Markdown' }, - { type: 'assistant', content: 'Markdown response' }, - ], { summary: 'Markdown Test Session' }); + createTestSession( + '/Users/dev/project1', + 'session-1', + [ + { type: 'user', content: 'Hello Markdown' }, + { type: 'assistant', content: 'Markdown response' }, + ], + { summary: 'Markdown Test Session' } + ); const { stdout, exitCode } = runCli('export 0 --format markdown'); @@ -208,9 +222,7 @@ describe('cch export', () => { }); it('should include metadata table in Markdown', () => { - createTestSession('/Users/dev/myproject', 'session-1', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/myproject', 'session-1', [{ type: 'user', content: 'Test' }]); const { stdout } = runCli('export 0 -F markdown'); @@ -233,9 +245,7 @@ describe('cch export', () => { }); it('should accept "md" as format shorthand', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Test' }]); const { stdout, exitCode } = runCli('export 0 --format md'); @@ -246,9 +256,12 @@ describe('cch export', () => { describe('export to file with --output (T044)', () => { it('should write JSON to file', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'File export test' }, - ], { summary: 'File export' }); + createTestSession( + '/Users/dev/project1', + 'session-1', + [{ type: 'user', content: 'File export test' }], + { summary: 'File export' } + ); const outputFile = join(TEST_OUTPUT_DIR, 'export.json'); const { stdout, exitCode } = runCli(`export 0 --output "${outputFile}"`); @@ -263,9 +276,12 @@ describe('cch export', () => { }); it('should write Markdown to file', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Markdown file test' }, - ], { summary: 'MD File Test' }); + createTestSession( + '/Users/dev/project1', + 'session-1', + [{ type: 'user', content: 'Markdown file test' }], + { summary: 'MD File Test' } + ); const outputFile = join(TEST_OUTPUT_DIR, 'export.md'); const { exitCode } = runCli(`export 0 --format markdown --output "${outputFile}"`); @@ -306,12 +322,18 @@ describe('cch export', () => { describe('export --all option (T045)', () => { it('should export all sessions to JSON', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'First session' }, - ], { summary: 'First' }); - createTestSession('/Users/dev/project2', 'session-2', [ - { type: 'user', content: 'Second session' }, - ], { summary: 'Second' }); + createTestSession( + '/Users/dev/project1', + 'session-1', + [{ type: 'user', content: 'First session' }], + { summary: 'First' } + ); + createTestSession( + '/Users/dev/project2', + 'session-2', + [{ type: 'user', content: 'Second session' }], + { summary: 'Second' } + ); const { stdout, exitCode } = runCli('export --all --format json'); @@ -322,12 +344,18 @@ describe('cch export', () => { }); it('should export all sessions to Markdown', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'First markdown' }, - ], { summary: 'First MD' }); - createTestSession('/Users/dev/project2', 'session-2', [ - { type: 'user', content: 'Second markdown' }, - ], { summary: 'Second MD' }); + createTestSession( + '/Users/dev/project1', + 'session-1', + [{ type: 'user', content: 'First markdown' }], + { summary: 'First MD' } + ); + createTestSession( + '/Users/dev/project2', + 'session-2', + [{ type: 'user', content: 'Second markdown' }], + { summary: 'Second MD' } + ); const { stdout, exitCode } = runCli('export --all --format markdown'); @@ -338,12 +366,8 @@ describe('cch export', () => { }); it('should write all sessions to file', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'First' }, - ]); - createTestSession('/Users/dev/project2', 'session-2', [ - { type: 'user', content: 'Second' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'First' }]); + createTestSession('/Users/dev/project2', 'session-2', [{ type: 'user', content: 'Second' }]); const outputFile = join(TEST_OUTPUT_DIR, 'all-sessions.json'); const { exitCode } = runCli(`export --all --output "${outputFile}"`); @@ -359,9 +383,7 @@ describe('cch export', () => { describe('error handling', () => { it('should error when no session specified and no --all flag', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Test' }]); const { exitCode, stdout, stderr } = runCli('export'); @@ -371,9 +393,7 @@ describe('cch export', () => { }); it('should error for invalid format', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Test' }]); const { exitCode, stdout, stderr } = runCli('export 0 --format invalid'); @@ -383,9 +403,7 @@ describe('cch export', () => { }); it('should error for non-existent session', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Test' }]); const { exitCode, stdout, stderr } = runCli('export 999'); diff --git a/tests/integration/cli/list.test.ts b/tests/integration/cli/list.test.ts index eb5caf9..e033cb6 100644 --- a/tests/integration/cli/list.test.ts +++ b/tests/integration/cli/list.test.ts @@ -48,18 +48,24 @@ function createTestSession( sessionId: sessionId, cwd: projectPath, version: '2.0.0', - message: msg.type === 'user' - ? { - role: 'user', - content: msg.content, - } - : { - role: 'assistant', - model: 'claude-3-sonnet', - content: [{ type: 'text', text: msg.content }], - stop_reason: 'end_turn', - usage: { input_tokens: 100, output_tokens: 200, cache_creation_input_tokens: 500, cache_read_input_tokens: 5000 }, - }, + message: + msg.type === 'user' + ? { + role: 'user', + content: msg.content, + } + : { + role: 'assistant', + model: 'claude-3-sonnet', + content: [{ type: 'text', text: msg.content }], + stop_reason: 'end_turn', + usage: { + input_tokens: 100, + output_tokens: 200, + cache_creation_input_tokens: 500, + cache_read_input_tokens: 5000, + }, + }, })); // Add summary entry @@ -185,9 +191,7 @@ describe('cch list', () => { }); it('should include pagination info in JSON output', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Hello' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Hello' }]); const { stdout } = runCli('list --json'); const json = JSON.parse(stdout); @@ -200,9 +204,7 @@ describe('cch list', () => { }); it('should include session index in each data item', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Hello' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Hello' }]); const { stdout } = runCli('list --json'); const json = JSON.parse(stdout); @@ -228,9 +230,7 @@ describe('cch list', () => { }); it('should show message when no sessions match workspace filter', () => { - createTestSession('/Users/dev/project-a', 'session-1', [ - { type: 'user', content: 'Hello' }, - ]); + createTestSession('/Users/dev/project-a', 'session-1', [{ type: 'user', content: 'Hello' }]); const { stdout } = runCli('list --workspace /nonexistent/path'); @@ -241,15 +241,9 @@ describe('cch list', () => { describe('--limit and --offset pagination (T015)', () => { it('should respect --limit option', () => { // Create multiple sessions - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'First' }, - ]); - createTestSession('/Users/dev/project2', 'session-2', [ - { type: 'user', content: 'Second' }, - ]); - createTestSession('/Users/dev/project3', 'session-3', [ - { type: 'user', content: 'Third' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'First' }]); + createTestSession('/Users/dev/project2', 'session-2', [{ type: 'user', content: 'Second' }]); + createTestSession('/Users/dev/project3', 'session-3', [{ type: 'user', content: 'Third' }]); const { stdout } = runCli('list --limit 2 --json'); const json = JSON.parse(stdout); @@ -258,12 +252,8 @@ describe('cch list', () => { }); it('should respect --offset option', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'First' }, - ]); - createTestSession('/Users/dev/project2', 'session-2', [ - { type: 'user', content: 'Second' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'First' }]); + createTestSession('/Users/dev/project2', 'session-2', [{ type: 'user', content: 'Second' }]); const { stdout } = runCli('list --offset 1 --json'); const json = JSON.parse(stdout); @@ -275,15 +265,9 @@ describe('cch list', () => { }); it('should show pagination hint when more results available', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'First' }, - ]); - createTestSession('/Users/dev/project2', 'session-2', [ - { type: 'user', content: 'Second' }, - ]); - createTestSession('/Users/dev/project3', 'session-3', [ - { type: 'user', content: 'Third' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'First' }]); + createTestSession('/Users/dev/project2', 'session-2', [{ type: 'user', content: 'Second' }]); + createTestSession('/Users/dev/project3', 'session-3', [{ type: 'user', content: 'Third' }]); const { stdout } = runCli('list --limit 1 --full'); diff --git a/tests/integration/cli/migrate.test.ts b/tests/integration/cli/migrate.test.ts index 8bd58af..fc4f370 100644 --- a/tests/integration/cli/migrate.test.ts +++ b/tests/integration/cli/migrate.test.ts @@ -48,18 +48,19 @@ function createTestSession( sessionId: sessionId, cwd: projectPath, version: '2.0.0', - message: msg.type === 'user' - ? { - role: 'user', - content: msg.content, - } - : { - role: 'assistant', - model: 'claude-3-sonnet', - content: [{ type: 'text', text: msg.content }], - stop_reason: 'end_turn', - usage: { input_tokens: 100, output_tokens: 200 }, - }, + message: + msg.type === 'user' + ? { + role: 'user', + content: msg.content, + } + : { + role: 'assistant', + model: 'claude-3-sonnet', + content: [{ type: 'text', text: msg.content }], + stop_reason: 'end_turn', + usage: { input_tokens: 100, output_tokens: 200 }, + }, })); // Add summary entry @@ -81,7 +82,10 @@ function createTestSession( /** * Execute CLI command and return stdout/stderr */ -function runCli(args: string, dataPath?: string): { stdout: string; stderr: string; exitCode: number } { +function runCli( + args: string, + dataPath?: string +): { stdout: string; stderr: string; exitCode: number } { const dataPathArg = dataPath ? `--data-path "${dataPath}"` : `--data-path "${TEST_DATA_DIR}"`; try { const stdout = execSync(`node ${CLI_PATH} ${dataPathArg} ${args}`, { @@ -146,10 +150,14 @@ describe('cch migrate', () => { it('should copy session to destination by index', () => { const sourceProject = '/Users/dev/source'; const destProject = '/Users/dev/destination'; - createTestSession(sourceProject, [ - { type: 'user', content: 'Hello' }, - { type: 'assistant', content: 'Hi there!' }, - ], { summary: 'Session to copy' }); + createTestSession( + sourceProject, + [ + { type: 'user', content: 'Hello' }, + { type: 'assistant', content: 'Hi there!' }, + ], + { summary: 'Session to copy' } + ); const { stdout, exitCode } = runCli(`migrate 0 --destination "${destProject}"`); @@ -191,9 +199,7 @@ describe('cch migrate', () => { it('should default to copy mode', () => { const sourceProject = '/Users/dev/source'; const destProject = '/Users/dev/destination'; - createTestSession(sourceProject, [ - { type: 'user', content: 'Default mode test' }, - ]); + createTestSession(sourceProject, [{ type: 'user', content: 'Default mode test' }]); const { exitCode } = runCli(`migrate 0 --destination "${destProject}"`); @@ -210,9 +216,7 @@ describe('cch migrate', () => { it('should show success message with session count', () => { const sourceProject = '/Users/dev/source'; const destProject = '/Users/dev/destination'; - createTestSession(sourceProject, [ - { type: 'user', content: 'Count test' }, - ]); + createTestSession(sourceProject, [{ type: 'user', content: 'Count test' }]); const { stdout } = runCli(`migrate 0 --destination "${destProject}"`); @@ -224,9 +228,7 @@ describe('cch migrate', () => { it('should move session to destination', () => { const sourceProject = '/Users/dev/source'; const destProject = '/Users/dev/destination'; - createTestSession(sourceProject, [ - { type: 'user', content: 'Session to move' }, - ]); + createTestSession(sourceProject, [{ type: 'user', content: 'Session to move' }]); const { stdout, exitCode } = runCli(`migrate 0 --destination "${destProject}" --mode move`); @@ -253,9 +255,7 @@ describe('cch migrate', () => { it('should support -m shorthand for mode', () => { const sourceProject = '/Users/dev/source'; const destProject = '/Users/dev/destination'; - createTestSession(sourceProject, [ - { type: 'user', content: 'Mode shorthand test' }, - ]); + createTestSession(sourceProject, [{ type: 'user', content: 'Mode shorthand test' }]); const { exitCode } = runCli(`migrate 0 -D "${destProject}" -m move`); @@ -275,11 +275,17 @@ describe('cch migrate', () => { it('should migrate multiple sessions by index', () => { const sourceProject = '/Users/dev/source'; const destProject = '/Users/dev/destination'; - const sessionId1 = createTestSession(sourceProject, [{ type: 'user', content: 'First session' }]); - const sessionId2 = createTestSession(sourceProject, [{ type: 'user', content: 'Second session' }]); + const sessionId1 = createTestSession(sourceProject, [ + { type: 'user', content: 'First session' }, + ]); + const sessionId2 = createTestSession(sourceProject, [ + { type: 'user', content: 'Second session' }, + ]); // Migrate both sessions by UUID to be deterministic - const { exitCode } = runCli(`migrate ${sessionId1},${sessionId2} --destination "${destProject}"`); + const { exitCode } = runCli( + `migrate ${sessionId1},${sessionId2} --destination "${destProject}"` + ); expect(exitCode).toBe(0); @@ -310,7 +316,9 @@ describe('cch migrate', () => { createTestSession(sourceProject, [{ type: 'user', content: 'Second' }]); createTestSession(sourceProject, [{ type: 'user', content: 'Third' }]); - const { exitCode } = runCli(`migrate --all --source "${sourceProject}" --destination "${destProject}"`); + const { exitCode } = runCli( + `migrate --all --source "${sourceProject}" --destination "${destProject}"` + ); expect(exitCode).toBe(0); @@ -333,9 +341,7 @@ describe('cch migrate', () => { }); it('should error when no destination specified', () => { - createTestSession('/Users/dev/source', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/source', [{ type: 'user', content: 'Test' }]); const { exitCode, stdout, stderr } = runCli('migrate 0'); @@ -345,9 +351,7 @@ describe('cch migrate', () => { }); it('should error for non-existent session', () => { - createTestSession('/Users/dev/source', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/source', [{ type: 'user', content: 'Test' }]); const { exitCode, stdout, stderr } = runCli('migrate 999 --destination /dest'); @@ -357,9 +361,7 @@ describe('cch migrate', () => { }); it('should error for invalid mode', () => { - createTestSession('/Users/dev/source', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/source', [{ type: 'user', content: 'Test' }]); const { exitCode, stdout, stderr } = runCli('migrate 0 -D /dest --mode invalid'); @@ -395,9 +397,7 @@ describe('cch migrate', () => { }); it('should error for --all without --source', () => { - createTestSession('/Users/dev/source', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/source', [{ type: 'user', content: 'Test' }]); const { exitCode, stdout, stderr } = runCli('migrate --all --destination /dest'); diff --git a/tests/integration/cli/search.test.ts b/tests/integration/cli/search.test.ts index bbb0a85..da01ad3 100644 --- a/tests/integration/cli/search.test.ts +++ b/tests/integration/cli/search.test.ts @@ -48,18 +48,29 @@ function createTestSession( sessionId: sessionId, cwd: projectPath, version: '2.0.0', - message: msg.type === 'user' - ? { - role: 'user', - content: msg.content, - } - : { - role: 'assistant', - model: 'claude-3-sonnet', - content: [{ type: 'text', text: msg.content }], - stop_reason: 'end_turn', - usage: { input_tokens: 100, output_tokens: 200 }, - }, + message: + msg.type === 'user' + ? { + role: 'user', + content: msg.content, + } + : msg.type === 'progress' + ? { + role: 'assistant', + content: [{ type: 'text', text: msg.content }], + } + : { + role: 'assistant', + model: 'claude-3-sonnet', + content: [{ type: 'text', text: msg.content }], + stop_reason: 'end_turn', + usage: { + input_tokens: 100, + output_tokens: 200, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, })); // Add summary entry @@ -81,7 +92,10 @@ function createTestSession( /** * Execute CLI command and return stdout/stderr */ -function runCli(args: string, dataPath?: string): { stdout: string; stderr: string; exitCode: number } { +function runCli( + args: string, + dataPath?: string +): { stdout: string; stderr: string; exitCode: number } { const dataPathArg = dataPath ? `--data-path "${dataPath}"` : `--data-path "${TEST_DATA_DIR}"`; try { const stdout = execSync(`node ${CLI_PATH} ${dataPathArg} ${args}`, { @@ -149,9 +163,12 @@ describe('cch search', () => { }); it('should show session info for each match', () => { - createTestSession('/Users/dev/myproject', 'session-1', [ - { type: 'user', content: 'Search test keyword here' }, - ], { summary: 'Test summary for search' }); + createTestSession( + '/Users/dev/myproject', + 'session-1', + [{ type: 'user', content: 'Search test keyword here' }], + { summary: 'Test summary for search' } + ); const { stdout, exitCode } = runCli('search "keyword" --full'); @@ -195,6 +212,20 @@ describe('cch search', () => { expect(exitCode).toBe(0); expect(stdout).toContain('TypeScript'); }); + + it('should surface progress-only matches with a PROGRESS label', () => { + createTestSession('/Users/dev/project1', 'progress-session', [ + { type: 'user', content: 'Start scanning' }, + { type: 'progress', content: 'PROGRESS_ONLY_TOKEN discovered during scan' }, + { type: 'assistant', content: 'Scan complete' }, + ]); + + const { stdout, exitCode } = runCli('search "PROGRESS_ONLY_TOKEN" --full'); + + expect(exitCode).toBe(0); + expect(stdout).toContain('PROGRESS'); + expect(stdout).toContain('PROGRESS_ONLY_TOKEN'); + }); }); describe('--session filter (T034)', () => { @@ -223,6 +254,21 @@ describe('cch search', () => { expect(exitCode).toBe(0); expect(stdout).toContain('xyz123'); }); + + it('should find progress-only matches within the specified session', () => { + const sessionId = createTestSession('/Users/dev/project1', 'progress-session', [ + { type: 'user', content: 'Start scanning' }, + { type: 'progress', content: 'AGENT_TASK_SCHEMA discovered during scan' }, + ]); + + const { stdout, exitCode } = runCli( + `search "AGENT_TASK_SCHEMA" --session ${sessionId} --full` + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain('AGENT_TASK_SCHEMA'); + expect(stdout).toContain('PROGRESS'); + }); }); describe('--context option (T035)', () => { diff --git a/tests/integration/cli/view.test.ts b/tests/integration/cli/view.test.ts index c046620..ce0f220 100644 --- a/tests/integration/cli/view.test.ts +++ b/tests/integration/cli/view.test.ts @@ -51,18 +51,29 @@ function createTestSession( sessionId: sessionId, cwd: projectPath, version: '2.0.0', - message: msg.type === 'user' - ? { - role: 'user', - content: msg.content, - } - : { - role: 'assistant', - model: 'claude-3-sonnet', - content: [{ type: 'text', text: msg.content }], - stop_reason: 'end_turn', - usage: { input_tokens: 100, output_tokens: 200, cache_creation_input_tokens: 500, cache_read_input_tokens: 5000 }, - }, + message: + msg.type === 'user' + ? { + role: 'user', + content: msg.content, + } + : msg.type === 'progress' + ? { + role: 'assistant', + content: [{ type: 'text', text: msg.content }], + } + : { + role: 'assistant', + model: 'claude-3-sonnet', + content: [{ type: 'text', text: msg.content }], + stop_reason: 'end_turn', + usage: { + input_tokens: 100, + output_tokens: 200, + cache_creation_input_tokens: 500, + cache_read_input_tokens: 5000, + }, + }, })); // Add summary entry @@ -82,10 +93,44 @@ function createTestSession( return sessionId; } +function createRawSession( + projectPath: string, + sessionLabel: string, + entries: Record[] +): string { + const sessionId = generateTestUUID(); + const encodedPath = projectPath.replace(/\//g, '-'); + const sessionDir = join(TEST_PROJECTS_DIR, encodedPath); + + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + + const resolvedEntries = entries.map((entry) => ({ ...entry, sessionId })); + const leafUuid = String(resolvedEntries.at(-1)?.uuid ?? ''); + resolvedEntries.unshift({ + type: 'summary', + uuid: `summary-${sessionId}`, + parentUuid: null, + timestamp: new Date().toISOString(), + summary: `Test session: ${sessionLabel}`, + leafUuid, + }); + + const jsonlContent = resolvedEntries.map((entry) => JSON.stringify(entry)).join('\n'); + writeFileSync(join(sessionDir, `${sessionId}.jsonl`), jsonlContent); + + testSessionIds.push(sessionId); + return sessionId; +} + /** * Execute CLI command and return stdout/stderr */ -function runCli(args: string, dataPath?: string): { stdout: string; stderr: string; exitCode: number } { +function runCli( + args: string, + dataPath?: string +): { stdout: string; stderr: string; exitCode: number } { const dataPathArg = dataPath ? `--data-path "${dataPath}"` : `--data-path "${TEST_DATA_DIR}"`; try { const stdout = execSync(`node ${CLI_PATH} ${dataPathArg} ${args}`, { @@ -182,9 +227,12 @@ describe('cch view', () => { }); it('should display session metadata header', () => { - createTestSession('/Users/dev/myproject', 'session-xyz', [ - { type: 'user', content: 'Test' }, - ], { summary: 'Test session summary' }); + createTestSession( + '/Users/dev/myproject', + 'session-xyz', + [{ type: 'user', content: 'Test' }], + { summary: 'Test session summary' } + ); const { stdout, exitCode } = runCli('view 0 --full'); @@ -262,9 +310,12 @@ describe('cch view', () => { }); it('should include session metadata in JSON output', () => { - createTestSession('/Users/dev/myproject', 'session-json-meta', [ - { type: 'user', content: 'Test' }, - ], { summary: 'JSON test summary' }); + createTestSession( + '/Users/dev/myproject', + 'session-json-meta', + [{ type: 'user', content: 'Test' }], + { summary: 'JSON test summary' } + ); const { stdout } = runCli('view 0 --json'); const json = JSON.parse(stdout); @@ -301,13 +352,151 @@ describe('cch view', () => { expect(json.data.messageCount).toBeDefined(); expect(typeof json.data.messageCount).toBe('number'); }); + + it('should preserve progress messages in JSON output', () => { + createTestSession('/Users/dev/project1', 'progress-json', [ + { type: 'user', content: 'Start scan' }, + { type: 'progress', content: 'Tool is scanning project files...' }, + { type: 'assistant', content: 'Done' }, + ]); + + const { stdout, exitCode } = runCli('view 0 --json'); + const json = JSON.parse(stdout); + + expect(exitCode).toBe(0); + expect( + json.data.messages.some((message: { type: string }) => message.type === 'progress') + ).toBe(true); + }); + }); + + describe('progress rendering and filtering', () => { + it('should show progress messages in the transcript order', () => { + createTestSession('/Users/dev/project1', 'progress-view', [ + { type: 'user', content: 'Start scan' }, + { type: 'progress', content: 'Tool is scanning project files...' }, + { type: 'assistant', content: 'Done' }, + ]); + + const { stdout, exitCode } = runCli('view 0 --full'); + + expect(exitCode).toBe(0); + expect(stdout).toContain('PROGRESS'); + expect(stdout).toContain('Tool is scanning project files...'); + expect(stdout.indexOf('USER')).toBeLessThan(stdout.indexOf('PROGRESS')); + expect(stdout.indexOf('PROGRESS')).toBeLessThan(stdout.indexOf('ASSISTANT')); + }); + + it('should support the dedicated --only progress filter', () => { + createTestSession('/Users/dev/project1', 'progress-only', [ + { type: 'user', content: 'Start scan' }, + { type: 'progress', content: 'Tool is scanning project files...' }, + { type: 'assistant', content: 'Done' }, + ]); + + const { stdout, exitCode } = runCli('view 0 --only progress --full'); + + expect(exitCode).toBe(0); + expect(stdout).toContain('PROGRESS'); + expect(stdout).toContain('Tool is scanning project files...'); + expect(stdout).not.toContain('ASSISTANT'); + expect(stdout).not.toContain('USER'); + }); + + it('should render readable text from nested real-style agent progress entries', () => { + createRawSession('/Users/dev/project1', 'progress-real-schema', [ + { + type: 'user', + uuid: 'msg-user', + parentUuid: null, + timestamp: new Date(Date.now() - 120000).toISOString(), + cwd: '/Users/dev/project1', + gitBranch: 'main', + version: '2.1.9', + message: { + role: 'user', + content: 'Start scan', + }, + }, + { + type: 'progress', + uuid: 'msg-progress', + parentUuid: 'msg-user', + timestamp: new Date(Date.now() - 60000).toISOString(), + cwd: '/Users/dev/project1', + gitBranch: 'main', + version: '2.1.9', + data: { + type: 'agent_progress', + message: { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_123', name: 'Glob', input: {} }], + }, + }, + normalizedMessages: [ + { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'I will scan the project files now.' }], + }, + }, + { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_123', name: 'Glob', input: {} }], + }, + }, + ], + }, + }, + { + type: 'assistant', + uuid: 'msg-assistant', + parentUuid: 'msg-progress', + timestamp: new Date().toISOString(), + message: { + role: 'assistant', + model: 'claude-3-sonnet', + content: [{ type: 'text', text: 'Done' }], + stop_reason: 'end_turn', + usage: { + input_tokens: 100, + output_tokens: 200, + cache_creation_input_tokens: 500, + cache_read_input_tokens: 5000, + }, + }, + }, + ]); + + const { stdout, exitCode } = runCli('view 0 --only progress --full'); + + expect(exitCode).toBe(0); + expect(stdout).toContain('PROGRESS'); + expect(stdout).toContain('I will scan the project files now.'); + expect(stdout).not.toContain('No human-readable progress text captured'); + }); + + it('should return an informative empty state when no progress messages exist', () => { + createTestSession('/Users/dev/project1', 'no-progress', [ + { type: 'user', content: 'Start scan' }, + { type: 'assistant', content: 'Done' }, + ]); + + const { stdout, exitCode } = runCli('view 0 --only progress --full'); + + expect(exitCode).toBe(0); + expect(stdout).toContain('No messages match filter: progress'); + }); }); 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' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Test' }]); const { exitCode, stdout, stderr } = runCli('view 999 --json'); @@ -318,9 +507,7 @@ describe('cch view', () => { }); it('should return error for non-existent UUID', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Test' }]); const { exitCode, stdout, stderr } = runCli('view nonexistent-uuid-12345 --json'); @@ -330,9 +517,7 @@ describe('cch view', () => { }); it('should provide helpful suggestion when session not found', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Test' }]); const { stdout, stderr } = runCli('view 999'); @@ -342,9 +527,7 @@ describe('cch view', () => { }); it('should return JSON error format with --json flag', () => { - createTestSession('/Users/dev/project1', 'session-1', [ - { type: 'user', content: 'Test' }, - ]); + createTestSession('/Users/dev/project1', 'session-1', [{ type: 'user', content: 'Test' }]); const { stdout, exitCode } = runCli('view 999 --json'); diff --git a/tests/integration/export-sessions.test.ts b/tests/integration/export-sessions.test.ts index 1f4ccb6..52065c4 100644 --- a/tests/integration/export-sessions.test.ts +++ b/tests/integration/export-sessions.test.ts @@ -97,9 +97,17 @@ describe('export functions', () => { '{"type":"assistant","uuid":"agent-msg-002","parentUuid":"agent-msg-001","timestamp":"2025-12-03T10:00:45.000Z","sessionId":"session-003","agentId":"abc123","message":{"model":"claude-haiku-4-5-20251001","role":"assistant","content":[{"type":"text","text":"Agent response"}],"stop_reason":"end_turn"}}', ].join('\n'); + const progressSession = [ + '{"type":"summary","summary":"Progress export example","leafUuid":"msg-010"}', + '{"type":"user","uuid":"msg-009","parentUuid":null,"timestamp":"2026-04-01T00:00:00.000Z","sessionId":"session-004","cwd":"/test/project","gitBranch":"main","version":"2.0.55","message":{"role":"user","content":"Start scan"}}', + '{"type":"progress","uuid":"msg-010","parentUuid":"msg-009","timestamp":"2026-04-01T00:00:01.000Z","sessionId":"session-004","cwd":"/test/project","gitBranch":"main","version":"2.0.55","message":{"role":"assistant","content":[{"type":"text","text":"Tool is scanning project files..."}]}}', + '{"type":"assistant","uuid":"msg-011","parentUuid":"msg-010","timestamp":"2026-04-01T00:00:02.000Z","sessionId":"session-004","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"Done"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}', + ].join('\n'); + const sessionUuid1 = '11111111-1111-1111-1111-111111111111'; const sessionUuid2 = '22222222-2222-2222-2222-222222222222'; const sessionUuid3 = '33333333-3333-3333-3333-333333333333'; + const sessionUuid4 = '44444444-4444-4444-4444-444444444444'; beforeAll(async () => { const projectDir = join(projectsPath, '-test-project'); @@ -108,6 +116,7 @@ describe('export functions', () => { await writeFile(join(projectDir, `${sessionUuid1}.jsonl`), session1); await writeFile(join(projectDir, `${sessionUuid2}.jsonl`), session2); await writeFile(join(projectDir, `${sessionUuid3}.jsonl`), sessionWithAgent); + await writeFile(join(projectDir, `${sessionUuid4}.jsonl`), progressSession); await writeFile(join(projectDir, 'agent-abc123.jsonl'), agentSession); }); @@ -145,6 +154,15 @@ describe('export functions', () => { expect(json).toContain('\n'); expect(json).toContain(' '); // 2-space indent }); + + it('should preserve progress messages in exported JSON', async () => { + const json = await exportSessionToJson(sessionUuid4, { dataPath: testDataPath }); + const session = JSON.parse(json); + + expect( + session.messages.some((message: { type: string }) => message.type === 'progress') + ).toBe(true); + }); }); describe('exportSessionToMarkdown', () => { @@ -229,6 +247,17 @@ describe('export functions', () => { expect(markdown).toContain('| Agent Sessions |'); expect(markdown).toContain('abc123'); }); + + it('should render progress messages and metadata in markdown export', async () => { + const markdown = await exportSessionToMarkdown(sessionUuid4, { + dataPath: testDataPath, + }); + + expect(markdown).toContain('## ⏳ Progress'); + expect(markdown).toContain('Tool is scanning project files...'); + expect(markdown).toContain('UUID'); + expect(markdown).toContain('CWD'); + }); }); describe('exportAllSessionsToJson', () => { @@ -237,7 +266,7 @@ describe('export functions', () => { const sessions = JSON.parse(json); expect(Array.isArray(sessions)).toBe(true); - expect(sessions.length).toBe(3); + expect(sessions.length).toBe(4); }); it('should include all sessions', async () => { @@ -248,6 +277,7 @@ describe('export functions', () => { expect(ids).toContain(sessionUuid1); expect(ids).toContain(sessionUuid2); expect(ids).toContain(sessionUuid3); + expect(ids).toContain(sessionUuid4); }); }); @@ -347,9 +377,9 @@ describe('export all unlimited behavior', () => { expect(sessions).toHaveLength(totalSessions); expect(sessions.some((session) => session.summary === finalSummary)).toBe(true); - expect(sessions.some((session) => session.id === createLargeExportSessionId(totalSessions - 1))).toBe( - true - ); + expect( + sessions.some((session) => session.id === createLargeExportSessionId(totalSessions - 1)) + ).toBe(true); }); it('should export all sessions to Markdown when limit is omitted on a 94-session fixture', async () => { diff --git a/tests/integration/get-session.test.ts b/tests/integration/get-session.test.ts index cff1b7c..4c6bec8 100644 --- a/tests/integration/get-session.test.ts +++ b/tests/integration/get-session.test.ts @@ -33,8 +33,16 @@ describe('getSession', () => { '{"type":"assistant","uuid":"agent-msg-002","parentUuid":"agent-msg-001","timestamp":"2025-12-01T10:05:30.000Z","sessionId":"session-001","agentId":"xyz789","message":{"model":"claude-haiku-4-5-20251001","role":"assistant","content":[{"type":"thinking","thinking":"Let me research..."},{"type":"text","text":"Here are my findings..."}],"stop_reason":"end_turn"}}', ].join('\n'); + const progressSession = [ + '{"type":"summary","summary":"Progress session","leafUuid":"msg-progress-003"}', + '{"type":"user","uuid":"msg-progress-001","parentUuid":null,"timestamp":"2026-04-01T00:00:00.000Z","sessionId":"session-004","cwd":"/test/project","gitBranch":"main","version":"2.0.55","message":{"role":"user","content":"Start scanning"}}', + '{"type":"progress","uuid":"msg-progress-002","parentUuid":"msg-progress-001","timestamp":"2026-04-01T00:00:01.000Z","sessionId":"session-004","cwd":"/test/project","gitBranch":"main","version":"2.0.55","message":{"role":"assistant","content":[{"type":"text","text":"PROGRESS_ONLY_TOKEN found while scanning"}]}}', + '{"type":"assistant","uuid":"msg-progress-003","parentUuid":"msg-progress-002","timestamp":"2026-04-01T00:00:02.000Z","sessionId":"session-004","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"Scan complete"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}', + ].join('\n'); + const sessionUuid1 = '11111111-1111-1111-1111-111111111111'; const sessionUuid2 = '22222222-2222-2222-2222-222222222222'; + const sessionUuid3 = '33333333-3333-3333-3333-333333333333'; beforeAll(async () => { const projectDir = join(projectsPath, '-test-project'); @@ -42,6 +50,7 @@ describe('getSession', () => { await writeFile(join(projectDir, `${sessionUuid1}.jsonl`), session1); await writeFile(join(projectDir, `${sessionUuid2}.jsonl`), session2); + await writeFile(join(projectDir, `${sessionUuid3}.jsonl`), progressSession); await writeFile(join(projectDir, 'agent-xyz789.jsonl'), agentSession); }); @@ -54,7 +63,7 @@ describe('getSession', () => { const session = await getSession(0, { dataPath: testDataPath }); // Should return one of the sessions (order depends on file mtime) - expect([sessionUuid1, sessionUuid2]).toContain(session.id); + expect([sessionUuid1, sessionUuid2, sessionUuid3]).toContain(session.id); expect(session.summary).toBeDefined(); }); @@ -64,7 +73,7 @@ describe('getSession', () => { // Should return different sessions expect(session0.id).not.toBe(session1.id); - expect([sessionUuid1, sessionUuid2]).toContain(session1.id); + expect([sessionUuid1, sessionUuid2, sessionUuid3]).toContain(session1.id); }); it('should throw SessionNotFoundError for out-of-bounds index', async () => { @@ -121,6 +130,20 @@ describe('getSession', () => { expect(session.messageCount).toBe(4); // 2 user + 2 assistant }); + it('should preserve progress messages in the session transcript and counts', async () => { + const session = await getSession(sessionUuid3, { dataPath: testDataPath }); + + const progressMessages = session.messages.filter((message) => message.type === 'progress'); + expect(progressMessages).toHaveLength(1); + expect(session.messageCount).toBe(3); + expect(session.lastActivityAt).toEqual(new Date('2026-04-01T00:00:02.000Z')); + if (progressMessages[0]?.type === 'progress') { + expect(progressMessages[0].cwd).toBe('/test/project'); + expect(progressMessages[0].gitBranch).toBe('main'); + expect(progressMessages[0].parentUuid).toBe('msg-progress-001'); + } + }); + it('should include project path', async () => { const session = await getSession(0, { dataPath: testDataPath }); diff --git a/tests/integration/search-sessions.test.ts b/tests/integration/search-sessions.test.ts index c094733..fdd5f3f 100644 --- a/tests/integration/search-sessions.test.ts +++ b/tests/integration/search-sessions.test.ts @@ -79,9 +79,18 @@ describe('searchSessions', () => { '{"type":"assistant","uuid":"msg-008","parentUuid":"msg-007","timestamp":"2025-12-03T10:00:30.000Z","sessionId":"session-003","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"I can see your TypeScript file defines a User interface."}],"stop_reason":"end_turn"}}', ].join('\n'); + const session4 = [ + '{"type":"summary","summary":"Progress search repro","leafUuid":"msg-012"}', + '{"type":"user","uuid":"msg-009","parentUuid":null,"timestamp":"2025-12-04T10:00:00.000Z","sessionId":"session-004","cwd":"/test/project","gitBranch":"main","version":"2.0.55","message":{"role":"user","content":"Scan the project files"}}', + '{"type":"progress","uuid":"msg-010","parentUuid":"msg-009","timestamp":"2025-12-04T10:00:05.000Z","sessionId":"session-004","cwd":"/test/project","gitBranch":"main","version":"2.0.55","message":{"role":"assistant","content":[{"type":"text","text":"PROGRESS_ONLY_TOKEN discovered in AGENT_TASK_SCHEMA"}]}}', + '{"type":"progress","uuid":"msg-011","parentUuid":"msg-010","timestamp":"2025-12-04T10:00:06.000Z","sessionId":"session-004","cwd":"/test/project","gitBranch":"main","version":"2.0.55","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_progress","name":"Glob","input":{"pattern":"src/**/*.ts"}}]}}', + '{"type":"assistant","uuid":"msg-012","parentUuid":"msg-011","timestamp":"2025-12-04T10:00:30.000Z","sessionId":"session-004","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"Scan complete"}],"stop_reason":"end_turn"}}', + ].join('\n'); + const sessionUuid1 = '11111111-1111-1111-1111-111111111111'; const sessionUuid2 = '22222222-2222-2222-2222-222222222222'; const sessionUuid3 = '33333333-3333-3333-3333-333333333333'; + const sessionUuid4 = '44444444-4444-4444-4444-444444444444'; beforeAll(async () => { const projectDir = join(projectsPath, '-test-project'); @@ -90,6 +99,7 @@ describe('searchSessions', () => { await writeFile(join(projectDir, `${sessionUuid1}.jsonl`), session1); await writeFile(join(projectDir, `${sessionUuid2}.jsonl`), session2); await writeFile(join(projectDir, `${sessionUuid3}.jsonl`), session3); + await writeFile(join(projectDir, `${sessionUuid4}.jsonl`), session4); }); afterAll(async () => { @@ -124,7 +134,7 @@ describe('searchSessions', () => { expect(result.data).toEqual([]); }); - it('should only search user and assistant messages, not summaries', async () => { + it('should not search summary messages', async () => { // "discussion" only appears in the summary, not in user/assistant messages // Search function explicitly skips summary and file-history-snapshot messages const result = await searchSessions('discussion', { dataPath: testDataPath }); @@ -133,6 +143,36 @@ describe('searchSessions', () => { }); }); + describe('progress message search', () => { + it('should find matches that only exist in progress messages across sessions', async () => { + const result = await searchSessions('PROGRESS_ONLY_TOKEN', { dataPath: testDataPath }); + + expect(result.data.length).toBeGreaterThan(0); + expect(result.data.some((match) => match.sessionId === sessionUuid4)).toBe(true); + expect( + result.data.every((match) => ['user', 'assistant', 'progress'].includes(match.messageType)) + ).toBe(true); + }); + + it('should search within a single session for progress-only matches', async () => { + const matches = await searchInSession(sessionUuid4, 'AGENT_TASK_SCHEMA', { + dataPath: testDataPath, + }); + + expect(matches.length).toBe(1); + expect(matches[0]?.messageType).toBe('progress'); + expect(matches[0]?.messageUuid).toBe('msg-010'); + }); + + it('should not invent matches from unreadable progress content', async () => { + const matches = await searchInSession(sessionUuid4, 'Glob', { + dataPath: testDataPath, + }); + + expect(matches).toEqual([]); + }); + }); + describe('match content', () => { it('should include session ID in matches', async () => { const result = await searchSessions('generics', { dataPath: testDataPath }); @@ -366,3 +406,79 @@ describe('searchSessions unlimited behavior', () => { expect(matchedSessionIds).toEqual(expectedSessionIds); }); }); + +describe('searchSessions performance with progress messages', () => { + const testDataPath = join(tmpdir(), `claude-search-progress-performance-${Date.now()}`); + const projectsPath = join(testDataPath, 'projects'); + const sessionId = '55555555-5555-5555-5555-555555555555'; + + beforeAll(async () => { + const projectDir = join(projectsPath, '-test-progress-performance'); + await mkdir(projectDir, { recursive: true }); + + const entries: string[] = [ + JSON.stringify({ + type: 'summary', + summary: 'Progress performance session', + leafUuid: 'msg-119', + }), + ]; + + for (let i = 0; i < 120; i++) { + const previousUuid = i === 0 ? null : `msg-${i - 1}`; + entries.push( + JSON.stringify({ + type: i % 3 === 0 ? 'progress' : i % 3 === 1 ? 'user' : 'assistant', + uuid: `msg-${i}`, + parentUuid: previousUuid, + timestamp: new Date(Date.UTC(2026, 3, 1, 0, 0, i)).toISOString(), + sessionId, + cwd: '/test/progress-performance', + gitBranch: 'main', + version: '2.0.55', + message: + i % 3 === 2 + ? { + role: 'assistant', + model: 'claude-opus-4-5-20251101', + content: [{ type: 'text', text: `assistant message ${i}` }], + stop_reason: 'end_turn', + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + } + : i % 3 === 1 + ? { + role: 'user', + content: `user message ${i}`, + } + : { + role: 'assistant', + content: [{ type: 'text', text: `progress performance token ${i}` }], + }, + }) + ); + } + + await writeFile(join(projectDir, `${sessionId}.jsonl`), entries.join('\n')); + }); + + afterAll(async () => { + await rm(testDataPath, { recursive: true, force: true }); + }); + + it('should search a 100+ message session in under one second', async () => { + const startedAt = Date.now(); + const result = await searchSessions('progress performance token 117', { + dataPath: testDataPath, + }); + const elapsedMs = Date.now() - startedAt; + + expect(result.pagination.total).toBe(1); + expect(result.data[0]?.messageType).toBe('progress'); + expect(elapsedMs).toBeLessThan(1000); + }); +}); diff --git a/tests/unit/cli/commands/list.test.ts b/tests/unit/cli/commands/list.test.ts index 35a4e2e..a8697b9 100644 --- a/tests/unit/cli/commands/list.test.ts +++ b/tests/unit/cli/commands/list.test.ts @@ -25,7 +25,14 @@ vi.mock('../../../../src/cli/formatters/pager.js', () => ({ outputWithPager: vi.fn(), })); -import { listSessions, getSession, isDataNotFoundError, createEmptyStats, computeTokenStats, addStats } from '../../../../src/lib/index.js'; +import { + listSessions, + getSession, + isDataNotFoundError, + createEmptyStats, + computeTokenStats, + addStats, +} from '../../../../src/lib/index.js'; import { outputWithPager } from '../../../../src/cli/formatters/pager.js'; describe('list command', () => { diff --git a/tests/unit/cli/commands/search.test.ts b/tests/unit/cli/commands/search.test.ts index aabbf00..28724af 100644 --- a/tests/unit/cli/commands/search.test.ts +++ b/tests/unit/cli/commands/search.test.ts @@ -20,11 +20,7 @@ vi.mock('../../../../src/cli/formatters/pager.js', () => ({ outputWithPager: vi.fn(), })); -import { - searchSessions, - searchInSession, - isDataNotFoundError, -} from '../../../../src/lib/index.js'; +import { searchSessions, searchInSession, isDataNotFoundError } from '../../../../src/lib/index.js'; import { outputWithPager } from '../../../../src/cli/formatters/pager.js'; function createMatch(index: number): { diff --git a/tests/unit/cli/commands/view.test.ts b/tests/unit/cli/commands/view.test.ts index cc395b8..7217e74 100644 --- a/tests/unit/cli/commands/view.test.ts +++ b/tests/unit/cli/commands/view.test.ts @@ -24,7 +24,13 @@ vi.mock('../../../../src/cli/formatters/pager.js', () => ({ outputWithPager: vi.fn(), })); -import { getSession, isSessionNotFoundError, isDataNotFoundError, filterMessages, computeTokenStats } from '../../../../src/lib/index.js'; +import { + getSession, + isSessionNotFoundError, + isDataNotFoundError, + filterMessages, + computeTokenStats, +} from '../../../../src/lib/index.js'; import { outputWithPager } from '../../../../src/cli/formatters/pager.js'; describe('view command', () => { @@ -78,6 +84,18 @@ describe('view command', () => { vi.mocked(filterMessages).mockReset(); vi.mocked(computeTokenStats).mockReset(); vi.mocked(outputWithPager).mockReset(); + + vi.mocked(filterMessages).mockImplementation( + (messages) => messages as typeof mockSession.messages + ); + vi.mocked(computeTokenStats).mockReturnValue({ + inputTokens: 50, + outputTokens: 25, + totalTokens: 75, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + messageCount: 2, + }); }); afterEach(() => { @@ -188,4 +206,25 @@ describe('view command', () => { expect.objectContaining({ only: ['user', 'assistant'] }) ); }); + + it('should accept progress as a valid filter type', async () => { + vi.mocked(getSession).mockResolvedValue(mockSession as any); + vi.mocked(filterMessages).mockReturnValue([]); + vi.mocked(computeTokenStats).mockReturnValue({ + inputTokens: 50, + outputTokens: 25, + totalTokens: 75, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + messageCount: 2, + }); + vi.mocked(outputWithPager).mockResolvedValue(); + + await program.parseAsync(['node', 'test', 'view', '0', '--only', 'progress']); + + expect(filterMessages).toHaveBeenCalledWith( + mockSession.messages, + expect.objectContaining({ only: ['progress'] }) + ); + }); }); diff --git a/tests/unit/cli/formatters/search.test.ts b/tests/unit/cli/formatters/search.test.ts index db74b48..3a1b34f 100644 --- a/tests/unit/cli/formatters/search.test.ts +++ b/tests/unit/cli/formatters/search.test.ts @@ -68,18 +68,14 @@ describe('formatSearchResults', () => { }); it('should include project name for each match', () => { - const matches = [ - createTestMatch({ projectPath: '/Users/dev/my-awesome-project' }), - ]; + const matches = [createTestMatch({ projectPath: '/Users/dev/my-awesome-project' })]; const output = formatSearchResults(matches, 'test', 2, defaultPagination); expect(output).toContain('my-awesome-project'); }); it('should include session summary', () => { - const matches = [ - createTestMatch({ sessionSummary: 'Discussion about TypeScript' }), - ]; + const matches = [createTestMatch({ sessionSummary: 'Discussion about TypeScript' })]; const output = formatSearchResults(matches, 'test', 2, defaultPagination); expect(output).toContain('Discussion about TypeScript'); @@ -106,6 +102,13 @@ describe('formatSearchResults', () => { expect(output).toContain('ASSISTANT'); }); + it('should label progress matches as PROGRESS', () => { + const matches = [createTestMatch({ messageType: 'progress' })]; + const output = formatSearchResults(matches, 'test', 2, defaultPagination); + + expect(output).toContain('PROGRESS'); + }); + it('should include line number', () => { const matches = [createTestMatch({ lineNumber: 42 })]; const output = formatSearchResults(matches, 'test', 2, defaultPagination); diff --git a/tests/unit/cli/formatters/session.test.ts b/tests/unit/cli/formatters/session.test.ts index cca8fa8..44bdad6 100644 --- a/tests/unit/cli/formatters/session.test.ts +++ b/tests/unit/cli/formatters/session.test.ts @@ -3,8 +3,18 @@ */ import { describe, it, expect } from 'vitest'; -import { formatSession, formatSessionForJson, formatTokenSummary } from '../../../../src/cli/formatters/session.js'; -import type { Session, UserMessage, AssistantMessage, AggregateTokenStats } from '../../../../src/lib/index.js'; +import { + formatSession, + formatSessionForJson, + formatTokenSummary, +} from '../../../../src/cli/formatters/session.js'; +import type { + Session, + UserMessage, + AssistantMessage, + ProgressMessage, + AggregateTokenStats, +} from '../../../../src/lib/index.js'; /** * Create a minimal test session @@ -75,6 +85,18 @@ describe('formatSession', () => { expect(output).toContain('5'); }); + it('should show the displayable message count without filtered wording when no filter is applied', () => { + const session = createTestSession({ messageCount: 2 }); + const output = formatSession(session, { + messages: session.messages, + filter: [], + totalMessageCount: 2, + }); + + expect(output).toContain('Messages: 2'); + expect(output).not.toContain('filtered from'); + }); + it('should include summary when present', () => { const session = createTestSession({ summary: 'Discussion about TypeScript generics' }); const output = formatSession(session); @@ -139,6 +161,71 @@ describe('formatSession', () => { expect(output).toContain('─'.repeat(80)); }); + it('should render progress messages distinctly', () => { + const session = createTestSession({ + messageCount: 3, + messages: [ + { + type: 'user', + uuid: 'msg-1', + parentUuid: null, + timestamp: new Date('2024-01-15T10:30:00Z'), + content: 'Start scanning', + cwd: '/Users/dev/test-project', + } as UserMessage, + { + type: 'progress', + uuid: 'msg-2', + parentUuid: 'msg-1', + timestamp: new Date('2024-01-15T10:30:03Z'), + content: [{ type: 'text', text: 'Scanning src/lib/types.ts' }], + cwd: '/Users/dev/test-project', + gitBranch: 'main', + isSidechain: false, + } as ProgressMessage, + { + type: 'assistant', + uuid: 'msg-3', + parentUuid: 'msg-2', + timestamp: new Date('2024-01-15T10:30:05Z'), + model: 'claude-3-sonnet', + content: [{ type: 'text', text: 'Scan complete.' }], + stopReason: 'end_turn', + usage: { inputTokens: 50, outputTokens: 100 }, + } as AssistantMessage, + ], + }); + + const output = formatSession(session); + + expect(output).toContain('PROGRESS'); + expect(output).toContain('Scanning src/lib/types.ts'); + expect(output).toContain('CWD: /Users/dev/test-project'); + }); + + it('should show a placeholder for progress messages without readable text', () => { + const session = createTestSession({ + messageCount: 1, + messages: [ + { + type: 'progress', + uuid: 'msg-progress', + parentUuid: null, + timestamp: new Date('2024-01-15T10:30:03Z'), + content: [], + cwd: '/Users/dev/test-project', + gitBranch: 'main', + isSidechain: false, + } as ProgressMessage, + ], + }); + + const output = formatSession(session); + + expect(output).toContain('PROGRESS'); + expect(output).toContain('No human-readable progress text captured'); + }); + it('should handle empty messages array', () => { const session = createTestSession({ messages: [], messageCount: 0 }); const output = formatSession(session); diff --git a/tests/unit/cli/formatters/table.test.ts b/tests/unit/cli/formatters/table.test.ts index ac81f86..34bdc3d 100644 --- a/tests/unit/cli/formatters/table.test.ts +++ b/tests/unit/cli/formatters/table.test.ts @@ -3,7 +3,11 @@ */ import { describe, it, expect } from 'vitest'; -import { formatSessionTable, formatSessionsForJson, formatAggregateStats } from '../../../../src/cli/formatters/table.js'; +import { + formatSessionTable, + formatSessionsForJson, + formatAggregateStats, +} from '../../../../src/cli/formatters/table.js'; import type { SessionSummary, AggregateTokenStats } from '../../../../src/lib/index.js'; function createMockSession(overrides: Partial = {}): SessionSummary { @@ -128,10 +132,7 @@ describe('formatSessionsForJson', () => { }); it('should add index to each session', () => { - const sessions = [ - createMockSession({ id: 'id-1' }), - createMockSession({ id: 'id-2' }), - ]; + const sessions = [createMockSession({ id: 'id-1' }), createMockSession({ id: 'id-2' })]; const result = formatSessionsForJson(sessions); expect(result[0].index).toBe(0); diff --git a/tests/unit/cli/utils/errors.test.ts b/tests/unit/cli/utils/errors.test.ts index b47aded..2c36b1d 100644 --- a/tests/unit/cli/utils/errors.test.ts +++ b/tests/unit/cli/utils/errors.test.ts @@ -57,11 +57,7 @@ describe('error utilities (T066)', () => { }); it('should create error without details', () => { - const error = new CliError( - 'No details', - ExitCode.GENERAL_ERROR, - 'INTERNAL_ERROR' - ); + const error = new CliError('No details', ExitCode.GENERAL_ERROR, 'INTERNAL_ERROR'); expect(error.details).toBeUndefined(); }); @@ -73,12 +69,7 @@ describe('error utilities (T066)', () => { describe('toCommandError', () => { it('should convert to CommandError with details', () => { - const error = new CliError( - 'Test message', - ExitCode.NOT_FOUND, - 'NOT_FOUND', - 'Some details' - ); + const error = new CliError('Test message', ExitCode.NOT_FOUND, 'NOT_FOUND', 'Some details'); const commandError = error.toCommandError(); @@ -90,11 +81,7 @@ describe('error utilities (T066)', () => { }); it('should convert to CommandError without details', () => { - const error = new CliError( - 'Test message', - ExitCode.IO_ERROR, - 'IO_ERROR' - ); + const error = new CliError('Test message', ExitCode.IO_ERROR, 'IO_ERROR'); const commandError = error.toCommandError(); @@ -193,7 +180,9 @@ describe('error utilities (T066)', () => { const error = notFoundError('Session not found', "Try 'cch list' to see available sessions."); const formatted = formatErrorHuman(error); - expect(formatted).toBe("Error: Session not found\n\nTry 'cch list' to see available sessions."); + expect(formatted).toBe( + "Error: Session not found\n\nTry 'cch list' to see available sessions." + ); }); }); diff --git a/tests/unit/cli/utils/output.test.ts b/tests/unit/cli/utils/output.test.ts index 139d90b..f195887 100644 --- a/tests/unit/cli/utils/output.test.ts +++ b/tests/unit/cli/utils/output.test.ts @@ -104,12 +104,7 @@ describe('output utilities (T067)', () => { describe('errorResult', () => { it('should create failed result from CliError', () => { - const error = new CliError( - 'Test error', - ExitCode.NOT_FOUND, - 'NOT_FOUND', - 'Some details' - ); + const error = new CliError('Test error', ExitCode.NOT_FOUND, 'NOT_FOUND', 'Some details'); const result = errorResult(error); @@ -123,11 +118,7 @@ describe('output utilities (T067)', () => { }); it('should create failed result without details', () => { - const error = new CliError( - 'Simple error', - ExitCode.GENERAL_ERROR, - 'INTERNAL_ERROR' - ); + const error = new CliError('Simple error', ExitCode.GENERAL_ERROR, 'INTERNAL_ERROR'); const result = errorResult(error); diff --git a/tests/unit/parser.test.ts b/tests/unit/parser.test.ts index 9e2556b..282680a 100644 --- a/tests/unit/parser.test.ts +++ b/tests/unit/parser.test.ts @@ -572,6 +572,129 @@ describe('transformEntry', () => { expect(content[0].is_error).toBe(true); } }); + + it('should transform progress entries with readable text', () => { + const entry = { + type: 'progress', + uuid: 'msg-progress', + parentUuid: 'msg-001', + timestamp: '2026-04-01T00:00:01.000Z', + cwd: '/tmp/project-progress', + gitBranch: 'main', + isSidechain: false, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Scanning src/lib/types.ts' }], + }, + }; + + const result = transformEntry(entry); + + expect(result?.type).toBe('progress'); + if (result?.type === 'progress') { + expect(result.content).toEqual([{ type: 'text', text: 'Scanning src/lib/types.ts' }]); + expect(result.cwd).toBe('/tmp/project-progress'); + expect(result.gitBranch).toBe('main'); + expect(result.isSidechain).toBe(false); + } + }); + + it('should transform progress entries with unreadable content safely', () => { + const entry = { + type: 'progress', + uuid: 'msg-progress', + parentUuid: 'msg-001', + timestamp: '2026-04-01T00:00:01.000Z', + cwd: '/tmp/project-progress', + gitBranch: 'main', + isSidechain: false, + message: { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_123', name: 'Glob', input: { pattern: 'src/**/*.ts' } }, + ], + }, + }; + + const result = transformEntry(entry); + + expect(result?.type).toBe('progress'); + if (result?.type === 'progress') { + expect(result.content).toEqual([]); + } + }); + + it('should transform agent progress entries using normalizedMessages text', () => { + const entry = { + type: 'progress', + uuid: 'msg-progress-agent', + parentUuid: 'msg-001', + timestamp: '2026-04-01T00:00:01.000Z', + cwd: '/tmp/project-progress', + gitBranch: 'main', + isSidechain: false, + data: { + type: 'agent_progress', + message: { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_123', name: 'Glob', input: {} }], + }, + }, + normalizedMessages: [ + { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Scanning memory-related files now.' }], + }, + }, + { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_123', name: 'Glob', input: {} }], + }, + }, + ], + }, + }; + + const result = transformEntry(entry); + + expect(result?.type).toBe('progress'); + if (result?.type === 'progress') { + expect(result.content).toEqual([ + { type: 'text', text: 'Scanning memory-related files now.' }, + ]); + } + }); + + it('should preserve unreadable hook progress entries as empty content', () => { + const entry = { + type: 'progress', + uuid: 'msg-progress-hook', + parentUuid: 'msg-001', + timestamp: '2026-04-01T00:00:01.000Z', + cwd: '/tmp/project-progress', + gitBranch: 'main', + isSidechain: false, + data: { + type: 'hook_progress', + hookEvent: 'PostToolUse', + hookName: 'PostToolUse:Read', + command: 'callback', + }, + }; + + const result = transformEntry(entry); + + expect(result?.type).toBe('progress'); + if (result?.type === 'progress') { + expect(result.content).toEqual([]); + } + }); }); describe('parseSessionFile', () => { @@ -603,6 +726,30 @@ describe('parseSessionFile', () => { // Should have warnings for invalid lines expect(result.warnings.length).toBeGreaterThan(0); }); + + it('should preserve progress entries from the progress fixture', async () => { + const filePath = join(FIXTURES_DIR, 'progress-session.jsonl'); + const result = await parseSessionFile(filePath); + + expect(result.warnings).toHaveLength(0); + const progressMessages = result.data.filter((message) => message.type === 'progress'); + expect(progressMessages).toHaveLength(2); + + const readableProgress = progressMessages.find( + (message) => message.uuid === 'msg-progress-readable' + ); + expect(readableProgress?.type).toBe('progress'); + if (readableProgress?.type === 'progress') { + expect(readableProgress.content[0]?.text).toContain('PROGRESS_ONLY_TOKEN'); + expect(readableProgress.parentUuid).toBe('msg-user'); + } + + const emptyProgress = progressMessages.find((message) => message.uuid === 'msg-progress-empty'); + expect(emptyProgress?.type).toBe('progress'); + if (emptyProgress?.type === 'progress') { + expect(emptyProgress.content).toEqual([]); + } + }); }); describe('extractMetadata', () => { @@ -648,6 +795,32 @@ describe('extractMetadata', () => { const metadata = extractMetadata(entries); expect(metadata.agentId).toBe('abc1234'); }); + + it('should count progress entries as displayable transcript messages', () => { + const entries = [ + { type: 'summary', summary: 'Progress session', leafUuid: 'msg-003' }, + { + type: 'user', + uuid: 'msg-001', + timestamp: '2026-04-01T00:00:00.000Z', + }, + { + type: 'progress', + uuid: 'msg-002', + timestamp: '2026-04-01T00:00:01.000Z', + }, + { + type: 'assistant', + uuid: 'msg-003', + timestamp: '2026-04-01T00:00:02.000Z', + }, + ]; + + const metadata = extractMetadata(entries); + expect(metadata.messageCount).toBe(3); + expect(metadata.firstTimestamp).toEqual(new Date('2026-04-01T00:00:00.000Z')); + expect(metadata.lastTimestamp).toEqual(new Date('2026-04-01T00:00:02.000Z')); + }); }); describe('parseSessionMetadata', () => { @@ -671,4 +844,13 @@ describe('parseSessionMetadata', () => { expect(result.data.agentId).toBe('abc1234'); expect(result.data.summary).toContain('Agent'); }); + + it('should extract progress-aware metadata from the progress fixture', async () => { + const filePath = join(FIXTURES_DIR, 'progress-session.jsonl'); + const result = await parseSessionMetadata(filePath); + + expect(result.warnings).toHaveLength(0); + expect(result.data.summary).toBe('Progress repro'); + expect(result.data.messageCount).toBe(4); + }); }); diff --git a/tests/unit/session.test.ts b/tests/unit/session.test.ts index 91e66b8..95d7958 100644 --- a/tests/unit/session.test.ts +++ b/tests/unit/session.test.ts @@ -4,7 +4,12 @@ import { describe, it, expect } from 'vitest'; import { classifyMessage, filterMessages } from '../../src/lib/session.js'; -import type { Message, UserMessage, AssistantMessage } from '../../src/lib/types.js'; +import type { + Message, + UserMessage, + AssistantMessage, + ProgressMessage, +} from '../../src/lib/types.js'; describe('classifyMessage', () => { describe('user messages', () => { @@ -184,6 +189,22 @@ describe('classifyMessage', () => { }); describe('non-displayable messages', () => { + it('should classify progress messages as progress', () => { + const message: ProgressMessage = { + type: 'progress', + uuid: 'msg-progress', + parentUuid: 'msg-001', + timestamp: new Date(), + content: [{ type: 'text', text: 'Scanning files' }], + cwd: '/test', + gitBranch: 'main', + isSidechain: false, + }; + + const types = classifyMessage(message); + expect(types).toEqual(['progress']); + }); + it('should return empty array for summary messages', () => { const message = { type: 'summary', @@ -260,6 +281,17 @@ describe('filterMessages', () => { ], }); + const createProgressMessage = (id: string, text = 'Progress update'): ProgressMessage => ({ + type: 'progress', + uuid: id, + parentUuid: null, + timestamp: new Date(), + content: [{ type: 'text', text }], + cwd: '/test', + gitBranch: 'main', + isSidechain: false, + }); + const createSummaryMessage = (): Message => ({ type: 'summary', @@ -272,31 +304,34 @@ describe('filterMessages', () => { createSummaryMessage(), createUserMessage('msg-001', 'Hello'), createAssistantMessage('msg-002', 'Hi there'), + createProgressMessage('msg-003', 'Scanning files'), ]; const filtered = filterMessages(messages); - expect(filtered.length).toBe(2); - expect(filtered.map((m) => m.uuid)).toEqual(['msg-001', 'msg-002']); + expect(filtered.length).toBe(3); + expect(filtered.map((m) => m.uuid)).toEqual(['msg-001', 'msg-002', 'msg-003']); }); it('should return all displayable messages with empty options', () => { const messages: Message[] = [ createUserMessage('msg-001', 'Hello'), createAssistantMessage('msg-002', 'Hi there'), + createProgressMessage('msg-003'), ]; const filtered = filterMessages(messages, {}); - expect(filtered.length).toBe(2); + expect(filtered.length).toBe(3); }); it('should return all displayable messages with empty only array', () => { const messages: Message[] = [ createUserMessage('msg-001', 'Hello'), createAssistantMessage('msg-002', 'Hi there'), + createProgressMessage('msg-003'), ]; const filtered = filterMessages(messages, { only: [] }); - expect(filtered.length).toBe(2); + expect(filtered.length).toBe(3); }); it('should filter to only user messages', () => { @@ -335,6 +370,18 @@ describe('filterMessages', () => { expect(filtered[0].uuid).toBe('msg-003'); }); + it('should filter to only progress messages', () => { + const messages: Message[] = [ + createUserMessage('msg-001', 'Hello'), + createProgressMessage('msg-002', 'Scanning files'), + createAssistantMessage('msg-003', 'Hi there'), + ]; + + const filtered = filterMessages(messages, { only: ['progress'] }); + expect(filtered.length).toBe(1); + expect(filtered[0]?.uuid).toBe('msg-002'); + }); + it('should filter to only error messages', () => { const messages: Message[] = [ createUserMessage('msg-001', 'Hello'), @@ -400,4 +447,15 @@ describe('filterMessages', () => { const filteredTool = filterMessages(messages, { only: ['tool'] }); expect(filteredTool.length).toBe(1); }); + + it('should preserve transcript order when progress messages are included', () => { + const messages: Message[] = [ + createUserMessage('msg-001', 'Hello'), + createProgressMessage('msg-002', 'Scanning files'), + createAssistantMessage('msg-003', 'Done'), + ]; + + const filtered = filterMessages(messages); + expect(filtered.map((message) => message.uuid)).toEqual(['msg-001', 'msg-002', 'msg-003']); + }); }); diff --git a/tests/unit/stats.test.ts b/tests/unit/stats.test.ts index faf9e9a..f4fe821 100644 --- a/tests/unit/stats.test.ts +++ b/tests/unit/stats.test.ts @@ -84,10 +84,7 @@ describe('computeTokenStats', () => { }); it('should return zeros when only user messages exist', () => { - const messages: Message[] = [ - createUserMessage('Hello'), - createUserMessage('Another message'), - ]; + const messages: Message[] = [createUserMessage('Hello'), createUserMessage('Another message')]; const stats = computeTokenStats(messages);