diff --git a/docs/ai/design/feature-gemini-cli-adapter.md b/docs/ai/design/feature-gemini-cli-adapter.md new file mode 100644 index 00000000..6bce7702 --- /dev/null +++ b/docs/ai/design/feature-gemini-cli-adapter.md @@ -0,0 +1,112 @@ +--- +phase: design +title: "Gemini CLI Adapter in @ai-devkit/agent-manager - Design" +feature: gemini-cli-adapter +description: Architecture and implementation design for introducing Gemini CLI adapter support in the shared agent manager package +--- + +# Design: Gemini CLI Adapter for @ai-devkit/agent-manager + +## Architecture Overview + +```mermaid +graph TD + User[User runs ai-devkit agent list/open] --> Cmd[packages/cli/src/commands/agent.ts] + Cmd --> Manager[AgentManager] + + subgraph Pkg[@ai-devkit/agent-manager] + Manager --> Claude[ClaudeCodeAdapter] + Manager --> Codex[CodexAdapter] + Manager --> Gemini[GeminiCliAdapter] + Gemini --> Proc[process utils - node argv scan] + Gemini --> File[fs read of ~/.gemini/tmp] + Gemini --> Hash[sha256 projectHash] + Gemini --> Types[AgentAdapter/AgentInfo/AgentStatus] + Focus[TerminalFocusManager] + end + + Cmd --> Focus + Cmd --> Output[CLI table/json rendering] +``` + +Responsibilities: +- `GeminiCliAdapter`: discover running Gemini processes, match them to local session files, and emit `AgentInfo` +- `AgentManager`: aggregate Gemini + Claude + Codex results in parallel +- CLI command: register adapter, render results, invoke open/focus + +## Data Models + +- Reuse `AgentAdapter`, `AgentInfo`, `AgentStatus`, and `AgentType` +- `AgentType` already supports `gemini`; adapter emits `type: 'gemini'` +- Gemini raw session shape (on disk): + - `sessionId`: uuid string + - `projectHash`: sha256 of the project root path (walked to nearest `.git` boundary) + - `startTime`, `lastUpdated`: ISO timestamps + - `messages[]`: entries with `id`, `timestamp`, `type` (`user` | `gemini` | `thought` | `tool`), and `content` / `displayContent` + - `content` is polymorphic: `string` for assistant-side, `Part[]` (e.g. `[{text: "..."}]`) for user-side + - `directories[]`, `kind` +- Normalized into `AgentInfo`: + - `id`: `gemini-` + - `name`: derived from session project directory (basename of registry path) + - `cwd`: project root + - `sessionStart`: parsed from `startTime` + - `status`: computed from `lastUpdated` vs shared status thresholds + - `pid`: matched live `node` process running the `gemini` script + +## API Design + +### Package Exports +- Add `GeminiCliAdapter` to: + - `packages/agent-manager/src/adapters/index.ts` + - `packages/agent-manager/src/index.ts` + +### CLI Integration +- Update `packages/cli/src/commands/agent.ts` to register `GeminiCliAdapter` alongside `ClaudeCodeAdapter` and `CodexAdapter` +- No presentation logic moves into the package; CLI retains formatting + +## Component Breakdown + +1. `packages/agent-manager/src/adapters/GeminiCliAdapter.ts` + - Implements adapter contract + - Detects live Gemini processes by scanning `node` processes for a `gemini` argv token + - Resolves the project-to-shortId mapping from `~/.gemini/projects.json` + - Reads session files from `~/.gemini/tmp//chats/session-*.json` + - Computes `projectHash` by walking the candidate project root up to `.git` and hashing with sha256 + - Normalizes `content`/`displayContent` via a shared `resolveContent(string | Part[])` helper + - Exposes `getConversation` for reading message history + +2. `packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts` + - Unit tests for process filtering, session parsing, array-of-parts content, empty/malformed cases, and status mapping + +3. `packages/agent-manager/src/adapters/index.ts` and `src/index.ts` + - Export adapter class + +4. `packages/cli/src/commands/agent.ts` + - Register Gemini adapter in manager setup paths + +5. `README.md` + - Flip Gemini CLI row to ✅ in the "Agent Control Support" matrix + +## Design Decisions + +- Decision: Detect Gemini CLI by scanning `node` processes and filtering argv for a `gemini`/`gemini.exe`/`gemini.js` token. + - Rationale: Gemini CLI is distributed as a pure Node script (unlike Claude's native binary or Codex's Node wrapper around Rust). `listAgentProcesses('gemini')` returns empty on macOS/Linux because `argv[0]` is `node`. +- Decision: Compute `projectHash` by walking from the candidate cwd up to the nearest `.git` directory, falling back to the starting directory when no `.git` is found. + - Rationale: matches the algorithm Gemini CLI uses internally (verified by sha256-hashing against a live session file's `projectHash`). +- Decision: Normalize polymorphic `content` through a single `resolveContent(string | Part[])` helper that extracts `.text` from each part and concatenates. + - Rationale: user messages are stored as `Part[]`; calling `.trim()` on an array throws `.trim is not a function`, which earlier caused `detectAgents` to throw and `AgentManager` to return an empty list. +- Decision: Keep `displayContent` preferred over `content` when both are present. + - Rationale: `displayContent` is the user-visible rendered string; `content` can include raw tool/thought payloads. +- Decision: Gate list membership on running `node` processes that match the Gemini token filter (process-first, like Codex). + - Rationale: stale session files from previous runs should not surface as active agents. +- Decision: Keep parsing resilient — adapter-level failures are caught and translated to empty results. + - Rationale: a malformed session file must not break the entire `agent list` command. +- Decision: Follow `CodexAdapter` structure for method names, helper extraction, and error handling. + - Rationale: maintainer guidance "đừng custom quá nhiều" — reduce cognitive load across adapters and keep the extension path uniform. + +## Non-Functional Requirements + +- Performance: adapter aggregation remains bounded by existing manager patterns; session file reads are limited to the directories of live processes. +- Reliability: Gemini adapter failures must be isolated so Claude/Codex entries still render. +- Maintainability: code structure mirrors Codex adapter for consistency. +- Security: only reads local files under `~/.gemini` and local `ps` output already permitted by existing adapters. diff --git a/docs/ai/implementation/feature-gemini-cli-adapter.md b/docs/ai/implementation/feature-gemini-cli-adapter.md new file mode 100644 index 00000000..02ec0f91 --- /dev/null +++ b/docs/ai/implementation/feature-gemini-cli-adapter.md @@ -0,0 +1,92 @@ +--- +phase: implementation +title: "Gemini CLI Adapter in @ai-devkit/agent-manager - Implementation" +feature: gemini-cli-adapter +description: Implementation notes for Gemini CLI adapter support in the package agent manager and CLI integration +--- + +# Implementation Guide: Gemini CLI Adapter in @ai-devkit/agent-manager + +## Development Setup + +- Branch: `feat/gemini-cli-adapter` +- Install dependencies with `npm ci` +- Build + lint + test with: + - `npx nx run agent-manager:build` + - `npx nx run agent-manager:lint` + - `npx nx run agent-manager:test` + - `npx nx run cli:test -- --runInBand src/__tests__/commands/agent.test.ts` + +## Code Structure + +- Package adapter implementation: + - `packages/agent-manager/src/adapters/GeminiCliAdapter.ts` +- Package exports: + - `packages/agent-manager/src/adapters/index.ts` + - `packages/agent-manager/src/index.ts` +- CLI wiring: + - `packages/cli/src/commands/agent.ts` +- Tests: + - `packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts` + - `packages/cli/src/__tests__/commands/agent.test.ts` +- README matrix update: + - `README.md` (Agent Control Support row for Gemini CLI) + +## Implementation Notes + +### Core Features +- Adapter contract: `type = 'gemini'`, plus `canHandle`, `detectAgents`, `getConversation`. +- Process detection: `listAgentProcesses('node')` + `isGeminiExecutable(argv)` token-scan for basename `gemini` / `gemini.exe` / `gemini.js`. +- Project-to-session mapping: + - Walk from the process cwd to the nearest `.git` boundary (fallback: starting cwd) + - Compute sha256 of the resolved project root → `projectHash` + - Cross-check against `~/.gemini/projects.json` for the `shortId` used in the session path +- Session file discovery: `~/.gemini/tmp//chats/session-*.json`, filtered to the matching `projectHash`. +- Content normalization: `resolveContent(content)` accepts `string | Part[]` and returns a concatenated string of `part.text` values; non-text parts are dropped. +- `messageText(entry)` prefers `displayContent` over `content`. + +### Patterns & Best Practices +- Follow `CodexAdapter` structure for helper extraction and error handling. +- Keep parsing inside the adapter; keep CLI-side formatting unchanged. +- Fail soft: malformed session entries are skipped; adapter-level exceptions return empty results so other adapters still render. +- Avoid adapter-specific customization the maintainer flagged as unnecessary (e.g., Windows-specific basename handling was reverted). + +## Integration Points + +- `AgentManager` parallel aggregation across Claude + Codex + Gemini +- `TerminalFocusManager` open/focus flow reused without Gemini-specific branches +- CLI list/json output mapping unchanged + +## Error Handling + +- Missing `~/.gemini/projects.json` or `~/.gemini/tmp` → empty result, no throw. +- Malformed session JSON → skip that file, continue with the rest. +- Polymorphic `content` → handled by `resolveContent` so `.trim()` never runs on an array. +- Adapter-level throw is caught at the manager layer, isolating the failure. + +## Performance Considerations + +- Process detection is bounded by the number of live `node` processes on the host. +- Session file reads are scoped to the `shortId` resolved from `projects.json` for each live Gemini process — not a full `~/.gemini/tmp` scan. +- Reuses existing async aggregation model in `AgentManager`. + +## Security Notes + +- Reads only local files under `~/.gemini` and local process metadata already permitted by existing adapters. +- No external network calls; no execution of user content. + +## Implementation Status + +- Completed: + - `packages/agent-manager/src/adapters/GeminiCliAdapter.ts` + - Package exports in `packages/agent-manager/src/adapters/index.ts` and `src/index.ts` + - `packages/cli/src/commands/agent.ts` registers `GeminiCliAdapter` for list and open + - README "Agent Control Support" row for Gemini CLI flipped to ✅ + - Unit tests (42 total) including the 5 added after maintainer review for array-shaped content +- Review-iteration fixes: + - Introduced `resolveContent(string | Part[])` + `messageText(entry)` helpers to handle Gemini's `Part[]` user content + - Reverted Windows-specific `path.win32.basename` customization (per "đừng custom quá nhiều") +- Commands verified: + - `npx nx run-many -t build test lint` ✅ + - `npx nx run agent-manager:test -- --runInBand src/__tests__/adapters/GeminiCliAdapter.test.ts` ✅ (42 passed) + - End-to-end: real Gemini CLI session surfaced correctly in `ai-devkit agent list` diff --git a/docs/ai/planning/feature-gemini-cli-adapter.md b/docs/ai/planning/feature-gemini-cli-adapter.md new file mode 100644 index 00000000..3aea75e4 --- /dev/null +++ b/docs/ai/planning/feature-gemini-cli-adapter.md @@ -0,0 +1,89 @@ +--- +phase: planning +title: "Gemini CLI Adapter in @ai-devkit/agent-manager - Planning" +feature: gemini-cli-adapter +description: Task plan for adding Gemini CLI adapter support and integrating it into CLI agent commands +--- + +# Planning: Gemini CLI Adapter in @ai-devkit/agent-manager + +## Milestones + +- [x] Milestone 1: Research Gemini CLI distribution, session schema, and projectHash algorithm +- [x] Milestone 2: Adapter implementation, package exports, CLI registration, README flip +- [x] Milestone 3: Unit tests + real-Gemini end-to-end verification + maintainer review iteration + +## Task Breakdown + +### Phase 1: Research & Foundation +- [x] Task 1.1: Investigate Gemini CLI process shape + - Confirmed Gemini runs as `node /path/to/gemini` (pure Node script), not a native binary + - `listAgentProcesses('gemini')` returns empty; need `listAgentProcesses('node')` + token-scan filter +- [x] Task 1.2: Reverse-engineer session storage + - Sessions live at `~/.gemini/tmp//chats/session-*.json` + - `~/.gemini/projects.json` maps `projectRoot → shortId` + - Session schema: `{sessionId, projectHash, startTime, lastUpdated, messages, directories, kind}` +- [x] Task 1.3: Confirm `projectHash` algorithm + - sha256 of project root walked to nearest `.git` boundary + - Verified against a real session written by Gemini CLI +- [x] Task 1.4: Scaffold adapter + test files following `CodexAdapter` structure + +### Phase 2: Core Implementation +- [x] Task 2.1: Implement process detection + - `isGeminiExecutable(argv)` token-scans for basename matching `gemini`/`gemini.exe`/`gemini.js` + - Enumerate candidate project roots from cwd walk +- [x] Task 2.2: Implement session parsing and mapping + - Normalize message `content` through `resolveContent(string | Part[])` helper + - Prefer `displayContent` over `content` when present + - Compute status from `lastUpdated` using shared thresholds +- [x] Task 2.3: Register adapter + - Add to `packages/agent-manager/src/adapters/index.ts` and `src/index.ts` + - Wire into `packages/cli/src/commands/agent.ts` for list/open paths +- [x] Task 2.4: Flip README "Agent Control Support" matrix for Gemini CLI + +### Phase 3: Testing & Review Iteration +- [x] Task 3.1: Unit tests (42 total) + - Process filtering with mixed `node` processes + - Session parsing — string content, array content, mixed, missing + - Status mapping, empty directory handling, malformed JSON +- [x] Task 3.2: End-to-end verification with real Gemini CLI + - User authenticated and ran a live Gemini chat session + - Verified `ai-devkit agent list` surfaces the Gemini process with correct cwd/sessionId mapping +- [x] Task 3.3: Address maintainer review feedback + - Fixed `.trim is not a function` crash on array-shaped user content (introduced `resolveContent` helper + 5 new tests) + - Reverted earlier Windows-specific basename customization (per maintainer: "đừng custom quá nhiều") +- [x] Task 3.4: Produce docs/ai artifacts per repo `dev-lifecycle` skill + +## Dependencies + +- Existing `@ai-devkit/agent-manager` adapter contract and utilities +- Existing CLI agent command integration points +- A live Gemini CLI install for end-to-end verification (provided by user auth during review) + +## Timeline & Estimates + +- Task 1.1–1.4 (research + scaffold): 0.5 day +- Task 2.1–2.4 (implementation): 1.0 day +- Task 3.1–3.4 (tests + E2E + review iteration + docs): 1.0 day +- Total: ~2.5 days across PR iterations + +## Risks & Mitigation + +- Risk: Gemini CLI session schema may evolve across versions. + - Mitigation: defensive parsing, tests for partial/malformed fixtures, polymorphic `content` handling. +- Risk: `node` argv scanning is broad and could false-positive on unrelated Node processes. + - Mitigation: strict token-scan requiring a `gemini`-basename match in argv. +- Risk: `projectHash` algorithm could drift if Gemini CLI changes its boundary-detection logic. + - Mitigation: walk-to-`.git` fallback to starting directory; verified against live session. +- Risk: Adding a third adapter increases list latency. + - Mitigation: existing parallel aggregation pattern, bounded file reads, early exits on no live processes. + +## Resources Needed + +- `CodexAdapter` as implementation template +- Live Gemini CLI session for verification +- Maintainer review cycle on `codeaholicguy/ai-devkit` PR #70 + +## Progress Summary + +Implementation is complete. `GeminiCliAdapter` ships in `@ai-devkit/agent-manager`, is exported through package entry points, and is registered in CLI `list`/`open` flows. Gemini CLI is now ✅ in the README "Agent Control Support" matrix. The maintainer review surfaced one regression (`.trim` on array content) and a suggestion to run the work through the repo's `dev-lifecycle` skill — both addressed. End-to-end verification against a live Gemini CLI session confirmed the mapping between live `node` processes, session files, and `AgentInfo` output. diff --git a/docs/ai/requirements/feature-gemini-cli-adapter.md b/docs/ai/requirements/feature-gemini-cli-adapter.md new file mode 100644 index 00000000..aa7fb216 --- /dev/null +++ b/docs/ai/requirements/feature-gemini-cli-adapter.md @@ -0,0 +1,77 @@ +--- +phase: requirements +title: "Gemini CLI Adapter in @ai-devkit/agent-manager - Requirements" +feature: gemini-cli-adapter +description: Add a Gemini CLI adapter to the shared agent-manager package so Gemini sessions are detected and listed alongside Claude and Codex +--- + +# Requirements: Add Gemini CLI Adapter to @ai-devkit/agent-manager + +## Problem Statement + +`@ai-devkit/agent-manager` ships `ClaudeCodeAdapter` and `CodexAdapter`, while `AgentType` already includes `gemini`. The README "Agent Control Support" matrix lists Gemini CLI as unsupported (❌), even though the adapter contract is designed to be pluggable and Gemini CLI stores session metadata on disk in a stable location. + +Who is affected: +- Users running Gemini CLI who expect `ai-devkit agent list` to surface Gemini sessions next to Claude/Codex +- Maintainers who want the "Agent Control Support" README matrix to reflect actual capability +- Contributors who need a reference for adapters that don't run as native binaries (Gemini CLI ships as a Node script) + +## Goals & Objectives + +### Primary Goals +- Implement a package-level `GeminiCliAdapter` under `packages/agent-manager` +- Export `GeminiCliAdapter` from package public entry points +- Register `GeminiCliAdapter` in CLI agent command wiring so `list`/`open` aggregate Gemini results +- Flip Gemini CLI from ❌ to ✅ in the README "Agent Control Support" matrix +- Preserve existing Claude/Codex behavior and output contracts + +### Secondary Goals +- Reuse shared process/file utilities and the existing `AgentAdapter` contract +- Cover detection, session parsing, status mapping, and conversation extraction with unit tests +- Handle Gemini-specific data shapes (array-of-parts `content`) without crashing +- Follow the maintainer's guidance: "đừng custom quá nhiều" — stay close to Codex structure + +### Non-Goals +- Redesigning the `ai-devkit agent` UX +- Refactoring unrelated CLI commands +- Supporting multi-user or hosted Gemini sessions (only local CLI sessions) + +## User Stories & Use Cases + +1. As a Gemini CLI user, I want active Gemini sessions to appear in `ai-devkit agent list` so I can inspect them alongside Claude/Codex. +2. As a CLI user, I want `ai-devkit agent open ` to focus a Gemini session with the same behavior as existing adapters. +3. As a maintainer, I want Gemini detection in `@ai-devkit/agent-manager` to avoid CLI/package drift. +4. As a contributor, I want the Gemini adapter to demonstrate how to support a CLI that runs as a Node script (not a native binary). + +## Success Criteria + +- `packages/agent-manager/src/adapters/GeminiCliAdapter.ts` exists and implements `AgentAdapter` +- `@ai-devkit/agent-manager` public exports include `GeminiCliAdapter` +- `packages/cli/src/commands/agent.ts` registers `GeminiCliAdapter` for list/open flows +- Unit tests cover happy path, empty path, malformed data, process filtering, and array-of-parts content +- `npx nx run agent-manager:test` and `npx nx run cli:test` pass without regressions +- README "Agent Control Support" table marks Gemini CLI as ✅ +- Real Gemini CLI session appears in `ai-devkit agent list` during end-to-end verification + +## Constraints & Assumptions + +### Technical Constraints +- Must follow existing Nx TypeScript project structure and Jest test conventions +- Must keep the `AgentAdapter` contract (`type`, `canHandle`, `detectAgents`, `getConversation`) +- Must not break JSON/table output schema already consumed by users +- Must isolate adapter errors so a Gemini failure does not break list/open for other adapters + +### Assumptions +- Gemini CLI stores sessions in `~/.gemini/tmp//chats/session-*.json` +- Session files include `sessionId`, `projectHash`, `startTime`, `lastUpdated`, `messages`, `directories` +- `projectHash = sha256(projectRootWalkedToGitBoundary)` — confirmed empirically against a live Gemini session +- `~/.gemini/projects.json` maps `projectRoot → shortId` +- Gemini CLI runs as a Node script (`node /path/to/gemini`), so process detection must inspect `node` argv rather than a dedicated binary name +- Message `content` is polymorphic: `string` for assistant messages, `Part[]` (e.g. `[{text: "..."}]`) for user messages + +## Questions & Open Items + +- Resolved (2026-04-22): Gemini CLI distribution is a pure Node script (unique among supported agents — Claude is native binary, Codex is Node wrapper spawning Rust). Detection uses `listAgentProcesses('node')` + a token-scan filter matching `gemini`/`gemini.exe`/`gemini.js` in argv. +- Resolved (2026-04-22): `projectHash` algorithm verified against a real session — sha256 of the project root path walked up to the nearest `.git` boundary, with fallback to the starting directory when no `.git` is found. +- Resolved (2026-04-22): User message `content` is `Part[]`; assistant `content` is a pre-joined string. Adapter normalizes via a `resolveContent(string | Part[])` helper so `.trim()` never runs on an array. +- Resolved (2026-04-22): Session membership is gated by running Gemini processes (same source-of-truth pattern as Codex); stale session files on disk without a live process are not surfaced. diff --git a/docs/ai/testing/feature-gemini-cli-adapter.md b/docs/ai/testing/feature-gemini-cli-adapter.md new file mode 100644 index 00000000..d4346258 --- /dev/null +++ b/docs/ai/testing/feature-gemini-cli-adapter.md @@ -0,0 +1,107 @@ +--- +phase: testing +title: "Gemini CLI Adapter in @ai-devkit/agent-manager - Testing" +feature: gemini-cli-adapter +description: Test strategy and coverage plan for Gemini CLI adapter integration +--- + +# Testing Strategy: Gemini CLI Adapter in @ai-devkit/agent-manager + +## Test Coverage Goals + +- Unit test coverage target: all new/changed paths in `GeminiCliAdapter` +- Integration scope: adapter registration in `AgentManager` and CLI `agent` command +- End-to-end scope: real `ai-devkit agent list` with a live Gemini CLI session + +## Unit Tests + +### `GeminiCliAdapter` +- [x] Detect Gemini processes from `node` argv with a `gemini` basename token +- [x] Reject non-Gemini `node` processes during filtering +- [x] Return empty array when no Gemini process is running +- [x] Return empty array when `~/.gemini/projects.json` or `~/.gemini/tmp` is missing +- [x] Parse valid session with string `content` (assistant messages) +- [x] Parse valid session with array `content` (user `Part[]`) +- [x] Handle mixed string / array content within a single session +- [x] Drop non-text parts (e.g. `{data: ...}`, `{file: ...}`) without crashing +- [x] Prefer `displayContent` over `content` when both are present +- [x] Skip malformed JSON session files without failing full result +- [x] Map status based on `lastUpdated` vs shared thresholds +- [x] Compute `projectHash` via sha256 walk to `.git` boundary +- [x] Fallback to starting directory when no `.git` ancestor is found +- [x] `getConversation` resolves array-of-parts content correctly +- [x] `getConversation` handles non-text parts gracefully + +### `AgentManager` integration seam +- [x] Aggregates Gemini + Claude + Codex adapter output +- [x] Gemini adapter errors do not break other adapters (soft-fail) + +## Integration Tests + +- [x] `agent` command registers `GeminiCliAdapter` in manager setup paths +- [x] `agent list` includes Gemini entries with expected fields +- [x] `agent open` handles Gemini agent command metadata path + +## End-to-End Tests + +- [x] User flow: `ai-devkit agent list` with real Gemini CLI running + - Verified session file `~/.gemini/tmp/ai-devkit/chats/session-2026-04-22T03-12-6149f390.json` + - `projectHash` matched sha256 of `/Users/.../ai-devkit` + - User content was `[{"text":"what is 2+2?"}]` (array); assistant content was `"2 + 2 is 4."` (string) +- [x] Regression: Claude/Codex list/open remain unchanged + +## Test Data + +- Mock Gemini session fixtures: + - valid (string + array content), empty directory, partial, malformed JSON + - `projects.json` present / missing +- Mock process utility responses for `node` argv enumeration + +## Test Reporting & Coverage + +- Commands: + - `npx nx run agent-manager:lint` ✅ + - `npx nx run agent-manager:build` ✅ + - `npx nx run agent-manager:test` ✅ + - `npx nx run agent-manager:test -- --runInBand src/__tests__/adapters/GeminiCliAdapter.test.ts` ✅ (42 tests passed) + - `npx nx run cli:test -- --runInBand src/__tests__/commands/agent.test.ts` ✅ + - `npx nx run-many -t build test lint` ✅ +- Coverage: + - New Gemini adapter suite passes on detection, filtering, status mapping, content normalization (string + array), fallback naming, and `projectHash` computation. + - 5 tests added after maintainer review specifically cover the `Part[]` content path that previously crashed. + +## Manual Testing + +- Verified table + json output include Gemini rows alongside Claude/Codex. +- Verified open/focus behavior on a live Gemini session. + +## Performance Testing + +- No observable latency regression in `agent list` after adding the Gemini adapter (session reads scoped to live-process shortIds, not a full `~/.gemini/tmp` scan). + +## Bug Tracking + +- Severity `blocking` — `.trim is not a function` on array-shaped user content (reported in PR #70 maintainer review): **Fixed** via `resolveContent` helper + 5 new tests. Verified with real Gemini session. +- Severity `minor` — initial Windows-specific basename customization: **Reverted** per maintainer guidance; POSIX/Windows behavior now matches `CodexAdapter`. + +## Phase 7 Execution (2026-04-22) + +### New Test Coverage Added + +- `parseSession` with array `content` (real Gemini user-message shape) +- `parseSession` does not throw when `content` is `Part[]` without `displayContent` +- `parseSession` drops non-text parts (`{data}`, `{file}`) without failure +- `getConversation` resolves concatenated text across multiple `Part[]` entries +- `getConversation` handles non-text parts gracefully + +### Commands Run + +- `npx nx run agent-manager:test -- --runInBand src/__tests__/adapters/GeminiCliAdapter.test.ts` ✅ (42 passed) +- `npx nx run cli:test -- --runInBand src/__tests__/commands/agent.test.ts` ✅ +- `npx nx run-many -t build test lint` ✅ + +### Phase 7 Assessment + +- All review-feedback gaps are covered by new tests and reproduced against a real Gemini session. +- Adapter paths used by `detectAgents` and `getConversation` are exercised for both string and array content shapes. +- No regressions observed in Claude/Codex suites. diff --git a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts new file mode 100644 index 00000000..6521d563 --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts @@ -0,0 +1,790 @@ +/** + * Tests for GeminiCliAdapter + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { beforeEach, afterEach, describe, expect, it, jest } from '@jest/globals'; +import { GeminiCliAdapter } from '../../adapters/GeminiCliAdapter'; +import type { ProcessInfo } from '../../adapters/AgentAdapter'; +import { AgentStatus } from '../../adapters/AgentAdapter'; +import { listAgentProcesses, enrichProcesses } from '../../utils/process'; +import { matchProcessesToSessions, generateAgentName } from '../../utils/matching'; + +jest.mock('../../utils/process', () => ({ + listAgentProcesses: jest.fn(), + enrichProcesses: jest.fn(), +})); + +jest.mock('../../utils/matching', () => ({ + matchProcessesToSessions: jest.fn(), + generateAgentName: jest.fn(), +})); + +const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction; +const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction; +const mockedMatchProcessesToSessions = matchProcessesToSessions as jest.MockedFunction; +const mockedGenerateAgentName = generateAgentName as jest.MockedFunction; + +describe('GeminiCliAdapter', () => { + let adapter: GeminiCliAdapter; + let tmpHome: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-adapter-test-')); + process.env.HOME = tmpHome; + + adapter = new GeminiCliAdapter(); + mockedListAgentProcesses.mockReset(); + mockedEnrichProcesses.mockReset(); + mockedMatchProcessesToSessions.mockReset(); + mockedGenerateAgentName.mockReset(); + + mockedEnrichProcesses.mockImplementation((procs) => procs); + mockedMatchProcessesToSessions.mockReturnValue([]); + mockedGenerateAgentName.mockImplementation((cwd: string, pid: number) => { + const folder = path.basename(cwd) || 'unknown'; + return `${folder} (${pid})`; + }); + }); + + afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + describe('initialization', () => { + it('should expose gemini_cli type', () => { + expect(adapter.type).toBe('gemini_cli'); + }); + }); + + describe('canHandle', () => { + it('should return true for plain gemini command', () => { + expect(adapter.canHandle({ pid: 1, command: 'gemini', cwd: '/repo', tty: 'ttys001' })).toBe(true); + }); + + it('should return true for gemini with full path (case-insensitive)', () => { + expect(adapter.canHandle({ + pid: 2, + command: '/usr/local/bin/GEMINI --yolo', + cwd: '/repo', + tty: 'ttys002', + })).toBe(true); + }); + + it('should return false for non-gemini processes', () => { + expect(adapter.canHandle({ pid: 3, command: 'node app.js', cwd: '/repo', tty: 'ttys003' })).toBe(false); + }); + + it('should return false when "gemini" appears only in path arguments', () => { + expect(adapter.canHandle({ + pid: 4, + command: 'node /path/to/gemini-runner.js', + cwd: '/repo', + tty: 'ttys004', + })).toBe(false); + }); + + it('should return true for Node-invoked gemini script (real install layout)', () => { + expect(adapter.canHandle({ + pid: 5, + command: 'node /Users/foo/.volta/tools/image/node/24.14.0/bin/gemini --help', + cwd: '/repo', + tty: 'ttys005', + })).toBe(true); + }); + + it('should return true for Node-invoked gemini.js bundle entrypoint', () => { + expect(adapter.canHandle({ + pid: 6, + command: 'node /opt/homebrew/lib/node_modules/@google/gemini-cli/bundle/gemini.js', + cwd: '/repo', + tty: 'ttys006', + })).toBe(true); + }); + }); + + describe('detectAgents', () => { + it('should return empty array when no gemini processes are running', async () => { + mockedListAgentProcesses.mockReturnValue([]); + const agents = await adapter.detectAgents(); + expect(agents).toEqual([]); + }); + + it('should filter non-gemini Node processes out of the node process pool', async () => { + const geminiProc: ProcessInfo = { + pid: 100, + command: 'node /Users/foo/.volta/tools/image/node/24.14.0/bin/gemini --help', + cwd: '/repo', + tty: 'ttys001', + startTime: new Date('2026-04-18T00:00:00Z'), + }; + const unrelatedNodeProc: ProcessInfo = { + pid: 200, + command: 'node /usr/local/bin/eslint src/', + cwd: '/other-repo', + tty: 'ttys002', + startTime: new Date('2026-04-18T00:00:00Z'), + }; + mockedListAgentProcesses.mockReturnValue([geminiProc, unrelatedNodeProc]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].pid).toBe(100); + }); + + it('should return process-only agents when no session files exist for the process', async () => { + const proc: ProcessInfo = { + pid: 1234, + command: 'gemini', + cwd: '/repo', + tty: 'ttys001', + startTime: new Date('2026-04-18T00:00:00Z'), + }; + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'gemini_cli', + pid: 1234, + projectPath: '/repo', + status: AgentStatus.RUNNING, + sessionId: 'pid-1234', + }); + }); + + it('should map a process to its matching session file via projectHash', async () => { + const cwd = '/repo/project-a'; + const projectHash = hashProjectRoot(cwd); + const shortId = 'abc123'; + const chatsDir = path.join(tmpHome, '.gemini', 'tmp', shortId, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const sessionPath = path.join(chatsDir, 'session-2026-04-18T00-00-session1.json'); + const sessionStart = new Date('2026-04-18T00:00:00Z').toISOString(); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'session1', + projectHash, + startTime: sessionStart, + lastUpdated: sessionStart, + kind: 'main', + messages: [ + { id: 'm1', timestamp: sessionStart, type: 'user', content: 'hello gemini' }, + ], + }), + ); + + const proc: ProcessInfo = { + pid: 42, + command: 'gemini', + cwd, + tty: 'ttys001', + startTime: new Date('2026-04-18T00:00:00Z'), + }; + mockedListAgentProcesses.mockReturnValue([proc]); + mockedMatchProcessesToSessions.mockReturnValue([ + { + process: proc, + session: { + sessionId: 'session1', + filePath: sessionPath, + projectDir: chatsDir, + birthtimeMs: Date.now(), + resolvedCwd: cwd, + }, + deltaMs: 0, + }, + ]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'gemini_cli', + pid: 42, + projectPath: cwd, + sessionId: 'session1', + sessionFilePath: sessionPath, + }); + expect(agents[0].summary).toContain('hello gemini'); + }); + + it('should not match sessions from other projects', async () => { + const procCwd = '/repo/project-a'; + const otherCwd = '/repo/project-b'; + const otherHash = hashProjectRoot(otherCwd); + const chatsDir = path.join(tmpHome, '.gemini', 'tmp', 'other', 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const sessionPath = path.join(chatsDir, 'session-2026-04-18T00-00-other.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'other-session', + projectHash: otherHash, + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + kind: 'main', + messages: [], + }), + ); + + const proc: ProcessInfo = { + pid: 7, + command: 'gemini', + cwd: procCwd, + tty: 'ttys001', + startTime: new Date(), + }; + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + const candidateSessions = mockedMatchProcessesToSessions.mock.calls[0]?.[1] ?? []; + expect(candidateSessions).toHaveLength(0); + + expect(agents).toHaveLength(1); + expect(agents[0].sessionId).toBe(`pid-${proc.pid}`); + }); + }); + + describe('discoverSessions', () => { + it('should return empty when ~/.gemini/tmp does not exist', () => { + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd: '/repo', + tty: 'ttys001', + startTime: new Date(), + }; + // tmp dir absent by default + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toEqual([]); + expect(result.contentCache.size).toBe(0); + }); + + it('should skip processes with empty cwd when building the hash map', () => { + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd: '', + tty: 'ttys001', + startTime: new Date(), + }; + writeSession(tmpHome, 'abc', 'session-x', { + sessionId: 's1', + projectHash: hashProjectRoot('/some/where'), + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + kind: 'main', + messages: [], + }); + + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toEqual([]); + }); + + it('should ignore sessions whose projectHash does not match any process cwd', () => { + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd: '/repo/a', + tty: 'ttys001', + startTime: new Date(), + }; + writeSession(tmpHome, 'other', 'session-other', { + sessionId: 's-other', + projectHash: hashProjectRoot('/repo/different'), + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + kind: 'main', + messages: [], + }); + + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toEqual([]); + }); + + it('should skip malformed JSON files and still return valid ones', () => { + const cwd = '/repo/valid'; + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd, + tty: 'ttys001', + startTime: new Date(), + }; + + const chatsDir = path.join(tmpHome, '.gemini', 'tmp', 'abc', 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + fs.writeFileSync(path.join(chatsDir, 'session-bad.json'), '{ not valid'); + writeSession(tmpHome, 'abc', 'session-good', { + sessionId: 's-good', + projectHash: hashProjectRoot(cwd), + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + kind: 'main', + messages: [], + }); + + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].sessionId).toBe('s-good'); + }); + + it('should match sessions whose projectHash is a parent of the process cwd (git root case)', () => { + const gitRoot = '/repo/monorepo'; + const procCwd = '/repo/monorepo/packages/inner'; + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd: procCwd, + tty: 'ttys001', + startTime: new Date(), + }; + writeSession(tmpHome, 'abc', 'session-rootmatch', { + sessionId: 's-root', + // Gemini CLI stores the hash of the walked-up project root, + // not the process CWD. + projectHash: hashProjectRoot(gitRoot), + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + kind: 'main', + messages: [], + }); + + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].resolvedCwd).toBe(procCwd); + }); + + it('should skip files that do not start with "session-"', () => { + const cwd = '/repo/keep'; + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd, + tty: 'ttys001', + startTime: new Date(), + }; + + const chatsDir = path.join(tmpHome, '.gemini', 'tmp', 'abc', 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + fs.writeFileSync( + path.join(chatsDir, 'notsession.json'), + JSON.stringify({ + sessionId: 'skip', + projectHash: hashProjectRoot(cwd), + messages: [], + }), + ); + + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toEqual([]); + }); + }); + + describe('helper methods', () => { + describe('determineStatus', () => { + it('should return "waiting" when the last message is from gemini', () => { + const session = { + sessionId: 's', projectPath: '', summary: '', + sessionStart: new Date(), lastActive: new Date(), + lastMessageType: 'gemini', + }; + expect((adapter as any).determineStatus(session)).toBe(AgentStatus.WAITING); + }); + + it('should return "waiting" when the last message is from assistant', () => { + const session = { + sessionId: 's', projectPath: '', summary: '', + sessionStart: new Date(), lastActive: new Date(), + lastMessageType: 'assistant', + }; + expect((adapter as any).determineStatus(session)).toBe(AgentStatus.WAITING); + }); + + it('should return "running" when the last message is from the user', () => { + const session = { + sessionId: 's', projectPath: '', summary: '', + sessionStart: new Date(), lastActive: new Date(), + lastMessageType: 'user', + }; + expect((adapter as any).determineStatus(session)).toBe(AgentStatus.RUNNING); + }); + + it('should return "idle" when last activity is older than the threshold', () => { + const session = { + sessionId: 's', projectPath: '', summary: '', + sessionStart: new Date(), + lastActive: new Date(Date.now() - 10 * 60 * 1000), + lastMessageType: 'gemini', + }; + expect((adapter as any).determineStatus(session)).toBe(AgentStatus.IDLE); + }); + }); + + describe('parseSession', () => { + it('should parse a valid session file', () => { + const filePath = writeSession(tmpHome, 'p', 'session-a', { + sessionId: 's1', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:05:00Z', + kind: 'main', + directories: ['/repo'], + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'user', content: 'hello' }, + ], + }); + + const result = (adapter as any).parseSession(undefined, filePath); + expect(result).toMatchObject({ + sessionId: 's1', + projectPath: '/repo', + summary: 'hello', + }); + }); + + it('should parse from cached content without reading disk', () => { + const content = JSON.stringify({ + sessionId: 's2', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + messages: [], + }); + + const result = (adapter as any).parseSession(content, '/does/not/exist.json'); + expect(result?.sessionId).toBe('s2'); + }); + + it('should return null for a missing file with no cached content', () => { + expect((adapter as any).parseSession(undefined, '/missing.json')).toBeNull(); + }); + + it('should return null when the file is not valid JSON', () => { + const filePath = path.join(tmpHome, 'broken.json'); + fs.writeFileSync(filePath, 'not json'); + expect((adapter as any).parseSession(undefined, filePath)).toBeNull(); + }); + + it('should return null when sessionId is missing', () => { + const filePath = path.join(tmpHome, 'no-id.json'); + fs.writeFileSync(filePath, JSON.stringify({ messages: [] })); + expect((adapter as any).parseSession(undefined, filePath)).toBeNull(); + }); + + it('should default the summary when no user message has content', () => { + const filePath = writeSession(tmpHome, 'p', 'session-empty', { + sessionId: 's3', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'gemini', content: 'only assistant' }, + ], + }); + + const result = (adapter as any).parseSession(undefined, filePath); + expect(result?.summary).toBe('Gemini CLI session active'); + }); + + it('should truncate long summaries to 120 characters', () => { + const longContent = 'x'.repeat(200); + const filePath = writeSession(tmpHome, 'p', 'session-long', { + sessionId: 's4', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'user', content: longContent }, + ], + }); + + const result = (adapter as any).parseSession(undefined, filePath); + expect(result?.summary.length).toBe(120); + expect(result?.summary.endsWith('...')).toBe(true); + }); + + it('should extract summary when user content is an array of parts (real Gemini shape)', () => { + const filePath = writeSession(tmpHome, 'p', 'session-parts', { + sessionId: 's-parts', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + messages: [ + { + id: 'm1', + timestamp: '2026-04-18T00:00:01Z', + type: 'user', + content: [{ text: 'hello from part' }, { text: ' continued' }], + }, + ], + }); + + const result = (adapter as any).parseSession(undefined, filePath); + expect(result?.summary).toBe('hello from part continued'); + }); + + it('should not throw when user content is an array and there is no displayContent', () => { + const filePath = writeSession(tmpHome, 'p', 'session-parts-only', { + sessionId: 's-parts-only', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + messages: [ + { + id: 'm1', + timestamp: '2026-04-18T00:00:01Z', + type: 'user', + content: [{ text: 'only via parts' }], + }, + ], + }); + + expect(() => (adapter as any).parseSession(undefined, filePath)).not.toThrow(); + }); + + it('should drop non-text parts (data/file) when resolving user content', () => { + const filePath = writeSession(tmpHome, 'p', 'session-mixed-parts', { + sessionId: 's-mixed', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + messages: [ + { + id: 'm1', + timestamp: '2026-04-18T00:00:01Z', + type: 'user', + content: [ + { text: 'readable text' }, + { inlineData: { mimeType: 'image/png', data: 'base64...' } }, + { text: ' + more' }, + ], + }, + ], + }); + + const result = (adapter as any).parseSession(undefined, filePath); + expect(result?.summary).toBe('readable text + more'); + }); + + it('should prefer lastUpdated over entry timestamp for lastActive', () => { + const filePath = writeSession(tmpHome, 'p', 'session-last', { + sessionId: 's5', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:10:00Z', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'user', content: 'hi' }, + ], + }); + + const result = (adapter as any).parseSession(undefined, filePath); + expect(result?.lastActive.toISOString()).toBe('2026-04-18T00:10:00.000Z'); + }); + }); + }); + + describe('getConversation', () => { + it('should return messages from a valid Gemini session file', () => { + const sessionPath = path.join(tmpHome, 'session-2026-04-18T00-00-id.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + projectHash: 'hash', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + kind: 'main', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'user', content: 'hi' }, + { id: 'm2', timestamp: '2026-04-18T00:00:02Z', type: 'gemini', content: 'hello' }, + { id: 'm3', timestamp: '2026-04-18T00:00:03Z', type: 'tool', content: 'unused' }, + ], + }), + ); + + const messages = adapter.getConversation(sessionPath); + expect(messages).toEqual([ + { role: 'user', content: 'hi', timestamp: '2026-04-18T00:00:01Z' }, + { role: 'assistant', content: 'hello', timestamp: '2026-04-18T00:00:02Z' }, + ]); + }); + + it('should include tool entries when verbose is true', () => { + const sessionPath = path.join(tmpHome, 'session-verbose.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + projectHash: 'hash', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + kind: 'main', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'tool', content: 'tool call' }, + ], + }), + ); + + const messages = adapter.getConversation(sessionPath, { verbose: true }); + expect(messages).toEqual([ + { role: 'system', content: 'tool call', timestamp: '2026-04-18T00:00:01Z' }, + ]); + }); + + it('should return empty array for missing or malformed files', () => { + expect(adapter.getConversation('/nonexistent/file.json')).toEqual([]); + + const brokenPath = path.join(tmpHome, 'broken.json'); + fs.writeFileSync(brokenPath, '{ not valid json'); + expect(adapter.getConversation(brokenPath)).toEqual([]); + }); + + it('should prefer displayContent over content when both are present', () => { + const sessionPath = path.join(tmpHome, 'session-display.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + messages: [ + { + id: 'm1', + timestamp: '2026-04-18T00:00:01Z', + type: 'user', + content: 'raw', + displayContent: 'rendered', + }, + ], + }), + ); + + const messages = adapter.getConversation(sessionPath); + expect(messages[0].content).toBe('rendered'); + }); + + it('should skip entries with empty content', () => { + const sessionPath = path.join(tmpHome, 'session-empty.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'user', content: '' }, + { id: 'm2', timestamp: '2026-04-18T00:00:02Z', type: 'user', content: 'real' }, + ], + }), + ); + + const messages = adapter.getConversation(sessionPath); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('real'); + }); + + it('should skip entries without a type', () => { + const sessionPath = path.join(tmpHome, 'session-no-type.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', content: 'typeless' }, + ], + }), + ); + + expect(adapter.getConversation(sessionPath)).toEqual([]); + }); + + it('should resolve user messages whose content is an array of text parts', () => { + const sessionPath = path.join(tmpHome, 'session-user-parts.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + messages: [ + { + id: 'm1', + timestamp: '2026-04-18T00:00:01Z', + type: 'user', + content: [{ text: 'hello' }, { text: ' world' }], + }, + { + id: 'm2', + timestamp: '2026-04-18T00:00:02Z', + type: 'gemini', + content: 'hi there', + }, + ], + }), + ); + + const messages = adapter.getConversation(sessionPath); + expect(messages).toEqual([ + { role: 'user', content: 'hello world', timestamp: '2026-04-18T00:00:01Z' }, + { role: 'assistant', content: 'hi there', timestamp: '2026-04-18T00:00:02Z' }, + ]); + }); + + it('should not throw when content is an array but no part carries text', () => { + const sessionPath = path.join(tmpHome, 'session-no-text-parts.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + messages: [ + { + id: 'm1', + timestamp: '2026-04-18T00:00:01Z', + type: 'user', + content: [{ inlineData: { mimeType: 'image/png' } }], + }, + ], + }), + ); + + expect(() => adapter.getConversation(sessionPath)).not.toThrow(); + expect(adapter.getConversation(sessionPath)).toEqual([]); + }); + + it('should return empty array when messages is not an array', () => { + const sessionPath = path.join(tmpHome, 'session-bad-messages.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ sessionId: 'abc', messages: 'not-an-array' }), + ); + + expect(adapter.getConversation(sessionPath)).toEqual([]); + }); + }); +}); + +/** + * Write a Gemini session JSON to the temporary home under the expected + * ~/.gemini/tmp//chats/.json layout. Returns the full path. + */ +function writeSession( + home: string, + shortId: string, + fileName: string, + body: Record, +): string { + const chatsDir = path.join(home, '.gemini', 'tmp', shortId, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const filePath = path.join(chatsDir, `${fileName}.json`); + fs.writeFileSync(filePath, JSON.stringify(body)); + return filePath; +} + +/** + * Mirror the projectHash algo used by Gemini CLI: + * sha256(projectRoot) as hex. + */ +function hashProjectRoot(projectRoot: string): string { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const crypto = require('crypto'); + return crypto.createHash('sha256').update(projectRoot).digest('hex'); +} diff --git a/packages/agent-manager/src/adapters/GeminiCliAdapter.ts b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts new file mode 100644 index 00000000..c37d4cc9 --- /dev/null +++ b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts @@ -0,0 +1,488 @@ +/** + * Gemini CLI Adapter + * + * Detects running Gemini CLI agents by: + * 1. Finding running gemini processes via shared listAgentProcesses() + * 2. Enriching with CWD and start times via shared enrichProcesses() + * 3. Discovering session files from ~/.gemini/tmp//chats/session-*.json + * 4. Matching sessions to processes via shared matchProcessesToSessions() + * using sha256(cwd) === session.projectHash as the resolvedCwd source + * 5. Extracting summary from the most recent user message in the session JSON + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter'; +import { AgentStatus } from './AgentAdapter'; +import { listAgentProcesses, enrichProcesses } from '../utils/process'; +import type { SessionFile } from '../utils/session'; +import { matchProcessesToSessions, generateAgentName } from '../utils/matching'; + +/** + * A single Gemini CLI message content part. Mirrors the `{text?: string}` + * shape that Gemini writes for user message parts (derived from the + * Gemini API `Content.parts[]` schema). Non-text part variants (data, + * file, etc.) are preserved via the index signature but ignored by + * `resolveContent` since the adapter only surfaces human-readable text. + */ +interface GeminiContentPart { + text?: string; + [key: string]: unknown; +} + +type GeminiMessageContent = string | GeminiContentPart[]; + +interface GeminiMessageEntry { + id?: string; + timestamp?: string; + type?: string; + /** + * Gemini CLI stores two different content shapes depending on the + * message origin: + * - `type: "user"` messages carry the raw Part[] from userContent.parts + * (e.g. `[{ text: "hello" }]`). + * - `type: "gemini"` (assistant) messages carry a pre-joined string + * built from `consolidatedParts.filter(p => p.text).join('').trim()`. + * Both forms must be normalized via resolveContent before any string + * operation is applied. + */ + content?: GeminiMessageContent; + displayContent?: GeminiMessageContent; +} + +interface GeminiSessionFile { + sessionId?: string; + projectHash?: string; + startTime?: string; + lastUpdated?: string; + messages?: GeminiMessageEntry[]; + directories?: string[]; + kind?: string; +} + +interface GeminiSession { + sessionId: string; + projectPath: string; + summary: string; + sessionStart: Date; + lastActive: Date; + lastMessageType?: string; +} + +export class GeminiCliAdapter implements AgentAdapter { + readonly type = 'gemini_cli' as const; + + private static readonly IDLE_THRESHOLD_MINUTES = 5; + private static readonly SESSION_FILE_PREFIX = 'session-'; + private static readonly CHATS_DIR_NAME = 'chats'; + private static readonly TMP_DIR_NAME = 'tmp'; + + private geminiTmpDir: string; + + constructor() { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + this.geminiTmpDir = path.join(homeDir, '.gemini', GeminiCliAdapter.TMP_DIR_NAME); + } + + canHandle(processInfo: ProcessInfo): boolean { + return this.isGeminiExecutable(processInfo.command); + } + + /** + * Detect running Gemini CLI agents. + * + * Gemini CLI ships as a Node script (`bundle/gemini.js` with shebang + * `#!/usr/bin/env node`) — unlike Claude Code (native binary per + * platform) or Codex CLI (Node wrapper that execs a native Rust + * binary). The primary running process is therefore the Node runtime + * itself, and `ps aux` lists it as `node /path/to/gemini ...` with + * argv[0] = `node`. We scan the Node process pool via the shared + * helper and keep only those whose command line references the gemini + * executable or script via isGeminiExecutable(). + */ + async detectAgents(): Promise { + const nodeProcesses = enrichProcesses(listAgentProcesses('node')); + const processes = nodeProcesses.filter((proc) => this.isGeminiExecutable(proc.command)); + if (processes.length === 0) return []; + + const { sessions, contentCache } = this.discoverSessions(processes); + if (sessions.length === 0) { + return processes.map((p) => this.mapProcessOnlyAgent(p)); + } + + const matches = matchProcessesToSessions(processes, sessions); + const matchedPids = new Set(matches.map((m) => m.process.pid)); + const agents: AgentInfo[] = []; + + for (const match of matches) { + const cachedContent = contentCache.get(match.session.filePath); + const sessionData = this.parseSession(cachedContent, match.session.filePath); + if (sessionData) { + agents.push(this.mapSessionToAgent(sessionData, match.process, match.session.filePath)); + } else { + matchedPids.delete(match.process.pid); + } + } + + for (const proc of processes) { + if (!matchedPids.has(proc.pid)) { + agents.push(this.mapProcessOnlyAgent(proc)); + } + } + + return agents; + } + + /** + * Discover session files for the given processes. + * + * Gemini CLI writes sessions to ~/.gemini/tmp//chats/session-*.json + * where is opaque (managed by a project registry). We scan every + * shortId directory and filter by matching session.projectHash against + * sha256(process.cwd) to bind each session to a candidate process CWD. + */ + private discoverSessions(processes: ProcessInfo[]): { + sessions: SessionFile[]; + contentCache: Map; + } { + const empty = { sessions: [] as SessionFile[], contentCache: new Map() }; + if (!fs.existsSync(this.geminiTmpDir)) return empty; + + const cwdHashMap = this.buildCwdHashMap(processes); + if (cwdHashMap.size === 0) return empty; + + const contentCache = new Map(); + const sessions: SessionFile[] = []; + + let shortIdEntries: string[]; + try { + shortIdEntries = fs.readdirSync(this.geminiTmpDir); + } catch { + return empty; + } + + for (const shortId of shortIdEntries) { + const chatsDir = path.join(this.geminiTmpDir, shortId, GeminiCliAdapter.CHATS_DIR_NAME); + try { + if (!fs.statSync(chatsDir).isDirectory()) continue; + } catch { + continue; + } + + let chatFiles: string[]; + try { + chatFiles = fs.readdirSync(chatsDir); + } catch { + continue; + } + + for (const fileName of chatFiles) { + if (!fileName.startsWith(GeminiCliAdapter.SESSION_FILE_PREFIX) || !fileName.endsWith('.json')) { + continue; + } + + const filePath = path.join(chatsDir, fileName); + + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + continue; + } + + let parsed: GeminiSessionFile; + try { + parsed = JSON.parse(content); + } catch { + continue; + } + + if (!parsed.projectHash) continue; + const resolvedCwd = cwdHashMap.get(parsed.projectHash); + if (!resolvedCwd) continue; + + let birthtimeMs = 0; + try { + birthtimeMs = fs.statSync(filePath).birthtimeMs; + } catch { + continue; + } + + const sessionId = + parsed.sessionId || fileName.replace(/\.json$/, ''); + + contentCache.set(filePath, content); + sessions.push({ + sessionId, + filePath, + projectDir: chatsDir, + birthtimeMs, + resolvedCwd, + }); + } + } + + return { sessions, contentCache }; + } + + private buildCwdHashMap(processes: ProcessInfo[]): Map { + const map = new Map(); + for (const proc of processes) { + if (!proc.cwd) continue; + // Gemini CLI resolves its project root by walking up from the + // startup directory looking for a `.git` boundary marker. A + // session's projectHash therefore tracks that ancestor rather + // than the process' actual CWD. Enumerate every ancestor as a + // candidate so subdirectory invocations still line up with the + // session the Gemini process wrote. + for (const candidate of this.candidateProjectRoots(proc.cwd)) { + if (!map.has(this.hashProjectRoot(candidate))) { + map.set(this.hashProjectRoot(candidate), proc.cwd); + } + } + } + return map; + } + + private candidateProjectRoots(cwd: string): string[] { + const roots: string[] = []; + let current = path.resolve(cwd); + let parent = path.dirname(current); + while (parent !== current) { + roots.push(current); + current = parent; + parent = path.dirname(current); + } + roots.push(current); + return roots; + } + + private hashProjectRoot(projectRoot: string): string { + return crypto.createHash('sha256').update(projectRoot).digest('hex'); + } + + /** + * Parse session file content into GeminiSession. + * Uses cached content if available, otherwise reads from disk. + */ + private parseSession(cachedContent: string | undefined, filePath: string): GeminiSession | null { + let content: string; + if (cachedContent !== undefined) { + content = cachedContent; + } else { + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + return null; + } + } + + let parsed: GeminiSessionFile; + try { + parsed = JSON.parse(content); + } catch { + return null; + } + + if (!parsed.sessionId) return null; + + const messages = Array.isArray(parsed.messages) ? parsed.messages : []; + const lastEntry = messages.length > 0 ? messages[messages.length - 1] : undefined; + + let mtime: Date | null = null; + try { + mtime = fs.statSync(filePath).mtime; + } catch { + mtime = null; + } + + const lastActive = + this.parseTimestamp(parsed.lastUpdated) || + this.parseTimestamp(lastEntry?.timestamp) || + mtime || + new Date(); + + const sessionStart = + this.parseTimestamp(parsed.startTime) || lastActive; + + const projectPath = + Array.isArray(parsed.directories) && parsed.directories.length > 0 + ? parsed.directories[0] + : ''; + + return { + sessionId: parsed.sessionId, + projectPath, + summary: this.extractSummary(messages), + sessionStart, + lastActive, + lastMessageType: lastEntry?.type, + }; + } + + private mapSessionToAgent(session: GeminiSession, processInfo: ProcessInfo, filePath: string): AgentInfo { + const projectPath = session.projectPath || processInfo.cwd || ''; + return { + name: generateAgentName(projectPath, processInfo.pid), + type: this.type, + status: this.determineStatus(session), + summary: session.summary || 'Gemini CLI session active', + pid: processInfo.pid, + projectPath, + sessionId: session.sessionId, + lastActive: session.lastActive, + sessionFilePath: filePath, + }; + } + + private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo { + return { + name: generateAgentName(processInfo.cwd || '', processInfo.pid), + type: this.type, + status: AgentStatus.RUNNING, + summary: 'Gemini CLI process running', + pid: processInfo.pid, + projectPath: processInfo.cwd || '', + sessionId: `pid-${processInfo.pid}`, + lastActive: new Date(), + }; + } + + private parseTimestamp(value?: string): Date | null { + if (!value) return null; + const timestamp = new Date(value); + return Number.isNaN(timestamp.getTime()) ? null : timestamp; + } + + private determineStatus(session: GeminiSession): AgentStatus { + const diffMs = Date.now() - session.lastActive.getTime(); + const diffMinutes = diffMs / 60000; + + if (diffMinutes > GeminiCliAdapter.IDLE_THRESHOLD_MINUTES) { + return AgentStatus.IDLE; + } + + if (session.lastMessageType === 'gemini' || session.lastMessageType === 'assistant') { + return AgentStatus.WAITING; + } + + return AgentStatus.RUNNING; + } + + private extractSummary(messages: GeminiMessageEntry[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const entry = messages[i]; + if (entry?.type !== 'user') continue; + const text = this.messageText(entry).trim(); + if (text) return this.truncate(text, 120); + } + + return 'Gemini CLI session active'; + } + + /** + * Normalize an entry's content/displayContent into a plain string. + * Prefers displayContent when both are present (matches Gemini CLI's + * own rendering priority for the /chat UI). + */ + private messageText(entry: GeminiMessageEntry): string { + const displayText = this.resolveContent(entry.displayContent); + if (displayText) return displayText; + return this.resolveContent(entry.content); + } + + /** + * Collapse a Gemini message content field into plain text. + * Accepts either a pre-joined string (assistant turns) or a Part[] + * list (user turns carrying `[{text: "..."}]`). Non-text part + * variants (data, file) are dropped since this helper is only used + * for summary/conversation rendering. + */ + private resolveContent(content: GeminiMessageContent | undefined): string { + if (!content) return ''; + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + + const parts: string[] = []; + for (const part of content) { + if (part && typeof part.text === 'string' && part.text) { + parts.push(part.text); + } + } + return parts.join(''); + } + + private truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; + } + + private isGeminiExecutable(command: string): boolean { + // Accept any token in the command line whose basename matches a + // known gemini entrypoint. This is intentionally broader than the + // other adapters' argv[0]-only check because the Node-script + // distribution puts the real gemini path in argv[1..], not argv[0]. + for (const token of command.trim().split(/\s+/)) { + const base = path.basename(token).toLowerCase(); + if (base === 'gemini' || base === 'gemini.exe' || base === 'gemini.js') { + return true; + } + } + return false; + } + + /** + * Read the full conversation from a Gemini CLI session JSON file. + * + * Gemini sessions store messages in an array with `type` field — typically + * 'user' or 'gemini' for visible turns, with tool and system entries mixed in. + */ + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + const verbose = options?.verbose ?? false; + + let content: string; + try { + content = fs.readFileSync(sessionFilePath, 'utf-8'); + } catch { + return []; + } + + let parsed: GeminiSessionFile; + try { + parsed = JSON.parse(content); + } catch { + return []; + } + + const messages: ConversationMessage[] = []; + if (!Array.isArray(parsed.messages)) return messages; + + for (const entry of parsed.messages) { + const entryType = entry?.type; + if (!entryType) continue; + + let role: ConversationMessage['role']; + if (entryType === 'user') { + role = 'user'; + } else if (entryType === 'gemini' || entryType === 'assistant') { + role = 'assistant'; + } else if (verbose) { + role = 'system'; + } else { + continue; + } + + const text = this.messageText(entry).trim(); + if (!text) continue; + + messages.push({ + role, + content: text, + timestamp: entry.timestamp, + }); + } + + return messages; + } +} diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts index 896bf41d..62e458b0 100644 --- a/packages/agent-manager/src/adapters/index.ts +++ b/packages/agent-manager/src/adapters/index.ts @@ -1,4 +1,5 @@ export { ClaudeCodeAdapter } from './ClaudeCodeAdapter'; export { CodexAdapter } from './CodexAdapter'; +export { GeminiCliAdapter } from './GeminiCliAdapter'; export { AgentStatus } from './AgentAdapter'; export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter'; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index 53e9ed9e..b10624ad 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -2,6 +2,7 @@ export { AgentManager } from './AgentManager'; export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; export { CodexAdapter } from './adapters/CodexAdapter'; +export { GeminiCliAdapter } from './adapters/GeminiCliAdapter'; export { AgentStatus } from './adapters/AgentAdapter'; export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo, ConversationMessage } from './adapters/AgentAdapter'; diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 8bbb2cb9..6bbef559 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -29,6 +29,7 @@ jest.mock('@ai-devkit/agent-manager', () => ({ AgentManager: jest.fn(() => mockManager), ClaudeCodeAdapter: jest.fn(), CodexAdapter: jest.fn(), + GeminiCliAdapter: jest.fn(), TerminalFocusManager: jest.fn(() => mockFocusManager), TtyWriter: { send: (location: any, message: string) => mockTtyWriterSend(location, message) }, AgentStatus: { diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 347ffd28..592c902c 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -6,6 +6,7 @@ import { AgentManager, ClaudeCodeAdapter, CodexAdapter, + GeminiCliAdapter, AgentStatus, TerminalFocusManager, TtyWriter, @@ -83,6 +84,7 @@ export function registerAgentCommand(program: Command): void { // In the future, we might load these dynamically or based on config manager.registerAdapter(new ClaudeCodeAdapter()); manager.registerAdapter(new CodexAdapter()); + manager.registerAdapter(new GeminiCliAdapter()); const agents = await manager.listAgents(); @@ -148,6 +150,7 @@ export function registerAgentCommand(program: Command): void { manager.registerAdapter(new ClaudeCodeAdapter()); manager.registerAdapter(new CodexAdapter()); + manager.registerAdapter(new GeminiCliAdapter()); const agents = await manager.listAgents(); if (agents.length === 0) { @@ -222,6 +225,7 @@ export function registerAgentCommand(program: Command): void { const manager = new AgentManager(); manager.registerAdapter(new ClaudeCodeAdapter()); manager.registerAdapter(new CodexAdapter()); + manager.registerAdapter(new GeminiCliAdapter()); const agents = await manager.listAgents(); if (agents.length === 0) { @@ -279,10 +283,12 @@ export function registerAgentCommand(program: Command): void { try { const claudeAdapter = new ClaudeCodeAdapter(); const codexAdapter = new CodexAdapter(); + const geminiAdapter = new GeminiCliAdapter(); const manager = new AgentManager(); manager.registerAdapter(claudeAdapter); manager.registerAdapter(codexAdapter); + manager.registerAdapter(geminiAdapter); const agents = await manager.listAgents(); if (agents.length === 0) { @@ -317,6 +323,7 @@ export function registerAgentCommand(program: Command): void { const adapters: Record = { claude: claudeAdapter, codex: codexAdapter, + gemini_cli: geminiAdapter, }; const adapter = adapters[agent.type]; if (!adapter) {