Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/ai/design/2026-08-14-feature-console-fast-initial-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
phase: design
title: Agent Console Fast Initial State Design
description: Synchronous cached identity snapshot with console stale-while-revalidate state
---

# Agent Console Fast Initial State Design

## Data Flow

```mermaid
flowchart LR
Registry[(AgentRegistry)] -->|sync live-PID identities| Snapshot[AgentManager.getCachedAgentSnapshot]
Snapshot -->|first render: unknown + cached| Console[useAgentList]
Console -->|async| Discovery[AgentManager.listAgents]
Discovery -->|one state update| Console
Console -->|live rows, cache markers cleared| UI[Agent list + footer]
```

## API Decision

Add `AgentManager.getCachedAgentSnapshot(): CachedAgentSnapshot[]`. The snapshot contains identity and registry metadata only: registered name, type, PID, cwd, start time, session ID, and optional session path. It filters out dead PIDs and types without a registered adapter and never invokes adapter discovery.

The alternative of returning `AgentInfo[]` was rejected because the registry cannot defensibly populate live `status`, `summary`, or `lastActive`. The console adapts snapshots into temporary `AgentInfo` placeholders with `status: unknown`, an empty summary, and explicit cached metadata kept separately in `cachedAgentPids`.

## Reconciliation and Errors

- `useState` uses a lazy synchronous initializer, so the first render cannot await discovery.
- The existing effect immediately calls `listAgents({ sortBy: 'status' })` and keeps the 3000 ms interval and in-flight guard unchanged.
- Successful discovery replaces the entire cached array and clears `cachedAgentPids` in one state update, including an empty result.
- A rejected refresh retains cached rows, clears the refreshing flag, and exposes the error.
- Existing name-based selection remains selected when the registered name survives live reconciliation; otherwise the existing selection effect chooses the first live row or clears selection.

## Merge/Overlap Risk

