Files
arboretum/packages/server/test/claude-adapter.test.ts
Johan LEROY 33a41e7a30 P3-B: claude-adapter — états fins busy/waiting/idle
Détection de l'état fin d'une session claude et du dialogue en cours, pour
savoir « quelle session attend une réponse ». Source primaire = registre
~/.claude/sessions (stable) ; l'écran reconstruit via @xterm/headless TYPE le
dialogue (trust/permission/question/plan) et couvre le cas Trust (avant registre).

- dép: @xterm/headless ^6.0.0 (aligné sur @xterm/xterm du front).
- core/screen-reader.ts: terminal headless persistant par session, alimenté
  par le flux PTY ; snapshotLines() préserve les espaces (bat le strip ANSI naïf).
- core/dialog-classifier.ts: typage pur (texte aplati + regex tolérante inter-
  versions) + extraction des options numérotées (❯ = sélection).
- core/claude-adapter.ts: SessionActivityTracker par session (registre + écran
  → activity + dialog), évaluation débouncée sur output + poll registre léger,
  émission sur changement effectif uniquement.
- shared: SessionSummary += activity?/waitingFor?/dialog? (optionnels, additif).
- pty-manager: tracker instancié pour claude, feed dans handleOutput, summarize
  enrichi, resize propagé, dispose à l'exit.
- web: composant SessionStateBadge (busy/waiting/idle colorés) réutilisable.
- tests: screen-reader + classifier sur fixtures réelles S3 (perm-write/ask/
  trust), claude-adapter (machine à états). 159 verts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:39:38 +02:00

75 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SessionActivityTracker } from '../src/core/claude-adapter.js';
const PID = 999_999;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
function writeRegistry(dir: string, status: string, waitingFor?: string): void {
writeFileSync(
join(dir, `${PID}.json`),
JSON.stringify({ pid: PID, procStart: '123', sessionId: 'sid', cwd: '/x', status, ...(waitingFor ? { waitingFor } : {}) }),
);
}
describe('SessionActivityTracker', () => {
let dir: string;
let tracker: SessionActivityTracker;
let changes: number;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arb-adapter-'));
changes = 0;
tracker = new SessionActivityTracker(PID, dir, () => {
changes++;
});
});
afterEach(() => {
tracker.dispose();
rmSync(dir, { recursive: true, force: true });
});
it('registre busy → idle : transitions dactivité + onChange sur changement uniquement', () => {
writeRegistry(dir, 'busy');
tracker.evaluate();
expect(tracker.snapshot().activity).toBe('busy');
expect(changes).toBe(1);
tracker.evaluate(); // rien n'a changé
expect(changes).toBe(1);
writeRegistry(dir, 'idle');
tracker.evaluate();
expect(tracker.snapshot().activity).toBe('idle');
expect(changes).toBe(2);
});
it('registre waiting + écran permission → activity waiting, dialog typé permission', async () => {
await tracker['reader'].feed('\x1b[2J\x1b[HDo you want to create x.txt?\r\n 1. Yes\r\n2. No\r\nEsc to cancel · Tab to amend\r\n');
await sleep(30);
writeRegistry(dir, 'waiting', 'permission prompt');
tracker.evaluate();
const s = tracker.snapshot();
expect(s.activity).toBe('waiting');
expect(s.waitingFor).toBe('permission prompt');
expect(s.dialog?.kind).toBe('permission');
expect(s.dialog?.options.find((o) => o.n === 1)).toMatchObject({ selected: true });
});
it('pas de registre + écran Trust → waiting/trust (le Trust précède le registre)', async () => {
await tracker['reader'].feed('\x1b[2J\x1b[HDo you trust the files in this folder?\r\n 1. Yes, I trust this folder\r\n2. No, exit\r\n');
await sleep(30);
tracker.evaluate();
const s = tracker.snapshot();
expect(s.activity).toBe('waiting');
expect(s.dialog?.kind).toBe('trust');
});
it('pas de registre + pas de dialogue → état vide (inconnu)', () => {
tracker.evaluate();
expect(tracker.snapshot()).toMatchObject({ activity: null, dialog: null });
});
});