P4-A: commande WS answer — répondre aux dialogues sans clavier

Nouvelle commande WS de haut niveau `answer {channel, action, optionN?}`
traduite côté serveur en keystrokes (chiffre+Entrée / Entrée / Esc) et
validée contre le dialogue courant du tracker (anti-frappe fantôme mobile).

- protocol.ts : message `answer` + validation parseClientMessage + ErrorCode INVALID_ANSWER
- pty-manager.answer() : mappe l'intention en write PTY, réutilise le modèle mono-utilisateur
- gateway : case `answer` (NOT_CONTROLLING / SESSION_EXITED / INVALID_ANSWER)
- web : Attachment.answer(), DialogPrompt.vue (attache interactive légère sink no-op),
  intégré dans SessionView, SessionsListView, WorktreeCard + i18n en/fr
- tests : parseClientMessage (answer valide/malformé + fuzz), pty-manager.answer (163 verts)
This commit is contained in:
2026-06-15 12:05:27 +02:00
parent 49a31d999b
commit 25d6d09d24
14 changed files with 265 additions and 6 deletions

View File

@@ -256,6 +256,36 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
return 'ok';
}
/**
* Répond à un dialogue Claude sans clavier (P4-A) : traduit une intention de haut
* niveau en keystrokes PTY, validée contre l'état fin du tracker (P3-B).
* - 'select' N : positionne le curseur sur l'option N puis confirme (`"N\r"`) — protocole acté spike S3.
* - 'confirm' : valide l'option pré-sélectionnée (`"\r"`).
* - 'deny' : refus universel (Esc).
* Réutilise le chemin write (mono-utilisateur : tout interactif peut répondre, observers non).
*/
answer(
sessionId: string,
binding: ClientBinding,
action: 'select' | 'confirm' | 'deny',
optionN?: number,
): 'ok' | 'not_controlling' | 'gone' | 'invalid' {
const s = this.live.get(sessionId);
if (!s || s.exited) return 'gone';
if (binding.mode !== 'interactive') return 'not_controlling';
const act = s.tracker?.snapshot();
if (action === 'select') {
// L'option doit exister dans le dialogue courant (anti-frappe fantôme mobile).
if (!act?.dialog?.options.some((o) => o.n === optionN)) return 'invalid';
s.proc.write(`${optionN}\r`);
return 'ok';
}
// confirm/deny n'exigent qu'un état d'attente (le dialogue Trust précède le registre — S1).
if (act?.activity !== 'waiting') return 'invalid';
s.proc.write(action === 'deny' ? '\x1b' : '\r');
return 'ok';
}
resize(sessionId: string, binding: ClientBinding, cols: number, rows: number): void {
const s = this.live.get(sessionId);
if (!s || s.exited || !binding.controlling) return;

View File

@@ -161,6 +161,22 @@ export function registerWsGateway(
}
return;
}
case 'answer': {
const st = channels.get(msg.channel);
if (!st) {
send({ type: 'error', code: 'NOT_ATTACHED', message: 'Unknown channel', channel: msg.channel });
return;
}
const r = manager.answer(st.sessionId, st.binding, msg.action, msg.optionN);
if (r === 'not_controlling') {
send({ type: 'error', code: 'NOT_CONTROLLING', message: 'Observer mode is read-only', channel: msg.channel });
} else if (r === 'gone') {
send({ type: 'error', code: 'SESSION_EXITED', message: 'Session has exited', channel: msg.channel });
} else if (r === 'invalid') {
send({ type: 'error', code: 'INVALID_ANSWER', message: 'No such option in the current dialog', channel: msg.channel });
}
return;
}
case 'resize': {
const st = channels.get(msg.channel);
if (st) manager.resize(st.sessionId, st.binding, msg.cols, msg.rows);

View File

@@ -73,6 +73,8 @@ type BindingSpies = ClientBinding & {
onControlChanged: Mock<(controlling: boolean) => void>;
};
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
let channelSeq = 1;
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
return {
@@ -290,6 +292,58 @@ describe('PtyManager (pty mocké)', () => {
});
});
describe('answer (P4-A)', () => {
it('select → "N\\r" si loption existe, deny → Esc ; rejets gone/not_controlling/invalid', async () => {
const sessDir = mkdtempSync(join(tmpdir(), 'arb-answer-'));
const m = new PtyManager(db, sessDir);
try {
const summary = m.spawn({ cwd, command: 'claude' });
const p = lastPty();
// écran : dialogue de permission à 2 options (la 1re est sélectionnée )
p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n 1. Yes\r\n2. No\r\nEsc to cancel\r\n');
writeFileSync(
join(sessDir, `${p.pid}.json`),
JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status: 'waiting', waitingFor: 'permission prompt' }),
);
await sleep(260); // laisse le screen reader + le debounce (200ms) du tracker établir létat
const a = makeBinding('interactive');
m.attach(summary.id, a, 80, 24);
p.write.mockClear();
// option inexistante → invalid (anti-frappe fantôme), aucun write
expect(m.answer(summary.id, a, 'select', 9)).toBe('invalid');
expect(p.write).not.toHaveBeenCalled();
// option existante → "1\r"
expect(m.answer(summary.id, a, 'select', 1)).toBe('ok');
expect(p.write).toHaveBeenCalledWith('1\r');
// deny → Esc
expect(m.answer(summary.id, a, 'deny')).toBe('ok');
expect(p.write).toHaveBeenCalledWith('\x1b');
// observer read-only
const obs = makeBinding('observer');
m.attach(summary.id, obs, 80, 24);
expect(m.answer(summary.id, obs, 'deny')).toBe('not_controlling');
// session inconnue → gone
expect(m.answer('nope', a, 'deny')).toBe('gone');
} finally {
m.shutdown();
rmSync(sessDir, { recursive: true, force: true });
}
});
it('session sans état waiting (bash, pas de tracker) → invalid', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
pty.write.mockClear();
expect(manager.answer(summary.id, a, 'deny')).toBe('invalid');
expect(manager.answer(summary.id, a, 'select', 1)).toBe('invalid');
expect(pty.write).not.toHaveBeenCalled();
});
});
describe('flow control', () => {
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
const { summary, pty } = spawnBash();

File diff suppressed because one or more lines are too long