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)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user