From cdbc71bcc456fb6d9d98e0e261e2adc9fd4f4946 Mon Sep 17 00:00:00 2001 From: borui Date: Wed, 1 Apr 2026 21:45:23 +0800 Subject: [PATCH 1/7] specs: add progress-message feature package Create the full Specify artifact set for first-class progress message support in cch. This commit adds: - the feature specification for preserving, searching, viewing, filtering, and exporting progress entries - a clarification recording the dedicated progress filter value for cch view - the implementation plan with technical context and constitution checks - research notes covering the current parser gap, message-model impact, and raw payload uncertainty - a data model for ProgressMessage and related search/filter type changes - CLI and library API contracts for progress-aware behavior - a quickstart guide describing implementation order and verification flow - an executable tasks list organized by user story - generated Codex agent context in AGENTS.md The resulting artifact set is ready for implementation work on branch 001-support-progress-messages. --- AGENTS.md | 29 +++ .../checklists/requirements.md | 35 +++ .../contracts/cli-interface.md | 218 ++++++++++++++++++ .../contracts/library-api.md | 182 +++++++++++++++ .../data-model.md | 174 ++++++++++++++ specs/001-support-progress-messages/plan.md | 107 +++++++++ .../quickstart.md | 102 ++++++++ .../001-support-progress-messages/research.md | 153 ++++++++++++ specs/001-support-progress-messages/spec.md | 112 +++++++++ specs/001-support-progress-messages/tasks.md | 197 ++++++++++++++++ 10 files changed, 1309 insertions(+) create mode 100644 AGENTS.md create mode 100644 specs/001-support-progress-messages/checklists/requirements.md create mode 100644 specs/001-support-progress-messages/contracts/cli-interface.md create mode 100644 specs/001-support-progress-messages/contracts/library-api.md create mode 100644 specs/001-support-progress-messages/data-model.md create mode 100644 specs/001-support-progress-messages/plan.md create mode 100644 specs/001-support-progress-messages/quickstart.md create mode 100644 specs/001-support-progress-messages/research.md create mode 100644 specs/001-support-progress-messages/spec.md create mode 100644 specs/001-support-progress-messages/tasks.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d0712ad --- /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`) (001-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 + +- 001-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/specs/001-support-progress-messages/checklists/requirements.md b/specs/001-support-progress-messages/checklists/requirements.md new file mode 100644 index 0000000..d0b6075 --- /dev/null +++ b/specs/001-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/001-support-progress-messages/contracts/cli-interface.md b/specs/001-support-progress-messages/contracts/cli-interface.md new file mode 100644 index 0000000..7ffb314 --- /dev/null +++ b/specs/001-support-progress-messages/contracts/cli-interface.md @@ -0,0 +1,218 @@ +# CLI Interface Contract: Support Progress Messages + +**Feature**: 001-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/001-support-progress-messages/contracts/library-api.md b/specs/001-support-progress-messages/contracts/library-api.md new file mode 100644 index 0000000..ea71dec --- /dev/null +++ b/specs/001-support-progress-messages/contracts/library-api.md @@ -0,0 +1,182 @@ +# Library API Contract: Support Progress Messages + +**Feature**: 001-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/001-support-progress-messages/data-model.md b/specs/001-support-progress-messages/data-model.md new file mode 100644 index 0000000..9c39c8d --- /dev/null +++ b/specs/001-support-progress-messages/data-model.md @@ -0,0 +1,174 @@ +# Data Model: Support Progress Messages + +**Feature**: 001-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/001-support-progress-messages/plan.md b/specs/001-support-progress-messages/plan.md new file mode 100644 index 0000000..3de0029 --- /dev/null +++ b/specs/001-support-progress-messages/plan.md @@ -0,0 +1,107 @@ +# Implementation Plan: Support Progress Messages + +**Branch**: `001-support-progress-messages` | **Date**: 2026-04-01 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/001-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. + +## 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**: Preserve current interactive behavior for search, view, and filtering on 100+ message sessions; 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 +**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 | Progress entries are preserved rather than silently dropped, improving fidelity. | + +**Technical Standards Compliance**: +- TypeScript strict mode remains in effect ✅ +- No new external runtime dependencies required ✅ +- Vitest unit and integration coverage will be extended ✅ +- 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 | JSON export and in-memory session models retain progress entries instead of discarding them. | + +No constitution violations require justification. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-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 metadata counts +│ ├── session.ts # Session counts, filtering, and displayable-message logic +│ ├── search.ts # Search extraction and match typing for progress messages +│ ├── export.ts # Preserve progress messages in markdown 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. + +## 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/001-support-progress-messages/quickstart.md b/specs/001-support-progress-messages/quickstart.md new file mode 100644 index 0000000..2f89451 --- /dev/null +++ b/specs/001-support-progress-messages/quickstart.md @@ -0,0 +1,102 @@ +# Quickstart: Support Progress Messages + +**Feature**: 001-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/001-support-progress-messages/research.md b/specs/001-support-progress-messages/research.md new file mode 100644 index 0000000..83b7216 --- /dev/null +++ b/specs/001-support-progress-messages/research.md @@ -0,0 +1,153 @@ +# Research: Support Progress Messages + +**Feature**: 001-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/001-support-progress-messages/spec.md b/specs/001-support-progress-messages/spec.md new file mode 100644 index 0000000..a2f1727 --- /dev/null +++ b/specs/001-support-progress-messages/spec.md @@ -0,0 +1,112 @@ +# Feature Specification: Support Progress Messages + +**Feature Branch**: `001-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`. + +## 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. + +--- + +### 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. + +--- + +### 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 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. +- **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. +- **FR-008**: `cch view --json` MUST include progress messages in the returned message list with their message type 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. +- **FR-013**: Existing visible search and view behavior for already-supported session content MUST remain unchanged except for the addition of progress-message support. + +### 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 be searchable and displayed as transcript content. + +## 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**: Users can isolate progress activity from a 100+ message session in a single filtered view without needing to inspect the raw session file. +- **SC-004**: Standard search and view behavior for sessions that do not contain progress messages remains unchanged in user-facing acceptance tests. diff --git a/specs/001-support-progress-messages/tasks.md b/specs/001-support-progress-messages/tasks.md new file mode 100644 index 0000000..2b1d0db --- /dev/null +++ b/specs/001-support-progress-messages/tasks.md @@ -0,0 +1,197 @@ +# Tasks: Support Progress Messages + +**Input**: Design documents from `/specs/001-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 story phases focus on implementation. Regression coverage is captured in the final polish phase to satisfy project standards. + +**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. + +- [ ] 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. + +- [ ] T002 Add `ProgressMessage`, `ProgressContent`, widened `MessageType`, and widened `SearchMatch` definitions in `src/lib/types.ts` +- [ ] T003 Implement raw `type: "progress"` normalization and displayable-message metadata handling in `src/lib/parser.ts` +- [ ] T004 Update session retrieval counts and activity timestamps to treat progress as displayable transcript content in `src/lib/session.ts` +- [ ] T005 Re-export progress-related public types from `src/lib/index.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 + +- [ ] T006 [US1] Extend searchable progress-text extraction and match typing in `src/lib/search.ts` +- [ ] T007 [US1] Surface `PROGRESS` search result labels in `src/cli/formatters/search.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 + +- [ ] T008 [US2] Render progress transcript blocks distinctly in `src/cli/formatters/session.ts` +- [ ] T009 [US2] Keep `cch view` message totals and JSON filtered counts aligned with progress displayability in `src/cli/commands/view.ts` +- [ ] T010 [US2] Preserve progress transcript entries in Markdown export rendering in `src/lib/export.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 + +- [ ] T011 [US3] Add `progress` to filter type definitions and valid filter values in `src/lib/types.ts` +- [ ] T012 [US3] Classify and retain progress messages during filtered session views in `src/lib/session.ts` +- [ ] T013 [US3] Accept `progress` in view filter parsing and help text in `src/cli/commands/view.ts` +- [ ] T014 [US3] Show progress-only filtered states and empty-result messaging in `src/cli/formatters/session.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 regression coverage, update documentation, and validate the end-to-end feature. + +- [ ] T015 [P] Add parser and session regression coverage for progress messages in `tests/unit/parser.test.ts` and `tests/unit/session.test.ts` +- [ ] T016 [P] Add search, view, and export regression coverage for progress messages in `tests/integration/search-sessions.test.ts`, `tests/integration/cli/search.test.ts`, `tests/integration/cli/view.test.ts`, and `tests/integration/export-sessions.test.ts` +- [ ] T017 Update progress-aware command examples and behavior notes in `README.md` +- [ ] T018 Run the implementation verification steps documented in `specs/001-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 + +- `T015` and `T016` can run in parallel after implementation is complete because they touch different unit and integration test 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 `T017` can be done while 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 progress transcript entries in 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 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. +- The feature branch currently coexists with another `001-*` spec directory; keep using the explicit feature path `specs/001-support-progress-messages/` when running follow-up Specify commands if ambiguity appears again. From 6c7190dfed294d80b644b41e67fa32818e6b60b2 Mon Sep 17 00:00:00 2001 From: borui Date: Wed, 1 Apr 2026 23:33:08 +0800 Subject: [PATCH 2/7] specs: tighten progress-message remediation Capture the agreed remediation decisions for the progress-message feature package. Update the spec to require full fidelity for progress entries across every supported output that surfaces them, including preserved fields, metadata, relationships, and explicit empty/non-readable handling. Add a measurable acceptance target for search, view, and filter operations on 100+ message sessions. Align the implementation plan with the stronger data-fidelity requirement and the new performance target, including explicit validation expectations for fidelity, empty/non-readable progress content, and acceptance-time performance checks. Restructure the task plan so each user story includes at least one explicit validation task, add the previously missing coverage for tests/integration/get-session.test.ts, tests/unit/cli/commands/view.test.ts, and tests/unit/cli/formatters/session.test.ts, and make empty/non-readable progress behavior a named test obligation instead of an implicit regression concern. --- specs/001-support-progress-messages/plan.md | 18 ++++---- specs/001-support-progress-messages/spec.md | 19 +++++--- specs/001-support-progress-messages/tasks.md | 47 +++++++++++--------- 3 files changed, 47 insertions(+), 37 deletions(-) diff --git a/specs/001-support-progress-messages/plan.md b/specs/001-support-progress-messages/plan.md index 3de0029..d5c2f96 100644 --- a/specs/001-support-progress-messages/plan.md +++ b/specs/001-support-progress-messages/plan.md @@ -5,7 +5,7 @@ ## 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. +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 @@ -15,8 +15,8 @@ Add first-class support for `progress` session entries so they survive parsing, **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**: Preserve current interactive behavior for search, view, and filtering on 100+ message sessions; 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 +**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 @@ -31,12 +31,12 @@ Add first-class support for `progress` session entries so they survive parsing, | 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 | Progress entries are preserved rather than silently dropped, improving fidelity. | +| 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 ✅ +- 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 @@ -47,7 +47,7 @@ Add first-class support for `progress` session entries so they survive parsing, | 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 | JSON export and in-memory session models retain progress entries instead of discarding them. | +| 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. @@ -73,10 +73,10 @@ specs/001-support-progress-messages/ src/ ├── lib/ │ ├── types.ts # Add progress message types and filter/search type updates -│ ├── parser.ts # Parse raw progress entries and metadata counts +│ ├── 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 in markdown export formatting +│ ├── export.ts # Preserve progress messages and metadata in export formatting │ └── index.ts # Export new public types └── cli/ ├── commands/ @@ -100,7 +100,7 @@ tests/ └── 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. +**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 diff --git a/specs/001-support-progress-messages/spec.md b/specs/001-support-progress-messages/spec.md index a2f1727..dca9be1 100644 --- a/specs/001-support-progress-messages/spec.md +++ b/specs/001-support-progress-messages/spec.md @@ -10,6 +10,8 @@ ### 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)* @@ -26,6 +28,7 @@ As a developer searching past Claude Code sessions, I want `cch search` to retur 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. --- @@ -42,6 +45,7 @@ As a developer reviewing a session transcript, I want `cch view` to show progres 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. --- @@ -62,6 +66,7 @@ As a developer troubleshooting a session, I want progress messages to participat ### 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. @@ -71,18 +76,19 @@ As a developer troubleshooting a session, I want progress messages to participat ### 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. +- **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. -- **FR-008**: `cch view --json` MUST include progress messages in the returned message list with their message type preserved. +- **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. +- **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)* @@ -95,7 +101,7 @@ As a developer troubleshooting a session, I want progress messages to participat - 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 be searchable and displayed as transcript content. +- 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 @@ -108,5 +114,6 @@ As a developer troubleshooting a session, I want progress messages to participat - **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**: Users can isolate progress activity from a 100+ message session in a single filtered view without needing to inspect the raw session file. +- **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/001-support-progress-messages/tasks.md b/specs/001-support-progress-messages/tasks.md index 2b1d0db..c4e1633 100644 --- a/specs/001-support-progress-messages/tasks.md +++ b/specs/001-support-progress-messages/tasks.md @@ -3,7 +3,7 @@ **Input**: Design documents from `/specs/001-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 story phases focus on implementation. Regression coverage is captured in the final polish phase to satisfy project standards. +**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. @@ -34,9 +34,10 @@ **⚠️ CRITICAL**: No user story work should begin until this phase is complete. - [ ] T002 Add `ProgressMessage`, `ProgressContent`, widened `MessageType`, and widened `SearchMatch` definitions in `src/lib/types.ts` -- [ ] T003 Implement raw `type: "progress"` normalization and displayable-message metadata handling in `src/lib/parser.ts` +- [ ] T003 Implement raw `type: "progress"` normalization and full-fidelity metadata preservation in `src/lib/parser.ts` - [ ] T004 Update session retrieval counts and activity timestamps to treat progress as displayable transcript content in `src/lib/session.ts` - [ ] T005 Re-export progress-related public types from `src/lib/index.ts` +- [ ] 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. @@ -50,8 +51,9 @@ ### Implementation for User Story 1 -- [ ] T006 [US1] Extend searchable progress-text extraction and match typing in `src/lib/search.ts` -- [ ] T007 [US1] Surface `PROGRESS` search result labels in `src/cli/formatters/search.ts` +- [ ] T007 [US1] Extend searchable progress-text extraction and match typing in `src/lib/search.ts` +- [ ] T008 [US1] Surface `PROGRESS` search result labels in `src/cli/formatters/search.ts` +- [ ] 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. @@ -65,9 +67,10 @@ ### Implementation for User Story 2 -- [ ] T008 [US2] Render progress transcript blocks distinctly in `src/cli/formatters/session.ts` -- [ ] T009 [US2] Keep `cch view` message totals and JSON filtered counts aligned with progress displayability in `src/cli/commands/view.ts` -- [ ] T010 [US2] Preserve progress transcript entries in Markdown export rendering in `src/lib/export.ts` +- [ ] T010 [US2] Render progress transcript blocks distinctly while surfacing preserved progress metadata in `src/cli/formatters/session.ts` +- [ ] T011 [US2] Keep `cch view` message totals and JSON filtered counts aligned with progress displayability and fidelity in `src/cli/commands/view.ts` +- [ ] T012 [US2] Preserve full-fidelity progress entries in JSON and Markdown export rendering in `src/lib/export.ts` +- [ ] 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. @@ -81,10 +84,11 @@ ### Implementation for User Story 3 -- [ ] T011 [US3] Add `progress` to filter type definitions and valid filter values in `src/lib/types.ts` -- [ ] T012 [US3] Classify and retain progress messages during filtered session views in `src/lib/session.ts` -- [ ] T013 [US3] Accept `progress` in view filter parsing and help text in `src/cli/commands/view.ts` -- [ ] T014 [US3] Show progress-only filtered states and empty-result messaging in `src/cli/formatters/session.ts` +- [ ] T014 [US3] Add `progress` to filter type definitions and valid filter values in `src/lib/types.ts` +- [ ] T015 [US3] Classify and retain progress messages during filtered session views in `src/lib/session.ts` +- [ ] T016 [US3] Accept `progress` in view filter parsing and help text in `src/cli/commands/view.ts` +- [ ] T017 [US3] Show progress-only filtered states and empty-result messaging in `src/cli/formatters/session.ts` +- [ ] 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. @@ -92,12 +96,11 @@ ## Phase 6: Polish & Cross-Cutting Concerns -**Purpose**: Lock in regression coverage, update documentation, and validate the end-to-end feature. +**Purpose**: Lock in shared regression coverage, update documentation, and validate the end-to-end feature. -- [ ] T015 [P] Add parser and session regression coverage for progress messages in `tests/unit/parser.test.ts` and `tests/unit/session.test.ts` -- [ ] T016 [P] Add search, view, and export regression coverage for progress messages in `tests/integration/search-sessions.test.ts`, `tests/integration/cli/search.test.ts`, `tests/integration/cli/view.test.ts`, and `tests/integration/export-sessions.test.ts` -- [ ] T017 Update progress-aware command examples and behavior notes in `README.md` -- [ ] T018 Run the implementation verification steps documented in `specs/001-support-progress-messages/quickstart.md` +- [ ] T019 [P] Add shared session-count and transcript-order regression coverage for progress messages in `tests/unit/session.test.ts` +- [ ] T020 [P] Update progress-aware command examples, fidelity guarantees, and performance expectations in `README.md` +- [ ] T021 Run the implementation verification steps documented in `specs/001-support-progress-messages/quickstart.md` --- @@ -128,17 +131,17 @@ Phase 2 Foundational └── Phase 4 US2 ↓ Phase 5 US3 - ↓ -Phase 6 Polish + ↓ + Phase 6 Polish ``` --- ## Parallel Opportunities -- `T015` and `T016` can run in parallel after implementation is complete because they touch different unit and integration test files. +- `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 `T017` can be done while regression coverage is being completed, once the final CLI behavior is stable. +- 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 @@ -153,7 +156,7 @@ Task: "Surface PROGRESS search result labels in src/cli/formatters/search.ts" ```bash # After foundational work is merged: Task: "Render progress transcript blocks distinctly in src/cli/formatters/session.ts" -Task: "Preserve progress transcript entries in Markdown export rendering in src/lib/export.ts" +Task: "Preserve full-fidelity progress entries in JSON and Markdown export rendering in src/lib/export.ts" ``` ## Parallel Example: User Story 3 @@ -181,7 +184,7 @@ Task: "Accept progress in view filter parsing and help text in src/cli/commands/ 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 regression coverage, docs, and quickstart validation +5. Finish with shared regression coverage, docs, and quickstart validation ### Suggested MVP Scope From 73cc2824cf682f52df8e4018726ad276f51612c2 Mon Sep 17 00:00:00 2001 From: borui Date: Thu, 2 Apr 2026 00:16:27 +0800 Subject: [PATCH 3/7] specs: renumber progress-message feature to 007 Rename the progress-message spec package from 001-support-progress-messages to 007-support-progress-messages to resolve the duplicate 001-* numbering conflict in specs/. Update the feature branch identifier, internal spec/package references, quickstart and contract metadata, task paths, and generated AGENTS.md entries so the renumbered package is self-consistent. This commit is a bookkeeping renumber only; it does not change feature scope or implementation behavior. --- AGENTS.md | 4 ++-- .../checklists/requirements.md | 0 .../contracts/cli-interface.md | 2 +- .../contracts/library-api.md | 2 +- .../data-model.md | 2 +- .../plan.md | 6 +++--- .../quickstart.md | 2 +- .../research.md | 2 +- .../spec.md | 2 +- .../tasks.md | 6 +++--- 10 files changed, 14 insertions(+), 14 deletions(-) rename specs/{001-support-progress-messages => 007-support-progress-messages}/checklists/requirements.md (100%) rename specs/{001-support-progress-messages => 007-support-progress-messages}/contracts/cli-interface.md (99%) rename specs/{001-support-progress-messages => 007-support-progress-messages}/contracts/library-api.md (99%) rename specs/{001-support-progress-messages => 007-support-progress-messages}/data-model.md (99%) rename specs/{001-support-progress-messages => 007-support-progress-messages}/plan.md (97%) rename specs/{001-support-progress-messages => 007-support-progress-messages}/quickstart.md (99%) rename specs/{001-support-progress-messages => 007-support-progress-messages}/research.md (99%) rename specs/{001-support-progress-messages => 007-support-progress-messages}/spec.md (99%) rename specs/{001-support-progress-messages => 007-support-progress-messages}/tasks.md (96%) diff --git a/AGENTS.md b/AGENTS.md index d0712ad..764cc05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ 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`) (001-support-progress-messages) +- TypeScript 5.x with strict mode enabled + Commander.js (CLI framework), Node.js built-ins (`fs`, `path`, `readline`) (007-support-progress-messages) ## Project Structure @@ -23,7 +23,7 @@ TypeScript 5.x with strict mode enabled: Follow standard conventions ## Recent Changes -- 001-support-progress-messages: Added TypeScript 5.x with strict mode enabled + Commander.js (CLI framework), Node.js built-ins (`fs`, `path`, `readline`) +- 007-support-progress-messages: Added TypeScript 5.x with strict mode enabled + Commander.js (CLI framework), Node.js built-ins (`fs`, `path`, `readline`) diff --git a/specs/001-support-progress-messages/checklists/requirements.md b/specs/007-support-progress-messages/checklists/requirements.md similarity index 100% rename from specs/001-support-progress-messages/checklists/requirements.md rename to specs/007-support-progress-messages/checklists/requirements.md diff --git a/specs/001-support-progress-messages/contracts/cli-interface.md b/specs/007-support-progress-messages/contracts/cli-interface.md similarity index 99% rename from specs/001-support-progress-messages/contracts/cli-interface.md rename to specs/007-support-progress-messages/contracts/cli-interface.md index 7ffb314..cdda8c5 100644 --- a/specs/001-support-progress-messages/contracts/cli-interface.md +++ b/specs/007-support-progress-messages/contracts/cli-interface.md @@ -1,6 +1,6 @@ # CLI Interface Contract: Support Progress Messages -**Feature**: 001-support-progress-messages +**Feature**: 007-support-progress-messages **Date**: 2026-04-01 ## Overview diff --git a/specs/001-support-progress-messages/contracts/library-api.md b/specs/007-support-progress-messages/contracts/library-api.md similarity index 99% rename from specs/001-support-progress-messages/contracts/library-api.md rename to specs/007-support-progress-messages/contracts/library-api.md index ea71dec..e0ce66c 100644 --- a/specs/001-support-progress-messages/contracts/library-api.md +++ b/specs/007-support-progress-messages/contracts/library-api.md @@ -1,6 +1,6 @@ # Library API Contract: Support Progress Messages -**Feature**: 001-support-progress-messages +**Feature**: 007-support-progress-messages **Date**: 2026-04-01 ## Overview diff --git a/specs/001-support-progress-messages/data-model.md b/specs/007-support-progress-messages/data-model.md similarity index 99% rename from specs/001-support-progress-messages/data-model.md rename to specs/007-support-progress-messages/data-model.md index 9c39c8d..d77d6d4 100644 --- a/specs/001-support-progress-messages/data-model.md +++ b/specs/007-support-progress-messages/data-model.md @@ -1,6 +1,6 @@ # Data Model: Support Progress Messages -**Feature**: 001-support-progress-messages +**Feature**: 007-support-progress-messages **Date**: 2026-04-01 ## Overview diff --git a/specs/001-support-progress-messages/plan.md b/specs/007-support-progress-messages/plan.md similarity index 97% rename from specs/001-support-progress-messages/plan.md rename to specs/007-support-progress-messages/plan.md index d5c2f96..86cc40c 100644 --- a/specs/001-support-progress-messages/plan.md +++ b/specs/007-support-progress-messages/plan.md @@ -1,7 +1,7 @@ # Implementation Plan: Support Progress Messages -**Branch**: `001-support-progress-messages` | **Date**: 2026-04-01 | **Spec**: [spec.md](./spec.md) -**Input**: Feature specification from `/specs/001-support-progress-messages/spec.md` +**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 @@ -56,7 +56,7 @@ No constitution violations require justification. ### Documentation (this feature) ```text -specs/001-support-progress-messages/ +specs/007-support-progress-messages/ ├── plan.md ├── research.md ├── data-model.md diff --git a/specs/001-support-progress-messages/quickstart.md b/specs/007-support-progress-messages/quickstart.md similarity index 99% rename from specs/001-support-progress-messages/quickstart.md rename to specs/007-support-progress-messages/quickstart.md index 2f89451..da757e3 100644 --- a/specs/001-support-progress-messages/quickstart.md +++ b/specs/007-support-progress-messages/quickstart.md @@ -1,6 +1,6 @@ # Quickstart: Support Progress Messages -**Feature**: 001-support-progress-messages +**Feature**: 007-support-progress-messages **Date**: 2026-04-01 ## Overview diff --git a/specs/001-support-progress-messages/research.md b/specs/007-support-progress-messages/research.md similarity index 99% rename from specs/001-support-progress-messages/research.md rename to specs/007-support-progress-messages/research.md index 83b7216..0fdd82b 100644 --- a/specs/001-support-progress-messages/research.md +++ b/specs/007-support-progress-messages/research.md @@ -1,6 +1,6 @@ # Research: Support Progress Messages -**Feature**: 001-support-progress-messages +**Feature**: 007-support-progress-messages **Date**: 2026-04-01 ## Overview diff --git a/specs/001-support-progress-messages/spec.md b/specs/007-support-progress-messages/spec.md similarity index 99% rename from specs/001-support-progress-messages/spec.md rename to specs/007-support-progress-messages/spec.md index dca9be1..0f68959 100644 --- a/specs/001-support-progress-messages/spec.md +++ b/specs/007-support-progress-messages/spec.md @@ -1,6 +1,6 @@ # Feature Specification: Support Progress Messages -**Feature Branch**: `001-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." diff --git a/specs/001-support-progress-messages/tasks.md b/specs/007-support-progress-messages/tasks.md similarity index 96% rename from specs/001-support-progress-messages/tasks.md rename to specs/007-support-progress-messages/tasks.md index c4e1633..31b184d 100644 --- a/specs/001-support-progress-messages/tasks.md +++ b/specs/007-support-progress-messages/tasks.md @@ -1,6 +1,6 @@ # Tasks: Support Progress Messages -**Input**: Design documents from `/specs/001-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. @@ -100,7 +100,7 @@ - [ ] T019 [P] Add shared session-count and transcript-order regression coverage for progress messages in `tests/unit/session.test.ts` - [ ] T020 [P] Update progress-aware command examples, fidelity guarantees, and performance expectations in `README.md` -- [ ] T021 Run the implementation verification steps documented in `specs/001-support-progress-messages/quickstart.md` +- [ ] T021 Run the implementation verification steps documented in `specs/007-support-progress-messages/quickstart.md` --- @@ -197,4 +197,4 @@ Task: "Accept progress in view filter parsing and help text in src/cli/commands/ - 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. -- The feature branch currently coexists with another `001-*` spec directory; keep using the explicit feature path `specs/001-support-progress-messages/` when running follow-up Specify commands if ambiguity appears again. +- This feature package is renumbered to `007-support-progress-messages`; use that explicit path in follow-up Specify commands if needed. From fe0757daa0b24f6ccb23d33c802da98baa6e90bf Mon Sep 17 00:00:00 2001 From: borui Date: Thu, 2 Apr 2026 16:07:44 +0800 Subject: [PATCH 4/7] feat: support progress messages across search, view, filter, and export Implement first-class support for top-level session entries with type "progress" so they are preserved in the typed message model, included in session counts and timestamps, searchable through library and CLI search flows, visible in session views, filterable via --only progress, and preserved in JSON and Markdown exports. This change extends the core library model with a dedicated ProgressMessage type, teaches the parser to normalize readable progress blocks while safely handling empty or non-readable payloads, updates session filtering and search extraction to treat progress as displayable/searchable transcript content, and adds distinct CLI rendering and labeling for progress output. It also adds a reusable progress fixture, publishing ignore rules, README updates, and full regression coverage across unit, integration, and CLI test paths. Verification: - npm run typecheck - npm test - npm run build - node dist/cli/index.js --data-path /tmp/cch-progress-verify-run search "PROGRESS_ONLY_TOKEN" --full - node dist/cli/index.js --data-path /tmp/cch-progress-verify-run view 0 --only progress --full File summary: .npmignore: add npm publishing ignore rules for source, specs, tests, local metadata, and build-only package contents. README.md: document searchable/viewable/exported progress messages and add progress-specific CLI examples. specs/007-support-progress-messages/tasks.md: mark all implementation and verification tasks complete. src/cli/commands/view.ts: treat session.messageCount as the displayable total and add progress to --only validation/help text. src/cli/formatters/search.ts: render PROGRESS as a first-class search result label. src/cli/formatters/session.ts: render progress transcript blocks with preserved metadata and empty-text placeholders. src/lib/export.ts: preserve progress messages in Markdown export and include full progress metadata in rendered output. src/lib/index.ts: re-export the new progress types from the public library surface. src/lib/parser.ts: normalize raw progress entries into typed messages and count them in metadata extraction. src/lib/search.ts: include progress content in search extraction and emit progress matches. src/lib/session.ts: count, timestamp, classify, and filter progress messages as displayable transcript content. src/lib/types.ts: add ProgressMessage and ProgressContent, widen message/filter/search unions, and expose progress as a valid filter type. tests/fixtures/progress-session.jsonl: add a reusable progress fixture covering readable and non-readable progress entries. tests/integration/cli/search.test.ts: verify CLI search returns progress-only matches and labels them as PROGRESS. tests/integration/cli/view.test.ts: verify transcript rendering, JSON output, progress-only filtering, and empty progress states. tests/integration/export-sessions.test.ts: verify JSON and Markdown export preserve progress entries and metadata. tests/integration/get-session.test.ts: verify getSession preserves progress entries, counts, timestamps, and metadata. tests/integration/search-sessions.test.ts: verify library search finds progress-only matches, ignores unreadable progress blocks, and meets the 100+ message target. tests/unit/cli/commands/view.test.ts: verify the view command accepts progress as a valid filter type. tests/unit/cli/formatters/search.test.ts: verify formatted search output labels progress matches correctly. tests/unit/cli/formatters/session.test.ts: verify formatted sessions render progress blocks and unreadable-progress placeholders. tests/unit/parser.test.ts: verify progress transformation, safe unreadable handling, and progress-aware metadata parsing. tests/unit/session.test.ts: verify progress classification, filtering, displayable defaults, and transcript ordering. --- .npmignore | 20 +++ README.md | 43 ++++-- specs/007-support-progress-messages/tasks.md | 42 +++--- src/cli/commands/view.ts | 11 +- src/cli/formatters/search.ts | 27 ++-- src/cli/formatters/session.ts | 93 +++++++------ src/lib/export.ts | 27 ++++ src/lib/index.ts | 3 + src/lib/parser.ts | 53 +++++++- src/lib/search.ts | 14 +- src/lib/session.ts | 26 ++-- src/lib/types.ts | 42 +++++- tests/fixtures/progress-session.jsonl | 5 + tests/integration/cli/search.test.ts | 78 ++++++++--- tests/integration/cli/view.test.ts | 136 ++++++++++++++----- tests/integration/export-sessions.test.ts | 38 +++++- tests/integration/get-session.test.ts | 27 +++- tests/integration/search-sessions.test.ts | 118 +++++++++++++++- tests/unit/cli/commands/view.test.ts | 29 +++- tests/unit/cli/formatters/search.test.ts | 15 +- tests/unit/cli/formatters/session.test.ts | 79 ++++++++++- tests/unit/parser.test.ts | 110 +++++++++++++++ tests/unit/session.test.ts | 68 +++++++++- 23 files changed, 920 insertions(+), 184 deletions(-) create mode 100644 .npmignore create mode 100644 tests/fixtures/progress-session.jsonl 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/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/specs/007-support-progress-messages/tasks.md b/specs/007-support-progress-messages/tasks.md index 31b184d..bd1c725 100644 --- a/specs/007-support-progress-messages/tasks.md +++ b/specs/007-support-progress-messages/tasks.md @@ -23,7 +23,7 @@ **Purpose**: Prepare a reusable progress-entry fixture for implementation and verification across the feature. -- [ ] T001 Create a reusable top-level progress-entry fixture in `tests/fixtures/progress-session.jsonl` +- [x] T001 Create a reusable top-level progress-entry fixture in `tests/fixtures/progress-session.jsonl` --- @@ -33,11 +33,11 @@ **⚠️ CRITICAL**: No user story work should begin until this phase is complete. -- [ ] T002 Add `ProgressMessage`, `ProgressContent`, widened `MessageType`, and widened `SearchMatch` definitions in `src/lib/types.ts` -- [ ] T003 Implement raw `type: "progress"` normalization and full-fidelity metadata preservation in `src/lib/parser.ts` -- [ ] T004 Update session retrieval counts and activity timestamps to treat progress as displayable transcript content in `src/lib/session.ts` -- [ ] T005 Re-export progress-related public types from `src/lib/index.ts` -- [ ] T006 Add foundational parser regression coverage for readable, empty, and non-readable progress entries in `tests/unit/parser.test.ts` +- [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. @@ -51,9 +51,9 @@ ### Implementation for User Story 1 -- [ ] T007 [US1] Extend searchable progress-text extraction and match typing in `src/lib/search.ts` -- [ ] T008 [US1] Surface `PROGRESS` search result labels in `src/cli/formatters/search.ts` -- [ ] 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` +- [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. @@ -67,10 +67,10 @@ ### Implementation for User Story 2 -- [ ] T010 [US2] Render progress transcript blocks distinctly while surfacing preserved progress metadata in `src/cli/formatters/session.ts` -- [ ] T011 [US2] Keep `cch view` message totals and JSON filtered counts aligned with progress displayability and fidelity in `src/cli/commands/view.ts` -- [ ] T012 [US2] Preserve full-fidelity progress entries in JSON and Markdown export rendering in `src/lib/export.ts` -- [ ] 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` +- [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. @@ -84,11 +84,11 @@ ### Implementation for User Story 3 -- [ ] T014 [US3] Add `progress` to filter type definitions and valid filter values in `src/lib/types.ts` -- [ ] T015 [US3] Classify and retain progress messages during filtered session views in `src/lib/session.ts` -- [ ] T016 [US3] Accept `progress` in view filter parsing and help text in `src/cli/commands/view.ts` -- [ ] T017 [US3] Show progress-only filtered states and empty-result messaging in `src/cli/formatters/session.ts` -- [ ] 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` +- [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. @@ -98,9 +98,9 @@ **Purpose**: Lock in shared regression coverage, update documentation, and validate the end-to-end feature. -- [ ] T019 [P] Add shared session-count and transcript-order regression coverage for progress messages in `tests/unit/session.test.ts` -- [ ] T020 [P] Update progress-aware command examples, fidelity guarantees, and performance expectations in `README.md` -- [ ] T021 Run the implementation verification steps documented in `specs/007-support-progress-messages/quickstart.md` +- [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` --- diff --git a/src/cli/commands/view.ts b/src/cli/commands/view.ts index a423a00..e8ab56e 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,7 @@ async function executeView( } const session = await getSession(sessionRef, libConfig); - const totalMessageCount = session.messages.filter( - (m) => m.type === 'user' || m.type === 'assistant' - ).length; + const totalMessageCount = session.messageCount; // Compute token statistics for the session const tokenStats = computeTokenStats(session.messages); @@ -189,7 +184,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/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..aec0de9 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}`, @@ -300,10 +306,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 +332,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 +344,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/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..f8dbd56 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,38 @@ 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); +} + /** * Transform a raw session entry into a typed Message. * @param entry - Raw parsed entry from JSONL @@ -262,6 +297,20 @@ export function transformEntry(entry: RawSessionEntry): Message | null { } as AssistantMessage; } + case 'progress': { + const message = entry.message as RawMessage | undefined; + return { + type: 'progress', + uuid, + parentUuid, + timestamp, + content: parseProgressContent(message?.content), + cwd: entry.cwd ?? '', + gitBranch: entry.gitBranch ?? null, + isSidechain: entry.isSidechain ?? false, + } as ProgressMessage; + } + case 'summary': { return { type: 'summary', @@ -367,8 +416,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/types.ts b/src/lib/types.ts index 8a24798..81f3aeb 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; 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/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..7fc800c 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 @@ -85,7 +96,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}`, { @@ -182,9 +196,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 +279,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 +321,73 @@ 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 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 +398,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 +408,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 +418,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/view.test.ts b/tests/unit/cli/commands/view.test.ts index cc395b8..c78a668 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', () => { @@ -188,4 +194,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..342bcfd 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 @@ -139,6 +149,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/parser.test.ts b/tests/unit/parser.test.ts index 9e2556b..297071f 100644 --- a/tests/unit/parser.test.ts +++ b/tests/unit/parser.test.ts @@ -572,6 +572,57 @@ 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([]); + } + }); }); describe('parseSessionFile', () => { @@ -603,6 +654,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 +723,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 +772,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']); + }); }); From 5f451c7bcd02ce763cf8295a9269b618eef74623 Mon Sep 17 00:00:00 2001 From: borui Date: Thu, 2 Apr 2026 16:33:51 +0800 Subject: [PATCH 5/7] fix(view): count only displayable messages in default session output Use filtered displayable messages as the default view set so unfiltered cch view no longer counts hidden message types such as summary entries in the Messages header. Adjust the session header formatter so filtered wording only appears when an actual filter is applied, and add regression coverage for the updated default path and header behavior. --- src/cli/commands/view.ts | 5 +++-- src/cli/formatters/session.ts | 4 +++- tests/unit/cli/commands/view.test.ts | 10 ++++++++++ tests/unit/cli/formatters/session.test.ts | 12 ++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/view.ts b/src/cli/commands/view.ts index e8ab56e..7d83fb8 100644 --- a/src/cli/commands/view.ts +++ b/src/cli/commands/view.ts @@ -93,7 +93,8 @@ async function executeView(sessionArg: string, options: ViewOptions): Promise { 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(() => { diff --git a/tests/unit/cli/formatters/session.test.ts b/tests/unit/cli/formatters/session.test.ts index 342bcfd..44bdad6 100644 --- a/tests/unit/cli/formatters/session.test.ts +++ b/tests/unit/cli/formatters/session.test.ts @@ -85,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); From f62dbe0292b5cd9aeab405d6df114e011d4189a2 Mon Sep 17 00:00:00 2001 From: borui Date: Thu, 2 Apr 2026 17:02:09 +0800 Subject: [PATCH 6/7] fix(progress): parse nested real-data progress payloads Extract progress text from real Claude session schemas that store readable progress content under nested agent_progress payloads, including data.message and data.normalizedMessages, while preserving the empty placeholder behavior for hook-style progress entries that do not carry human-readable text. Add focused parser and CLI view coverage for the nested real-data variants so progress rendering is verified against the same schema shape observed in host data. Run the repository formatter as requested before validation. Because the project format script rewrites all src and test TypeScript files, this commit also includes the resulting repo-wide Prettier normalization changes. Validation: - npm run format - npm run typecheck - npm test --- src/cli/commands/export.ts | 25 ++-- src/cli/commands/list.ts | 11 +- src/cli/commands/migrate.ts | 41 ++++--- src/cli/commands/search.ts | 30 +---- src/cli/formatters/pager.ts | 5 +- src/cli/formatters/table.ts | 20 +--- src/cli/utils/config.ts | 3 +- src/cli/utils/errors.ts | 13 +-- src/cli/utils/output.ts | 16 +-- src/lib/parser.ts | 53 ++++++++- src/lib/stats.ts | 11 +- src/lib/types.ts | 2 + tests/integration/cli/config.test.ts | 72 ++++++------ tests/integration/cli/export.test.ts | 144 +++++++++++++----------- tests/integration/cli/list.test.ts | 74 +++++------- tests/integration/cli/migrate.test.ts | 90 +++++++-------- tests/integration/cli/view.test.ts | 109 ++++++++++++++++++ tests/unit/cli/commands/list.test.ts | 9 +- tests/unit/cli/commands/search.test.ts | 6 +- tests/unit/cli/commands/view.test.ts | 4 +- tests/unit/cli/formatters/table.test.ts | 11 +- tests/unit/cli/utils/errors.test.ts | 23 +--- tests/unit/cli/utils/output.test.ts | 13 +-- tests/unit/parser.test.ts | 72 ++++++++++++ tests/unit/stats.test.ts | 5 +- 25 files changed, 512 insertions(+), 350 deletions(-) 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/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/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/parser.ts b/src/lib/parser.ts index f8dbd56..204fd03 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -256,6 +256,56 @@ function parseProgressContent(rawContent: unknown): ProgressContent[] { .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 @@ -298,13 +348,12 @@ export function transformEntry(entry: RawSessionEntry): Message | null { } case 'progress': { - const message = entry.message as RawMessage | undefined; return { type: 'progress', uuid, parentUuid, timestamp, - content: parseProgressContent(message?.content), + content: extractProgressContent(entry), cwd: entry.cwd ?? '', gitBranch: entry.gitBranch ?? null, isSidechain: entry.isSidechain ?? false, 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 81f3aeb..8fff73e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -431,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/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/view.test.ts b/tests/integration/cli/view.test.ts index 7fc800c..ce0f220 100644 --- a/tests/integration/cli/view.test.ts +++ b/tests/integration/cli/view.test.ts @@ -93,6 +93,37 @@ 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 */ @@ -372,6 +403,84 @@ describe('cch view', () => { 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' }, 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 bffaa53..7217e74 100644 --- a/tests/unit/cli/commands/view.test.ts +++ b/tests/unit/cli/commands/view.test.ts @@ -85,7 +85,9 @@ describe('view command', () => { vi.mocked(computeTokenStats).mockReset(); vi.mocked(outputWithPager).mockReset(); - vi.mocked(filterMessages).mockImplementation((messages) => messages as typeof mockSession.messages); + vi.mocked(filterMessages).mockImplementation( + (messages) => messages as typeof mockSession.messages + ); vi.mocked(computeTokenStats).mockReturnValue({ inputTokens: 50, outputTokens: 25, 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 297071f..282680a 100644 --- a/tests/unit/parser.test.ts +++ b/tests/unit/parser.test.ts @@ -623,6 +623,78 @@ describe('transformEntry', () => { 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', () => { 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); From f259d078ec148a98af9be3eaa769f4d51ab6cad0 Mon Sep 17 00:00:00 2001 From: borui Date: Thu, 2 Apr 2026 17:03:48 +0800 Subject: [PATCH 7/7] chore(release): bump version to 0.6.0 Advance the package version from 0.5.0 to 0.6.0 for the progress-message feature release. Also synchronize the npm lockfile root package metadata, which was previously stale at 0.3.1. No tests were run for this commit because it only updates release metadata. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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",