The responsiveness work landed in `main` first through the preview memoization, input isolation, and split-context changes (#156–#158). This branch was rebased onto that sequence. The resolution preserves its separate agent/channel contexts and adds only `isRefreshing` and `cachedAgentPids` to the agent context, alongside the lazy cached initializer and atomic cache-marker clearing. For backports or alternate merge orders, preserve the responsiveness scheduling/context structure and never reintroduce adapter discovery on the render path.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
phase: implementation
title: Agent Console Fast Initial State Implementation
description: Implementation notes for cached first-frame rendering and live reconciliation
---

# Agent Console Fast Initial State Implementation

## Changed Areas

- `AgentManager.ts` exposes a synchronous, read-only cached identity snapshot filtered by adapter registration and PID liveness.
- `useAgentList.ts` converts snapshots to `unknown` placeholders in its lazy initial state, tracks cached PIDs and initial refreshing state, and clears both atomically on live success.
- `AgentListPane.tsx` marks cached rows, uses cwd rather than a fabricated summary, and keeps refresh errors visible alongside retained cached rows.
- `StatusFooter.tsx` distinguishes cached refresh, cached refresh failure, initial no-cache loading, and normal live update states.
- `PreviewPane.tsx` replaces registry-derived relative time with an explicit cached refresh label until live reconciliation.

## Compatibility

`listAgents()`, registry writes/pruning, sorting, adapter error handling, polling cadence, refresh in-flight suppression, and every non-console caller remain unchanged. No schema or dependency change is introduced.

## Merge Note

The responsiveness changes landed first in `main`. This branch was rebased onto #156–#158; the conflict resolution keeps the split agent/channel contexts and preview memoization, then adds cached-list metadata only to the agent context. Backports should keep that ordering and retain this branch's synchronous snapshot initializer plus atomic live replacement.

## Validation Evidence

- Focused manager contract: 34/34 tests passed.
- Focused console stale-while-revalidate/UI: 19/19 tests passed.
- Full agent-manager: 24 files, 504/504 tests passed (`--maxWorkers=1`; process-identity permission enabled for the existing print integration).
- Full CLI after rebasing onto the split-context changes: 83 files, 974/974 tests passed.
- Agent-manager lint, typecheck, and build passed.
- CLI lint passed with five pre-existing warnings and zero errors; CLI build passed.
- Feature docs lint passed.
- Pull request: https://github.com/codeaholicguy/ai-devkit/pull/162
17 changes: 17 additions & 0 deletions docs/ai/planning/2026-08-14-feature-console-fast-initial-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
phase: planning
title: Agent Console Fast Initial State Plan
description: Test-first implementation tasks for cached first-frame rendering
---

# Agent Console Fast Initial State Plan

- [x] Add a failing manager test for a synchronous, filtered cached identity snapshot.
- [x] Add deterministic failing Ink hook tests using an unresolved live-list promise.
- [x] Implement the additive cached snapshot API without changing `listAgents()`.
- [x] Seed `useAgentList` synchronously and reconcile atomically on live success.
- [x] Add failing UI render tests for cached, refreshing, failed-refresh, and visible-error labels.
- [x] Implement cached list/footer representation.
- [x] Document merge-order and overlap risks with `feature-console-main-thread-responsiveness`.
- [x] Run focused and full agent-manager/CLI tests, lint, builds, and docs lint.
- [x] Review, commit, push, and open PR #162 targeting `main`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
phase: requirements
title: Agent Console Fast Initial State Requirements
description: Render a defensible cached agent list before asynchronous live discovery
---

# Agent Console Fast Initial State Requirements

## Problem

`agent console` starts with an empty/loading list until every adapter finishes discovery. The persistent agent registry already contains useful identities, but it does not contain trustworthy live status, summary, or last-active data.

## Goals

- Render defensible cached agent identities on the console's first frame without awaiting adapter discovery.
- Revalidate immediately and replace the cached list atomically with sorted live results.
- Show that cached rows are cached/refreshing and never claim a live status.
- Preserve registered names, selection behavior, error and empty states, polling cadence, manual refresh, sorting, rename semantics, and non-console `AgentManager.listAgents()` callers.
- Retain cached rows with a visible error if the initial live refresh rejects; remove stale cached rows when a successful live result omits them.

## Constraints

- Cached rows must be limited to registry entries whose PID is currently alive and whose adapter type is registered in this manager.
- No wall-clock freshness threshold is used.
- The change must be additive and focused so it can compose with `feature-console-main-thread-responsiveness`.

## Success Criteria

- A deliberately unresolved `listAgents()` promise does not prevent cached rows from rendering.
- Tests cover successful reconciliation, stale removal, refresh errors, and no-cache loading behavior.
- Focused and full agent-manager/CLI tests, lint, and builds pass.
24 changes: 24 additions & 0 deletions docs/ai/testing/2026-08-14-feature-console-fast-initial-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
phase: testing
title: Agent Console Fast Initial State Testing
description: Deterministic validation matrix for cached first-frame behavior
---

# Agent Console Fast Initial State Testing

## Automated Matrix

- [x] Manager snapshot preserves registered names and metadata.
- [x] Manager snapshot excludes dead PIDs and types without registered adapters.
- [x] Manager snapshot does not call adapter discovery.
- [x] Cached agents render while the live-list promise remains deliberately unresolved.
- [x] Live results replace cached rows atomically and remove omitted stale rows.
- [x] Live refresh rejection retains cached rows and exposes the error.
- [x] No-cache startup preserves the existing empty loading state and reconciles to live empty.
- [x] Cached rows and footer explicitly say cached/refreshing or cached/refresh failed.
- [x] Cached preview metadata never presents registry start time as live activity.
- [x] Full agent-manager and CLI suites (504 and 974 tests respectively after rebase).
- [x] Agent-manager and CLI lint/build.
- [x] Repository docs lint.

Tests coordinate async work through controlled promises and render-state observers. They do not use sleeps, elapsed-time assertions, or freshness thresholds.
36 changes: 36 additions & 0 deletions packages/agent-manager/src/AgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ export interface ListAgentsOptions {
sortBy?: AgentSortKey;
}

/**
* Registry-backed identity that is safe to show while live adapter discovery
* is still in progress. It deliberately omits live-only status and activity
* fields.
*/
export interface CachedAgentSnapshot {
name: string;
type: AgentInfo['type'];
pid: number;
projectPath: string;
startedAt: Date;
sessionId: string;
sessionFilePath?: string;
}

/**
* Agent Manager Class
*
Expand Down Expand Up @@ -105,6 +120,27 @@ export class AgentManager {
return this.adapters.has(type);
}

/**
* Read live registry identities synchronously without invoking adapters.
*
* Rows are limited to registered adapter types and PIDs that currently
* exist. Callers must still treat them as cached: this method does not
* claim a live agent status, summary, or last-active timestamp.
*/
getCachedAgentSnapshot(): CachedAgentSnapshot[] {
return this.registry.list()
.filter(entry => this.adapters.has(entry.type) && this.registry.isAlive(entry))
.map(entry => ({
name: entry.name,
type: entry.type,
pid: entry.pid,
projectPath: entry.cwd,
startedAt: new Date(entry.startedAt),
sessionId: entry.sessionId,
sessionFilePath: entry.sessionFilePath || undefined,
}));
}

/**
* List all running AI agents detected by registered adapters
*
Expand Down
53 changes: 53 additions & 0 deletions packages/agent-manager/src/__tests__/AgentManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,59 @@ describe('AgentManager', () => {
});
});

describe('getCachedAgentSnapshot', () => {
it('returns only live cached identities for registered adapter types without discovery', () => {
const registry = new AgentRegistry(path.join(tmpDir, 'cached-agents.json'));
const scopedManager = new AgentManager(registry);
const adapter = new MockAdapter('claude');
const detectSpy = vi.spyOn(adapter, 'detectAgents');
scopedManager.registerAdapter(adapter);
registry.registerBatch([
{
name: 'registered-name',
type: 'claude',
pid: process.pid,
tmuxSession: 'registered-name',
cwd: '/repo/cached',
startedAt: '2026-08-14T10:00:00.000Z',
sessionId: 'cached-session',
sessionFilePath: '/sessions/cached.jsonl',
},
{
name: 'dead-agent',
type: 'claude',
pid: 999999,
tmuxSession: '',
cwd: '/repo/dead',
startedAt: '2026-08-14T09:00:00.000Z',
sessionId: 'dead-session',
sessionFilePath: '',
},
{
name: 'unsupported-agent',
type: 'codex',
pid: process.pid,
tmuxSession: '',
cwd: '/repo/unsupported',
startedAt: '2026-08-14T08:00:00.000Z',
sessionId: 'unsupported-session',
sessionFilePath: '',
},
]);

expect(scopedManager.getCachedAgentSnapshot()).toEqual([{
name: 'registered-name',
type: 'claude',
pid: process.pid,
projectPath: '/repo/cached',
startedAt: new Date('2026-08-14T10:00:00.000Z'),
sessionId: 'cached-session',
sessionFilePath: '/sessions/cached.jsonl',
}]);
expect(detectSpy).not.toHaveBeenCalled();
});
});

describe('listAgents', () => {
it('should return empty array when no adapters registered', async () => {
const agents = await manager.listAgents();
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-manager/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export { TtyWriter } from './terminal/TtyWriter.js';

export { getProcessTty } from './utils/process.js';
export type { AgentSortKey } from './utils/sortAgents.js';
export type { ListAgentsOptions } from './AgentManager.js';
export type { CachedAgentSnapshot, ListAgentsOptions } from './AgentManager.js';

export { AgentRegistry, RenameNotFoundError, RenameConflictError } from './utils/AgentRegistry.js';
export type { RegistryEntry } from './utils/AgentRegistry.js';
Expand Down
27 changes: 26 additions & 1 deletion packages/cli/src/__tests__/tui/console/AgentListPane.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import React from 'react';
import { renderToString } from 'ink';
import { describe, expect, it, vi } from 'vitest';

vi.mock('@ai-devkit/agent-manager', () => ({
Expand All @@ -9,7 +11,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({
},
}));

import { getAgentChannelMarker } from '../../../tui/console/AgentListPane.js';
import { AgentListPane, getAgentChannelMarker } from '../../../tui/console/AgentListPane.js';

describe('AgentListPane helpers', () => {
it('uses a compact ASCII remote marker for connected agents', () => {
Expand All @@ -19,4 +21,27 @@ describe('AgentListPane helpers', () => {
it('uses blank spacing for disconnected agents', () => {
expect(getAgentChannelMarker(undefined)).toBe(' ');
});

it('marks cached rows and keeps a refresh error visible alongside them', () => {
const output = renderToString(React.createElement(AgentListPane, {
agents: [{
name: 'cached-agent',
type: 'claude',
status: 'unknown',
summary: '',
pid: 42,
projectPath: '/repo/cached',
sessionId: 'cached-session',
lastActive: new Date('2026-08-14T10:00:00.000Z'),
}],
selectedName: 'cached-agent',
onSelect: vi.fn(),
error: 'adapter unavailable',
cachedAgentPids: new Set([42]),
width: 60,
}));

expect(output).toContain('cached · /repo/cached');
expect(output).toContain('adapter unavailable');
});
});
21 changes: 21 additions & 0 deletions packages/cli/src/__tests__/tui/console/PreviewPane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,27 @@ describe('PreviewPane helpers', () => {
expect(output).not.toContain('assistant │ first answer');
});

it('labels cached preview metadata without presenting registry time as live activity', () => {
const agent = {
name: 'cached-preview',
type: 'claude',
status: AgentStatus.UNKNOWN,
projectPath: '/tmp/cached',
lastActive: new Date('2026-08-14T10:00:00.000Z'),
} as AgentInfo;
const output = stripVTControlCharacters(renderToString(React.createElement(PreviewPane, {
agent,
messages: [],
error: null,
isLoading: true,
isCached: true,
isRefreshing: true,
}), { columns: 80 }));

expect(output).toContain('cached · refreshing live state');
expect(output).not.toContain('10:00');
});

it('adjusts positive scroll offsets by newly appended rendered rows', () => {
expect(adjustPreviewScrollOffsetForAppendedRows(5, 7, 2)).toBe(4);
expect(adjustPreviewScrollOffsetForAppendedRows(5, 7, 0)).toBe(0);
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/__tests__/tui/console/StatusFooter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import { renderToString } from 'ink';
import { describe, expect, it } from 'vitest';
import { StatusFooter } from '../../../tui/console/StatusFooter.js';

describe('StatusFooter cached agent state', () => {
it('describes cached rows as refreshing instead of live', () => {
const output = renderToString(React.createElement(StatusFooter, {
agents: [],
lastUpdated: null,
isLoading: true,
isRefreshing: true,
cachedAgentCount: 2,
narrowNote: null,
transient: null,
}));

expect(output).toContain('cached · refreshing live state…');
});

it('describes retained cached rows after refresh failure', () => {
const output = renderToString(React.createElement(StatusFooter, {
agents: [],
lastUpdated: null,
isLoading: false,
isRefreshing: false,
cachedAgentCount: 1,
narrowNote: null,
transient: null,
}));

expect(output).toContain('cached · refresh failed');
});
});
Loading
Loading