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:
@@ -166,6 +166,10 @@ export type ClientMessage =
|
||||
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
|
||||
| { type: 'detach'; channel: number }
|
||||
| { type: 'stdin'; channel: number; data: string }
|
||||
// P4-A : répondre à un dialogue Claude sans clavier. Le serveur traduit l'intention
|
||||
// en keystrokes (chiffre+Entrée pour 'select', Entrée pour 'confirm', Esc pour 'deny')
|
||||
// et valide l'option contre le dialogue courant (anti-frappe fantôme mobile).
|
||||
| { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number }
|
||||
| { type: 'resize'; channel: number; cols: number; rows: number }
|
||||
| { type: 'ack'; channel: number; bytes: number }
|
||||
| { type: 'sub'; topics: Array<'sessions' | 'worktrees'> }
|
||||
@@ -193,6 +197,7 @@ export type ErrorCode =
|
||||
| 'NOT_ATTACHED'
|
||||
| 'NOT_CONTROLLING'
|
||||
| 'SESSION_EXITED'
|
||||
| 'INVALID_ANSWER'
|
||||
| 'INTERNAL';
|
||||
|
||||
export function parseClientMessage(raw: string): ClientMessage | null {
|
||||
@@ -220,6 +225,16 @@ export function parseClientMessage(raw: string): ClientMessage | null {
|
||||
return isU32(m.channel) && typeof m.data === 'string' && m.data.length <= 65536
|
||||
? { type: 'stdin', channel: m.channel, data: m.data }
|
||||
: null;
|
||||
case 'answer': {
|
||||
if (!isU32(m.channel)) return null;
|
||||
if (m.action === 'select')
|
||||
return isCount(m.optionN) && (m.optionN as number) >= 1 && (m.optionN as number) <= 99
|
||||
? { type: 'answer', channel: m.channel, action: 'select', optionN: m.optionN as number }
|
||||
: null;
|
||||
return m.action === 'confirm' || m.action === 'deny'
|
||||
? { type: 'answer', channel: m.channel, action: m.action }
|
||||
: null;
|
||||
}
|
||||
case 'resize':
|
||||
return isU32(m.channel) && isDim(m.cols) && isDim(m.rows)
|
||||
? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows }
|
||||
|
||||
@@ -42,6 +42,15 @@ function assertInvariants(msg: ClientMessage): void {
|
||||
expect(typeof msg.data).toBe('string');
|
||||
expect(msg.data.length).toBeLessThanOrEqual(65536);
|
||||
break;
|
||||
case 'answer':
|
||||
expect(isU32(msg.channel)).toBe(true);
|
||||
expect(['select', 'confirm', 'deny']).toContain(msg.action);
|
||||
if (msg.action === 'select') {
|
||||
expect(typeof msg.optionN).toBe('number');
|
||||
expect(msg.optionN).toBeGreaterThanOrEqual(1);
|
||||
expect(msg.optionN).toBeLessThanOrEqual(99);
|
||||
}
|
||||
break;
|
||||
case 'resize':
|
||||
expect(isU32(msg.channel)).toBe(true);
|
||||
expect(isDim(msg.cols)).toBe(true);
|
||||
@@ -92,6 +101,18 @@ describe('parseClientMessage — cas valides', () => {
|
||||
.toEqual({ type: 'resize', channel: 3, cols: 120, rows: 32 });
|
||||
});
|
||||
|
||||
it('answer select / confirm / deny', () => {
|
||||
expect(parseClientMessage('{"type":"answer","channel":2,"action":"select","optionN":3}'))
|
||||
.toEqual({ type: 'answer', channel: 2, action: 'select', optionN: 3 });
|
||||
expect(parseClientMessage('{"type":"answer","channel":2,"action":"confirm"}'))
|
||||
.toEqual({ type: 'answer', channel: 2, action: 'confirm' });
|
||||
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny"}'))
|
||||
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
|
||||
// optionN n'est porté que par 'select' (ignoré pour confirm/deny)
|
||||
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny","optionN":3}'))
|
||||
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
|
||||
});
|
||||
|
||||
it('ack à zéro octet', () => {
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
|
||||
});
|
||||
@@ -153,6 +174,15 @@ describe('parseClientMessage — cas malformés', () => {
|
||||
expect(parseClientMessage('{"type":"stdin","channel":1}')).toBeNull();
|
||||
});
|
||||
|
||||
it('answer : action inconnue, optionN manquant/hors bornes, channel invalide', () => {
|
||||
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select"}')).toBeNull(); // optionN requis
|
||||
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":0}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":100}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":1.5}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"answer","channel":1,"action":"nope"}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"answer","channel":-1,"action":"deny"}')).toBeNull();
|
||||
});
|
||||
|
||||
it('ack : bytes négatif ou non numérique', () => {
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull();
|
||||
@@ -168,16 +198,16 @@ describe('parseClientMessage — cas malformés', () => {
|
||||
describe('parseClientMessage — fuzz rapide', () => {
|
||||
it('ne lève jamais et tout message accepté respecte les invariants', () => {
|
||||
const rand = mulberry32(0xa5b0e7);
|
||||
const types = ['hello', 'attach', 'detach', 'stdin', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
|
||||
const types = ['hello', 'attach', 'detach', 'stdin', 'answer', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
|
||||
const values: unknown[] = [
|
||||
undefined, null, true, false, 0, -1, 1, 1.5, 2, 999, 1000, 1001, 0xffffffff, 0x100000000,
|
||||
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', {}, [], ['sessions'],
|
||||
undefined, null, true, false, 0, -1, 1, 1.5, 2, 99, 100, 999, 1000, 1001, 0xffffffff, 0x100000000,
|
||||
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', 'select', 'confirm', 'deny', {}, [], ['sessions'],
|
||||
['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000),
|
||||
];
|
||||
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const obj: Record<string, unknown> = { type: pick(types) };
|
||||
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics']) {
|
||||
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics', 'action', 'optionN']) {
|
||||
if (rand() < 0.7) obj[key] = pick(values);
|
||||
}
|
||||
const raw = JSON.stringify(obj);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user