diff --git a/docs/ai/design/2026-08-14-feature-console-incremental-tailing.md b/docs/ai/design/2026-08-14-feature-console-incremental-tailing.md new file mode 100644 index 00000000..5a70bfd8 --- /dev/null +++ b/docs/ai/design/2026-08-14-feature-console-incremental-tailing.md @@ -0,0 +1,84 @@ +--- +phase: design +title: Agent Console Incremental Conversation Tailing +description: Shared asynchronous tail API, incremental JSONL state, and adapter-specific efficient readers +--- + +# Design + +## Architecture + +```mermaid +flowchart LR + H[useAgentConversation] --> M[AgentManager async tail API] + M --> A{Adapter tail capability} + A -->|Codex| J[Incremental JSONL cache] + A -->|OpenCode| S[SQL newest-row query] + A -->|other formats| F[Async full-parse fallback] + J --> R[ConversationTailResult] + S --> R + F --> R + R -->|request token still current| H +``` + +## Public Contract + +Add tail options and a result envelope alongside `ConversationMessage`: + +- `ConversationTailOptions`: `verbose`, `limit`. +- `ConversationReadStats`: bytes and complete records processed for this request, cache-hit flag, and reset reason. +- `ConversationTailResult`: newest messages plus stats. +- `AgentAdapter.getConversationTail?`: optional optimized async adapter implementation. +- `AgentManager.getConversationTail(type, path, options)`: the single console entry point. It delegates to an optimized method or the safe async fallback and always applies the requested tail bound. + +The existing synchronous method stays intact for compatibility. The new manager method owns fallback selection so UI code never chooses between architectures. + +## Incremental JSONL Cache + +Each LRU entry is keyed by adapter/parser namespace, absolute session reference, verbosity, and tail limit. It stores: + +- file identity (`dev`, `ino`) and last observed size/mtime; +- next byte offset; +- raw incomplete final-record bytes; +- adapter reducer state and bounded output messages; +- diagnostics accumulated for the current request. + +Reads use `fs.promises.open`, `stat`, and positional reads. On first load, identity change, or `size < offset`, state resets and reading begins at zero. An unchanged identity/size/mtime returns cached messages without opening the data range. Complete newline-delimited records are decoded and parsed independently. Empty lines are ignored; malformed complete records increment `parseErrors`; the remaining suffix is retained until a newline arrives. + +The cache holds 50 sessions and refreshes LRU order on access. Eviction discards the entire state, so a later access performs a correct full rebuild. + +## Codex Reducer + +Codex retains the exact existing line-to-message conversion. Reducer state additionally tracks response-item mirror keys and mirror metadata for retained event messages: + +- response item first: record its key and emit it; a later mirrored event is skipped; +- event first: emit it provisionally; a later mirrored response removes the retained event and emits the response in its actual order; +- entries without turn IDs remain independent. + +The response-key set is retained for the cache lifetime because a later event can refer to an earlier response. Output messages are bounded to the requested tail, while dedup state preserves semantics across appends. + +## OpenCode + +OpenCode implements the async API directly with SQL ordering newest-first and `LIMIT`, filtering to displayable part types for non-verbose preview. Rows are reversed before mapping so visible chronological ordering matches `getConversation()`. No file-stat cache is applied to encoded database references; SQLite is the source of truth. + +## Safe Fallback + +Adapters without an optimized tail method retain exact `getConversation()` semantics. The manager defers that compatibility parse out of the initiating render/effect stack, slices only after parsing, and caches unchanged real files. Gemini, the monolithic JSON adapter, provides an optimized worker-thread implementation so its read and `JSON.parse` do not block Ink. Claude, Copilot, Grok, and Pi continue through the awaited compatibility path in this change; adopting the reusable JSONL reducer is an explicit follow-up. This is intentionally a migration bridge, not a second API. + +## Concurrency and UI + +`useAgentConversation` awaits the manager API. Every request captures a monotonically increasing token and the selected session identity. Results/errors are committed only when mounted and still current. Poll ticks do not start a second read while one is active; a later selection invalidates the previous request. Cached messages are shown immediately, the 150 ms selection debounce and 3 s polling fallback remain, and `PREVIEW_TAIL` remains 20. + +## Alternatives Considered + +- Reparse asynchronously on the main thread: rejected because `Promise`/`setImmediate` changes scheduling but CPU parsing still freezes Ink. +- Replace every synchronous adapter method: rejected as an unnecessary breaking change for command and channel callers. +- Put a separate JSONL cache in the hook: rejected because it duplicates adapter parsing semantics and cannot correctly preserve Codex deduplication. +- Build adapter-specific UI readers: rejected because it creates competing architectures. + +## Risks and Mitigations + +- File rewritten in place between polls: identity and size regression trigger reset; replacement/rotation changes inode. Tests cover both reset paths. +- Unbounded session state: LRU bounds session count and message arrays are tail-bounded; Codex mirror keys are the only file-lifetime semantic index. +- Worker/fallback failure: return a rejected async result, preserve previously rendered messages, and surface the existing parse-error state. +- Adapter drift: parity tests compare async tail output with existing synchronous semantics. diff --git a/docs/ai/implementation/2026-08-14-feature-console-incremental-tailing.md b/docs/ai/implementation/2026-08-14-feature-console-incremental-tailing.md new file mode 100644 index 00000000..e20ea68c --- /dev/null +++ b/docs/ai/implementation/2026-08-14-feature-console-incremental-tailing.md @@ -0,0 +1,72 @@ +--- +phase: implementation +title: Agent Console Incremental Conversation Tailing +description: Implementation record for async preview reads and incremental JSONL caching +--- + +# Implementation Record + +## Status + +Implemented and validated; publication remains. + +## Baseline + +- Fixture: Codex JSONL, 86,682,730 bytes (82.7 MiB), 468 visible messages. +- Existing synchronous parser, five warm runs: 250.5, 261.8, 292.0, 304.4, 307.2 ms; median 292.0 ms on 2026-08-14. +- User-provided measured hotspot: 336.6 ms for the same size class. + +## Intended Changed Surfaces + +- `packages/agent-manager`: shared tail types/cache, manager API, Codex incremental reducer, OpenCode limited reader, fallback, exports, and tests. +- `packages/cli`: awaited hook integration, stale request rejection, cache/polling behavior, and tests. + +## Decisions + +- Preserve the synchronous adapter contract for existing callers. +- Give the console one manager-level async API; optimized and fallback paths remain hidden below it. +- Measure bytes and complete records processed in tests and benchmark output. + +## Implemented Surfaces + +- `AgentAdapter` exports async tail options/result/stats and an optional optimized method; `AgentManager.getConversationTail()` is the single UI entry point. +- `JsonlConversationTailCache` performs serialized positional reads and maintains identity, byte offset, incomplete bytes, reducer state, deterministic diagnostics, and a 50-entry LRU. +- `CodexAdapter` uses the cache and retains response-item mirror keys so mirrored event records remain deduplicated even when the pair straddles polls. +- `OpenCodeAdapter` filters displayable parts, orders newest-first, applies SQL `LIMIT ?`, then reverses mapped results into chronological order. +- `GeminiCliAdapter` reads and parses monolithic JSON in a worker thread and caches unchanged results. +- `useAgentConversation` awaits the manager API, prevents overlapping reads, invalidates stale selection tokens, keeps immediate display caching, preserves the 20-message default, and retains 3-second polling. +- A checked-in `benchmark:conversation-tail` command copies the supplied fixture to a temporary directory before appending, leaving the source untouched. + +## Edge Cases + +- Partial final JSONL records remain buffered until newline completion. +- Malformed complete lines are counted and skipped; later records continue. +- Inode changes, size regression, and same-size in-place rewrites reset state. +- Missing files evict state and return a missing reset result. +- Per-key reads serialize to protect offsets from overlapping requests. +- LRU eviction drops complete parser state and causes a correct rebuild on return. + +## Adapter Support and Follow-ups + +- Optimized now: Codex (incremental JSONL), OpenCode (limited SQLite), Gemini (off-thread monolithic JSON). +- Compatibility fallback now: Claude, Copilot, Grok, Pi. These preserve their existing `getConversation()` semantics and unchanged-file caching through the shared async manager API, but a changed file still receives a deferred full parse. +- Follow-up: migrate Claude, Copilot, Grok, and Pi to `JsonlConversationTailCache` with adapter-specific reducers and parity tests. No second hook or adapter API is needed. + +## Benchmark + +82.7 MiB Codex fixture (86,682,730 bytes, 11,747 complete records, 468 visible legacy messages): + +- Legacy full parse: five runs 300.5, 350.9, 276.2, 313.1, 276.4 ms; median 300.5 ms. User-reported prior hotspot: 336.6 ms. +- Incremental initial load: 241.4 ms, 86,682,730 bytes, 11,747 records. +- One appended record: 0.246 ms, 130 bytes, 1 record. +- Unchanged refresh: 0.029 ms, 0 bytes, 0 records, cache hit. + +## Validation + +- Focused new/changed agent-manager tests: 5 files, 104 tests passed. +- Full agent-manager: 27 files, 526 tests passed sequentially; the default fixed 5-second print-agent integration timeout was exceeded during loaded parallel runs, so the unrelated process-inspection integration was validated separately with a 30-second allowance. +- Full CLI after rebasing onto current `main`: 81 files, 970 tests passed. +- Agent-manager lint: exit 0. CLI lint: exit 0 with five pre-existing unused-catch warnings outside touched files. +- Monorepo build: all 6 projects passed. +- Feature lint and `git diff --check`: exit 0. +- Regression proof: removing same-size rewrite detection made its deterministic test fail with stale content; restoring it passed. diff --git a/docs/ai/planning/2026-08-14-feature-console-incremental-tailing.md b/docs/ai/planning/2026-08-14-feature-console-incremental-tailing.md new file mode 100644 index 00000000..3a9cf75b --- /dev/null +++ b/docs/ai/planning/2026-08-14-feature-console-incremental-tailing.md @@ -0,0 +1,37 @@ +--- +phase: planning +title: Agent Console Incremental Conversation Tailing +description: Ordered implementation plan for async and incremental conversation preview reads +--- + +# Implementation Plan + +## 1. Baseline and contracts + +- [x] Confirm clean feature worktree and measure the existing Codex full parse. +- [x] Review adapter and hook semantics and choose the shared API architecture. +- [x] Add public tail result/options/stats types and manager delegation. + +## 2. Deterministic red tests + +- [x] Add reusable JSONL cache tests for initial bytes, append-only bytes, unchanged hits, partial records, malformed records, truncate, replacement, and LRU eviction. +- [x] Add Codex tests for incremental append and mirrored-message deduplication. +- [x] Add OpenCode limited-query coverage. +- [x] Add hook tests for awaited results, stale selection rejection, 20-message slicing, unchanged cache behavior, and polling fallback. +- [x] Add synthetic large-fixture benchmark/tests that assert processed bytes/records rather than timing. + +## 3. Implementation + +- [x] Implement the reusable async JSONL cache and Codex reducer. +- [x] Implement manager async delegation and safe monolithic fallback. +- [x] Implement OpenCode storage-native tail query. +- [x] Convert `useAgentConversation` to awaited requests with stale-result protection. +- [x] Document optimized adapters and fallback follow-ups. + +## 4. Validation and publication + +- [x] Run focused and full agent-manager and CLI tests. +- [x] Run package/repository lint and builds. +- [x] Run repeatable full-read versus appended-read benchmark. +- [x] Review design alignment and final diffs. +- [ ] Commit conventionally, rebase on `origin/main`, push `feature-console-incremental-tailing`, and open a PR targeting `main` without merging. diff --git a/docs/ai/requirements/2026-08-14-feature-console-incremental-tailing.md b/docs/ai/requirements/2026-08-14-feature-console-incremental-tailing.md new file mode 100644 index 00000000..2970c2f6 --- /dev/null +++ b/docs/ai/requirements/2026-08-14-feature-console-incremental-tailing.md @@ -0,0 +1,44 @@ +--- +phase: requirements +title: Agent Console Incremental Conversation Tailing +description: Keep console conversation previews responsive by reading only appended session data when formats allow +--- + +# Requirements & Problem Understanding + +## Problem Statement + +`useAgentConversation` currently calls synchronous adapter parsers whenever a selected session file changes. A measured 86,682,730-byte (82.7 MiB) Codex JSONL session requires hundreds of milliseconds for one full reread and parse, blocking Ink input and rendering on every poll that observes a write. + +## Goals + +- Add one asynchronous conversation-tail API used by the console preview. +- Keep the newest 20-message default and adapter-specific conversation semantics. +- For append-friendly JSONL, cache file identity, byte offset, incomplete record bytes, parser state, and recent messages so later polls read only appended bytes. +- Handle unchanged files, append, partial final lines, malformed complete records, truncation, replacement/rotation, missing files, and bounded cache eviction deterministically. +- Preserve Codex response-item/event-message mirrored-message deduplication across incremental reads. +- Let adapters provide more efficient storage-native implementations, including a limited OpenCode SQL query. +- Keep monolithic formats and unsupported incremental formats off the Ink event loop through a safe asynchronous fallback. +- Ignore stale asynchronous results after selection changes or overlapping polls. +- Preserve polling when filesystem watching is unavailable or unreliable. + +## Constraints and Acceptance Criteria + +- Existing synchronous `getConversation()` behavior and callers remain compatible. +- The async API returns read diagnostics suitable for deterministic tests and benchmarks (`bytesRead`, `recordsProcessed`, cache/reset information); tests must not depend on wall-clock thresholds. +- A completed malformed JSONL record is skipped and counted; an incomplete final record is buffered without being reported as malformed. +- File identity changes or size regression reset parser state and rebuild from byte zero. +- Cache capacity is bounded and least-recently-used entries are evicted. +- Initial parsing may read the complete file; an append-only refresh must read only the appended byte range. +- Console state is updated only by the newest request for the currently selected agent. +- Focused and full agent-manager and CLI tests, lint, builds, and a repeatable before/after benchmark must pass before publication. + +## Scope Decision + +The shared async API, reusable JSONL tail cache, Codex integration, OpenCode limited query, console integration, and safe fallback are required now. Additional adapters may adopt the reusable incremental reducer in follow-up changes if preserving their exact stateful semantics would make this change unsafe; they must still work through the shared async API rather than through a second UI architecture. + +## Non-goals + +- Changing visible conversation content, verbose rendering, or non-console command output. +- Replacing adapter detection/session discovery. +- Merging the resulting pull request. diff --git a/docs/ai/testing/2026-08-14-feature-console-incremental-tailing.md b/docs/ai/testing/2026-08-14-feature-console-incremental-tailing.md new file mode 100644 index 00000000..8aefaf50 --- /dev/null +++ b/docs/ai/testing/2026-08-14-feature-console-incremental-tailing.md @@ -0,0 +1,41 @@ +--- +phase: testing +title: Agent Console Incremental Conversation Tailing - Testing Strategy +description: Deterministic correctness, regression, and benchmark coverage for async incremental preview reads +--- + +# Testing Strategy + +## TDD Coverage + +- [x] Initial JSONL load reports exact fixture bytes and complete record count. +- [x] Unchanged load processes zero bytes and zero records. +- [x] Append load processes exactly appended bytes/records. +- [x] Partial final record is buffered and completed by a later append. +- [x] Malformed complete record is counted and skipped without poisoning later records. +- [x] Truncation and identity replacement rebuild state from byte zero, including same-size in-place rewrites. +- [x] LRU eviction forces a rebuild when the evicted path returns. +- [x] Synthetic large fixture proves append work is independent of prior file size using byte/record assertions. +- [x] Codex async output preserves legacy roles/content/order and mirrored-message deduplication, including mirrors split across reads. +- [x] OpenCode returns the newest requested displayable rows in chronological order using a limited query. +- [x] Gemini preserves monolithic adapter semantics while reading and parsing off the Ink event loop. +- [x] Hook ignores stale result/error completions after selection changes and keeps the newest 20 messages. +- [x] Hook serves unchanged cached data and continues interval polling when no watch event exists. + +## Validation Commands + +- Focused Vitest files during each red/green/refactor cycle. +- Full `packages/agent-manager` and `packages/cli` test suites. +- Package lint/typecheck/build plus repository lint/build. +- `npx ai-devkit@latest lint --feature console-incremental-tailing`. +- Repeatable benchmark against the 82.7 MiB Codex fixture, reporting full-load and append-refresh work/time. + +## Evidence + +- Focused agent-manager feature set: 5 files / 104 tests passed. +- Hook/cache test: 14 tests passed. +- Full agent-manager: 27 files / 526 tests passed sequentially with a 30-second allowance for the process-inspection integration. +- Full CLI after rebasing onto current `main`: 81 files / 970 tests passed. +- Agent-manager and CLI lint exited 0; CLI reported five pre-existing warnings outside touched files. +- Six-project monorepo build and feature-doc lint exited 0. +- Benchmark append refresh processed exactly 130 bytes / 1 record versus 86,682,730 bytes / 11,747 records on initial load. diff --git a/packages/agent-manager/package.json b/packages/agent-manager/package.json index d9cf81a0..78b5b26e 100644 --- a/packages/agent-manager/package.json +++ b/packages/agent-manager/package.json @@ -19,6 +19,7 @@ "test:coverage": "vitest run --coverage", "lint": "eslint src --ext .ts", "typecheck": "tsc --noEmit", + "benchmark:conversation-tail": "node scripts/benchmark-codex-conversation-tail.mjs", "clean": "rm -rf dist" }, "keywords": [ diff --git a/packages/agent-manager/scripts/benchmark-codex-conversation-tail.mjs b/packages/agent-manager/scripts/benchmark-codex-conversation-tail.mjs new file mode 100644 index 00000000..f7454049 --- /dev/null +++ b/packages/agent-manager/scripts/benchmark-codex-conversation-tail.mjs @@ -0,0 +1,75 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { CodexAdapter } from '../dist/index.js'; + +const sourcePath = process.argv[2]; +if (!sourcePath) { + console.error('Usage: npm run benchmark:conversation-tail -- '); + process.exitCode = 1; +} else { + const sourceStat = fs.statSync(sourcePath); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-tail-benchmark-')); + const fixturePath = path.join(tempDir, 'session.jsonl'); + + try { + fs.copyFileSync(sourcePath, fixturePath); + const legacy = new CodexAdapter(); + legacy.getConversation(fixturePath); + const fullRunsMs = []; + let visibleMessages = 0; + for (let index = 0; index < 5; index++) { + const started = performance.now(); + visibleMessages = legacy.getConversation(fixturePath).length; + fullRunsMs.push(performance.now() - started); + } + + const incremental = new CodexAdapter(); + const initialStarted = performance.now(); + const initial = await incremental.getConversationTail(fixturePath, { limit: 20 }); + const initialMs = performance.now() - initialStarted; + + const handle = fs.openSync(fixturePath, 'r'); + const finalByte = Buffer.alloc(1); + if (sourceStat.size > 0) fs.readSync(handle, finalByte, 0, 1, sourceStat.size - 1); + fs.closeSync(handle); + const separator = sourceStat.size > 0 && finalByte[0] !== 0x0a ? '\n' : ''; + const appended = `${separator}${JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'event', + payload: { type: 'agent_message', message: 'incremental-tail-benchmark' }, + })}\n`; + fs.appendFileSync(fixturePath, appended); + + const appendStarted = performance.now(); + const append = await incremental.getConversationTail(fixturePath, { limit: 20 }); + const appendMs = performance.now() - appendStarted; + const unchangedStarted = performance.now(); + const unchanged = await incremental.getConversationTail(fixturePath, { limit: 20 }); + const unchangedMs = performance.now() - unchangedStarted; + + const sorted = [...fullRunsMs].sort((a, b) => a - b); + console.log(JSON.stringify({ + fixture: { + bytes: sourceStat.size, + mebibytes: Number((sourceStat.size / 1024 / 1024).toFixed(1)), + visibleMessages, + }, + legacyFullParse: { + runsMs: fullRunsMs.map(value => Number(value.toFixed(1))), + medianMs: Number(sorted[Math.floor(sorted.length / 2)].toFixed(1)), + }, + incremental: { + initialMs: Number(initialMs.toFixed(1)), + initialStats: initial.stats, + appendMs: Number(appendMs.toFixed(3)), + appendStats: append.stats, + unchangedMs: Number(unchangedMs.toFixed(3)), + unchangedStats: unchanged.stats, + }, + }, null, 2)); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} diff --git a/packages/agent-manager/src/AgentManager.ts b/packages/agent-manager/src/AgentManager.ts index 3220cf90..81b9ef57 100644 --- a/packages/agent-manager/src/AgentManager.ts +++ b/packages/agent-manager/src/AgentManager.ts @@ -5,9 +5,13 @@ * Manages adapter registration and aggregates results from all adapters. */ +import * as fs from 'node:fs'; import type { AgentAdapter, AgentInfo, + ConversationMessage, + ConversationTailOptions, + ConversationTailResult, SessionSummary, ListSessionsOptions, } from './adapters/AgentAdapter.js'; @@ -40,6 +44,15 @@ export interface ListAgentsOptions { export class AgentManager { private adapters: Map = new Map(); private registry: AgentRegistry; + private readonly conversationFallbackCache = new Map(); + + private static readonly CONVERSATION_CACHE_MAX = 50; constructor(registry: AgentRegistry = AgentRegistry.default()) { this.registry = registry; @@ -105,6 +118,107 @@ export class AgentManager { return this.adapters.has(type); } + /** + * Read the newest conversation messages without synchronously parsing in + * the caller's stack. Adapters may provide storage-aware incremental + * implementations; legacy adapters use a deferred compatibility path. + */ + async getConversationTail( + type: string, + sessionFilePath: string, + options: ConversationTailOptions = {}, + ): Promise { + const adapter = this.getAdapter(type); + if (!adapter) throw new Error(`Unsupported agent type: ${type}`); + + const limit = Math.max(0, options.limit ?? 20); + const normalizedOptions = { ...options, limit }; + if (adapter.getConversationTail) { + const result = await adapter.getConversationTail(sessionFilePath, normalizedOptions); + return { ...result, messages: this.tailMessages(result.messages, limit) }; + } + + const cacheKey = `${type}\0${sessionFilePath}\0${options.verbose === true}\0${limit}`; + const stat = await this.safeConversationStat(sessionFilePath); + const cached = this.conversationFallbackCache.get(cacheKey); + if ( + stat && cached && + cached.dev === stat.dev && + cached.ino === stat.ino && + cached.size === stat.size && + cached.mtimeMs === stat.mtimeMs + ) { + this.touchConversationCache(cacheKey, cached); + return { + messages: [...cached.messages], + stats: { + bytesRead: 0, + recordsProcessed: 0, + parseErrors: 0, + cacheHit: true, + resetReason: null, + }, + }; + } + + // Keep legacy synchronous parsing out of the initiating render/effect + // stack. Monolithic formats provide an off-thread optimized method; + // this bridge remains for incremental-adapter migration compatibility. + await new Promise(resolve => setImmediate(resolve)); + const messages = this.tailMessages( + adapter.getConversation(sessionFilePath, { verbose: options.verbose }), + limit, + ); + + if (stat) { + this.touchConversationCache(cacheKey, { + dev: stat.dev, + ino: stat.ino, + size: stat.size, + mtimeMs: stat.mtimeMs, + messages, + }); + } + + return { + messages, + stats: { + bytesRead: stat?.size ?? 0, + recordsProcessed: messages.length, + parseErrors: 0, + cacheHit: false, + resetReason: cached + ? (stat && (cached.dev !== stat.dev || cached.ino !== stat.ino) + ? 'identity-changed' + : stat && stat.size < cached.size ? 'truncated' : null) + : 'initial', + }, + }; + } + + private async safeConversationStat(filePath: string): Promise { + try { + return await fs.promises.stat(filePath); + } catch { + return null; + } + } + + private tailMessages(messages: ConversationMessage[], limit: number): ConversationMessage[] { + return limit > 0 && messages.length > limit ? messages.slice(-limit) : [...messages]; + } + + private touchConversationCache( + key: string, + entry: { dev: number; ino: number; size: number; mtimeMs: number; messages: ConversationMessage[] }, + ): void { + this.conversationFallbackCache.delete(key); + this.conversationFallbackCache.set(key, entry); + while (this.conversationFallbackCache.size > AgentManager.CONVERSATION_CACHE_MAX) { + this.conversationFallbackCache.delete(this.conversationFallbackCache.keys().next().value!); + } + } + /** * List all running AI agents detected by registered adapters * diff --git a/packages/agent-manager/src/__tests__/AgentManagerConversationTail.test.ts b/packages/agent-manager/src/__tests__/AgentManagerConversationTail.test.ts new file mode 100644 index 00000000..aeaa05ae --- /dev/null +++ b/packages/agent-manager/src/__tests__/AgentManagerConversationTail.test.ts @@ -0,0 +1,98 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AgentManager } from '../AgentManager.js'; +import type { + AgentAdapter, + ConversationMessage, + ConversationTailResult, +} from '../adapters/AgentAdapter.js'; + +const messages = (...contents: string[]): ConversationMessage[] => + contents.map(content => ({ role: 'user', content })); + +function makeAdapter(overrides: Partial = {}): AgentAdapter { + return { + type: 'other', + detectAgents: async () => [], + canHandle: () => false, + getConversation: () => [], + listSessions: async () => [], + ...overrides, + }; +} + +describe('AgentManager.getConversationTail', () => { + let dir: string; + let filePath: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'manager-tail-')); + filePath = path.join(dir, 'session.jsonl'); + fs.writeFileSync(filePath, '{}\n'); + }); + + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('delegates to an optimized adapter reader and enforces the requested tail bound', async () => { + const optimized = vi.fn(async (): Promise => ({ + messages: messages('one', 'two', 'three'), + stats: { + bytesRead: 3, + recordsProcessed: 3, + parseErrors: 0, + cacheHit: false, + resetReason: 'initial', + }, + })); + const manager = new AgentManager(); + manager.registerAdapter(makeAdapter({ getConversationTail: optimized })); + + const result = await manager.getConversationTail('other', filePath, { limit: 2 }); + + expect(optimized).toHaveBeenCalledWith(filePath, { limit: 2 }); + expect(result.messages.map(message => message.content)).toEqual(['two', 'three']); + }); + + it('defers the legacy fallback and caches an unchanged real file', async () => { + let invoked = false; + const legacy = vi.fn(() => { + invoked = true; + return messages('one', 'two', 'three'); + }); + const manager = new AgentManager(); + manager.registerAdapter(makeAdapter({ getConversation: legacy })); + + const pending = manager.getConversationTail('other', filePath, { limit: 2 }); + expect(invoked).toBe(false); + const first = await pending; + expect(first.messages.map(message => message.content)).toEqual(['two', 'three']); + expect(first.stats.cacheHit).toBe(false); + + const second = await manager.getConversationTail('other', filePath, { limit: 2 }); + expect(second.messages).toEqual(first.messages); + expect(second.stats).toMatchObject({ bytesRead: 0, recordsProcessed: 0, cacheHit: true }); + expect(legacy).toHaveBeenCalledTimes(1); + }); + + it('invalidates the fallback cache after an append', async () => { + const legacy = vi.fn(() => messages(`read-${fs.statSync(filePath).size}`)); + const manager = new AgentManager(); + manager.registerAdapter(makeAdapter({ getConversation: legacy })); + await manager.getConversationTail('other', filePath, { limit: 20 }); + + fs.appendFileSync(filePath, '{}\n'); + const changed = await manager.getConversationTail('other', filePath, { limit: 20 }); + + expect(legacy).toHaveBeenCalledTimes(2); + expect(changed.stats.cacheHit).toBe(false); + expect(changed.stats.resetReason).toBeNull(); + }); + + it('rejects an unsupported adapter type', async () => { + const manager = new AgentManager(); + await expect(manager.getConversationTail('codex', filePath, { limit: 20 })) + .rejects.toThrow('Unsupported agent type: codex'); + }); +}); diff --git a/packages/agent-manager/src/__tests__/adapters/CodexConversationTail.test.ts b/packages/agent-manager/src/__tests__/adapters/CodexConversationTail.test.ts new file mode 100644 index 00000000..317bbaed --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/CodexConversationTail.test.ts @@ -0,0 +1,126 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { CodexAdapter } from '../../adapters/CodexAdapter.js'; + +const meta = { + type: 'session_meta', + payload: { id: 'session-1', cwd: '/repo', timestamp: '2026-08-14T10:00:00Z' }, +}; + +const legacy = (type: 'user_message' | 'agent_message', message: string) => ({ + type: 'event', + timestamp: '2026-08-14T10:00:01Z', + payload: { type, message }, +}); + +const response = (role: 'user' | 'assistant', content: string, turnId: string) => ({ + type: 'response_item', + timestamp: '2026-08-14T10:00:03Z', + payload: { + type: 'message', + role, + content: [{ type: role === 'user' ? 'input_text' : 'output_text', text: content }], + internal_chat_message_metadata_passthrough: { turn_id: turnId }, + }, +}); + +const eventMirror = (role: 'UserMessage' | 'AgentMessage', content: string, turnId: string) => ({ + type: 'event_msg', + timestamp: '2026-08-14T10:00:02Z', + payload: { + type: 'item_completed', + turn_id: turnId, + item: { type: role, content: [{ type: 'Text', text: content }] }, + }, +}); + +const encode = (...records: object[]): string => records.map(record => `${JSON.stringify(record)}\n`).join(''); + +describe('CodexAdapter.getConversationTail', () => { + let dir: string; + let filePath: string; + let adapter: CodexAdapter; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-tail-')); + filePath = path.join(dir, 'session.jsonl'); + adapter = new CodexAdapter(); + }); + + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('preserves synchronous conversation semantics while reading only appended records', async () => { + const initial = encode(meta, legacy('user_message', 'question')); + fs.writeFileSync(filePath, initial); + + const first = await adapter.getConversationTail!(filePath, { limit: 20 }); + expect(first.messages).toEqual(adapter.getConversation(filePath)); + expect(first.stats).toMatchObject({ + bytesRead: Buffer.byteLength(initial), + recordsProcessed: 2, + resetReason: 'initial', + }); + + const appended = encode(legacy('agent_message', 'answer')); + fs.appendFileSync(filePath, appended); + const second = await adapter.getConversationTail!(filePath, { limit: 20 }); + expect(second.messages).toEqual(adapter.getConversation(filePath)); + expect(second.stats).toMatchObject({ + bytesRead: Buffer.byteLength(appended), + recordsProcessed: 1, + resetReason: null, + }); + }); + + it('does not emit a partial appended record before it is completed', async () => { + fs.writeFileSync(filePath, encode(meta)); + await adapter.getConversationTail!(filePath, { limit: 20 }); + + const record = JSON.stringify(legacy('user_message', 'split')); + fs.appendFileSync(filePath, record.slice(0, -3)); + const partial = await adapter.getConversationTail!(filePath, { limit: 20 }); + expect(partial.messages).toEqual([]); + expect(partial.stats.recordsProcessed).toBe(0); + + fs.appendFileSync(filePath, `${record.slice(-3)}\n`); + const complete = await adapter.getConversationTail!(filePath, { limit: 20 }); + expect(complete.messages.map(message => message.content)).toEqual(['split']); + expect(complete.stats.recordsProcessed).toBe(1); + }); + + it('removes an event mirror when its response item arrives in a later append', async () => { + fs.writeFileSync(filePath, encode(meta, eventMirror('AgentMessage', 'answer', 'turn-1'))); + const beforeMirror = await adapter.getConversationTail!(filePath, { limit: 20 }); + expect(beforeMirror.messages.map(message => message.content)).toEqual(['answer']); + + fs.appendFileSync(filePath, encode(response('assistant', 'answer', 'turn-1'))); + const afterMirror = await adapter.getConversationTail!(filePath, { limit: 20 }); + + expect(afterMirror.messages).toEqual(adapter.getConversation(filePath)); + expect(afterMirror.messages).toHaveLength(1); + expect(afterMirror.messages[0].timestamp).toBe('2026-08-14T10:00:03Z'); + expect(afterMirror.stats.recordsProcessed).toBe(1); + }); + + it('skips an event mirror appended after its response item', async () => { + fs.writeFileSync(filePath, encode(meta, response('user', 'question', 'turn-2'))); + await adapter.getConversationTail!(filePath, { limit: 20 }); + + fs.appendFileSync(filePath, encode(eventMirror('UserMessage', 'question', 'turn-2'))); + const result = await adapter.getConversationTail!(filePath, { limit: 20 }); + + expect(result.messages).toEqual(adapter.getConversation(filePath)); + expect(result.messages).toHaveLength(1); + }); + + it('retains only the requested newest messages', async () => { + fs.writeFileSync(filePath, encode(meta, ...Array.from({ length: 30 }, (_, index) => + legacy(index % 2 === 0 ? 'user_message' : 'agent_message', `message-${index}`), + ))); + + const result = await adapter.getConversationTail!(filePath, { limit: 20 }); + expect(result.messages).toEqual(adapter.getConversation(filePath).slice(-20)); + }); +}); diff --git a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts index 07ec453f..f5232583 100644 --- a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts @@ -1082,6 +1082,42 @@ describe('GeminiCliAdapter', () => { }); }); + describe('getConversationTail', () => { + it('parses a monolithic session asynchronously, preserves semantics, and caches unchanged files', async () => { + const sessionPath = path.join(tmpHome, 'session-tail.json'); + fs.writeFileSync(sessionPath, JSON.stringify({ + sessionId: 'abc', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'user', content: 'one' }, + { id: 'm2', timestamp: '2026-04-18T00:00:02Z', type: 'gemini', content: 'two' }, + { id: 'm3', timestamp: '2026-04-18T00:00:03Z', type: 'user', content: 'three' }, + ], + })); + + const first = await adapter.getConversationTail!(sessionPath, { limit: 2 }); + expect(first.messages).toEqual(adapter.getConversation(sessionPath).slice(-2)); + expect(first.stats).toMatchObject({ + bytesRead: fs.statSync(sessionPath).size, + recordsProcessed: 3, + cacheHit: false, + resetReason: 'initial', + }); + + const unchanged = await adapter.getConversationTail!(sessionPath, { limit: 2 }); + expect(unchanged.messages).toEqual(first.messages); + expect(unchanged.stats).toMatchObject({ bytesRead: 0, recordsProcessed: 0, cacheHit: true }); + }); + + it('reports malformed monolithic JSON without throwing', async () => { + const sessionPath = path.join(tmpHome, 'session-tail-broken.json'); + fs.writeFileSync(sessionPath, '{ broken'); + + const result = await adapter.getConversationTail!(sessionPath, { limit: 20 }); + expect(result.messages).toEqual([]); + expect(result.stats).toMatchObject({ parseErrors: 1, cacheHit: false, resetReason: 'initial' }); + }); + }); + describe('listSessions', () => { it('returns empty when ~/.gemini/tmp does not exist', async () => { // tmpHome has no .gemini dir by default diff --git a/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts index e4664f16..455d7b9e 100644 --- a/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts @@ -33,9 +33,11 @@ function makeDb(queries: { lastAssistant?: { completed: number | null; errored: number | null } | null; firstUserText?: { text: string } | null; parts?: Array<{ role: string; partData: string; timeCreated: number }>; + preparedSql?: string[]; }) { const prepareImpl = (sql: string) => { const normalized = sql.replace(/\s+/g, ' ').trim().toLowerCase(); + queries.preparedSql?.push(normalized); if (normalized.includes('from session')) { return { @@ -84,6 +86,21 @@ function makeDb(queries: { }; } + if (normalized.includes('order by p.time_created desc')) { + return { + all: (_sessionId: string, limit: number) => [...(queries.parts ?? [])] + .filter(row => { + if (normalized.includes("or json_extract(p.data, '$.type') = 'reasoning'")) return true; + try { + const part = JSON.parse(row.partData); + return part.type === 'text' && typeof part.text === 'string' && part.text.length > 0; + } catch { return false; } + }) + .sort((a, b) => b.timeCreated - a.timeCreated) + .slice(0, limit), + }; + } + return { all: () => [], get: () => undefined }; }; @@ -412,6 +429,61 @@ describe('OpenCodeAdapter', () => { }); }); + describe('getConversationTail', () => { + it('queries only the newest displayable rows and returns them chronologically', async () => { + const preparedSql: string[] = []; + const db = makeDb({ + preparedSql, + parts: [ + { role: 'user', partData: JSON.stringify({ type: 'text', text: 'one' }), timeCreated: 1000 }, + { role: 'assistant', partData: JSON.stringify({ type: 'text', text: 'two' }), timeCreated: 2000 }, + { role: 'user', partData: JSON.stringify({ type: 'text', text: 'three' }), timeCreated: 3000 }, + ], + }); + (adapter as any).db = db; + + const result = await adapter.getConversationTail!(`${dbPath}::sess-tail`, { limit: 2 }); + + expect(result.messages.map(message => message.content)).toEqual(['two', 'three']); + const tailSql = preparedSql.find(sql => sql.includes('order by p.time_created desc')); + expect(tailSql).toContain('limit ?'); + expect(result.stats).toMatchObject({ bytesRead: 0, recordsProcessed: 2, cacheHit: false }); + }); + + it('filters non-displayable rows before applying the non-verbose limit', async () => { + const db = makeDb({ + parts: [ + { role: 'assistant', partData: JSON.stringify({ type: 'reasoning', reasoning: 'hidden' }), timeCreated: 1000 }, + { role: 'user', partData: JSON.stringify({ type: 'text', text: 'visible-one' }), timeCreated: 2000 }, + { role: 'assistant', partData: JSON.stringify({ type: 'tool', tool: 'read' }), timeCreated: 3000 }, + { role: 'assistant', partData: JSON.stringify({ type: 'text', text: 'visible-two' }), timeCreated: 4000 }, + ], + }); + (adapter as any).db = db; + + const result = await adapter.getConversationTail!(`${dbPath}::sess-tail`, { limit: 2 }); + expect(result.messages.map(message => message.content)).toEqual(['visible-one', 'visible-two']); + }); + + it('filters empty text before applying the limit', async () => { + const preparedSql: string[] = []; + const db = makeDb({ + preparedSql, + parts: [ + { role: 'user', partData: JSON.stringify({ type: 'text', text: 'visible-one' }), timeCreated: 1000 }, + { role: 'assistant', partData: JSON.stringify({ type: 'text', text: 'visible-two' }), timeCreated: 2000 }, + { role: 'assistant', partData: JSON.stringify({ type: 'text', text: '' }), timeCreated: 3000 }, + ], + }); + (adapter as any).db = db; + + const result = await adapter.getConversationTail!(`${dbPath}::sess-tail`, { limit: 2 }); + expect(result.messages.map(message => message.content)).toEqual(['visible-one', 'visible-two']); + expect(preparedSql.find(sql => sql.includes('order by p.time_created desc'))) + .toContain("json_extract(p.data, '$.text') <> ''"); + }); + }); + describe('listSessions', () => { it('returns empty array when DB does not exist', async () => { const sessions = await adapter.listSessions(); diff --git a/packages/agent-manager/src/__tests__/utils/JsonlConversationTailCache.test.ts b/packages/agent-manager/src/__tests__/utils/JsonlConversationTailCache.test.ts new file mode 100644 index 00000000..9ae176f7 --- /dev/null +++ b/packages/agent-manager/src/__tests__/utils/JsonlConversationTailCache.test.ts @@ -0,0 +1,199 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ConversationMessage } from '../../adapters/AgentAdapter.js'; +import { + JsonlConversationTailCache, + type JsonlConversationReducer, +} from '../../utils/JsonlConversationTailCache.js'; + +interface TestState { + messages: ConversationMessage[]; +} + +const reducer: JsonlConversationReducer = { + createState: () => ({ messages: [] }), + processRecord(state, record) { + const value = record as { role?: ConversationMessage['role']; content?: string }; + if (value.role && value.content) state.messages.push({ role: value.role, content: value.content }); + }, + getMessages: state => state.messages, +}; + +const line = (content: string): string => JSON.stringify({ role: 'user', content }); + +describe('JsonlConversationTailCache', () => { + let dir: string; + let filePath: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-tail-')); + filePath = path.join(dir, 'session.jsonl'); + }); + + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('processes every byte and complete record once, then serves an unchanged cache hit', async () => { + const content = `${line('one')}\n${line('two')}\n`; + fs.writeFileSync(filePath, content); + const cache = new JsonlConversationTailCache(); + + const initial = await cache.read({ key: 'test', filePath, limit: 20, reducer }); + expect(initial.messages.map(message => message.content)).toEqual(['one', 'two']); + expect(initial.stats).toMatchObject({ + bytesRead: Buffer.byteLength(content), + recordsProcessed: 2, + parseErrors: 0, + cacheHit: false, + resetReason: 'initial', + }); + + const unchanged = await cache.read({ key: 'test', filePath, limit: 20, reducer }); + expect(unchanged.messages).toEqual(initial.messages); + expect(unchanged.stats).toMatchObject({ + bytesRead: 0, + recordsProcessed: 0, + parseErrors: 0, + cacheHit: true, + resetReason: null, + }); + }); + + it('reads only appended bytes and retains only the requested tail', async () => { + const initial = `${line('one')}\n${line('two')}\n`; + const appended = `${line('three')}\n${line('four')}\n`; + fs.writeFileSync(filePath, initial); + const cache = new JsonlConversationTailCache(); + await cache.read({ key: 'test', filePath, limit: 3, reducer }); + + fs.appendFileSync(filePath, appended); + const result = await cache.read({ key: 'test', filePath, limit: 3, reducer }); + + expect(result.messages.map(message => message.content)).toEqual(['two', 'three', 'four']); + expect(result.stats).toMatchObject({ + bytesRead: Buffer.byteLength(appended), + recordsProcessed: 2, + cacheHit: false, + resetReason: null, + }); + }); + + it('buffers a partial final record without parsing it until a newline arrives', async () => { + const complete = `${line('one')}\n`; + const partial = line('two'); + const split = partial.length - 4; + fs.writeFileSync(filePath, complete + partial.slice(0, split)); + const cache = new JsonlConversationTailCache(); + + const first = await cache.read({ key: 'test', filePath, limit: 20, reducer }); + expect(first.messages.map(message => message.content)).toEqual(['one']); + expect(first.stats).toMatchObject({ recordsProcessed: 1, parseErrors: 0 }); + + const suffix = `${partial.slice(split)}\n`; + fs.appendFileSync(filePath, suffix); + const second = await cache.read({ key: 'test', filePath, limit: 20, reducer }); + expect(second.messages.map(message => message.content)).toEqual(['one', 'two']); + expect(second.stats).toMatchObject({ + bytesRead: Buffer.byteLength(suffix), + recordsProcessed: 1, + parseErrors: 0, + }); + }); + + it('counts malformed complete records and continues with later records', async () => { + const content = `${line('one')}\nnot-json\n${line('two')}\n`; + fs.writeFileSync(filePath, content); + const result = await new JsonlConversationTailCache().read({ key: 'test', filePath, limit: 20, reducer }); + + expect(result.messages.map(message => message.content)).toEqual(['one', 'two']); + expect(result.stats).toMatchObject({ recordsProcessed: 3, parseErrors: 1 }); + }); + + it('rebuilds from byte zero after truncation', async () => { + fs.writeFileSync(filePath, `${line('old-one')}\n${line('old-two')}\n`); + const cache = new JsonlConversationTailCache(); + await cache.read({ key: 'test', filePath, limit: 20, reducer }); + + const replacement = `${line('new')}\n`; + fs.truncateSync(filePath, 0); + fs.writeFileSync(filePath, replacement); + const result = await cache.read({ key: 'test', filePath, limit: 20, reducer }); + + expect(result.messages.map(message => message.content)).toEqual(['new']); + expect(result.stats).toMatchObject({ + bytesRead: Buffer.byteLength(replacement), + recordsProcessed: 1, + resetReason: 'truncated', + }); + }); + + it('rebuilds after an in-place rewrite that ends at the same byte length', async () => { + const original = `${line('old')}\n`; + const replacement = `${line('new')}\n`; + expect(Buffer.byteLength(replacement)).toBe(Buffer.byteLength(original)); + fs.writeFileSync(filePath, original); + const cache = new JsonlConversationTailCache(); + await cache.read({ key: 'test', filePath, limit: 20, reducer }); + + fs.writeFileSync(filePath, replacement); + const future = new Date(Date.now() + 2000); + fs.utimesSync(filePath, future, future); + const result = await cache.read({ key: 'test', filePath, limit: 20, reducer }); + + expect(result.messages.map(message => message.content)).toEqual(['new']); + expect(result.stats).toMatchObject({ + bytesRead: Buffer.byteLength(replacement), + recordsProcessed: 1, + resetReason: 'truncated', + }); + }); + + it('rebuilds when replacement or rotation changes file identity', async () => { + fs.writeFileSync(filePath, `${line('old')}\n`); + const cache = new JsonlConversationTailCache(); + await cache.read({ key: 'test', filePath, limit: 20, reducer }); + + const rotated = path.join(dir, 'rotated.jsonl'); + const replacement = `${line('replacement')}\n`; + fs.writeFileSync(rotated, replacement); + fs.renameSync(rotated, filePath); + const result = await cache.read({ key: 'test', filePath, limit: 20, reducer }); + + expect(result.messages.map(message => message.content)).toEqual(['replacement']); + expect(result.stats).toMatchObject({ + bytesRead: Buffer.byteLength(replacement), + resetReason: 'identity-changed', + }); + }); + + it('processes only one appended record after a synthetic large initial fixture', async () => { + const records = Array.from({ length: 20_000 }, (_, index) => `${line(`message-${index}`)}\n`); + const initial = records.join(''); + fs.writeFileSync(filePath, initial); + const cache = new JsonlConversationTailCache(); + const first = await cache.read({ key: 'test', filePath, limit: 20, reducer }); + expect(first.stats).toMatchObject({ bytesRead: Buffer.byteLength(initial), recordsProcessed: 20_000 }); + + const appended = `${line('appended')}\n`; + fs.appendFileSync(filePath, appended); + const second = await cache.read({ key: 'test', filePath, limit: 20, reducer }); + expect(second.stats).toMatchObject({ bytesRead: Buffer.byteLength(appended), recordsProcessed: 1 }); + expect(second.messages.at(-1)?.content).toBe('appended'); + }); + + it('evicts the least-recently-used entry at capacity', async () => { + const cache = new JsonlConversationTailCache({ maxEntries: 2 }); + const paths = ['a', 'b', 'c'].map(name => path.join(dir, `${name}.jsonl`)); + paths.forEach((target, index) => fs.writeFileSync(target, `${line(String(index))}\n`)); + + await cache.read({ key: 'a', filePath: paths[0], limit: 20, reducer }); + await cache.read({ key: 'b', filePath: paths[1], limit: 20, reducer }); + await cache.read({ key: 'a', filePath: paths[0], limit: 20, reducer }); + await cache.read({ key: 'c', filePath: paths[2], limit: 20, reducer }); + const rebuilt = await cache.read({ key: 'b', filePath: paths[1], limit: 20, reducer }); + + expect(rebuilt.stats.resetReason).toBe('initial'); + expect(rebuilt.stats.bytesRead).toBe(fs.statSync(paths[1]).size); + }); +}); diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index 2444d644..ae06b7be 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -84,6 +84,31 @@ export interface ConversationMessage { timestamp?: string; } +export type ConversationResetReason = 'initial' | 'identity-changed' | 'truncated' | 'missing' | null; + +export interface ConversationReadStats { + /** Bytes read from the backing session source for this request. */ + bytesRead: number; + /** Complete records considered, including malformed records. */ + recordsProcessed: number; + /** Complete records that were not valid JSON. */ + parseErrors: number; + /** True when the source was unchanged and cached messages were returned. */ + cacheHit: boolean; + /** Why incremental parser state was rebuilt, if it was rebuilt. */ + resetReason: ConversationResetReason; +} + +export interface ConversationTailOptions { + verbose?: boolean; + limit?: number; +} + +export interface ConversationTailResult { + messages: ConversationMessage[]; + stats: ConversationReadStats; +} + /** * A historical session discovered on disk (running or not). * @@ -178,6 +203,12 @@ export interface AgentAdapter { */ getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[]; + /** Optimized asynchronous conversation tail reader when the adapter provides one. */ + getConversationTail?( + sessionFilePath: string, + options?: ConversationTailOptions, + ): Promise; + /** * Enumerate historical sessions for this tool from disk. * diff --git a/packages/agent-manager/src/adapters/CodexAdapter.ts b/packages/agent-manager/src/adapters/CodexAdapter.ts index 28c1629f..b4585cf1 100644 --- a/packages/agent-manager/src/adapters/CodexAdapter.ts +++ b/packages/agent-manager/src/adapters/CodexAdapter.ts @@ -18,6 +18,8 @@ import type { AgentInfo, ProcessInfo, ConversationMessage, + ConversationTailOptions, + ConversationTailResult, SessionSummary, ListSessionsOptions, } from './AgentAdapter.js'; @@ -27,6 +29,10 @@ import { batchGetSessionFileBirthtimes, isDirectory, safeReadFile, safeReaddir, import type { SessionFile } from '../utils/session.js'; import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js'; import { AgentRegistry } from '../utils/AgentRegistry.js'; +import { + JsonlConversationTailCache, + type JsonlConversationReducer, +} from '../utils/JsonlConversationTailCache.js'; interface CodexEventEntry { timestamp?: string; @@ -86,6 +92,17 @@ interface MappingMatchResult { fallback: ProcessInfo[]; } +interface CodexTailMessage { + message: ConversationMessage; + source: string | undefined; + mirrorKey: string | null; +} + +interface CodexTailState { + messages: CodexTailMessage[]; + responseItemMirrorKeys: Set; +} + export class CodexAdapter implements AgentAdapter { readonly type = 'codex' as const; @@ -96,6 +113,7 @@ export class CodexAdapter implements AgentAdapter { private codexSessionsDir: string; private sessionMappingPath: string; private registry: AgentRegistry; + private readonly conversationTailCache = new JsonlConversationTailCache(); constructor(registry: AgentRegistry = AgentRegistry.default()) { const homeDir = process.env.HOME || process.env.USERPROFILE || ''; @@ -714,6 +732,51 @@ export class CodexAdapter implements AgentAdapter { return messages; } + async getConversationTail( + sessionFilePath: string, + options?: ConversationTailOptions, + ): Promise { + const verbose = options?.verbose ?? false; + const limit = Math.max(0, options?.limit ?? 20); + const reducer: JsonlConversationReducer = { + createState: () => ({ messages: [], responseItemMirrorKeys: new Set() }), + processRecord: (state, record) => { + const entry = record as CodexEventEntry; + const message = this.toConversationMessage(entry, verbose); + if (!message) return; + + const mirrorKey = this.mirroredMessageKey(entry, message); + if (entry.type === 'response_item' && mirrorKey) { + state.responseItemMirrorKeys.add(mirrorKey); + state.messages = state.messages.filter(candidate => + candidate.source !== 'event_msg' || candidate.mirrorKey !== mirrorKey, + ); + } else if ( + entry.type === 'event_msg' && + mirrorKey && + state.responseItemMirrorKeys.has(mirrorKey) + ) { + return; + } + + state.messages.push({ message, source: entry.type, mirrorKey }); + }, + getMessages: state => state.messages.map(candidate => candidate.message), + trim: (state, tailLimit) => { + if (tailLimit > 0 && state.messages.length > tailLimit) { + state.messages.splice(0, state.messages.length - tailLimit); + } + }, + }; + + return this.conversationTailCache.read({ + key: `codex:${verbose ? 'verbose' : 'default'}`, + filePath: sessionFilePath, + limit, + reducer, + }); + } + private toConversationMessage(entry: CodexEventEntry, verbose: boolean): ConversationMessage | null { if (entry.type === 'session_meta') return null; diff --git a/packages/agent-manager/src/adapters/GeminiCliAdapter.ts b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts index 5e3e7481..2fbae50a 100644 --- a/packages/agent-manager/src/adapters/GeminiCliAdapter.ts +++ b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts @@ -18,6 +18,8 @@ import type { AgentInfo, ProcessInfo, ConversationMessage, + ConversationTailOptions, + ConversationTailResult, SessionSummary, ListSessionsOptions, } from './AgentAdapter.js'; @@ -27,6 +29,7 @@ import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/sessi import type { SessionFile } from '../utils/session.js'; import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js'; import { AgentRegistry, type RegistryEntry } from '../utils/AgentRegistry.js'; +import { parseJsonFileOffThread } from '../utils/parseJsonFileOffThread.js'; /** * A single Gemini CLI message content part. Mirrors the `{text?: string}` @@ -79,6 +82,14 @@ interface GeminiSession { lastMessageType?: string; } +interface GeminiConversationCacheEntry { + dev: number; + ino: number; + size: number; + mtimeMs: number; + messages: ConversationMessage[]; +} + export class GeminiCliAdapter implements AgentAdapter { readonly type = 'gemini_cli' as const; @@ -89,6 +100,7 @@ export class GeminiCliAdapter implements AgentAdapter { private geminiTmpDir: string; private registry: AgentRegistry; + private readonly conversationCache = new Map(); constructor(registry: AgentRegistry = AgentRegistry.default()) { const homeDir = process.env.HOME || process.env.USERPROFILE || ''; @@ -568,6 +580,71 @@ export class GeminiCliAdapter implements AgentAdapter { return []; } + return this.conversationMessages(parsed, verbose); + } + + async getConversationTail( + sessionFilePath: string, + options?: ConversationTailOptions, + ): Promise { + const verbose = options?.verbose ?? false; + const limit = Math.max(0, options?.limit ?? 20); + const key = `${sessionFilePath}\0${verbose}\0${limit}`; + let stat: fs.Stats; + try { + stat = await fs.promises.stat(sessionFilePath); + } catch { + this.conversationCache.delete(key); + return this.geminiTailResult([], 0, 0, false, 'missing'); + } + + const cached = this.conversationCache.get(key); + if ( + cached && cached.dev === stat.dev && cached.ino === stat.ino && + cached.size === stat.size && cached.mtimeMs === stat.mtimeMs + ) { + this.touchConversationCache(key, cached); + return this.geminiTailResult([...cached.messages], 0, 0, true, null); + } + + const resetReason = !cached + ? 'initial' + : cached.dev !== stat.dev || cached.ino !== stat.ino + ? 'identity-changed' + : stat.size < cached.size ? 'truncated' : null; + + let parsed: GeminiSessionFile; + try { + parsed = await parseJsonFileOffThread(sessionFilePath); + } catch { + return { + ...this.geminiTailResult([], stat.size, 0, false, resetReason), + stats: { + ...this.geminiTailResult([], stat.size, 0, false, resetReason).stats, + parseErrors: 1, + }, + }; + } + + const allMessages = this.conversationMessages(parsed, verbose); + const messages = limit > 0 && allMessages.length > limit ? allMessages.slice(-limit) : allMessages; + this.touchConversationCache(key, { + dev: stat.dev, + ino: stat.ino, + size: stat.size, + mtimeMs: stat.mtimeMs, + messages, + }); + return this.geminiTailResult( + messages, + stat.size, + Array.isArray(parsed.messages) ? parsed.messages.length : 0, + false, + resetReason, + ); + } + + private conversationMessages(parsed: GeminiSessionFile, verbose: boolean): ConversationMessage[] { const messages: ConversationMessage[] = []; if (!Array.isArray(parsed.messages)) return messages; @@ -599,6 +676,27 @@ export class GeminiCliAdapter implements AgentAdapter { return messages; } + private geminiTailResult( + messages: ConversationMessage[], + bytesRead: number, + recordsProcessed: number, + cacheHit: boolean, + resetReason: ConversationTailResult['stats']['resetReason'], + ): ConversationTailResult { + return { + messages, + stats: { bytesRead, recordsProcessed, parseErrors: 0, cacheHit, resetReason }, + }; + } + + private touchConversationCache(key: string, entry: GeminiConversationCacheEntry): void { + this.conversationCache.delete(key); + this.conversationCache.set(key, entry); + while (this.conversationCache.size > 50) { + this.conversationCache.delete(this.conversationCache.keys().next().value!); + } + } + async listSessions(opts?: ListSessionsOptions): Promise { if (!isDirectory(this.geminiTmpDir)) return []; diff --git a/packages/agent-manager/src/adapters/OpenCodeAdapter.ts b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts index f7b2771d..bc5bc24a 100644 --- a/packages/agent-manager/src/adapters/OpenCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts @@ -19,6 +19,8 @@ import type { AgentInfo, ProcessInfo, ConversationMessage, + ConversationTailOptions, + ConversationTailResult, SessionSummary, ListSessionsOptions, } from './AgentAdapter.js'; @@ -135,36 +137,104 @@ export class OpenCodeAdapter implements AgentAdapter { ORDER BY p.time_created ASC `).all(ref.sessionId); - const messages: ConversationMessage[] = []; + return this.rowsToMessages(rows, verbose); + } catch { + this.close(); + return []; + } + } - for (const row of rows) { - let partData: { type?: string; text?: string; reasoning?: string; tool?: string } = {}; - try { - partData = JSON.parse(row.partData); - } catch { - continue; - } - - const role = row.role === 'user' ? 'user' : 'assistant'; - - if (partData.type === 'text' && partData.text) { - messages.push({ role, content: partData.text }); - } else if (partData.type === 'reasoning' && verbose) { - const text = partData.reasoning || partData.text || ''; - if (text) messages.push({ role: 'assistant', content: `[thinking] ${text}` }); - } else if (partData.type === 'tool' && verbose) { - const toolName = partData.tool || 'tool'; - messages.push({ role: 'assistant', content: `[tool: ${toolName}]` }); - } - } + async getConversationTail( + sessionFilePath: string, + options?: ConversationTailOptions, + ): Promise { + const verbose = options?.verbose ?? false; + const limit = Math.max(0, options?.limit ?? 20); + const ref = decodeSessionRef(sessionFilePath); + const db = ref ? this.openDb() : null; + if (!ref || !db) return this.emptyTailResult(); + + if (limit === 0) { + const messages = this.getConversation(sessionFilePath, { verbose }); + return this.tailResult(messages, messages.length); + } + + const displayable = verbose + ? `((json_extract(p.data, '$.type') = 'text' + AND json_extract(p.data, '$.text') IS NOT NULL + AND json_extract(p.data, '$.text') <> '') + OR (json_extract(p.data, '$.type') = 'reasoning' + AND (COALESCE(json_extract(p.data, '$.reasoning'), '') <> '' + OR COALESCE(json_extract(p.data, '$.text'), '') <> '')) + OR json_extract(p.data, '$.type') = 'tool')` + : `(json_extract(p.data, '$.type') = 'text' + AND json_extract(p.data, '$.text') IS NOT NULL + AND json_extract(p.data, '$.text') <> '')`; + + try { + const rows = db.prepare<[string, number], { role: string; partData: string; timeCreated: number }>(` + SELECT json_extract(m.data, '$.role') AS role, + p.data AS partData, + p.time_created AS timeCreated + FROM part p + JOIN message m ON p.message_id = m.id + WHERE p.session_id = ? AND ${displayable} + ORDER BY p.time_created DESC + LIMIT ? + `).all(ref.sessionId, limit); - return messages; + return this.tailResult(this.rowsToMessages(rows.reverse(), verbose), rows.length); } catch { this.close(); - return []; + return this.emptyTailResult(); } } + private rowsToMessages( + rows: Array<{ role: string; partData: string; timeCreated: number }>, + verbose: boolean, + ): ConversationMessage[] { + const messages: ConversationMessage[] = []; + + for (const row of rows) { + let partData: { type?: string; text?: string; reasoning?: string; tool?: string } = {}; + try { + partData = JSON.parse(row.partData); + } catch { + continue; + } + + const role = row.role === 'user' ? 'user' : 'assistant'; + if (partData.type === 'text' && partData.text) { + messages.push({ role, content: partData.text }); + } else if (partData.type === 'reasoning' && verbose) { + const text = partData.reasoning || partData.text || ''; + if (text) messages.push({ role: 'assistant', content: `[thinking] ${text}` }); + } else if (partData.type === 'tool' && verbose) { + messages.push({ role: 'assistant', content: `[tool: ${partData.tool || 'tool'}]` }); + } + } + + return messages; + } + + private tailResult(messages: ConversationMessage[], recordsProcessed: number): ConversationTailResult { + return { + messages, + stats: { + bytesRead: 0, + recordsProcessed, + parseErrors: 0, + cacheHit: false, + resetReason: null, + }, + }; + } + + private emptyTailResult(): ConversationTailResult { + return this.tailResult([], 0); + } + async listSessions(opts?: ListSessionsOptions): Promise { const db = this.openDb(); if (!db) return []; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index ced400ec..61d664ec 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -14,10 +14,21 @@ export type { AgentInfo, ProcessInfo, ConversationMessage, + ConversationReadStats, + ConversationResetReason, + ConversationTailOptions, + ConversationTailResult, SessionSummary, ListSessionsOptions, } from './adapters/AgentAdapter.js'; +export { JsonlConversationTailCache } from './utils/JsonlConversationTailCache.js'; +export type { + JsonlConversationReducer, + JsonlConversationReadOptions, + JsonlConversationTailCacheOptions, +} from './utils/JsonlConversationTailCache.js'; + export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager.js'; export type { TerminalLocation } from './terminal/TerminalFocusManager.js'; export { TtyWriter } from './terminal/TtyWriter.js'; diff --git a/packages/agent-manager/src/utils/JsonlConversationTailCache.ts b/packages/agent-manager/src/utils/JsonlConversationTailCache.ts new file mode 100644 index 00000000..78e6a4e6 --- /dev/null +++ b/packages/agent-manager/src/utils/JsonlConversationTailCache.ts @@ -0,0 +1,211 @@ +import * as fs from 'node:fs'; +import type { + ConversationMessage, + ConversationResetReason, + ConversationTailResult, +} from '../adapters/AgentAdapter.js'; + +export interface JsonlConversationReducer { + createState(): State; + processRecord(state: State, record: unknown): void; + getMessages(state: State): ConversationMessage[]; + trim?(state: State, limit: number): void; +} + +export interface JsonlConversationReadOptions { + key: string; + filePath: string; + limit: number; + reducer: JsonlConversationReducer; +} + +interface FileIdentity { + dev: number; + ino: number; +} + +interface CacheEntry { + identity: FileIdentity; + size: number; + mtimeMs: number; + offset: number; + incomplete: Buffer; + state: State; +} + +export interface JsonlConversationTailCacheOptions { + maxEntries?: number; +} + +const DEFAULT_MAX_ENTRIES = 50; + +export class JsonlConversationTailCache { + private readonly maxEntries: number; + private readonly entries = new Map(); + private readonly pending = new Map>(); + + constructor(options: JsonlConversationTailCacheOptions = {}) { + this.maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES); + } + + async read(options: JsonlConversationReadOptions): Promise { + const cacheKey = this.cacheKey(options); + const previous = this.pending.get(cacheKey) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(() => this.readUnlocked(cacheKey, options)); + this.pending.set(cacheKey, current); + + try { + return await current; + } finally { + if (this.pending.get(cacheKey) === current) this.pending.delete(cacheKey); + } + } + + clear(): void { + this.entries.clear(); + } + + private async readUnlocked( + cacheKey: string, + options: JsonlConversationReadOptions, + ): Promise { + let stat: fs.Stats; + try { + stat = await fs.promises.stat(options.filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + this.entries.delete(cacheKey); + return { + messages: [], + stats: { + bytesRead: 0, + recordsProcessed: 0, + parseErrors: 0, + cacheHit: false, + resetReason: 'missing', + }, + }; + } + + const identity = { dev: stat.dev, ino: stat.ino }; + let entry = this.entries.get(cacheKey) as CacheEntry | undefined; + let resetReason: ConversationResetReason = null; + + if (!entry) { + resetReason = 'initial'; + } else if (entry.identity.dev !== identity.dev || entry.identity.ino !== identity.ino) { + resetReason = 'identity-changed'; + } else if (stat.size < entry.offset) { + resetReason = 'truncated'; + } else if (stat.size === entry.offset && stat.mtimeMs !== entry.mtimeMs) { + // The path changed without growing. This covers truncate-and-rewrite + // cycles that finish at the previous size while retaining the inode. + resetReason = 'truncated'; + } + + if (resetReason !== null) { + entry = { + identity, + size: 0, + mtimeMs: 0, + offset: 0, + incomplete: Buffer.alloc(0), + state: options.reducer.createState(), + }; + } + + if ( + resetReason === null && + entry && + stat.size === entry.size && + stat.mtimeMs === entry.mtimeMs + ) { + this.touch(cacheKey, entry); + return { + messages: this.tail(options.reducer.getMessages(entry.state), options.limit), + stats: { + bytesRead: 0, + recordsProcessed: 0, + parseErrors: 0, + cacheHit: true, + resetReason: null, + }, + }; + } + + const start = entry!.offset; + const bytesToRead = Math.max(0, stat.size - start); + let appended = Buffer.alloc(0); + if (bytesToRead > 0) { + const handle = await fs.promises.open(options.filePath, 'r'); + try { + appended = Buffer.allocUnsafe(bytesToRead); + const { bytesRead } = await handle.read(appended, 0, bytesToRead, start); + appended = appended.subarray(0, bytesRead); + } finally { + await handle.close(); + } + } + + const input = entry!.incomplete.length > 0 + ? Buffer.concat([entry!.incomplete, appended]) + : appended; + let recordStart = 0; + let recordsProcessed = 0; + let parseErrors = 0; + + for (let index = 0; index < input.length; index++) { + if (input[index] !== 0x0a) continue; + const raw = input.subarray(recordStart, index).toString('utf8').trim(); + recordStart = index + 1; + if (!raw) continue; + + recordsProcessed++; + try { + options.reducer.processRecord(entry!.state, JSON.parse(raw)); + options.reducer.trim?.(entry!.state, options.limit); + } catch (error) { + if (error instanceof SyntaxError) { + parseErrors++; + } else { + throw error; + } + } + } + + entry!.identity = identity; + entry!.size = stat.size; + entry!.mtimeMs = stat.mtimeMs; + entry!.offset = start + appended.length; + entry!.incomplete = Buffer.from(input.subarray(recordStart)); + this.touch(cacheKey, entry!); + + return { + messages: this.tail(options.reducer.getMessages(entry!.state), options.limit), + stats: { + bytesRead: appended.length, + recordsProcessed, + parseErrors, + cacheHit: false, + resetReason, + }, + }; + } + + private cacheKey(options: JsonlConversationReadOptions): string { + return `${options.key}\0${options.filePath}\0${options.limit}`; + } + + private touch(key: string, entry: CacheEntry): void { + this.entries.delete(key); + this.entries.set(key, entry); + while (this.entries.size > this.maxEntries) { + this.entries.delete(this.entries.keys().next().value!); + } + } + + private tail(messages: ConversationMessage[], limit: number): ConversationMessage[] { + return limit > 0 && messages.length > limit ? messages.slice(-limit) : [...messages]; + } +} diff --git a/packages/agent-manager/src/utils/parseJsonFileOffThread.ts b/packages/agent-manager/src/utils/parseJsonFileOffThread.ts new file mode 100644 index 00000000..1694849b --- /dev/null +++ b/packages/agent-manager/src/utils/parseJsonFileOffThread.ts @@ -0,0 +1,31 @@ +import { Worker } from 'node:worker_threads'; + +const WORKER_SOURCE = ` +const fs = require('node:fs'); +const { parentPort, workerData } = require('node:worker_threads'); +fs.promises.readFile(workerData.filePath, 'utf8') + .then(content => parentPort.postMessage({ ok: true, value: JSON.parse(content) })) + .catch(error => parentPort.postMessage({ ok: false, message: error instanceof Error ? error.message : String(error) })); +`; + +/** Read and parse monolithic JSON without performing either operation on the caller's event loop. */ +export function parseJsonFileOffThread(filePath: string): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(WORKER_SOURCE, { eval: true, workerData: { filePath } }); + let settled = false; + + worker.once('message', (result: { ok: boolean; value?: T; message?: string }) => { + settled = true; + void worker.terminate(); + if (result.ok) resolve(result.value as T); + else reject(new SyntaxError(result.message || 'Unable to parse JSON file')); + }); + worker.once('error', (error) => { + settled = true; + reject(error); + }); + worker.once('exit', (code) => { + if (!settled) reject(new Error(`JSON parser worker exited before replying (code ${code})`)); + }); + }); +} diff --git a/packages/cli/src/__tests__/tui/console/hooks/conversationCache.test.ts b/packages/cli/src/__tests__/tui/console/hooks/conversationCache.test.ts index 6188bc8d..e495fa3e 100644 --- a/packages/cli/src/__tests__/tui/console/hooks/conversationCache.test.ts +++ b/packages/cli/src/__tests__/tui/console/hooks/conversationCache.test.ts @@ -1,11 +1,14 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { cacheSet, conversationCache, CACHE_MAX, messagesEqual, + ConversationRequestGate, + loadAgentConversation, + startConversationPolling, } from '../../../../tui/console/hooks/useAgentConversation.js'; -import type { ConversationMessage } from '@ai-devkit/agent-manager'; +import type { AgentInfo, AgentManager, ConversationMessage } from '@ai-devkit/agent-manager'; const msg = (role: ConversationMessage['role'], content: string, timestamp?: string): ConversationMessage => ({ role, content, timestamp } as ConversationMessage); @@ -85,3 +88,72 @@ describe('messagesEqual', () => { expect(messagesEqual([msg('user', 'x')], [msg('user', 'x')])).toBe(true); }); }); + +describe('async conversation requests', () => { + const agent = { + name: 'codex-one', + type: 'codex', + sessionFilePath: '/tmp/session.jsonl', + } as AgentInfo; + + afterEach(() => vi.useRealTimers()); + + it('rejects a stale selection result after a newer request begins', async () => { + let resolveFirst!: (value: any) => void; + let resolveSecond!: (value: any) => void; + const first = new Promise(resolve => { resolveFirst = resolve; }); + const second = new Promise(resolve => { resolveSecond = resolve; }); + const getConversationTail = vi.fn() + .mockReturnValueOnce(first) + .mockReturnValueOnce(second); + const manager = { + getAdapter: () => ({}), + getConversationTail, + } as unknown as AgentManager; + const gate = new ConversationRequestGate(); + + const firstToken = gate.begin(); + const oldRequest = loadAgentConversation(manager, agent, 20, gate, firstToken); + const secondToken = gate.begin(); + const newRequest = loadAgentConversation(manager, agent, 20, gate, secondToken); + resolveSecond({ messages: [msg('assistant', 'new')], stats: {} }); + expect((await newRequest)?.messages.map(message => message.content)).toEqual(['new']); + + resolveFirst({ messages: [msg('assistant', 'old')], stats: {} }); + expect(await oldRequest).toBeNull(); + }); + + it('rejects a stale error and retains only the newest 20 messages', async () => { + let rejectFirst!: (error: Error) => void; + const first = new Promise((_resolve, reject) => { rejectFirst = reject; }); + const many = Array.from({ length: 30 }, (_, index) => msg('user', `message-${index}`)); + const manager = { + getAdapter: () => ({}), + getConversationTail: vi.fn() + .mockReturnValueOnce(first) + .mockResolvedValueOnce({ messages: many, stats: {} }), + } as unknown as AgentManager; + const gate = new ConversationRequestGate(); + + const staleToken = gate.begin(); + const stale = loadAgentConversation(manager, agent, 20, gate, staleToken); + const currentToken = gate.begin(); + const current = await loadAgentConversation(manager, agent, 20, gate, currentToken); + expect(current?.messages).toHaveLength(20); + expect(current?.messages[0].content).toBe('message-10'); + + rejectFirst(new Error('old parse failed')); + expect(await stale).toBeNull(); + }); + + it('keeps interval polling as a fallback', async () => { + vi.useFakeTimers(); + const fetchOnce = vi.fn(async () => undefined); + const handle = startConversationPolling(fetchOnce, 3000); + + await vi.advanceTimersByTimeAsync(9000); + clearInterval(handle); + + expect(fetchOnce).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/cli/src/tui/console/hooks/useAgentConversation.ts b/packages/cli/src/tui/console/hooks/useAgentConversation.ts index 1aa95c55..d0458c87 100644 --- a/packages/cli/src/tui/console/hooks/useAgentConversation.ts +++ b/packages/cli/src/tui/console/hooks/useAgentConversation.ts @@ -1,4 +1,3 @@ -import fs from 'fs'; import { useEffect, useRef, useState } from 'react'; import type { AgentInfo, AgentManager, ConversationMessage } from '@ai-devkit/agent-manager'; @@ -60,6 +59,78 @@ export function cacheSet(key: string, entry: CacheEntry): void { conversationCache.set(key, entry); } +export class ConversationRequestGate { + private currentToken = 0; + + begin(): number { + return ++this.currentToken; + } + + invalidate(): void { + this.currentToken++; + } + + isCurrent(token: number): boolean { + return token === this.currentToken; + } +} + +export async function loadAgentConversation( + manager: AgentManager, + agent: AgentInfo, + tail: number, + gate: ConversationRequestGate, + token: number, +): Promise { + if (!agent.sessionFilePath) { + return gate.isCurrent(token) ? { + messages: [], + error: { kind: 'no-session-file', message: `No session file for "${agent.name}".` }, + lastUpdated: null, + isLoading: false, + } : null; + } + + if (!manager.getAdapter(agent.type)) { + return gate.isCurrent(token) ? { + messages: [], + error: { kind: 'no-adapter', message: `Unsupported agent type: ${agent.type}` }, + lastUpdated: null, + isLoading: false, + } : null; + } + + try { + const result = await manager.getConversationTail(agent.type, agent.sessionFilePath, { + verbose: false, + limit: tail, + }); + if (!gate.isCurrent(token)) return null; + const messages = tail > 0 && result.messages.length > tail + ? result.messages.slice(-tail) + : result.messages; + return { messages, error: null, lastUpdated: new Date(), isLoading: false }; + } catch (error) { + if (!gate.isCurrent(token)) return null; + return { + messages: [], + error: { + kind: 'parse-error', + message: error instanceof Error ? error.message : String(error), + }, + lastUpdated: null, + isLoading: false, + }; + } +} + +export function startConversationPolling( + fetchOnce: () => Promise, + intervalMs: number, +): ReturnType { + return setInterval(() => { void fetchOnce(); }, intervalMs); +} + export function useAgentConversation({ manager, agent, @@ -69,7 +140,7 @@ export function useAgentConversation({ }: Params): UseAgentConversationResult { const [state, setState] = useState(EMPTY_STATE); - const runTokenRef = useRef(0); + const gateRef = useRef(new ConversationRequestGate()); const mountedRef = useRef(true); useEffect(() => { @@ -77,7 +148,10 @@ export function useAgentConversation({ if (!agent) { setState(prev => prev === EMPTY_STATE ? prev : EMPTY_STATE); - return () => { mountedRef.current = false; }; + return () => { + mountedRef.current = false; + gateRef.current.invalidate(); + }; } // If we have a cached result for this agent, show it immediately while @@ -88,98 +162,59 @@ export function useAgentConversation({ : { messages: [], error: null, lastUpdated: null, isLoading: true }, ); - const fetchOnce = (): void => { - const token = ++runTokenRef.current; - - if (!agent.sessionFilePath) { - if (token !== runTokenRef.current || !mountedRef.current) return; - setState(prev => prev.error?.kind === 'no-session-file' && !prev.isLoading - ? prev - : { - messages: [], - error: { kind: 'no-session-file', message: `No session file for "${agent.name}".` }, - lastUpdated: prev.lastUpdated, - isLoading: false, - }); - return; - } + let inFlight = false; + const fetchOnce = async (): Promise => { + if (inFlight) return; + inFlight = true; + const token = gateRef.current.begin(); + try { + const result = await loadAgentConversation(manager, agent, tail, gateRef.current, token); + if (!result || !mountedRef.current) return; - const adapter = manager.getAdapter(agent.type); - if (!adapter) { - if (token !== runTokenRef.current || !mountedRef.current) return; - setState(prev => prev.error?.kind === 'no-adapter' && !prev.isLoading - ? prev - : { - messages: [], - error: { kind: 'no-adapter', message: `Unsupported agent type: ${agent.type}` }, + if (result.error) { + setState(prev => ({ + ...prev, + error: result.error, lastUpdated: prev.lastUpdated, isLoading: false, - }); - return; - } - - try { - let mtime: number | null = null; - try { - mtime = fs.statSync(agent.sessionFilePath).mtimeMs; - } catch { - mtime = null; - } - - const cached = conversationCache.get(agent.sessionFilePath); - if (mtime !== null && cached && cached.mtime === mtime) { - // File unchanged — serve from cache, no JSONL parse needed. - if (token !== runTokenRef.current || !mountedRef.current) return; - setState(prev => { - const changed = !messagesEqual(prev.messages, cached.messages); - if (!changed && prev.error === null && !prev.isLoading && prev.lastUpdated !== null) return prev; - return { messages: changed ? cached.messages : prev.messages, error: null, lastUpdated: new Date(), isLoading: false }; - }); + })); return; } - const conversation = adapter.getConversation(agent.sessionFilePath, { verbose: false }); - if (token !== runTokenRef.current || !mountedRef.current) return; - - const sliced = tail > 0 && conversation.length > tail - ? conversation.slice(-tail) - : conversation; - if (mtime !== null) { - cacheSet(agent.sessionFilePath, { mtime, messages: sliced }); + if (agent.sessionFilePath) { + cacheSet(agent.sessionFilePath, { mtime: Date.now(), messages: result.messages }); } setState(prev => { - const changed = !messagesEqual(prev.messages, sliced); - if (!changed && prev.error === null && !prev.isLoading && prev.lastUpdated !== null) { - return prev; - } + const changed = !messagesEqual(prev.messages, result.messages); + if (!changed && prev.error === null && !prev.isLoading && prev.lastUpdated !== null) return prev; return { - messages: changed ? sliced : prev.messages, + messages: changed ? result.messages : prev.messages, error: null, - lastUpdated: new Date(), + lastUpdated: result.lastUpdated, isLoading: false, }; }); - } catch (err) { - if (token !== runTokenRef.current || !mountedRef.current) return; - const message = err instanceof Error ? err.message : String(err); - setState(prev => ({ ...prev, error: { kind: 'parse-error', message }, isLoading: false })); + } finally { + inFlight = false; } }; // Debounce the immediate fetch on selection change so rapid arrow-key // navigation doesn't fire a synchronous getConversation() per keystroke. - const debounceHandle = setTimeout(fetchOnce, SELECTION_DEBOUNCE_MS); + const debounceHandle = setTimeout(() => { void fetchOnce(); }, SELECTION_DEBOUNCE_MS); if (paused) { return () => { mountedRef.current = false; + gateRef.current.invalidate(); clearTimeout(debounceHandle); }; } - const intervalHandle = setInterval(fetchOnce, intervalMs); + const intervalHandle = startConversationPolling(fetchOnce, intervalMs); return () => { mountedRef.current = false; + gateRef.current.invalidate(); clearTimeout(debounceHandle); clearInterval(intervalHandle); };