Files
arboretum/packages/server/test/dialog-detection.test.ts
Johan LEROY 01c6e617f2 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

80 lines
3.8 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 } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ScreenReader } from '../src/core/screen-reader.js';
import { classifyDialog, parseOptions, type ClassifiedDialog } from '../src/core/dialog-classifier.js';
const capturesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'spikes', 's3-tui', 'captures');
/** Rejoue un flux PTY brut par segments (comme l'adapter en prod) et collecte écran+dialogue détecté. */
async function replay(fixture: string): Promise<Array<{ dialog: ClassifiedDialog; text: string }>> {
const data = readFileSync(join(capturesDir, fixture));
const reader = new ScreenReader(120, 40);
const seen: Array<{ dialog: ClassifiedDialog; text: string }> = [];
const STEP = 512;
for (let i = 0; i < data.length; i += STEP) {
await reader.feed(data.subarray(i, i + STEP)); // xterm gère les séquences coupées en frontière
const lines = reader.snapshotLines();
const d = classifyDialog(lines);
if (d) seen.push({ dialog: d, text: lines.join('\n') });
}
reader.dispose();
return seen;
}
describe('ScreenReader (@xterm/headless) — anti strip-ANSI naïf', () => {
it('préserve les espaces du dialogue (« Do you want to create », pas « Doyouwant »)', async () => {
const seen = await replay('perm-write2.raw.log');
expect(seen.length).toBeGreaterThan(0);
expect(seen.some((s) => /Do you want to create/.test(s.text))).toBe(true);
});
});
describe('classifyDialog — unitaire (lignes synthétiques)', () => {
it('trust', () => {
const d = classifyDialog(['Do you trust the files in this folder?', ' 1. Yes, I trust this folder', '2. No, exit']);
expect(d?.kind).toBe('trust');
expect(d?.options).toHaveLength(2);
expect(d?.options[0]).toMatchObject({ n: 1, selected: true });
});
it('permission', () => {
const d = classifyDialog(['Do you want to create s3-edit.txt?', ' 1. Yes', '3. No', 'Esc to cancel · Tab to amend']);
expect(d?.kind).toBe('permission');
});
it('question (prime sur permission malgré « Esc to cancel »)', () => {
const d = classifyDialog(['☐ Couleur', ' 1. Rouge', '2. Bleu', 'Enter to select · ↑/↓ to navigate · Esc to cancel']);
expect(d?.kind).toBe('question');
});
it('aucun dialogue → null', () => {
expect(classifyDialog(['just some output', 'no options here'])).toBeNull();
});
it('parseOptions tolère « 2) Label » et trim', () => {
expect(parseOptions([' 2) Yes, allow all '])).toEqual([{ n: 2, label: 'Yes, allow all', selected: true }]);
});
});
describe('détection sur fixtures réelles S3 (replay headless)', () => {
it('perm-write2 → permission avec options Yes/…/No, option 1 sélectionnée', async () => {
const perms = (await replay('perm-write2.raw.log')).filter((s) => s.dialog.kind === 'permission');
expect(perms.length).toBeGreaterThan(0);
const withOpts = perms.find((s) => s.dialog.options.length >= 2);
expect(withOpts).toBeDefined();
const labels = withOpts!.dialog.options.map((o) => o.label).join(' | ');
expect(labels).toMatch(/Yes/);
expect(labels).toMatch(/No/);
expect(withOpts!.dialog.options.find((o) => o.n === 1)?.selected).toBe(true);
});
it('ask2 → question avec une option « Rouge »', async () => {
const qs = (await replay('ask2.raw.log')).filter((s) => s.dialog.kind === 'question');
expect(qs.length).toBeGreaterThan(0);
expect(qs.some((s) => s.dialog.options.some((o) => /Rouge/.test(o.label)))).toBe(true);
});
it('trust → dialogue trust détecté', async () => {
const seen = await replay('trust.raw.log');
expect(seen.some((s) => s.dialog.kind === 'trust')).toBe(true);
});
});