diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index dc162c90..c0c79134 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -25,6 +25,8 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - [No prod changes for testability](feedback_no_prod_changes_for_tests.md) — mock implementations in tests (spyOn os.homedir works cross-module); never alter prod semantics for test isolation - [Pick the right enforcement layer](feedback_prefer_tests_over_adr_rules.md) — static syntactic invariants → custom oxlint rule in `.archgate/lint/oxlint.ts`; tests are for executable behavior; ADR .rules.ts for cross-file/governance checks +- [This repo is PUBLIC — no private sibling-repo internals in memory/PRs](feedback_public_repo_privacy.md) — split captures: public-safe summary here, full detail in the private repo's own memory + ## Known Bugs - _(none currently)_ @@ -44,8 +46,10 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - **YAML double-quoted strings require escaped backslashes for Windows paths in tests** — YAML interprets `\` as an escape character inside double-quoted strings. Writing `cwd: "E:\project"` silently corrupts the parsed value because `\p` is not a valid escape sequence. Fix: use `JSON.stringify(path)` to produce properly escaped YAML values (e.g., `cwd: ${JSON.stringify(cwd)}`). JSON and YAML double-quoted strings share the same escape syntax. Encountered in Copilot CLI session-context tests (`workspace.yaml` with Windows paths). - **`Bun.Glob.match()` triggers oxlint `prefer-regexp-test`** — `Bun.Glob.match()` returns a boolean (not a RegExp), but oxlint can't tell. Suppress with `// oxlint-disable-next-line prefer-regexp-test -- Bun.Glob.match() returns boolean, not RegExp`. - **Live `opencode.db` can't be opened `readonly: true` while opencode runs** — `new Database(path, { readonly: true })` fails with `SQLITE_CANTOPEN` (errno 14) on the live WAL-mode DB (`~/.local/share/opencode/opencode.db`). To inspect real data, copy `opencode.db` + `.db-wal` + `.db-shm` to a temp dir and open the copy. Data model facts load-bearing for `session-context opencode`: sub-agent runs are child sessions (`parent_id` set) sharing the parent's `directory`, and opencode skills run INLINE in the calling session (no own session row) — recency selection must filter `parent_id IS NULL` (fixed 2026-07-01; `--root` resolves a `--session-id` child to its top-level ancestor). -- **The distributed opencode lessons-learned skill references session-context CLI flags — sequence releases** — the skill instructs `archgate session-context opencode --root` (was `--skip 1`, which read sub-agent transcripts). When changing session-context CLI semantics, update the shipped skill in the same effort AND sequence releases: the CLI ships the flag first, the plugin follows — a skill referencing a flag the installed CLI lacks dies with "unknown option". Don't hand-edit the installed copy under `~/.config/opencode/skills/` before the CLI release. +- [session-context --skip 1 inline-skill bug](project_session_context_skip_root_fix.md) — opencode fixed via top-level default + `--root`; claude-code/cursor/copilot guidance fixed with plain command — the "skill runs as a sub-agent" premise was false everywhere +- **The distributed opencode lessons-learned skill references session-context CLI flags — sequence releases** — the skill instructs the plain `archgate session-context opencode` (was `--skip 1`, which read sub-agent transcripts; the escape hatch references `session-context list`/`show`). When changing session-context CLI semantics, update the shipped skill in the same effort AND sequence releases. Flag ADDITIONS: CLI ships first (a skill referencing a flag the installed CLI lacks dies with "unknown option"). Flag REMOVALS (e.g. --skip, removed 2026-07-02): already-installed skills still reference the dead flag and their command errors on the new CLI — ship the plugin release promptly after the CLI release and keep an error-fallback path in the skill text. Don't hand-edit the installed copy under `~/.config/opencode/skills/` before the CLI release. - **Verify haiku reviewer findings against the cited ADR's text before blocking** — A haiku review sub-agent reported "await on a synchronous helper" as an ARCH-012 violation; ARCH-012 only mandates try-catch boundaries/exit codes and its automated rules passed. Re-read the cited ADR's Decision/Do's before accepting a FAIL; overrule misattributed style nits but still surface them as non-ADR notes. Recurred 2026-07-01: the same await-on-sync-helper nit came back self-labeled "ARCH-NONE" — a finding citing no ADR can never block. +- **Bun `mock.module` state is process-global across test FILES** — a helper mock registered in one command test file leaks into other files' imports of the same module (live bindings get re-bound to the mock), and module-level constants that capture function references freeze whatever binding existed at load time. Symptom: tests pass in isolation, fail (or silently hit mocks) in the full run. When a command test needs REAL helper behavior while sibling files mock those helpers, spawn the CLI via `tests/integration/cli-harness` `runCli` with `HOME`/`USERPROFILE`/`XDG_DATA_HOME` redirected to a temp dir (fresh subprocess = env-based homedir works). This is also why ARCH-005 prefers `spyOn` over `mock.module`. Hit in session-context list/show tests (PR #446). - **Git Bash `/tmp` is invisible to Windows-native tools** — A bash redirect to `/tmp/x` writes into Git Bash's virtual mount, but Windows-native python/node then fail with `FileNotFoundError` on that path (gh.exe survives only because Git Bash rewrites path-looking arguments). When piping a file between bash and Windows-native tools, use a repo-relative path (and clean it up) or `$TMPDIR`. - **oxlint `no-negated-condition`** — Always write ternaries with the positive condition first: `x === null ? A : B` not `x !== null ? B : A`. Applies to both `if/else` blocks and ternary expressions. - **oxlint `no-unused-vars` on catch parameters** — Use bare `catch { }` (no parameter) when the caught error is not used. `catch (err) { }` with unused `err` triggers the rule. diff --git a/.claude/agent-memory/archgate-developer/feedback_public_repo_privacy.md b/.claude/agent-memory/archgate-developer/feedback_public_repo_privacy.md new file mode 100644 index 00000000..40b09434 --- /dev/null +++ b/.claude/agent-memory/archgate-developer/feedback_public_repo_privacy.md @@ -0,0 +1,12 @@ +--- +name: feedback-public-repo-privacy +description: This repo is PUBLIC — never commit private sibling-repo internals into agent memory, PR bodies, or PR comments here +metadata: + type: feedback +--- + +Never put internals of private sibling repos (repo-relative paths, build-script names, service/backend structure, private PR numbers/links) into anything committed or posted to this repo: agent-memory files, MEMORY.md, PR bodies, PR comments, commit messages. This repo is public; the sibling plugins repo is private. + +**Why:** On 2026-07-02 the user flagged that agent-memory files describing the private sibling repo's build pipeline had been pushed to public PRs — they had to be scrubbed from the branch, the closed PR's branch deleted, and the PR body/comments edited. GitHub retains closed-PR diffs, so leaked content is hard to fully purge after the fact. + +**How to apply:** Before committing memory or posting PR text here, check for private-repo references. The private repo's _existence_ and the distributed plugin's _user-facing behavior_ (installed skill paths like `~/.config/opencode/skills/`, CLI flags the skills invoke) are public knowledge and fine to mention; its internal structure is not. Detailed sibling-repo knowledge belongs in that repo's own agent memory. When work spans both repos, split the capture: public-safe summary here, full detail there. diff --git a/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md index d2e4b95e..969bfe30 100644 --- a/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md +++ b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md @@ -1,6 +1,6 @@ --- name: project-session-context-skip-root-fix -description: opencode session-context --skip 1 sibling/inline-skill bug fixed 2026-07-01 (top-level default + --root ancestry walk); same class of bug likely still open for claude-code/cursor/copilot +description: session-context --skip 1 inline-skill bug — opencode fixed 2026-07-01 (top-level default + --root); claude-code/cursor/copilot guidance fixed 2026-07-02 (plain command; --skip 1 premise was false everywhere) metadata: type: project --- @@ -11,6 +11,8 @@ Fixed a real, reproduced bug in `archgate session-context opencode --skip 1`: it Top-level filtering fixes the common case regardless of nesting depth or sibling count; `--session-id --root` gives deterministic ancestry resolution when the caller knows a session inside the right conversation tree (relevant when several top-level sessions share a directory — recency alone can pick a different conversation's root). The distributed archgate editor plugin's opencode lessons-learned skill was updated to use `--root` instead of `--skip 1`. -**Open/unresolved:** The _same class_ of bug likely still affects the other 3 editors' lessons-learned skill guidance (`archgate session-context claude-code --skip 1`, `cursor --skip 1`). Live-reproduced for Claude Code: running `archgate session-context claude-code --skip 1` from within an inline Skill invocation returned an unrelated, unconnected past session (`6ee6f0a5-...` about "ExitWorktree"), not the current/parent conversation — because Claude Code's Skill tool also runs inline, so `--skip 1` skips past the correct (current) session file. Unlike opencode, Claude Code/Cursor have no `parent_id`-equivalent — sessions are flat per-conversation JSONL/directory files with no structural parent link, so the `--root` fix pattern doesn't directly transfer; a different fix (e.g. detecting "am I inline vs a real sub-agent" some other way, or defaulting to `--skip 0` for inline skill runs) would be needed. Not investigated for Copilot. This has NOT been fixed — flag to the user before doing more lessons-learned/reviewer work that depends on `--skip 1` being reliable for non-opencode editors. +**Resolved for the other editors (2026-07-02):** The `--skip 1` guidance was wrong for ALL editors, not just opencode, but for a different reason — the premise "this skill runs as a sub-agent with its own session file" is simply false. Verified for Claude Code on real data: (1) skills run inline, so the current conversation is the most recent session file and `--skip 1` reads an unrelated previous conversation (live-reproduced: got an unrelated "ExitWorktree" session); (2) Agent-tool sub-agents do NOT write session files into `~/.claude/projects//` (4 sub-agents spawned, zero new .jsonl files) and modern sessions contain zero `isSidechain: true` entries — so even from a sub-agent, the most recent project session is the main conversation. Conclusion: the plain command (skip 0) is correct in every case; no `parent_id`-style CLI fix is needed for Claude Code. Cursor/Copilot skill variants had the same false guidance (inherited from the canonical claude-code SKILL.md source); their sub-agent storage behavior is unverified but the plain command + transcript sanity check is the right default there too. Fixed at two levels: the distributed lessons-learned skill's canonical source now instructs the plain command, and — since the flag's only advertised purpose was this false premise — `--skip` was REMOVED from all four subcommands entirely (2026-07-02, user decision). Explicit selection replaced it as verbs nested under each editor (user decision): `archgate session-context list` and `archgate session-context show ` (`--root` exists only on `opencode show`, resolving a child to its top-level ancestor; opencode list is top-level sessions only). The bare editor subcommands take only `--max-entries` and always read the current conversation. + +Remaining caveat (all editors): when several conversations for the same project run concurrently, most-recent-by-mtime can pick the other live conversation — for opencode, `--session-id --root` is deterministic; claude-code/cursor have no equivalent linkage. **How to apply:** If asked to investigate "session context returns wrong data" for claude-code, cursor, or copilot, start here — this is the same architectural flaw already fixed once for opencode, not a new mystery. diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index ce4149ca..e6705596 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4340,105 +4340,157 @@ Source: https://cli.archgate.dev/reference/cli/session-context/ Read AI editor session transcripts for the project. Useful for auditing what an AI agent did during a coding session. ```bash -archgate session-context [options] +archgate session-context [subcommand] [options] ``` +Each editor subcommand reads the **current conversation** — the most recent session for the project. Every editor also has two nested subcommands: `list` to discover earlier sessions and `show ` to read a specific one. + ## Subcommands ### archgate session-context claude-code -Read the Claude Code session transcript for the project. +Read the current Claude Code session transcript for the project. ```bash archgate session-context claude-code [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | + +#### archgate session-context claude-code list + +List available Claude Code sessions for the project as JSON (`id` + `updatedAt`), most recent first. + +```bash +archgate session-context claude-code list +``` + +#### archgate session-context claude-code show + +Read a specific session by ID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context claude-code show +``` ### archgate session-context copilot -Read the Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. +Read the current Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. ```bash archgate session-context copilot [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | + +#### archgate session-context copilot list + +List available Copilot CLI sessions for the project as JSON, most recent first. + +```bash +archgate session-context copilot list +``` + +#### archgate session-context copilot show + +Read a specific session by UUID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context copilot show +``` ### archgate session-context cursor -Read the Cursor agent session transcript for the project. +Read the current Cursor agent session transcript for the project. ```bash archgate session-context cursor [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | + +#### archgate session-context cursor list + +List available Cursor agent sessions for the project as JSON, most recent first. + +```bash +archgate session-context cursor list +``` + +#### archgate session-context cursor show + +Read a specific session by UUID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context cursor show +``` ### archgate session-context opencode -Read the opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. Use `--session-id` to read a specific session, including a sub-agent child session. +Read the current opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. -When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), pass `--session-id --root` to resolve its top-level ancestor deterministically instead of relying on recency. +When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), use `show --root` to resolve its top-level ancestor deterministically instead of relying on recency. ```bash archgate session-context opencode [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent top-level sessions (sub-agent sessions always excluded) | -| `--session-id ` | Specific session ID to read | -| `--root` | Resolve to the top-level (root) session — with `--session-id`, walks up from a sub-agent child session | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | -## Examples +#### archgate session-context opencode list -Read the latest Claude Code session: +List available top-level opencode sessions for the project as JSON (`id`, `title`, `updatedAt`), most recent first. Sub-agent child sessions are excluded (they can still be read via `show`). ```bash -archgate session-context claude-code +archgate session-context opencode list ``` -Read a specific Cursor session: +#### archgate session-context opencode show + +Read a specific session by ID (from `list`), including sub-agent child sessions. Accepts `--max-entries` and `--root` (resolve a sub-agent child session up to its top-level ancestor). ```bash -archgate session-context cursor --session-id abc123 +archgate session-context opencode show [--root] ``` -Read the latest Copilot CLI session: +## Examples + +Read the current Claude Code session: ```bash -archgate session-context copilot +archgate session-context claude-code ``` -Read the latest opencode session: +Read the current opencode session: ```bash archgate session-context opencode ``` -Read the parent session (skip the sub-agent's own session): +List the project's Claude Code sessions: + +```bash +archgate session-context claude-code list +``` + +Read a specific earlier session: ```bash -archgate session-context claude-code --skip 1 +archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Resolve an opencode sub-agent child session to its top-level ancestor: ```bash -archgate session-context opencode --session-id ses_child123 --root +archgate session-context opencode show ses_child123 --root ``` --- diff --git a/docs/src/content/docs/nb/reference/cli/session-context.mdx b/docs/src/content/docs/nb/reference/cli/session-context.mdx index 8786fda5..b83b2142 100644 --- a/docs/src/content/docs/nb/reference/cli/session-context.mdx +++ b/docs/src/content/docs/nb/reference/cli/session-context.mdx @@ -6,103 +6,155 @@ description: "Les AI-editor-sesjonslogger for prosjektet." Les AI-editor-sesjonslogger for prosjektet. Nyttig for å revidere hva en AI-agent gjorde under en kodeøkt. ```bash -archgate session-context [options] +archgate session-context [subcommand] [options] ``` +Hver editor-underkommando leser den **gjeldende samtalen** — den nyeste sesjonen for prosjektet. Hver editor har også to nestede underkommandoer: `list` for å oppdage tidligere sesjoner og `show ` for å lese en bestemt en. + ## Underkommandoer ### archgate session-context claude-code -Les Claude Code-sesjonsloggen for prosjektet. +Les den gjeldende Claude Code-sesjonsloggen for prosjektet. ```bash archgate session-context claude-code [options] ``` -| Valg | Beskrivelse | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste sesjonene (nyttig ved kjøring som underagent) | +| Valg | Beskrivelse | +| ------------------- | -------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | + +#### archgate session-context claude-code list + +List opp tilgjengelige Claude Code-sesjoner for prosjektet som JSON (`id` + `updatedAt`), nyeste først. + +```bash +archgate session-context claude-code list +``` + +#### archgate session-context claude-code show + +Les en bestemt sesjon etter ID (fra `list`). Godtar `--max-entries`. + +```bash +archgate session-context claude-code show +``` ### archgate session-context copilot -Les Copilot CLI-sesjonsloggen for prosjektet. Sesjoner matches etter arbeidsområdets `cwd`-felt. +Les den gjeldende Copilot CLI-sesjonsloggen for prosjektet. Sesjoner matches via arbeidsområdets `cwd`-felt. ```bash archgate session-context copilot [options] ``` -| Valg | Beskrivelse | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste sesjonene (nyttig ved kjøring som underagent) | -| `--session-id ` | Spesifikk sesjons-UUID å lese | +| Valg | Beskrivelse | +| ------------------- | -------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | + +#### archgate session-context copilot list + +List opp tilgjengelige Copilot CLI-sesjoner for prosjektet som JSON, nyeste først. + +```bash +archgate session-context copilot list +``` + +#### archgate session-context copilot show + +Les en bestemt sesjon etter UUID (fra `list`). Godtar `--max-entries`. + +```bash +archgate session-context copilot show +``` ### archgate session-context cursor -Les Cursor-agentsesjonsloggen for prosjektet. +Les den gjeldende Cursor-agentsesjonsloggen for prosjektet. ```bash archgate session-context cursor [options] ``` -| Valg | Beskrivelse | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste sesjonene (nyttig ved kjøring som underagent) | -| `--session-id ` | Spesifikk sesjons-UUID å lese | +| Valg | Beskrivelse | +| ------------------- | -------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | + +#### archgate session-context cursor list + +List opp tilgjengelige Cursor-agentsesjoner for prosjektet som JSON, nyeste først. + +```bash +archgate session-context cursor list +``` + +#### archgate session-context cursor show + +Les en bestemt sesjon etter UUID (fra `list`). Godtar `--max-entries`. + +```bash +archgate session-context cursor show +``` ### archgate session-context opencode -Les opencode-sesjonsloggen for prosjektet. Sesjoner matches ved å sammenligne sesjonens `directory`-felt med prosjektroten. opencode lagrer underagent-kjøringer som barnesesjoner som deler foreldrenes katalog — disse ekskluderes fra utvalget etter nylighet, slik at den nyeste toppnivåsesjonen alltid er hovedutviklingssesjonen. Bruk `--session-id` for å lese en bestemt sesjon, inkludert en underagent-barnesesjon. +Les den gjeldende opencode-sesjonsloggen for prosjektet. Sesjoner matches ved å sammenligne sesjonens `directory`-felt med prosjektroten. opencode lagrer underagent-kjøringer som barnesesjoner som deler foreldrenes katalog — disse ekskluderes fra utvalget etter nylighet, slik at den nyeste toppnivåsesjonen alltid er hovedutviklingssesjonen. -Når flere toppnivåsesjoner finnes for samme katalog, velger nylighetsutvalget den som sist ble oppdatert — som kanskje ikke er samtalen du er en del av. Hvis du kjenner en sesjons-ID i riktig samtaletre (for eksempel kjenner en underagent sin egen barnesesjons-ID), send `--session-id --root` for å løse opp toppnivåforelderen deterministisk i stedet for å stole på nylighet. +Når flere toppnivåsesjoner finnes for samme katalog, velger nylighetsutvalget den som sist ble oppdatert — som kanskje ikke er samtalen du er en del av. Hvis du kjenner en sesjons-ID i riktig samtaletre (for eksempel kjenner en underagent sin egen barnesesjons-ID), bruk `show --root` for å løse opp toppnivåforelderen deterministisk i stedet for å stole på nylighet. ```bash archgate session-context opencode [options] ``` -| Valg | Beskrivelse | -| ------------------- | ----------------------------------------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste toppnivåsesjonene (underagentsesjoner ekskluderes alltid) | -| `--session-id ` | Spesifikk sesjons-ID å lese | -| `--root` | Løs opp til toppnivåsesjonen (rot) — med `--session-id` gås det oppover fra en underagent-barnesesjon | +| Valg | Beskrivelse | +| ------------------- | -------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -## Eksempler +#### archgate session-context opencode list -Les den nyeste Claude Code-sesjonen: +List opp tilgjengelige toppnivåsesjoner i opencode for prosjektet som JSON (`id`, `title`, `updatedAt`), nyeste først. Underagent-barnesesjoner ekskluderes (de kan fortsatt leses via `show`). ```bash -archgate session-context claude-code +archgate session-context opencode list ``` -Les en bestemt Cursor-sesjon: +#### archgate session-context opencode show + +Les en bestemt sesjon etter ID (fra `list`), inkludert underagent-barnesesjoner. Godtar `--max-entries` og `--root` (løs opp en underagent-barnesesjon til dens toppnivåforelder). ```bash -archgate session-context cursor --session-id abc123 +archgate session-context opencode show [--root] ``` -Les den nyeste Copilot CLI-sesjonen: +## Eksempler + +Les den gjeldende Claude Code-sesjonen: ```bash -archgate session-context copilot +archgate session-context claude-code ``` -Les den nyeste opencode-sesjonen: +Les den gjeldende opencode-sesjonen: ```bash archgate session-context opencode ``` -Les foreldresesjonen (hopp over underagentens egen sesjon): +List opp prosjektets Claude Code-sesjoner: + +```bash +archgate session-context claude-code list +``` + +Les en bestemt tidligere sesjon: ```bash -archgate session-context claude-code --skip 1 +archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Løs opp en underagent-barnesesjon i opencode til dens toppnivåforelder: ```bash -archgate session-context opencode --session-id ses_child123 --root +archgate session-context opencode show ses_child123 --root ``` diff --git a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx index 8247e4f3..9036e8db 100644 --- a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx @@ -6,103 +6,155 @@ description: "Lê transcrições de sessão de editores de IA para o projeto." Lê transcrições de sessão de editores de IA para o projeto. Útil para auditar o que um agente de IA fez durante uma sessão de codificação. ```bash -archgate session-context [options] +archgate session-context [subcommand] [options] ``` +Cada subcomando de editor lê a **conversa atual** — a sessão mais recente do projeto. Cada editor também tem dois subcomandos aninhados: `list` para descobrir sessões anteriores e `show ` para ler uma sessão específica. + ## Subcomandos ### archgate session-context claude-code -Lê a transcrição de sessão do Claude Code para o projeto. +Lê a transcrição da sessão atual do Claude Code para o projeto. ```bash archgate session-context claude-code [options] ``` -| Opção | Descrição | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões mais recentes (útil ao executar como sub-agente) | +| Opção | Descrição | +| ------------------- | ------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | + +#### archgate session-context claude-code list + +Lista as sessões disponíveis do Claude Code para o projeto como JSON (`id` + `updatedAt`), da mais recente para a mais antiga. + +```bash +archgate session-context claude-code list +``` + +#### archgate session-context claude-code show + +Lê uma sessão específica por ID (de `list`). Aceita `--max-entries`. + +```bash +archgate session-context claude-code show +``` ### archgate session-context copilot -Lê a transcrição de sessão do Copilot CLI para o projeto. Sessões são correspondidas pelo campo `cwd` do workspace. +Lê a transcrição da sessão atual do Copilot CLI para o projeto. Sessões são correspondidas pelo campo `cwd` do workspace. ```bash archgate session-context copilot [options] ``` -| Opção | Descrição | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões mais recentes (útil ao executar como sub-agente) | -| `--session-id ` | UUID específico da sessão a ser lida | +| Opção | Descrição | +| ------------------- | ------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | + +#### archgate session-context copilot list + +Lista as sessões disponíveis do Copilot CLI para o projeto como JSON, da mais recente para a mais antiga. + +```bash +archgate session-context copilot list +``` + +#### archgate session-context copilot show + +Lê uma sessão específica por UUID (de `list`). Aceita `--max-entries`. + +```bash +archgate session-context copilot show +``` ### archgate session-context cursor -Lê a transcrição de sessão do agente Cursor para o projeto. +Lê a transcrição da sessão atual do agente Cursor para o projeto. ```bash archgate session-context cursor [options] ``` -| Opção | Descrição | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões mais recentes (útil ao executar como sub-agente) | -| `--session-id ` | UUID específico da sessão a ser lida | +| Opção | Descrição | +| ------------------- | ------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | + +#### archgate session-context cursor list + +Lista as sessões disponíveis do agente Cursor para o projeto como JSON, da mais recente para a mais antiga. + +```bash +archgate session-context cursor list +``` + +#### archgate session-context cursor show + +Lê uma sessão específica por UUID (de `list`). Aceita `--max-entries`. + +```bash +archgate session-context cursor show +``` ### archgate session-context opencode -Lê a transcrição de sessão do opencode para o projeto. Sessões são correspondidas comparando o campo `directory` da sessão com a raiz do projeto. O opencode registra execuções de sub-agentes como sessões filhas que compartilham o diretório da sessão pai — elas são excluídas da seleção por recência, de modo que a sessão de nível superior mais recente é sempre a sessão principal de desenvolvimento. Use `--session-id` para ler uma sessão específica, incluindo uma sessão filha de sub-agente. +Lê a transcrição da sessão atual do opencode para o projeto. Sessões são correspondidas comparando o campo `directory` da sessão com a raiz do projeto. O opencode registra execuções de sub-agentes como sessões filhas que compartilham o diretório da sessão pai — elas são excluídas da seleção por recência, de modo que a sessão de nível superior mais recente é sempre a sessão principal de desenvolvimento. -Quando existem várias sessões de nível superior para o mesmo diretório, a seleção por recência escolhe a atualizada mais recentemente — que pode não ser a conversa da qual você faz parte. Se você conhece um ID de sessão dentro da árvore de conversa correta (por exemplo, um sub-agente conhece o ID da sua própria sessão filha), passe `--session-id --root` para resolver o ancestral de nível superior de forma determinística em vez de depender da recência. +Quando existem várias sessões de nível superior para o mesmo diretório, a seleção por recência escolhe a atualizada mais recentemente — que pode não ser a conversa da qual você faz parte. Se você conhece um ID de sessão dentro da árvore de conversa correta (por exemplo, um sub-agente conhece o ID da sua própria sessão filha), use `show --root` para resolver o ancestral de nível superior de forma determinística em vez de depender da recência. ```bash archgate session-context opencode [options] ``` -| Opção | Descrição | -| ------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões de nível superior mais recentes (sessões de sub-agente são sempre excluídas) | -| `--session-id ` | ID específico da sessão a ser lida | -| `--root` | Resolve para a sessão de nível superior (raiz) — com `--session-id`, sobe a partir de uma sessão filha de sub-agente | +| Opção | Descrição | +| ------------------- | ------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -## Exemplos +#### archgate session-context opencode list -Ler a última sessão do Claude Code: +Lista as sessões de nível superior disponíveis do opencode para o projeto como JSON (`id`, `title`, `updatedAt`), da mais recente para a mais antiga. Sessões filhas de sub-agente são excluídas (elas ainda podem ser lidas via `show`). ```bash -archgate session-context claude-code +archgate session-context opencode list ``` -Ler uma sessão específica do Cursor: +#### archgate session-context opencode show + +Lê uma sessão específica por ID (de `list`), incluindo sessões filhas de sub-agente. Aceita `--max-entries` e `--root` (resolve uma sessão filha de sub-agente até seu ancestral de nível superior). ```bash -archgate session-context cursor --session-id abc123 +archgate session-context opencode show [--root] ``` -Ler a última sessão do Copilot CLI: +## Exemplos + +Ler a sessão atual do Claude Code: ```bash -archgate session-context copilot +archgate session-context claude-code ``` -Ler a última sessão do opencode: +Ler a sessão atual do opencode: ```bash archgate session-context opencode ``` -Ler a sessão pai (pular a sessão do sub-agente): +Listar as sessões do Claude Code do projeto: + +```bash +archgate session-context claude-code list +``` + +Ler uma sessão anterior específica: ```bash -archgate session-context claude-code --skip 1 +archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Resolver uma sessão filha de sub-agente do opencode para seu ancestral de nível superior: ```bash -archgate session-context opencode --session-id ses_child123 --root +archgate session-context opencode show ses_child123 --root ``` diff --git a/docs/src/content/docs/reference/cli/session-context.mdx b/docs/src/content/docs/reference/cli/session-context.mdx index 131b4886..121ee4e4 100644 --- a/docs/src/content/docs/reference/cli/session-context.mdx +++ b/docs/src/content/docs/reference/cli/session-context.mdx @@ -6,103 +6,155 @@ description: "Read AI editor session transcripts for the project." Read AI editor session transcripts for the project. Useful for auditing what an AI agent did during a coding session. ```bash -archgate session-context [options] +archgate session-context [subcommand] [options] ``` +Each editor subcommand reads the **current conversation** — the most recent session for the project. Every editor also has two nested subcommands: `list` to discover earlier sessions and `show ` to read a specific one. + ## Subcommands ### archgate session-context claude-code -Read the Claude Code session transcript for the project. +Read the current Claude Code session transcript for the project. ```bash archgate session-context claude-code [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | + +#### archgate session-context claude-code list + +List available Claude Code sessions for the project as JSON (`id` + `updatedAt`), most recent first. + +```bash +archgate session-context claude-code list +``` + +#### archgate session-context claude-code show + +Read a specific session by ID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context claude-code show +``` ### archgate session-context copilot -Read the Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. +Read the current Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. ```bash archgate session-context copilot [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | + +#### archgate session-context copilot list + +List available Copilot CLI sessions for the project as JSON, most recent first. + +```bash +archgate session-context copilot list +``` + +#### archgate session-context copilot show + +Read a specific session by UUID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context copilot show +``` ### archgate session-context cursor -Read the Cursor agent session transcript for the project. +Read the current Cursor agent session transcript for the project. ```bash archgate session-context cursor [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | + +#### archgate session-context cursor list + +List available Cursor agent sessions for the project as JSON, most recent first. + +```bash +archgate session-context cursor list +``` + +#### archgate session-context cursor show + +Read a specific session by UUID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context cursor show +``` ### archgate session-context opencode -Read the opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. Use `--session-id` to read a specific session, including a sub-agent child session. +Read the current opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. -When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), pass `--session-id --root` to resolve its top-level ancestor deterministically instead of relying on recency. +When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), use `show --root` to resolve its top-level ancestor deterministically instead of relying on recency. ```bash archgate session-context opencode [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent top-level sessions (sub-agent sessions always excluded) | -| `--session-id ` | Specific session ID to read | -| `--root` | Resolve to the top-level (root) session — with `--session-id`, walks up from a sub-agent child session | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | -## Examples +#### archgate session-context opencode list -Read the latest Claude Code session: +List available top-level opencode sessions for the project as JSON (`id`, `title`, `updatedAt`), most recent first. Sub-agent child sessions are excluded (they can still be read via `show`). ```bash -archgate session-context claude-code +archgate session-context opencode list ``` -Read a specific Cursor session: +#### archgate session-context opencode show + +Read a specific session by ID (from `list`), including sub-agent child sessions. Accepts `--max-entries` and `--root` (resolve a sub-agent child session up to its top-level ancestor). ```bash -archgate session-context cursor --session-id abc123 +archgate session-context opencode show [--root] ``` -Read the latest Copilot CLI session: +## Examples + +Read the current Claude Code session: ```bash -archgate session-context copilot +archgate session-context claude-code ``` -Read the latest opencode session: +Read the current opencode session: ```bash archgate session-context opencode ``` -Read the parent session (skip the sub-agent's own session): +List the project's Claude Code sessions: + +```bash +archgate session-context claude-code list +``` + +Read a specific earlier session: ```bash -archgate session-context claude-code --skip 1 +archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Resolve an opencode sub-agent child session to its top-level ancestor: ```bash -archgate session-context opencode --session-id ses_child123 --root +archgate session-context opencode show ses_child123 --root ``` diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts index a4a6bc7a..760864ab 100644 --- a/src/commands/session-context/claude-code.ts +++ b/src/commands/session-context/claude-code.ts @@ -1,38 +1,92 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; +import { InvalidArgumentError, Option } from "@commander-js/extra-typings"; import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readClaudeCodeSession } from "../../helpers/session-context"; +import { + listClaudeCodeSessions, + readClaudeCodeSession, +} from "../../helpers/session-context"; -const maxEntriesOption = new Option( - "--max-entries ", - "maximum entries to return (default: 200)" -).argParser((val) => Math.trunc(Number(val))); - -const skipOption = new Option( - "--skip ", - "skip the N most recent sessions (useful when running as a sub-agent)" -) - .argParser((val) => Math.trunc(Number(val))) - .default(0); +/** + * Shared `--max-entries` option factory for the session-context commands. + * Exported from this command file (not a helper) per the cross-command I/O + * sharing convention. Rejects non-numeric or non-positive input — a NaN + * limit would silently disable transcript trimming downstream. + */ +export const makeMaxEntriesOption = () => + new Option( + "--max-entries ", + "maximum entries to return (default: 200)" + ).argParser((val) => { + const n = Math.trunc(Number(val)); + if (!Number.isFinite(n) || n < 1) { + throw new InvalidArgumentError("must be a positive integer"); + } + return n; + }); export function registerClaudeCodeSessionContextCommand(parent: Command) { - parent + const cmd = parent .command("claude-code") - .description("Read Claude Code session transcript for the project") - .addOption(maxEntriesOption) - .addOption(skipOption) + .description( + "Read the current Claude Code session transcript for the project" + ) + .addOption(makeMaxEntriesOption()) .action(async (opts) => { try { const projectRoot = findProjectRoot(); const result = await readClaudeCodeSession(projectRoot, { maxEntries: opts.maxEntries, - skip: opts.skip, + }); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("list") + .description("List available Claude Code sessions for the project") + .action(async () => { + try { + const projectRoot = findProjectRoot(); + const result = await listClaudeCodeSessions(projectRoot); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("show") + .description("Read a specific Claude Code session by ID") + .argument("", "session ID from `list`") + .addOption(makeMaxEntriesOption()) + .action(async (sessionId, opts) => { + try { + const projectRoot = findProjectRoot(); + const result = await readClaudeCodeSession(projectRoot, { + maxEntries: opts.maxEntries, + sessionId, }); if (!result.ok) { diff --git a/src/commands/session-context/copilot.ts b/src/commands/session-context/copilot.ts index 1dedd2e2..942ca56f 100644 --- a/src/commands/session-context/copilot.ts +++ b/src/commands/session-context/copilot.ts @@ -1,40 +1,74 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readCopilotSession } from "../../helpers/session-context-copilot"; - -const maxEntriesOption = new Option( - "--max-entries ", - "maximum entries to return (default: 200)" -).argParser((val) => Math.trunc(Number(val))); - -const skipOption = new Option( - "--skip ", - "skip the N most recent sessions (useful when running as a sub-agent)" -) - .argParser((val) => Math.trunc(Number(val))) - .default(0); +import { + listCopilotSessions, + readCopilotSession, +} from "../../helpers/session-context-copilot"; +import { makeMaxEntriesOption } from "./claude-code"; export function registerCopilotSessionContextCommand(parent: Command) { - parent + const cmd = parent .command("copilot") - .description("Read Copilot CLI session transcript for the project") - .addOption(maxEntriesOption) - .addOption(skipOption) - .option("--session-id ", "Specific session UUID to read") + .description( + "Read the current Copilot CLI session transcript for the project" + ) + .addOption(makeMaxEntriesOption()) .action(async (opts) => { try { const projectRoot = findProjectRoot(); const result = await readCopilotSession(projectRoot, { maxEntries: opts.maxEntries, - skip: opts.skip, - sessionId: opts.sessionId, + }); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("list") + .description("List available Copilot CLI sessions for the project") + .action(async () => { + try { + const projectRoot = findProjectRoot(); + const result = await listCopilotSessions(projectRoot); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("show") + .description("Read a specific Copilot CLI session by UUID") + .argument("", "session UUID from `list`") + .addOption(makeMaxEntriesOption()) + .action(async (sessionId, opts) => { + try { + const projectRoot = findProjectRoot(); + const result = await readCopilotSession(projectRoot, { + maxEntries: opts.maxEntries, + sessionId, }); if (!result.ok) { diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts index b9a9b2a4..9bc4d6ef 100644 --- a/src/commands/session-context/cursor.ts +++ b/src/commands/session-context/cursor.ts @@ -1,40 +1,74 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readCursorSession } from "../../helpers/session-context"; - -const maxEntriesOption = new Option( - "--max-entries ", - "maximum entries to return (default: 200)" -).argParser((val) => Math.trunc(Number(val))); - -const skipOption = new Option( - "--skip ", - "skip the N most recent sessions (useful when running as a sub-agent)" -) - .argParser((val) => Math.trunc(Number(val))) - .default(0); +import { + listCursorSessions, + readCursorSession, +} from "../../helpers/session-context"; +import { makeMaxEntriesOption } from "./claude-code"; export function registerCursorSessionContextCommand(parent: Command) { - parent + const cmd = parent .command("cursor") - .description("Read Cursor agent session transcript for the project") - .addOption(maxEntriesOption) - .addOption(skipOption) - .option("--session-id ", "Specific session UUID to read") + .description( + "Read the current Cursor agent session transcript for the project" + ) + .addOption(makeMaxEntriesOption()) .action(async (opts) => { try { const projectRoot = findProjectRoot(); const result = await readCursorSession(projectRoot, { maxEntries: opts.maxEntries, - skip: opts.skip, - sessionId: opts.sessionId, + }); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("list") + .description("List available Cursor agent sessions for the project") + .action(async () => { + try { + const projectRoot = findProjectRoot(); + const result = await listCursorSessions(projectRoot); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("show") + .description("Read a specific Cursor agent session by UUID") + .argument("", "session UUID from `list`") + .addOption(makeMaxEntriesOption()) + .action(async (sessionId, opts) => { + try { + const projectRoot = findProjectRoot(); + const result = await readCursorSession(projectRoot, { + maxEntries: opts.maxEntries, + sessionId, }); if (!result.ok) { diff --git a/src/commands/session-context/opencode.ts b/src/commands/session-context/opencode.ts index d530c767..b7550a17 100644 --- a/src/commands/session-context/opencode.ts +++ b/src/commands/session-context/opencode.ts @@ -1,44 +1,76 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readOpencodeSession } from "../../helpers/session-context-opencode"; - -const maxEntriesOption = new Option( - "--max-entries ", - "maximum entries to return (default: 200)" -).argParser((val) => Math.trunc(Number(val))); - -const skipOption = new Option( - "--skip ", - "skip the N most recent top-level sessions (sub-agent sessions are always excluded)" -) - .argParser((val) => Math.trunc(Number(val))) - .default(0); +import { + listOpencodeSessions, + readOpencodeSession, +} from "../../helpers/session-context-opencode"; +import { makeMaxEntriesOption } from "./claude-code"; export function registerOpencodeSessionContextCommand(parent: Command) { - parent + const cmd = parent .command("opencode") - .description("Read opencode session transcript for the project") - .addOption(maxEntriesOption) - .addOption(skipOption) - .option("--session-id ", "Specific session ID to read") + .description("Read the current opencode session transcript for the project") + .addOption(makeMaxEntriesOption()) + .action(async (opts) => { + try { + const projectRoot = findProjectRoot(); + const result = readOpencodeSession(projectRoot, { + maxEntries: opts.maxEntries, + }); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("list") + .description("List available top-level opencode sessions for the project") + .action(async () => { + try { + const projectRoot = findProjectRoot(); + const result = listOpencodeSessions(projectRoot); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("show") + .description("Read a specific opencode session by ID") + .argument("", "session ID from `list`") + .addOption(makeMaxEntriesOption()) .option( "--root", - "resolve to the top-level (root) session — with --session-id, walks up from a sub-agent child session" + "resolve a sub-agent child session up to its top-level ancestor" ) - .action(async (opts) => { + .action(async (sessionId, opts) => { try { const projectRoot = findProjectRoot(); - const result = await readOpencodeSession(projectRoot, { + const result = readOpencodeSession(projectRoot, { maxEntries: opts.maxEntries, - skip: opts.skip, - sessionId: opts.sessionId, + sessionId, root: opts.root, }); diff --git a/src/helpers/session-context-copilot.ts b/src/helpers/session-context-copilot.ts index dcf5c856..e56004cf 100644 --- a/src/helpers/session-context-copilot.ts +++ b/src/helpers/session-context-copilot.ts @@ -8,6 +8,7 @@ import { copilotSessionStateDir } from "./paths"; import { isWindows } from "./platform"; import { type ReadSessionOptions, + type SessionListResult, type TranscriptEntry, getContentPreview, } from "./session-context"; @@ -40,8 +41,17 @@ function normalizePath(p: string): string { const COPILOT_RELEVANT_TYPES = new Set(["user.message", "assistant.message"]); +interface CopilotSessionMatch { + name: string; + mtime: number; +} + +type CopilotMatchResult = + | { ok: true; matching: CopilotSessionMatch[]; stateDir: string } + | { ok: false; error: string; path?: string; available?: string[] }; + /** - * Read a Copilot CLI session transcript for a project. + * Find Copilot CLI sessions matching a project, most recent first. * * Copilot CLI stores sessions under `~/.copilot/session-state//`. * Each session directory contains: @@ -51,11 +61,9 @@ const COPILOT_RELEVANT_TYPES = new Set(["user.message", "assistant.message"]); * Sessions are matched by comparing the `cwd` field in workspace.yaml * to the provided project root. */ -export async function readCopilotSession( - projectRoot: string | null, - options?: ReadCopilotSessionOptions -): Promise { - const limit = options?.maxEntries ?? 200; +async function findMatchingCopilotSessions( + projectRoot: string | null +): Promise { const stateDir = copilotSessionStateDir(); const normalizedProjectRoot = normalizePath(projectRoot ?? process.cwd()); @@ -87,11 +95,7 @@ export async function readCopilotSession( } // 2. Parse workspace.yaml for each session to match by cwd - interface SessionMatch { - name: string; - mtime: number; - } - const matching: SessionMatch[] = []; + const matching: CopilotSessionMatch[] = []; const allSessionIds: string[] = []; for (const dir of allDirs) { @@ -119,17 +123,56 @@ export async function readCopilotSession( }; } - // 3. Select session by ID or most recent (with optional skip) - const skip = options?.skip ?? 0; + return { ok: true, matching, stateDir }; +} + +/** List Copilot CLI sessions for a project, most recent first. */ +export async function listCopilotSessions( + projectRoot: string | null +): Promise { + const found = await findMatchingCopilotSessions(projectRoot); + if (!found.ok) { + return { ok: false, error: found.error, path: found.path }; + } + return { + ok: true, + data: { + sessions: found.matching.map((s) => ({ + id: s.name, + updatedAt: new Date(s.mtime).toISOString(), + })), + }, + }; +} + +/** + * Read the most recent Copilot CLI session transcript for a project — + * normally the conversation that is running right now. Pass `sessionId` + * (from `listCopilotSessions`) to read an earlier session instead. + */ +export async function readCopilotSession( + projectRoot: string | null, + options?: ReadCopilotSessionOptions +): Promise { + const limit = options?.maxEntries ?? 200; + + const found = await findMatchingCopilotSessions(projectRoot); + if (!found.ok) { + return found; + } + const { matching, stateDir } = found; + + // 3. Select session by ID or most recent const target = options?.sessionId ? matching.find((s) => s.name === options.sessionId) - : matching[skip]; + : matching[0]; if (!target) { - const error = options?.sessionId - ? `Session not found: ${options.sessionId}` - : `Only ${String(matching.length)} session(s) available but --skip ${String(skip)} requested`; - return { ok: false, error, available: matching.map((s) => s.name) }; + return { + ok: false, + error: `Session not found: ${options?.sessionId ?? ""}`, + available: matching.map((s) => s.name), + }; } // 4. Read events.jsonl diff --git a/src/helpers/session-context-opencode.ts b/src/helpers/session-context-opencode.ts index c850b914..9f958c05 100644 --- a/src/helpers/session-context-opencode.ts +++ b/src/helpers/session-context-opencode.ts @@ -10,6 +10,7 @@ import { isWindows } from "./platform"; import { RELEVANT_ROLES, type ReadSessionOptions, + type SessionListResult, type TranscriptEntry, getContentPreview, } from "./session-context"; @@ -32,12 +33,11 @@ interface ReadOpencodeSessionOptions extends ReadSessionOptions { * * Opencode is the only session-context backend with a real parent/child * session graph, so this option lives here rather than in the shared - * `ReadSessionOptions`. A recency-based guess (the old bare `skip: 1`) - * cannot distinguish the true parent from a sibling sub-agent session - * once more than one sibling exists — and an inline Skill invocation - * creates no session row at all, so there is nothing to skip past. - * Ancestry via `parent_id` is correct regardless of nesting depth or - * sibling fan-out. + * `ReadSessionOptions`. A recency-based guess cannot distinguish the + * true parent from a sibling sub-agent session once more than one + * sibling exists — and an inline Skill invocation creates no session + * row at all. Ancestry via `parent_id` is correct regardless of nesting + * depth or sibling fan-out. */ root?: boolean; } @@ -56,6 +56,78 @@ function normalizePath(p: string): string { return isWindows() ? resolved.toLowerCase() : resolved; } +interface SessionRow { + id: string; + directory: string; + parent_id: string | null; + title: string; + time_updated: number; +} + +/** Query all sessions from an open opencode DB, most recent first. */ +function queryAllSessions(db: Database): SessionRow[] { + return db + .query( + "SELECT id, directory, parent_id, title, time_updated FROM session ORDER BY time_updated DESC" + ) + .all(); +} + +/** + * List opencode sessions for a project, most recent first. + * Only top-level sessions are listed — sub-agent child sessions are + * excluded (they can still be read explicitly via `sessionId`). + */ +export function listOpencodeSessions( + projectRoot: string | null +): SessionListResult { + const dbPath = opencodeDbPath(); + const normalizedProjectRoot = normalizePath(projectRoot ?? process.cwd()); + + if (!existsSync(dbPath)) { + return { ok: false, error: "No opencode database found", path: dbPath }; + } + + let db: Database; + try { + db = new Database(dbPath, { readonly: true }); + } catch { + return { + ok: false, + error: "Failed to open opencode database", + path: dbPath, + }; + } + + try { + const topLevel = queryAllSessions(db).filter( + (s) => + s.parent_id === null && + s.directory && + normalizePath(s.directory) === normalizedProjectRoot + ); + return { + ok: true, + data: { + sessions: topLevel.map((s) => ({ + id: s.id, + title: s.title, + updatedAt: new Date(s.time_updated).toISOString(), + })), + }, + }; + } catch (err) { + logDebug(`Failed to read opencode database: ${String(err)}`); + return { + ok: false, + error: "Failed to read opencode database", + path: dbPath, + }; + } finally { + db.close(); + } +} + /** * Read an opencode session transcript for a project. * @@ -70,12 +142,12 @@ function normalizePath(p: string): string { * to the provided project root. * * Sub-agent runs are stored as child sessions (`parent_id` set) that share - * the parent's `directory`, so recency-based selection (`skip`) only - * considers top-level sessions — otherwise sub-agent transcripts shadow the - * main session. Note that opencode skills run inline in the calling session - * (they do NOT create their own session), so no `skip` is needed to read - * the current development session. An explicit `sessionId` can still read - * any session, including sub-agent children. + * the parent's `directory`, so recency selection only considers top-level + * sessions — otherwise sub-agent transcripts shadow the main session. Note + * that opencode skills run inline in the calling session (they do NOT + * create their own session), so the most recent top-level session is the + * current development session. An explicit `sessionId` can still read any + * session, including sub-agent children. */ export function readOpencodeSession( projectRoot: string | null, @@ -102,17 +174,7 @@ export function readOpencodeSession( try { // 1. Find all sessions, sorted by most recently updated first - interface SessionRow { - id: string; - directory: string; - parent_id: string | null; - time_updated: number; - } - const allSessions = db - .query( - "SELECT id, directory, parent_id, time_updated FROM session ORDER BY time_updated DESC" - ) - .all(); + const allSessions = queryAllSessions(db); if (allSessions.length === 0) { return { ok: false, error: "No opencode sessions found", path: dbPath }; @@ -132,16 +194,14 @@ export function readOpencodeSession( }; } - // 3. Select session by ID or most recent (with optional skip). + // 3. Select session by ID or most recent. // Recency selection only considers top-level sessions: sub-agent runs // are child sessions (parent_id set) sharing the parent's directory, - // and would otherwise shadow the main session in the skip index. + // and would otherwise shadow the main session. // An explicit --session-id can read any session, including children. - const topLevel = matching.filter((s) => s.parent_id === null); - const skip = options?.skip ?? 0; const target = options?.sessionId ? matching.find((s) => s.id === options.sessionId) - : topLevel[skip]; + : matching.find((s) => s.parent_id === null); if (!target) { if (options?.sessionId) { @@ -153,8 +213,8 @@ export function readOpencodeSession( } return { ok: false, - error: `Only ${String(topLevel.length)} top-level session(s) available but --skip ${String(skip)} requested`, - available: topLevel.map((s) => s.id), + error: "No top-level opencode session found for this project", + available: matching.map((s) => s.id), }; } diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index dcb97f15..7fe25e0c 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -64,6 +64,18 @@ interface ClaudeSessionSummary { transcript: Array<{ type: string; role?: string; contentPreview: string }>; } +/** One entry in a list result: session id + last-update timestamp. */ +export interface SessionListEntry { + id: string; + /** Session title — only populated by editors that store one (opencode). */ + title?: string; + updatedAt: string; +} + +export type SessionListResult = + | { ok: true; data: { sessions: SessionListEntry[] } } + | { ok: false; error: string; path?: string }; + interface CursorSessionSummary { sessionId: string; sessionFile: string; @@ -74,7 +86,7 @@ interface CursorSessionSummary { type ClaudeSessionResult = | { ok: true; data: ClaudeSessionSummary } - | { ok: false; error: string; path?: string }; + | { ok: false; error: string; path?: string; available?: string[] }; type CursorSessionResult = | { ok: true; data: CursorSessionSummary } @@ -109,40 +121,80 @@ export function getContentPreview(entry: TranscriptEntry): string { export interface ReadSessionOptions { maxEntries?: number; - /** - * Skip the N most recent sessions before selecting the one to read. - * Useful when running as a sub-agent: the sub-agent's own session is - * the most recent, so `skip: 1` reads the parent session instead. - */ - skip?: number; +} + +interface ReadClaudeSessionOptions extends ReadSessionOptions { + sessionId?: string; } interface ReadCursorSessionOptions extends ReadSessionOptions { sessionId?: string; } +/** Resolve the Claude Code projects dir for a project root. */ +async function claudeProjectsDir(projectRoot: string | null): Promise { + const encodedPath = await encodeProjectPath(projectRoot ?? process.cwd()); + return join(homedir(), ".claude", "projects", encodedPath); +} + +/** + * Enumerate Claude Code session files for a project, most recent first. + * Returns null when the projects directory cannot be read. + */ +function enumerateClaudeSessionFiles( + projectsDir: string +): Array<{ name: string; mtime: number }> | null { + try { + return readdirSync(projectsDir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => ({ name: f, mtime: statSync(join(projectsDir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + } catch { + return null; + } +} + +/** + * List Claude Code sessions for a project, most recent first. + * Session ids are the JSONL file basenames (without extension). + */ +export async function listClaudeCodeSessions( + projectRoot: string | null +): Promise { + const projectsDir = await claudeProjectsDir(projectRoot); + const files = enumerateClaudeSessionFiles(projectsDir); + if (files === null) { + return { ok: false, error: "No session files found", path: projectsDir }; + } + return { + ok: true, + data: { + sessions: files.map((f) => ({ + id: basename(f.name, ".jsonl"), + updatedAt: new Date(f.mtime).toISOString(), + })), + }, + }; +} + /** - * Read the most recent Claude Code session transcript for a project. + * Read the most recent Claude Code session transcript for a project — + * normally the conversation that is running right now. Pass `sessionId` + * (from `listClaudeCodeSessions`) to read an earlier session instead. * Falls back to cwd when no project root is found. */ export async function readClaudeCodeSession( projectRoot: string | null, - options?: ReadSessionOptions + options?: ReadClaudeSessionOptions ): Promise { const limit = options?.maxEntries ?? 200; - const encodedPath = await encodeProjectPath(projectRoot ?? process.cwd()); - const projectsDir = join(homedir(), ".claude", "projects", encodedPath); + const projectsDir = await claudeProjectsDir(projectRoot); - let files: string[]; - try { - files = readdirSync(projectsDir) - .filter((f) => f.endsWith(".jsonl")) - .map((f) => ({ name: f, mtime: statSync(join(projectsDir, f)).mtimeMs })) - .sort((a, b) => b.mtime - a.mtime) - .map((f) => f.name); - } catch { + const allFiles = enumerateClaudeSessionFiles(projectsDir); + if (allFiles === null) { return { ok: false, error: "No session files found", path: projectsDir }; } + const files = allFiles.map((f) => f.name); if (files.length === 0) { return { @@ -152,16 +204,20 @@ export async function readClaudeCodeSession( }; } - const skip = options?.skip ?? 0; - if (skip >= files.length) { + const targetName = options?.sessionId + ? files.find((f) => f === `${options.sessionId}.jsonl`) + : files[0]; + + if (!targetName) { return { ok: false, - error: `Only ${String(files.length)} session(s) available but --skip ${String(skip)} requested`, + error: `Session not found: ${options?.sessionId ?? ""}`, path: projectsDir, + available: files.map((f) => basename(f, ".jsonl")), }; } - const sessionFile = join(projectsDir, files[skip]); + const sessionFile = join(projectsDir, targetName); let entries: TranscriptEntry[]; try { const raw = await Bun.file(sessionFile).text(); @@ -196,30 +252,32 @@ export async function readClaudeCodeSession( }; } -/** - * Read a Cursor agent session transcript for a project. - * Falls back to cwd when no project root is found. - */ -export async function readCursorSession( - projectRoot: string | null, - options?: ReadCursorSessionOptions -): Promise { - const limit = options?.maxEntries ?? 200; +/** Resolve the Cursor agent-transcripts dir for a project root. */ +async function cursorTranscriptsDir( + projectRoot: string | null +): Promise { const encodedPath = await encodeProjectPath( projectRoot ?? process.cwd(), "cursor" ); - const transcriptsDir = join( + return join( homedir(), ".cursor", "projects", encodedPath, "agent-transcripts" ); +} - let sessionDirs: Array<{ name: string; mtime: number }>; +/** + * Enumerate Cursor session directories for a project, most recent first. + * Returns null when the transcripts directory cannot be read. + */ +function enumerateCursorSessionDirs( + transcriptsDir: string +): Array<{ name: string; mtime: number }> | null { try { - sessionDirs = readdirSync(transcriptsDir) + return readdirSync(transcriptsDir) .map((name) => { const fullPath = join(transcriptsDir, name); try { @@ -232,6 +290,49 @@ export async function readCursorSession( .filter((d): d is { name: string; mtime: number } => d !== null) .sort((a, b) => b.mtime - a.mtime); } catch { + return null; + } +} + +/** List Cursor agent sessions for a project, most recent first. */ +export async function listCursorSessions( + projectRoot: string | null +): Promise { + const transcriptsDir = await cursorTranscriptsDir(projectRoot); + const sessionDirs = enumerateCursorSessionDirs(transcriptsDir); + if (sessionDirs === null) { + return { + ok: false, + error: "No Cursor agent-transcripts directory found", + path: transcriptsDir, + }; + } + return { + ok: true, + data: { + sessions: sessionDirs.map((d) => ({ + id: d.name, + updatedAt: new Date(d.mtime).toISOString(), + })), + }, + }; +} + +/** + * Read the most recent Cursor agent session transcript for a project — + * normally the conversation that is running right now. Pass `sessionId` + * (from `listCursorSessions`) to read an earlier session instead. + * Falls back to cwd when no project root is found. + */ +export async function readCursorSession( + projectRoot: string | null, + options?: ReadCursorSessionOptions +): Promise { + const limit = options?.maxEntries ?? 200; + const transcriptsDir = await cursorTranscriptsDir(projectRoot); + + const sessionDirs = enumerateCursorSessionDirs(transcriptsDir); + if (sessionDirs === null) { return { ok: false, error: "No Cursor agent-transcripts directory found", @@ -247,16 +348,16 @@ export async function readCursorSession( }; } - const skip = options?.skip ?? 0; const targetDir = options?.sessionId ? sessionDirs.find((d) => d.name === options.sessionId) - : sessionDirs[skip]; + : sessionDirs[0]; if (!targetDir) { - const error = options?.sessionId - ? `Session not found: ${options.sessionId}` - : `Only ${String(sessionDirs.length)} session(s) available but --skip ${String(skip)} requested`; - return { ok: false, error, available: sessionDirs.map((d) => d.name) }; + return { + ok: false, + error: `Session not found: ${options?.sessionId ?? ""}`, + available: sessionDirs.map((d) => d.name), + }; } const sessionFile = join( diff --git a/tests/commands/session-context.test.ts b/tests/commands/session-context.test.ts index f98b8afd..55857ee7 100644 --- a/tests/commands/session-context.test.ts +++ b/tests/commands/session-context.test.ts @@ -61,7 +61,7 @@ describe("registerSessionContextCommand", () => { expect(sub).toBeDefined(); }); - test("claude-code subcommand has --max-entries and --skip options", () => { + test("claude-code subcommand has only --max-entries (read current conversation)", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -70,10 +70,12 @@ describe("registerSessionContextCommand", () => { const sub = parent.commands.find((c) => c.name() === "claude-code")!; const opts = sub.options.map((o) => o.long); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--skip"); + expect(opts).not.toContain("--session-id"); + expect(opts).not.toContain("--list"); + expect(opts).not.toContain("--skip"); }); - test("cursor subcommand has --max-entries, --skip, and --session-id options", () => { + test("cursor subcommand has only --max-entries (read current conversation)", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -82,11 +84,12 @@ describe("registerSessionContextCommand", () => { const sub = parent.commands.find((c) => c.name() === "cursor")!; const opts = sub.options.map((o) => o.long); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--skip"); - expect(opts).toContain("--session-id"); + expect(opts).not.toContain("--session-id"); + expect(opts).not.toContain("--list"); + expect(opts).not.toContain("--skip"); }); - test("copilot subcommand has --max-entries, --skip, and --session-id options", () => { + test("copilot subcommand has only --max-entries (read current conversation)", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -95,11 +98,51 @@ describe("registerSessionContextCommand", () => { const sub = parent.commands.find((c) => c.name() === "copilot")!; const opts = sub.options.map((o) => o.long); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--skip"); - expect(opts).toContain("--session-id"); + expect(opts).not.toContain("--session-id"); + expect(opts).not.toContain("--list"); + expect(opts).not.toContain("--skip"); }); - test("opencode subcommand has --max-entries, --skip, and --session-id options", () => { + test("each editor subcommand has list and show children", () => { + const program = new Command(); + registerSessionContextCommand(program); + const parent = program.commands.find( + (c) => c.name() === "session-context" + )!; + // list/show are NOT direct children of session-context + expect(parent.commands.map((c) => c.name())).toEqual([ + "claude-code", + "copilot", + "cursor", + "opencode", + ]); + for (const editor of ["claude-code", "copilot", "cursor", "opencode"]) { + const sub = parent.commands.find((c) => c.name() === editor)!; + const children = sub.commands.map((c) => c.name()).sort(); + expect(children).toEqual(["list", "show"]); + } + }); + + test("only opencode show has --root", () => { + const program = new Command(); + registerSessionContextCommand(program); + const parent = program.commands.find( + (c) => c.name() === "session-context" + )!; + for (const editor of ["claude-code", "copilot", "cursor", "opencode"]) { + const sub = parent.commands.find((c) => c.name() === editor)!; + const show = sub.commands.find((c) => c.name() === "show")!; + const opts = show.options.map((o) => o.long); + expect(opts).toContain("--max-entries"); + if (editor === "opencode") { + expect(opts).toContain("--root"); + } else { + expect(opts).not.toContain("--root"); + } + } + }); + + test("opencode subcommand has only --max-entries (read current conversation)", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -108,7 +151,9 @@ describe("registerSessionContextCommand", () => { const sub = parent.commands.find((c) => c.name() === "opencode")!; const opts = sub.options.map((o) => o.long); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--skip"); - expect(opts).toContain("--session-id"); + expect(opts).not.toContain("--session-id"); + expect(opts).not.toContain("--root"); + expect(opts).not.toContain("--list"); + expect(opts).not.toContain("--skip"); }); }); diff --git a/tests/commands/session-context/claude-code.test.ts b/tests/commands/session-context/claude-code.test.ts index 50c10eb4..da908561 100644 --- a/tests/commands/session-context/claude-code.test.ts +++ b/tests/commands/session-context/claude-code.test.ts @@ -1,43 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; -// --------------------------------------------------------------------------- -// Module mocks — declared before the import under test. -// -// Only the session reader is mocked (unique to this command, no leak risk). -// findProjectRoot is controlled via process.chdir + ARCHGATE_PROJECT_CEILING -// to avoid Bun mock.module global-leak issues. -// --------------------------------------------------------------------------- - -const mockReadClaudeCodeSession = mock( - () => - Promise.resolve({ ok: true, data: {} }) as Promise< - { ok: true; data: unknown } | { ok: false; error: string } - > -); -mock.module("../../../src/helpers/session-context", () => ({ - readClaudeCodeSession: mockReadClaudeCodeSession, -})); - -// --------------------------------------------------------------------------- -// Import under test — loaded AFTER mocks are registered. -// --------------------------------------------------------------------------- - import { registerClaudeCodeSessionContextCommand } from "../../../src/commands/session-context/claude-code"; +import * as sessionContextHelpers from "../../../src/helpers/session-context"; +import { runCli } from "../../integration/cli-harness"; import { safeRmSync } from "../../test-utils"; // --------------------------------------------------------------------------- @@ -66,6 +38,13 @@ describe("registerClaudeCodeSessionContextCommand", () => { const opt = sub.options.find((o) => o.long === "--max-entries"); expect(opt).toBeDefined(); }); + + test("has list and show subcommands", () => { + const parent = new Command("session-context"); + registerClaudeCodeSessionContextCommand(parent); + const sub = parent.commands.find((c) => c.name() === "claude-code")!; + expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); + }); }); describe("claude-code action handler", () => { @@ -74,6 +53,17 @@ describe("claude-code action handler", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; + let readSpy: ReturnType; + + /** Minimal complete summary for the default happy-path spy. */ + function emptySummary() { + return { + sessionFile: "s.jsonl", + totalEntries: 0, + relevantEntries: 0, + transcript: [], + }; + } beforeEach(() => { // realpathSync normalizes macOS /var → /private/var symlink so the @@ -85,8 +75,8 @@ describe("claude-code action handler", () => { Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; process.chdir(tempDir); - mockReadClaudeCodeSession.mockReset(); - mockReadClaudeCodeSession.mockResolvedValue({ ok: true, data: {} }); + readSpy = spyOn(sessionContextHelpers, "readClaudeCodeSession"); + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -98,6 +88,7 @@ describe("claude-code action handler", () => { process.chdir(originalCwd); delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); + readSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -110,7 +101,7 @@ describe("claude-code action handler", () => { } test("prints JSON on successful result", async () => { - mockReadClaudeCodeSession.mockResolvedValue({ + readSpy.mockResolvedValue({ ok: true, data: { entries: [{ role: "user", content: "hello" }], total: 1 }, }); @@ -126,10 +117,7 @@ describe("claude-code action handler", () => { }); test("exits 1 when reader returns error result", async () => { - mockReadClaudeCodeSession.mockResolvedValue({ - ok: false, - error: "No session found", - }); + readSpy.mockResolvedValue({ ok: false, error: "No session found" }); await expect( makeProgram().parseAsync(["node", "session-context", "claude-code"]) @@ -143,9 +131,7 @@ describe("claude-code action handler", () => { }); test("exits 2 when unexpected error is thrown", async () => { - mockReadClaudeCodeSession.mockRejectedValue( - new Error("Unexpected disk failure") - ); + readSpy.mockRejectedValue(new Error("Unexpected disk failure")); await expect( makeProgram().parseAsync(["node", "session-context", "claude-code"]) @@ -161,7 +147,7 @@ describe("claude-code action handler", () => { test("re-throws ExitPromptError", async () => { const exitPromptError = new Error("prompt cancelled"); exitPromptError.name = "ExitPromptError"; - mockReadClaudeCodeSession.mockRejectedValue(exitPromptError); + readSpy.mockRejectedValue(exitPromptError); await expect( makeProgram().parseAsync(["node", "session-context", "claude-code"]) @@ -171,14 +157,183 @@ describe("claude-code action handler", () => { }); test("passes findProjectRoot result to reader", async () => { - mockReadClaudeCodeSession.mockResolvedValue({ ok: true, data: {} }); + readSpy.mockResolvedValue({ ok: true, data: {} }); await makeProgram().parseAsync(["node", "session-context", "claude-code"]); // findProjectRoot found our tempDir (which has .archgate/) - expect(mockReadClaudeCodeSession).toHaveBeenCalledWith(tempDir, { + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); + }); + + test("list subcommand prints sessions", async () => { + const listSpy = spyOn(sessionContextHelpers, "listClaudeCodeSessions"); + try { + listSpy.mockResolvedValue({ + ok: true, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "claude-code", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); + } finally { + listSpy.mockRestore(); + } + }); + + test("list subcommand exits 1 on error result", async () => { + const listSpy = spyOn(sessionContextHelpers, "listClaudeCodeSessions"); + try { + listSpy.mockResolvedValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "claude-code", + "list", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); + } finally { + listSpy.mockRestore(); + } + }); + + test("show subcommand reads the given session id", async () => { + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "claude-code", + "show", + "abc123", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - skip: 0, + sessionId: "abc123", + }); + }); + + test("show subcommand exits 1 on error result", async () => { + readSpy.mockResolvedValue({ + ok: false, + error: "Session not found: abc123", }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "claude-code", + "show", + "abc123", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe("claude-code list/show (CLI subprocess)", () => { + // Subprocess tests avoid Bun's process-global mock.module state — this + // file mocks the read helper for the in-process tests above, so the + // nested subcommands are exercised against real stores in a child + // process with HOME/USERPROFILE redirected. + let tempHome: string; + let projectDir: string; + let env: Record; + + function encodeClaude(p: string): string { + return p + .replaceAll("\\", "-") + .replaceAll("/", "-") + .replaceAll(":", "-") + .replaceAll(".", "-"); + } + + beforeEach(() => { + tempHome = realpathSync(mkdtempSync(join(tmpdir(), "archgate-cc-home-"))); + projectDir = join(tempHome, "project"); + mkdirSync(join(projectDir, ".archgate", "adrs"), { recursive: true }); + env = { HOME: tempHome, USERPROFILE: tempHome }; + }); + + afterEach(() => { + safeRmSync(tempHome); + }); + + function seedSession(id: string, content: string): void { + const dir = join(tempHome, ".claude", "projects", encodeClaude(projectDir)); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, `${id}.jsonl`), + JSON.stringify({ type: "user", message: { role: "user", content } }) + ); + } + + test("list returns the project's sessions", async () => { + seedSession("abc123", "hi"); + + const { exitCode, stdout } = await runCli( + ["session-context", "claude-code", "list"], + projectDir, + env + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as { + sessions: Array<{ id: string; updatedAt: string }>; + }; + expect(parsed.sessions.map((s) => s.id)).toEqual(["abc123"]); + expect(Date.parse(parsed.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); + }); + + test("show reads a specific session by id", async () => { + seedSession("older", "earlier content"); + seedSession("newer", "current content"); + + const { exitCode, stdout } = await runCli( + ["session-context", "claude-code", "show", "older"], + projectDir, + env + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as { + sessionFile: string; + transcript: Array<{ contentPreview: string }>; + }; + expect(parsed.sessionFile).toBe("older.jsonl"); + expect(parsed.transcript[0]?.contentPreview).toBe("earlier content"); + }); + + test("show with an unknown id exits 1", async () => { + seedSession("only", "hi"); + + const { exitCode, stderr } = await runCli( + ["session-context", "claude-code", "show", "nope"], + projectDir, + env + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Session not found: nope"); }); }); diff --git a/tests/commands/session-context/copilot.test.ts b/tests/commands/session-context/copilot.test.ts index dda70150..1fc3d327 100644 --- a/tests/commands/session-context/copilot.test.ts +++ b/tests/commands/session-context/copilot.test.ts @@ -1,39 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; -// --------------------------------------------------------------------------- -// Module mocks — declared before the import under test. -// --------------------------------------------------------------------------- - -const mockReadCopilotSession = mock( - () => - Promise.resolve({ ok: true, data: {} }) as Promise< - { ok: true; data: unknown } | { ok: false; error: string } - > -); -mock.module("../../../src/helpers/session-context-copilot", () => ({ - readCopilotSession: mockReadCopilotSession, -})); - -// --------------------------------------------------------------------------- -// Import under test — loaded AFTER mocks are registered. -// --------------------------------------------------------------------------- - import { registerCopilotSessionContextCommand } from "../../../src/commands/session-context/copilot"; +import * as copilotHelpers from "../../../src/helpers/session-context-copilot"; import { safeRmSync } from "../../test-utils"; // --------------------------------------------------------------------------- @@ -63,12 +38,11 @@ describe("registerCopilotSessionContextCommand", () => { expect(opt).toBeDefined(); }); - test("accepts --session-id option", () => { + test("has list and show subcommands", () => { const parent = new Command("session-context"); registerCopilotSessionContextCommand(parent); const sub = parent.commands.find((c) => c.name() === "copilot")!; - const opt = sub.options.find((o) => o.long === "--session-id"); - expect(opt).toBeDefined(); + expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); }); }); @@ -78,6 +52,18 @@ describe("copilot action handler", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; + let readSpy: ReturnType; + + /** Minimal complete summary for the default happy-path spy. */ + function emptySummary() { + return { + sessionId: "s", + sessionFile: "events.jsonl", + totalEntries: 0, + relevantEntries: 0, + transcript: [], + }; + } beforeEach(() => { // realpathSync normalizes macOS /var → /private/var symlink so the @@ -90,8 +76,8 @@ describe("copilot action handler", () => { Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; process.chdir(tempDir); - mockReadCopilotSession.mockReset(); - mockReadCopilotSession.mockResolvedValue({ ok: true, data: {} }); + readSpy = spyOn(copilotHelpers, "readCopilotSession"); + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -103,6 +89,7 @@ describe("copilot action handler", () => { process.chdir(originalCwd); delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); + readSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -115,7 +102,7 @@ describe("copilot action handler", () => { } test("prints JSON on successful result", async () => { - mockReadCopilotSession.mockResolvedValue({ + readSpy.mockResolvedValue({ ok: true, data: { entries: [{ role: "assistant", content: "hi" }], total: 1 }, }); @@ -131,10 +118,7 @@ describe("copilot action handler", () => { }); test("exits 1 when reader returns error result", async () => { - mockReadCopilotSession.mockResolvedValue({ - ok: false, - error: "No copilot session found", - }); + readSpy.mockResolvedValue({ ok: false, error: "No copilot session found" }); await expect( makeProgram().parseAsync(["node", "session-context", "copilot"]) @@ -148,7 +132,7 @@ describe("copilot action handler", () => { }); test("exits 2 when unexpected error is thrown", async () => { - mockReadCopilotSession.mockRejectedValue(new Error("Permission denied")); + readSpy.mockRejectedValue(new Error("Permission denied")); await expect( makeProgram().parseAsync(["node", "session-context", "copilot"]) @@ -164,7 +148,7 @@ describe("copilot action handler", () => { test("re-throws ExitPromptError", async () => { const exitPromptError = new Error("prompt cancelled"); exitPromptError.name = "ExitPromptError"; - mockReadCopilotSession.mockRejectedValue(exitPromptError); + readSpy.mockRejectedValue(exitPromptError); await expect( makeProgram().parseAsync(["node", "session-context", "copilot"]) @@ -174,14 +158,90 @@ describe("copilot action handler", () => { }); test("passes findProjectRoot result to reader", async () => { - mockReadCopilotSession.mockResolvedValue({ ok: true, data: {} }); + readSpy.mockResolvedValue({ ok: true, data: {} }); await makeProgram().parseAsync(["node", "session-context", "copilot"]); - expect(mockReadCopilotSession).toHaveBeenCalledWith(tempDir, { + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); + }); + + test("list subcommand prints sessions", async () => { + const listSpy = spyOn(copilotHelpers, "listCopilotSessions"); + try { + listSpy.mockResolvedValue({ + ok: true, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "copilot", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); + } finally { + listSpy.mockRestore(); + } + }); + + test("list subcommand exits 1 on error result", async () => { + const listSpy = spyOn(copilotHelpers, "listCopilotSessions"); + try { + listSpy.mockResolvedValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync(["node", "session-context", "copilot", "list"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); + } finally { + listSpy.mockRestore(); + } + }); + + test("show subcommand reads the given session id", async () => { + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "copilot", + "show", + "abc123", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - skip: 0, - sessionId: undefined, + sessionId: "abc123", + }); + }); + + test("show subcommand exits 1 on error result", async () => { + readSpy.mockResolvedValue({ + ok: false, + error: "Session not found: abc123", }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "copilot", + "show", + "abc123", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); }); }); diff --git a/tests/commands/session-context/cursor.test.ts b/tests/commands/session-context/cursor.test.ts index 05c7ae49..183dff64 100644 --- a/tests/commands/session-context/cursor.test.ts +++ b/tests/commands/session-context/cursor.test.ts @@ -1,39 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; -// --------------------------------------------------------------------------- -// Module mocks — declared before the import under test. -// --------------------------------------------------------------------------- - -const mockReadCursorSession = mock( - () => - Promise.resolve({ ok: true, data: {} }) as Promise< - { ok: true; data: unknown } | { ok: false; error: string } - > -); -mock.module("../../../src/helpers/session-context", () => ({ - readCursorSession: mockReadCursorSession, -})); - -// --------------------------------------------------------------------------- -// Import under test — loaded AFTER mocks are registered. -// --------------------------------------------------------------------------- - import { registerCursorSessionContextCommand } from "../../../src/commands/session-context/cursor"; +import * as sessionContextHelpers from "../../../src/helpers/session-context"; import { safeRmSync } from "../../test-utils"; // --------------------------------------------------------------------------- @@ -63,12 +38,11 @@ describe("registerCursorSessionContextCommand", () => { expect(opt).toBeDefined(); }); - test("accepts --session-id option", () => { + test("has list and show subcommands", () => { const parent = new Command("session-context"); registerCursorSessionContextCommand(parent); const sub = parent.commands.find((c) => c.name() === "cursor")!; - const opt = sub.options.find((o) => o.long === "--session-id"); - expect(opt).toBeDefined(); + expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); }); }); @@ -78,6 +52,18 @@ describe("cursor action handler", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; + let readSpy: ReturnType; + + /** Minimal complete summary for the default happy-path spy. */ + function emptySummary() { + return { + sessionId: "s", + sessionFile: "s.jsonl", + totalEntries: 0, + relevantEntries: 0, + transcript: [], + }; + } beforeEach(() => { // realpathSync normalizes macOS /var → /private/var symlink so the @@ -90,8 +76,8 @@ describe("cursor action handler", () => { Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; process.chdir(tempDir); - mockReadCursorSession.mockReset(); - mockReadCursorSession.mockResolvedValue({ ok: true, data: {} }); + readSpy = spyOn(sessionContextHelpers, "readCursorSession"); + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -103,6 +89,7 @@ describe("cursor action handler", () => { process.chdir(originalCwd); delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); + readSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -115,7 +102,7 @@ describe("cursor action handler", () => { } test("prints JSON on successful result", async () => { - mockReadCursorSession.mockResolvedValue({ + readSpy.mockResolvedValue({ ok: true, data: { entries: [{ role: "user", content: "test" }], total: 1 }, }); @@ -131,10 +118,7 @@ describe("cursor action handler", () => { }); test("exits 1 when reader returns error result", async () => { - mockReadCursorSession.mockResolvedValue({ - ok: false, - error: "No cursor session found", - }); + readSpy.mockResolvedValue({ ok: false, error: "No cursor session found" }); await expect( makeProgram().parseAsync(["node", "session-context", "cursor"]) @@ -148,7 +132,7 @@ describe("cursor action handler", () => { }); test("exits 2 when unexpected error is thrown", async () => { - mockReadCursorSession.mockRejectedValue(new Error("File system error")); + readSpy.mockRejectedValue(new Error("File system error")); await expect( makeProgram().parseAsync(["node", "session-context", "cursor"]) @@ -164,7 +148,7 @@ describe("cursor action handler", () => { test("re-throws ExitPromptError", async () => { const exitPromptError = new Error("prompt cancelled"); exitPromptError.name = "ExitPromptError"; - mockReadCursorSession.mockRejectedValue(exitPromptError); + readSpy.mockRejectedValue(exitPromptError); await expect( makeProgram().parseAsync(["node", "session-context", "cursor"]) @@ -174,14 +158,90 @@ describe("cursor action handler", () => { }); test("passes findProjectRoot result to reader", async () => { - mockReadCursorSession.mockResolvedValue({ ok: true, data: {} }); + readSpy.mockResolvedValue({ ok: true, data: {} }); await makeProgram().parseAsync(["node", "session-context", "cursor"]); - expect(mockReadCursorSession).toHaveBeenCalledWith(tempDir, { + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); + }); + + test("list subcommand prints sessions", async () => { + const listSpy = spyOn(sessionContextHelpers, "listCursorSessions"); + try { + listSpy.mockResolvedValue({ + ok: true, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "cursor", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); + } finally { + listSpy.mockRestore(); + } + }); + + test("list subcommand exits 1 on error result", async () => { + const listSpy = spyOn(sessionContextHelpers, "listCursorSessions"); + try { + listSpy.mockResolvedValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync(["node", "session-context", "cursor", "list"]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); + } finally { + listSpy.mockRestore(); + } + }); + + test("show subcommand reads the given session id", async () => { + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "cursor", + "show", + "abc123", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - skip: 0, - sessionId: undefined, + sessionId: "abc123", + }); + }); + + test("show subcommand exits 1 on error result", async () => { + readSpy.mockResolvedValue({ + ok: false, + error: "Session not found: abc123", }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "cursor", + "show", + "abc123", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); }); }); diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts index 85e677b5..8bcc77b1 100644 --- a/tests/commands/session-context/opencode.test.ts +++ b/tests/commands/session-context/opencode.test.ts @@ -1,39 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; -// --------------------------------------------------------------------------- -// Module mocks — declared before the import under test. -// --------------------------------------------------------------------------- - -const mockReadOpencodeSession = mock( - () => - Promise.resolve({ ok: true, data: {} }) as Promise< - { ok: true; data: unknown } | { ok: false; error: string } - > -); -mock.module("../../../src/helpers/session-context-opencode", () => ({ - readOpencodeSession: mockReadOpencodeSession, -})); - -// --------------------------------------------------------------------------- -// Import under test — loaded AFTER mocks are registered. -// --------------------------------------------------------------------------- - import { registerOpencodeSessionContextCommand } from "../../../src/commands/session-context/opencode"; +import * as opencodeHelpers from "../../../src/helpers/session-context-opencode"; +import { runCli } from "../../integration/cli-harness"; import { safeRmSync } from "../../test-utils"; // --------------------------------------------------------------------------- @@ -63,12 +40,14 @@ describe("registerOpencodeSessionContextCommand", () => { expect(opt).toBeDefined(); }); - test("accepts --session-id option", () => { + test("has list and show subcommands; --root only on show", () => { const parent = new Command("session-context"); registerOpencodeSessionContextCommand(parent); const sub = parent.commands.find((c) => c.name() === "opencode")!; - const opt = sub.options.find((o) => o.long === "--session-id"); - expect(opt).toBeDefined(); + expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); + const show = sub.commands.find((c) => c.name() === "show")!; + expect(show.options.map((o) => o.long)).toContain("--root"); + expect(sub.options.map((o) => o.long)).not.toContain("--root"); }); }); @@ -78,6 +57,17 @@ describe("opencode action handler", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; + let readSpy: ReturnType; + + /** Minimal complete summary for the default happy-path spy. */ + function emptySummary() { + return { + sessionId: "s", + totalEntries: 0, + relevantEntries: 0, + transcript: [], + }; + } beforeEach(() => { // realpathSync normalizes macOS /var → /private/var symlink so the @@ -90,8 +80,8 @@ describe("opencode action handler", () => { Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; process.chdir(tempDir); - mockReadOpencodeSession.mockReset(); - mockReadOpencodeSession.mockResolvedValue({ ok: true, data: {} }); + readSpy = spyOn(opencodeHelpers, "readOpencodeSession"); + readSpy.mockReturnValue({ ok: true, data: emptySummary() }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -103,6 +93,7 @@ describe("opencode action handler", () => { process.chdir(originalCwd); delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); + readSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -115,7 +106,7 @@ describe("opencode action handler", () => { } test("prints JSON on successful result", async () => { - mockReadOpencodeSession.mockResolvedValue({ + readSpy.mockReturnValue({ ok: true, data: { entries: [{ role: "assistant", content: "done" }], total: 1 }, }); @@ -131,10 +122,7 @@ describe("opencode action handler", () => { }); test("exits 1 when reader returns error result", async () => { - mockReadOpencodeSession.mockResolvedValue({ - ok: false, - error: "No opencode session found", - }); + readSpy.mockReturnValue({ ok: false, error: "No opencode session found" }); await expect( makeProgram().parseAsync(["node", "session-context", "opencode"]) @@ -148,9 +136,9 @@ describe("opencode action handler", () => { }); test("exits 2 when unexpected error is thrown", async () => { - mockReadOpencodeSession.mockRejectedValue( - new Error("ENOENT: no such file") - ); + readSpy.mockImplementation(() => { + throw new Error("ENOENT: no such file"); + }); await expect( makeProgram().parseAsync(["node", "session-context", "opencode"]) @@ -166,7 +154,9 @@ describe("opencode action handler", () => { test("re-throws ExitPromptError", async () => { const exitPromptError = new Error("prompt cancelled"); exitPromptError.name = "ExitPromptError"; - mockReadOpencodeSession.mockRejectedValue(exitPromptError); + readSpy.mockImplementation(() => { + throw exitPromptError; + }); await expect( makeProgram().parseAsync(["node", "session-context", "opencode"]) @@ -176,15 +166,245 @@ describe("opencode action handler", () => { }); test("passes findProjectRoot result to reader", async () => { - mockReadOpencodeSession.mockResolvedValue({ ok: true, data: {} }); + readSpy.mockReturnValue({ ok: true, data: {} }); await makeProgram().parseAsync(["node", "session-context", "opencode"]); - expect(mockReadOpencodeSession).toHaveBeenCalledWith(tempDir, { + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); + }); + + test("list subcommand prints sessions", async () => { + const listSpy = spyOn(opencodeHelpers, "listOpencodeSessions"); + try { + listSpy.mockReturnValue({ + ok: true, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); + } finally { + listSpy.mockRestore(); + } + }); + + test("list subcommand exits 1 on error result", async () => { + const listSpy = spyOn(opencodeHelpers, "listOpencodeSessions"); + try { + listSpy.mockReturnValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "list", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); + } finally { + listSpy.mockRestore(); + } + }); + + test("show subcommand reads the given session id", async () => { + readSpy.mockReturnValue({ ok: true, data: emptySummary() }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "show", + "abc123", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - skip: 0, - sessionId: undefined, + sessionId: "abc123", root: undefined, }); }); + + test("show subcommand exits 1 on error result", async () => { + readSpy.mockReturnValue({ ok: false, error: "Session not found: abc123" }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "show", + "abc123", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + test("show subcommand passes --root through", async () => { + readSpy.mockReturnValue({ ok: true, data: emptySummary() }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "show", + "abc123", + "--root", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { + maxEntries: undefined, + sessionId: "abc123", + root: true, + }); + }); +}); + +describe("opencode list/show (CLI subprocess)", () => { + // Subprocess tests avoid Bun's process-global mock.module state — this + // file mocks the read helper for the in-process tests above, so the + // nested subcommands are exercised against a real temp opencode DB in a + // child process with XDG_DATA_HOME redirected. + let tempHome: string; + let projectDir: string; + let env: Record; + + beforeEach(() => { + tempHome = realpathSync(mkdtempSync(join(tmpdir(), "archgate-oc-home-"))); + projectDir = join(tempHome, "project"); + mkdirSync(join(projectDir, ".archgate", "adrs"), { recursive: true }); + env = { + HOME: tempHome, + USERPROFILE: tempHome, + XDG_DATA_HOME: join(tempHome, "xdg"), + }; + }); + + afterEach(() => { + safeRmSync(tempHome); + }); + + /** Seed an opencode DB: parent session + sub-agent child, each with a message. */ + function seedOpencode(): void { + mkdirSync(join(tempHome, "xdg", "opencode"), { recursive: true }); + const db = new Database(join(tempHome, "xdg", "opencode", "opencode.db")); + db.exec("PRAGMA journal_mode = DELETE"); + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, parent_id TEXT, + directory TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', + time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL DEFAULT '{}' + ); + `); + const addSession = (id: string, parent: string | null, t: number) => { + db.run( + "INSERT INTO session (id, parent_id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)", + [ + id, + parent, + projectDir, + id === "ses_parent" ? "main work" : "sub", + t, + t, + ] + ); + db.run( + "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", + [`msg_${id}`, id, t + 1, JSON.stringify({ role: "user" })] + ); + db.run( + "INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)", + [ + `prt_${id}`, + `msg_${id}`, + id, + t + 1, + JSON.stringify({ type: "text", text: `content of ${id}` }), + ] + ); + }; + addSession("ses_parent", null, 1000); + addSession("ses_child", "ses_parent", 2000); + db.close(); + } + + test("list returns top-level sessions only", async () => { + seedOpencode(); + + const { exitCode, stdout } = await runCli( + ["session-context", "opencode", "list"], + projectDir, + env + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as { + sessions: Array<{ id: string; title: string; updatedAt: string }>; + }; + expect(parsed.sessions.map((s) => s.id)).toEqual(["ses_parent"]); + expect(parsed.sessions[0]?.title).toBe("main work"); + }); + + test("show reads a specific session; --root resolves to the ancestor", async () => { + seedOpencode(); + + const shown = await runCli( + ["session-context", "opencode", "show", "ses_child"], + projectDir, + env + ); + expect(shown.exitCode).toBe(0); + expect((JSON.parse(shown.stdout) as { sessionId: string }).sessionId).toBe( + "ses_child" + ); + + const rooted = await runCli( + ["session-context", "opencode", "show", "ses_child", "--root"], + projectDir, + env + ); + expect(rooted.exitCode).toBe(0); + expect((JSON.parse(rooted.stdout) as { sessionId: string }).sessionId).toBe( + "ses_parent" + ); + }); + + test("show with an unknown id exits 1", async () => { + seedOpencode(); + + const { exitCode, stderr } = await runCli( + ["session-context", "opencode", "show", "ses_nope"], + projectDir, + env + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Session not found: ses_nope"); + }); }); diff --git a/tests/helpers/session-context-copilot.test.ts b/tests/helpers/session-context-copilot.test.ts index 43f0f292..49fb6026 100644 --- a/tests/helpers/session-context-copilot.test.ts +++ b/tests/helpers/session-context-copilot.test.ts @@ -5,7 +5,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { readCopilotSession } from "../../src/helpers/session-context-copilot"; +import { + listCopilotSessions, + readCopilotSession, +} from "../../src/helpers/session-context-copilot"; // This file covers readCopilotSession happy-path and error-case tests. @@ -239,52 +242,52 @@ describe("readCopilotSession", () => { } }); - test("skip option reads the second-most-recent matching session", async () => { - // Create parent session (make it older by creating first) - const parentId = `copilot-${uniqueId}-parent`; - makeSession(parentId, projectRoot, [ + test("sessionId reads an earlier session; default reads the most recent", async () => { + // Create an earlier session (make it older by backdating) + const earlierId = `copilot-${uniqueId}-earlier`; + makeSession(earlierId, projectRoot, [ JSON.stringify({ type: "user.message", - data: { content: "parent question" }, + data: { content: "earlier question" }, }), JSON.stringify({ type: "assistant.message", - data: { content: "parent answer" }, + data: { content: "earlier answer" }, }), ]); - // Make parent dir older so sub-agent dir is most recent + // Make the earlier dir older so the current dir is most recent const { utimesSync } = await import("node:fs"); const past = new Date(Date.now() - 60_000); - utimesSync(join(stateDir, parentId), past, past); + utimesSync(join(stateDir, earlierId), past, past); - // Create sub-agent session (newer) - const subagentId = `copilot-${uniqueId}-subagent`; - makeSession(subagentId, projectRoot, [ + // Create the current session (newer) + const currentId = `copilot-${uniqueId}-current`; + makeSession(currentId, projectRoot, [ JSON.stringify({ type: "user.message", - data: { content: "sub-agent init" }, + data: { content: "current msg" }, }), ]); - // Without skip → reads sub-agent session (most recent) - const resultNoSkip = await readCopilotSession(projectRoot); - expect(resultNoSkip.ok).toBe(true); - if (!resultNoSkip.ok) throw new Error("expected ok"); - expect(resultNoSkip.data.sessionId).toBe(subagentId); - - // With skip=1 → reads parent session - const resultSkip = await readCopilotSession(projectRoot, { skip: 1 }); - expect(resultSkip.ok).toBe(true); - if (!resultSkip.ok) throw new Error("expected ok"); - expect(resultSkip.data.sessionId).toBe(parentId); - expect(resultSkip.data.transcript[0]?.contentPreview).toBe( - "parent question" - ); + // Default → reads the most recent session (the current conversation) + const latest = await readCopilotSession(projectRoot); + expect(latest.ok).toBe(true); + if (!latest.ok) throw new Error("expected ok"); + expect(latest.data.sessionId).toBe(currentId); + + // sessionId → reads the earlier session explicitly + const earlier = await readCopilotSession(projectRoot, { + sessionId: earlierId, + }); + expect(earlier.ok).toBe(true); + if (!earlier.ok) throw new Error("expected ok"); + expect(earlier.data.sessionId).toBe(earlierId); + expect(earlier.data.transcript[0]?.contentPreview).toBe("earlier question"); }); - test("skip beyond available matching sessions returns error", async () => { - const sessionId = `copilot-${uniqueId}-onlysession`; + test("sessionId not found returns error with available ids", async () => { + const sessionId = `copilot-${uniqueId}-notfound-only`; makeSession(sessionId, projectRoot, [ JSON.stringify({ type: "user.message", @@ -292,13 +295,43 @@ describe("readCopilotSession", () => { }), ]); - const result = await readCopilotSession(projectRoot, { skip: 2 }); + const result = await readCopilotSession(projectRoot, { + sessionId: "copilot-nope", + }); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error).toContain("--skip 2 requested"); + expect(result.error).toContain("Session not found: copilot-nope"); + expect(result.available).toContain(sessionId); } }); + test("list returns matching sessions most recent first", async () => { + const earlierId = `copilot-${uniqueId}-list-earlier`; + makeSession(earlierId, projectRoot, [ + JSON.stringify({ type: "user.message", data: { content: "old" } }), + ]); + const { utimesSync } = await import("node:fs"); + const past = new Date(Date.now() - 60_000); + utimesSync(join(stateDir, earlierId), past, past); + const currentId = `copilot-${uniqueId}-list-current`; + makeSession(currentId, projectRoot, [ + JSON.stringify({ type: "user.message", data: { content: "new" } }), + ]); + // A session for a different project must not be listed + makeSession(`copilot-${uniqueId}-other`, "/some/other/project", [ + JSON.stringify({ type: "user.message", data: { content: "other" } }), + ]); + + const result = await listCopilotSessions(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.sessions.map((s) => s.id)).toEqual([ + currentId, + earlierId, + ]); + expect(Date.parse(result.data.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); + }); + test("truncates string content preview to 500 chars", async () => { const sessionId = `copilot-${uniqueId}-truncate`; makeSession(sessionId, projectRoot, [ diff --git a/tests/helpers/session-context-cursor.test.ts b/tests/helpers/session-context-cursor.test.ts index 77e07c13..9cb661a3 100644 --- a/tests/helpers/session-context-cursor.test.ts +++ b/tests/helpers/session-context-cursor.test.ts @@ -5,7 +5,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import { join } from "node:path"; -import { readCursorSession } from "../../src/helpers/session-context"; +import { + listCursorSessions, + readCursorSession, +} from "../../src/helpers/session-context"; // This file covers readCursorSession happy-path tests. // Error cases for readCursorSession live in session-context.test.ts. @@ -201,49 +204,49 @@ describe("readCursorSession", () => { expect(result.data.transcript[1]?.contentPreview).toBe("msg 7"); }); - test("skip option reads the second-most-recent session directory", async () => { - // Create parent session (make it older) - makeSession("session-parent", [ + test("sessionId reads an earlier session; default reads the most recent", async () => { + // Create an earlier session (make it older) + makeSession("session-earlier", [ JSON.stringify({ role: "user", - message: { role: "user", content: "parent question" }, + message: { role: "user", content: "earlier question" }, }), JSON.stringify({ role: "assistant", - message: { role: "assistant", content: "parent answer" }, + message: { role: "assistant", content: "earlier answer" }, }), ]); - // Make parent dir older + // Make the earlier dir older const { utimesSync } = await import("node:fs"); const past = new Date(Date.now() - 60_000); - utimesSync(join(transcriptsDir, "session-parent"), past, past); + utimesSync(join(transcriptsDir, "session-earlier"), past, past); - // Create sub-agent session (newer) - makeSession("session-subagent", [ + // Create the current session (newer) + makeSession("session-current", [ JSON.stringify({ role: "user", - message: { role: "user", content: "sub-agent init" }, + message: { role: "user", content: "current msg" }, }), ]); - // Without skip → reads sub-agent session (most recent) - const resultNoSkip = await readCursorSession(projectRoot); - expect(resultNoSkip.ok).toBe(true); - if (!resultNoSkip.ok) throw new Error("expected ok"); - expect(resultNoSkip.data.sessionId).toBe("session-subagent"); - - // With skip=1 → reads parent session - const resultSkip = await readCursorSession(projectRoot, { skip: 1 }); - expect(resultSkip.ok).toBe(true); - if (!resultSkip.ok) throw new Error("expected ok"); - expect(resultSkip.data.sessionId).toBe("session-parent"); - expect(resultSkip.data.transcript[0]?.contentPreview).toBe( - "parent question" - ); + // Default → reads the most recent session (the current conversation) + const latest = await readCursorSession(projectRoot); + expect(latest.ok).toBe(true); + if (!latest.ok) throw new Error("expected ok"); + expect(latest.data.sessionId).toBe("session-current"); + + // sessionId → reads the earlier session explicitly + const earlier = await readCursorSession(projectRoot, { + sessionId: "session-earlier", + }); + expect(earlier.ok).toBe(true); + if (!earlier.ok) throw new Error("expected ok"); + expect(earlier.data.sessionId).toBe("session-earlier"); + expect(earlier.data.transcript[0]?.contentPreview).toBe("earlier question"); }); - test("skip beyond available sessions returns error", async () => { + test("sessionId not found returns error with available ids", async () => { makeSession("session-only", [ JSON.stringify({ role: "user", @@ -251,13 +254,43 @@ describe("readCursorSession", () => { }), ]); - const result = await readCursorSession(projectRoot, { skip: 2 }); + const result = await readCursorSession(projectRoot, { + sessionId: "session-nope", + }); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error).toContain("--skip 2 requested"); + expect(result.error).toContain("Session not found: session-nope"); + expect(result.available).toEqual(["session-only"]); } }); + test("list returns sessions most recent first with timestamps", async () => { + makeSession("session-earlier", [ + JSON.stringify({ + role: "user", + message: { role: "user", content: "old" }, + }), + ]); + const { utimesSync } = await import("node:fs"); + const past = new Date(Date.now() - 60_000); + utimesSync(join(transcriptsDir, "session-earlier"), past, past); + makeSession("session-current", [ + JSON.stringify({ + role: "user", + message: { role: "user", content: "new" }, + }), + ]); + + const result = await listCursorSessions(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.sessions.map((s) => s.id)).toEqual([ + "session-current", + "session-earlier", + ]); + expect(Date.parse(result.data.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); + }); + test("ignores non-directory entries in transcripts dir", async () => { // Put a plain file in the transcripts dir — it should be skipped writeFileSync(join(transcriptsDir, "stray-file.txt"), "noise"); diff --git a/tests/helpers/session-context-opencode.test.ts b/tests/helpers/session-context-opencode.test.ts index 11a47f9b..5738109f 100644 --- a/tests/helpers/session-context-opencode.test.ts +++ b/tests/helpers/session-context-opencode.test.ts @@ -6,7 +6,10 @@ import { mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { readOpencodeSession } from "../../src/helpers/session-context-opencode"; +import { + listOpencodeSessions, + readOpencodeSession, +} from "../../src/helpers/session-context-opencode"; /** * Tests for readOpencodeSession — reads session data from @@ -412,21 +415,22 @@ describe("readOpencodeSession", () => { } }); - test("skip indexes over top-level sessions only", async () => { + test("list returns top-level sessions only, most recent first", () => { const db = createDb(); makeSimpleSession(db, "ses_older", "older top-level", 1000); makeSimpleSession(db, "ses_newer", "newer top-level", 2000); - // Child session between the two — must not consume a skip index + // Child session between the two — must not appear in the list makeSimpleSession(db, "ses_child", "child", 1500, "ses_older"); db.close(); - const noSkip = await readOpencodeSession(projectRoot); - expect(noSkip.ok).toBe(true); - if (noSkip.ok) expect(noSkip.data.sessionId).toBe("ses_newer"); - - const skipped = await readOpencodeSession(projectRoot, { skip: 1 }); - expect(skipped.ok).toBe(true); - if (skipped.ok) expect(skipped.data.sessionId).toBe("ses_older"); + const result = listOpencodeSessions(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.sessions.map((s) => s.id)).toEqual([ + "ses_newer", + "ses_older", + ]); + expect(Date.parse(result.data.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); }); test("root resolves to the top-level ancestor session", async () => { @@ -464,10 +468,12 @@ describe("readOpencodeSession", () => { makeSimpleSession(db, "ses_sib_d", "domain review d", 5000, "ses_parent"); db.close(); - // skip: 1 now errors honestly — only one top-level session exists, so - // there is no second one to skip to (previously it returned a sibling). - const skipped = await readOpencodeSession(projectRoot, { skip: 1 }); - expect(skipped.ok).toBe(false); + // The list shows only the parent — no sibling can shadow it. + const listed = listOpencodeSessions(projectRoot); + expect(listed.ok).toBe(true); + if (listed.ok) { + expect(listed.data.sessions.map((s) => s.id)).toEqual(["ses_parent"]); + } // Default selection and explicit root both resolve to the parent. const byDefault = await readOpencodeSession(projectRoot); @@ -479,20 +485,22 @@ describe("readOpencodeSession", () => { if (rooted.ok) expect(rooted.data.sessionId).toBe("ses_parent"); }); - test("skip beyond available top-level sessions returns error", async () => { + test("returns error when only child sessions match the project", async () => { const db = createDb(); - makeSimpleSession(db, "ses_only", "only", 1000); - // Child sessions must not count toward the top-level total - makeSimpleSession(db, "ses_child", "child", 2000, "ses_only"); + // Parent lives in a different directory; only the child matches here + makeSession(db, "ses_parent", "/some/other/project", [ + { id: "msg_p", role: "user", content: "elsewhere" }, + ]); + makeSimpleSession(db, "ses_child", "child", 2000, "ses_parent"); db.close(); - const result = await readOpencodeSession(projectRoot, { skip: 1 }); + const result = await readOpencodeSession(projectRoot); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain( - "Only 1 top-level session(s) available but --skip 1 requested" + "No top-level opencode session found for this project" ); - expect(result.available).toEqual(["ses_only"]); + expect(result.available).toEqual(["ses_child"]); } }); diff --git a/tests/helpers/session-context.test.ts b/tests/helpers/session-context.test.ts index 70ec25e9..1ae28a38 100644 --- a/tests/helpers/session-context.test.ts +++ b/tests/helpers/session-context.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { encodeProjectPath, + listClaudeCodeSessions, readClaudeCodeSession, readCursorSession, } from "../../src/helpers/session-context"; @@ -248,67 +249,102 @@ describe("readClaudeCodeSession", () => { expect(result.data.transcript[2]?.contentPreview).toBe("message 9"); }); - test("skip option reads the second-most-recent session file", async () => { - // Write a newer session file (the sub-agent's session) + test("sessionId reads a specific earlier session file", async () => { + // Write a newer session file (the current conversation) writeFileSync( - join(projectsDir, "subagent.jsonl"), + join(projectsDir, "current.jsonl"), [ JSON.stringify({ type: "user", - message: { role: "user", content: "sub-agent msg" }, + message: { role: "user", content: "current msg" }, }), ].join("\n") ); - // Write an older session file (the parent's session) - const olderFile = join(projectsDir, "parent.jsonl"); + // Write an older session file (an earlier conversation) + const olderFile = join(projectsDir, "earlier.jsonl"); writeFileSync( olderFile, [ JSON.stringify({ type: "user", - message: { role: "user", content: "parent msg" }, + message: { role: "user", content: "earlier msg" }, }), JSON.stringify({ type: "assistant", - message: { role: "assistant", content: "parent reply" }, + message: { role: "assistant", content: "earlier reply" }, }), ].join("\n") ); - // Make parent older than subagent by backdating its mtime + // Backdate the earlier session's mtime const { utimesSync } = await import("node:fs"); const past = new Date(Date.now() - 60_000); utimesSync(olderFile, past, past); - // Without skip → reads subagent (most recent) - const resultNoSkip = await readClaudeCodeSession(projectRoot); - expect(resultNoSkip.ok).toBe(true); - if (!resultNoSkip.ok) throw new Error("expected ok"); - expect(resultNoSkip.data.transcript[0]?.contentPreview).toBe( - "sub-agent msg" - ); + // Default → reads the most recent session (the current conversation) + const latest = await readClaudeCodeSession(projectRoot); + expect(latest.ok).toBe(true); + if (!latest.ok) throw new Error("expected ok"); + expect(latest.data.transcript[0]?.contentPreview).toBe("current msg"); - // With skip=1 → reads parent session - const resultSkip = await readClaudeCodeSession(projectRoot, { skip: 1 }); - expect(resultSkip.ok).toBe(true); - if (!resultSkip.ok) throw new Error("expected ok"); - expect(resultSkip.data.transcript[0]?.contentPreview).toBe("parent msg"); - expect(resultSkip.data.relevantEntries).toBe(2); + // sessionId → reads the earlier conversation explicitly + const earlier = await readClaudeCodeSession(projectRoot, { + sessionId: "earlier", + }); + expect(earlier.ok).toBe(true); + if (!earlier.ok) throw new Error("expected ok"); + expect(earlier.data.transcript[0]?.contentPreview).toBe("earlier msg"); + expect(earlier.data.relevantEntries).toBe(2); }); - test("skip beyond available sessions returns error", async () => { + test("sessionId not found returns error with available ids", async () => { writeSession([ { type: "user", message: { role: "user", content: "only session" } }, ]); - const result = await readClaudeCodeSession(projectRoot, { skip: 5 }); + const result = await readClaudeCodeSession(projectRoot, { + sessionId: "nonexistent", + }); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error).toContain("--skip 5 requested"); + expect(result.error).toContain("Session not found: nonexistent"); + expect(result.available).toEqual(["session"]); } }); + test("list returns sessions most recent first with timestamps", async () => { + writeFileSync( + join(projectsDir, "current.jsonl"), + JSON.stringify({ + type: "user", + message: { role: "user", content: "hi" }, + }) + ); + const olderFile = join(projectsDir, "earlier.jsonl"); + writeFileSync( + olderFile, + JSON.stringify({ + type: "user", + message: { role: "user", content: "old" }, + }) + ); + const { utimesSync } = await import("node:fs"); + const past = new Date(Date.now() - 60_000); + utimesSync(olderFile, past, past); + + const result = await listClaudeCodeSessions(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.sessions.map((s) => s.id)).toEqual([ + "current", + "earlier", + ]); + expect( + Date.parse(result.data.sessions[0]?.updatedAt ?? "") + ).not.toBeNaN(); + }); + test("returns error when directory exists but has no .jsonl files", async () => { writeFileSync(join(projectsDir, "notes.txt"), "not a session");