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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 l’option 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
Reference in New Issue
Block a user