Skip to main content

phi_ext/pxb/
msg.rs

1//! Typed message payloads, encoded as tagged fields (see `fields.rs`).
2//!
3//! Every struct mirrors `ext/go/pxb` one-to-one so the two SDKs interop at
4//! the byte level. Encode field order matches Go's writers — golden tests in
5//! `tests/pxb_test.rs` pin the bytes against `ext/go/pxb/testdata/*.bin`.
6
7use super::codec::Error;
8use super::fields::{walk_fields, FieldReader, FieldWriter, WIRE_BYTES, WIRE_U64};
9
10// Field tags, namespaced per message type. Ranges: 1–63 message-defined,
11// 64–127 reserved, 128+ experimental (must remain skippable).
12const F_HELLO_NAME: u16 = 1;
13const F_HELLO_VERSION: u16 = 2;
14const F_HELLO_CAPS: u16 = 3;
15const F_HELLO_PROTOCOL: u16 = 4;
16
17const F_ACK_PROTOCOL: u16 = 1;
18const F_ACK_PHI_VERSION: u16 = 2;
19const F_ACK_CWD: u16 = 3;
20const F_ACK_SESSION_ID: u16 = 4;
21const F_ACK_EXT_DIR: u16 = 5;
22
23const F_REG_CMD_NAME: u16 = 1;
24const F_REG_CMD_DESC: u16 = 2;
25
26const F_REG_TOOL_NAME: u16 = 1;
27const F_REG_TOOL_DESC: u16 = 2;
28const F_REG_TOOL_SCHEMA: u16 = 3;
29const F_REG_TOOL_TIMEOUT_SEC: u16 = 4;
30
31const F_SUB_EVENTS: u16 = 1;
32const F_SUB_INTERCEPT: u16 = 2;
33
34const F_CMD_INV_NAME: u16 = 1;
35const F_CMD_INV_ARGS: u16 = 2;
36
37const F_CMD_RES_OK: u16 = 1;
38const F_CMD_RES_ERROR: u16 = 2;
39const F_CMD_RES_NOTIFY: u16 = 3;
40const F_CMD_RES_SUBMIT: u16 = 4;
41
42const F_TOOL_INV_NAME: u16 = 1;
43const F_TOOL_INV_ARGS: u16 = 2;
44
45const F_TOOL_RES_CONTENT: u16 = 1;
46const F_TOOL_RES_DETAIL: u16 = 2;
47const F_TOOL_RES_OUTPUT: u16 = 3;
48const F_TOOL_RES_IS_ERROR: u16 = 4;
49const F_TOOL_RES_ERROR: u16 = 5;
50
51const F_IX_REQ_EVENT: u16 = 1;
52const F_IX_REQ_TOOL_NAME: u16 = 2;
53const F_IX_REQ_TOOL_CALL_ID: u16 = 3;
54const F_IX_REQ_INPUT: u16 = 4;
55const F_IX_REQ_CONTENT: u16 = 5;
56const F_IX_REQ_IS_ERROR: u16 = 6;
57const F_IX_REQ_ERR_TEXT: u16 = 7;
58const F_IX_REQ_PROMPT: u16 = 8;
59const F_IX_REQ_REASON: u16 = 9;
60const F_IX_REQ_TARGET_ID: u16 = 10;
61const F_IX_REQ_TURN_INDEX: u16 = 11;
62
63const F_IX_RES_BLOCK: u16 = 1;
64const F_IX_RES_STOP: u16 = 2;
65const F_IX_RES_CANCEL: u16 = 3;
66const F_IX_RES_REASON: u16 = 4;
67const F_IX_RES_INPUT: u16 = 5;
68const F_IX_RES_CONTENT: u16 = 6;
69const F_IX_RES_CONTEXT: u16 = 7;
70const F_IX_RES_SYS_APPEND: u16 = 8;
71const F_IX_RES_TOAST: u16 = 9;
72const F_IX_RES_HANDLED: u16 = 10;
73const F_IX_RES_PROMPT: u16 = 11;
74const F_IX_RES_CONTINUE: u16 = 12;
75
76const F_EV_EVENT: u16 = 1;
77const F_EV_TOOL_NAME: u16 = 2;
78const F_EV_TOOL_CALL_ID: u16 = 3;
79const F_EV_INPUT: u16 = 4;
80const F_EV_IS_ERROR: u16 = 5;
81const F_EV_PROMPT: u16 = 6;
82const F_EV_REASON: u16 = 7;
83const F_EV_TURN_INDEX: u16 = 8;
84const F_EV_SESSION_ID: u16 = 9;
85const F_EV_PREVIOUS_SESSION_ID: u16 = 10;
86const F_EV_TARGET_SESSION_ID: u16 = 11;
87
88const F_NOTIFY_LEVEL: u16 = 1;
89const F_NOTIFY_MESSAGE: u16 = 2;
90const F_NOTIFY_STATUS: u16 = 3;
91const F_NOTIFY_STATUS_SET: u16 = 4;
92
93const F_HOST_REQ_METHOD: u16 = 1;
94const F_HOST_REQ_ARG: u16 = 2;
95
96const F_HOST_RES_OK: u16 = 1;
97const F_HOST_RES_ERROR: u16 = 2;
98const F_HOST_RES_BODY: u16 = 3;
99
100const F_META_SESSION_ID: u16 = 1;
101const F_META_CWD: u16 = 2;
102
103fn take_u64(kind: u8, fr: &mut FieldReader<'_>) -> Result<u64, Error> {
104    if kind != WIRE_U64 {
105        fr.skip(kind)?;
106        return Err(Error::BadWire);
107    }
108    fr.u64()
109}
110
111fn take_bytes<'a>(kind: u8, fr: &mut FieldReader<'a>) -> Result<&'a [u8], Error> {
112    if kind != WIRE_BYTES {
113        fr.skip(kind)?;
114        return Err(Error::BadWire);
115    }
116    fr.bytes()
117}
118
119/// Wire strings are byte blobs; text fields surface as UTF-8 with invalid
120/// sequences replaced (Go keeps raw bytes, which is unusable for `String`).
121fn take_string(kind: u8, fr: &mut FieldReader<'_>) -> Result<String, Error> {
122    Ok(String::from_utf8_lossy(take_bytes(kind, fr)?).into_owned())
123}
124
125/// The first frame from an extension.
126#[derive(Debug, Clone, PartialEq, Eq, Default)]
127pub struct Hello {
128    pub name: String,
129    pub version: String,
130    pub caps: u32,
131    pub protocol: u16,
132}
133
134pub fn encode_hello(h: &Hello) -> Vec<u8> {
135    let mut fw = FieldWriter::new();
136    fw.put_string(F_HELLO_NAME, &h.name);
137    fw.put_string(F_HELLO_VERSION, &h.version);
138    fw.put_u32(F_HELLO_CAPS, h.caps);
139    fw.put_u16(F_HELLO_PROTOCOL, h.protocol);
140    fw.into_vec()
141}
142
143pub fn decode_hello(b: &[u8]) -> Result<Hello, Error> {
144    let mut h = Hello::default();
145    walk_fields(b, |tag, kind, fr| {
146        match tag {
147            F_HELLO_NAME => h.name = take_string(kind, fr)?,
148            F_HELLO_VERSION => h.version = take_string(kind, fr)?,
149            F_HELLO_CAPS => h.caps = take_u64(kind, fr)? as u32,
150            F_HELLO_PROTOCOL => h.protocol = take_u64(kind, fr)? as u16,
151            _ => fr.skip(kind)?,
152        }
153        Ok(())
154    })?;
155    Ok(h)
156}
157
158/// The host reply to `Hello`.
159#[derive(Debug, Clone, PartialEq, Eq, Default)]
160pub struct HelloAck {
161    pub protocol: u16,
162    pub phi_version: String,
163    pub cwd: String,
164    pub session_id: String,
165    pub extension_dir: String,
166}
167
168pub fn encode_hello_ack(h: &HelloAck) -> Vec<u8> {
169    let mut fw = FieldWriter::new();
170    fw.put_u16(F_ACK_PROTOCOL, h.protocol);
171    fw.put_string(F_ACK_PHI_VERSION, &h.phi_version);
172    fw.put_string(F_ACK_CWD, &h.cwd);
173    fw.put_string(F_ACK_SESSION_ID, &h.session_id);
174    fw.put_string(F_ACK_EXT_DIR, &h.extension_dir);
175    fw.into_vec()
176}
177
178pub fn decode_hello_ack(b: &[u8]) -> Result<HelloAck, Error> {
179    let mut h = HelloAck::default();
180    walk_fields(b, |tag, kind, fr| {
181        match tag {
182            F_ACK_PROTOCOL => h.protocol = take_u64(kind, fr)? as u16,
183            F_ACK_PHI_VERSION => h.phi_version = take_string(kind, fr)?,
184            F_ACK_CWD => h.cwd = take_string(kind, fr)?,
185            F_ACK_SESSION_ID => h.session_id = take_string(kind, fr)?,
186            F_ACK_EXT_DIR => h.extension_dir = take_string(kind, fr)?,
187            _ => fr.skip(kind)?,
188        }
189        Ok(())
190    })?;
191    Ok(h)
192}
193
194/// Registers a slash command.
195#[derive(Debug, Clone, PartialEq, Eq, Default)]
196pub struct RegisterCommand {
197    pub name: String,
198    pub description: String,
199}
200
201pub fn encode_register_command(r: &RegisterCommand) -> Vec<u8> {
202    let mut fw = FieldWriter::new();
203    fw.put_string(F_REG_CMD_NAME, &r.name);
204    fw.put_string(F_REG_CMD_DESC, &r.description);
205    fw.into_vec()
206}
207
208pub fn decode_register_command(b: &[u8]) -> Result<RegisterCommand, Error> {
209    let mut r = RegisterCommand::default();
210    walk_fields(b, |tag, kind, fr| {
211        match tag {
212            F_REG_CMD_NAME => r.name = take_string(kind, fr)?,
213            F_REG_CMD_DESC => r.description = take_string(kind, fr)?,
214            _ => fr.skip(kind)?,
215        }
216        Ok(())
217    })?;
218    Ok(r)
219}
220
221/// Registers an LLM tool; `schema_json` is opaque JSON Schema bytes.
222#[derive(Debug, Clone, PartialEq, Eq, Default)]
223pub struct RegisterTool {
224    pub name: String,
225    pub description: String,
226    pub schema_json: Vec<u8>,
227    /// Host RPC wait for this tool's result, in seconds. `0` omits the field
228    /// (host default). Host clamps to a maximum.
229    pub timeout_sec: u32,
230}
231
232pub fn encode_register_tool(r: &RegisterTool) -> Vec<u8> {
233    let mut fw = FieldWriter::new();
234    fw.put_string(F_REG_TOOL_NAME, &r.name);
235    fw.put_string(F_REG_TOOL_DESC, &r.description);
236    fw.put_bytes(F_REG_TOOL_SCHEMA, &r.schema_json);
237    if r.timeout_sec > 0 {
238        fw.put_u32(F_REG_TOOL_TIMEOUT_SEC, r.timeout_sec);
239    }
240    fw.into_vec()
241}
242
243pub fn decode_register_tool(b: &[u8]) -> Result<RegisterTool, Error> {
244    let mut r = RegisterTool::default();
245    walk_fields(b, |tag, kind, fr| {
246        match tag {
247            F_REG_TOOL_NAME => r.name = take_string(kind, fr)?,
248            F_REG_TOOL_DESC => r.description = take_string(kind, fr)?,
249            F_REG_TOOL_SCHEMA => r.schema_json = take_bytes(kind, fr)?.to_vec(),
250            F_REG_TOOL_TIMEOUT_SEC => r.timeout_sec = take_u64(kind, fr)? as u32,
251            _ => fr.skip(kind)?,
252        }
253        Ok(())
254    })?;
255    Ok(r)
256}
257
258/// Declares event / intercept interests.
259#[derive(Debug, Clone, PartialEq, Eq, Default)]
260pub struct Subscribe {
261    pub events: Vec<u16>,
262    pub intercept: Vec<u16>,
263}
264
265pub fn encode_subscribe(s: &Subscribe) -> Vec<u8> {
266    let mut fw = FieldWriter::new();
267    fw.put_u16s(F_SUB_EVENTS, &s.events);
268    fw.put_u16s(F_SUB_INTERCEPT, &s.intercept);
269    fw.into_vec()
270}
271
272pub fn decode_subscribe(b: &[u8]) -> Result<Subscribe, Error> {
273    let mut s = Subscribe::default();
274    walk_fields(b, |tag, kind, fr| {
275        match tag {
276            F_SUB_EVENTS => s.events = decode_u16s(take_bytes(kind, fr)?)?,
277            F_SUB_INTERCEPT => s.intercept = decode_u16s(take_bytes(kind, fr)?)?,
278            _ => fr.skip(kind)?,
279        }
280        Ok(())
281    })?;
282    Ok(s)
283}
284
285fn decode_u16s(p: &[u8]) -> Result<Vec<u16>, Error> {
286    // Inner format is plain ByteReader: u16 count + u16 values (no tags).
287    if p.len() < 2 {
288        return Err(Error::Truncated);
289    }
290    let n = u16::from_le_bytes([p[0], p[1]]) as usize;
291    if p.len() < 2 + n * 2 {
292        return Err(Error::Truncated);
293    }
294    let mut out = Vec::with_capacity(n);
295    for i in 0..n {
296        let off = 2 + i * 2;
297        out.push(u16::from_le_bytes([p[off], p[off + 1]]));
298    }
299    Ok(out)
300}
301
302/// Host→ext when the user runs a slash command.
303#[derive(Debug, Clone, PartialEq, Eq, Default)]
304pub struct CommandInvoked {
305    pub name: String,
306    pub args: String,
307}
308
309pub fn encode_command_invoked(c: &CommandInvoked) -> Vec<u8> {
310    let mut fw = FieldWriter::new();
311    fw.put_string(F_CMD_INV_NAME, &c.name);
312    fw.put_string(F_CMD_INV_ARGS, &c.args);
313    fw.into_vec()
314}
315
316pub fn decode_command_invoked(b: &[u8]) -> Result<CommandInvoked, Error> {
317    let mut c = CommandInvoked::default();
318    walk_fields(b, |tag, kind, fr| {
319        match tag {
320            F_CMD_INV_NAME => c.name = take_string(kind, fr)?,
321            F_CMD_INV_ARGS => c.args = take_string(kind, fr)?,
322            _ => fr.skip(kind)?,
323        }
324        Ok(())
325    })?;
326    Ok(c)
327}
328
329/// Ext→host slash command outcome.
330#[derive(Debug, Clone, PartialEq, Eq, Default)]
331pub struct CommandResponse {
332    pub ok: bool,
333    pub error: String,
334    pub notify: String,
335    pub submit: String,
336}
337
338pub fn encode_command_response(c: &CommandResponse) -> Vec<u8> {
339    let mut fw = FieldWriter::new();
340    fw.put_bool(F_CMD_RES_OK, c.ok);
341    fw.put_string(F_CMD_RES_ERROR, &c.error);
342    fw.put_string(F_CMD_RES_NOTIFY, &c.notify);
343    fw.put_string(F_CMD_RES_SUBMIT, &c.submit);
344    fw.into_vec()
345}
346
347pub fn decode_command_response(b: &[u8]) -> Result<CommandResponse, Error> {
348    let mut c = CommandResponse::default();
349    walk_fields(b, |tag, kind, fr| {
350        match tag {
351            F_CMD_RES_OK => c.ok = take_u64(kind, fr)? != 0,
352            F_CMD_RES_ERROR => c.error = take_string(kind, fr)?,
353            F_CMD_RES_NOTIFY => c.notify = take_string(kind, fr)?,
354            F_CMD_RES_SUBMIT => c.submit = take_string(kind, fr)?,
355            _ => fr.skip(kind)?,
356        }
357        Ok(())
358    })?;
359    Ok(c)
360}
361
362/// Host→ext for a registered tool.
363#[derive(Debug, Clone, PartialEq, Eq, Default)]
364pub struct ToolInvoke {
365    pub name: String,
366    pub args: Vec<u8>,
367}
368
369pub fn encode_tool_invoke(t: &ToolInvoke) -> Vec<u8> {
370    let mut fw = FieldWriter::new();
371    fw.put_string(F_TOOL_INV_NAME, &t.name);
372    fw.put_bytes(F_TOOL_INV_ARGS, &t.args);
373    fw.into_vec()
374}
375
376pub fn decode_tool_invoke(b: &[u8]) -> Result<ToolInvoke, Error> {
377    let mut t = ToolInvoke::default();
378    walk_fields(b, |tag, kind, fr| {
379        match tag {
380            F_TOOL_INV_NAME => t.name = take_string(kind, fr)?,
381            F_TOOL_INV_ARGS => t.args = take_bytes(kind, fr)?.to_vec(),
382            _ => fr.skip(kind)?,
383        }
384        Ok(())
385    })?;
386    Ok(t)
387}
388
389/// Ext→host tool outcome.
390#[derive(Debug, Clone, PartialEq, Eq, Default)]
391pub struct ToolResultMsg {
392    pub content: String,
393    pub detail: String,
394    pub output: String,
395    pub is_error: bool,
396    pub error: String,
397}
398
399pub fn encode_tool_result(t: &ToolResultMsg) -> Vec<u8> {
400    let mut fw = FieldWriter::new();
401    fw.put_string(F_TOOL_RES_CONTENT, &t.content);
402    fw.put_string(F_TOOL_RES_DETAIL, &t.detail);
403    fw.put_string(F_TOOL_RES_OUTPUT, &t.output);
404    fw.put_bool(F_TOOL_RES_IS_ERROR, t.is_error);
405    fw.put_string(F_TOOL_RES_ERROR, &t.error);
406    fw.into_vec()
407}
408
409pub fn decode_tool_result(b: &[u8]) -> Result<ToolResultMsg, Error> {
410    let mut t = ToolResultMsg::default();
411    walk_fields(b, |tag, kind, fr| {
412        match tag {
413            F_TOOL_RES_CONTENT => t.content = take_string(kind, fr)?,
414            F_TOOL_RES_DETAIL => t.detail = take_string(kind, fr)?,
415            F_TOOL_RES_OUTPUT => t.output = take_string(kind, fr)?,
416            F_TOOL_RES_IS_ERROR => t.is_error = take_u64(kind, fr)? != 0,
417            F_TOOL_RES_ERROR => t.error = take_string(kind, fr)?,
418            _ => fr.skip(kind)?,
419        }
420        Ok(())
421    })?;
422    Ok(t)
423}
424
425/// Host→ext for a blocking decision point.
426#[derive(Debug, Clone, PartialEq, Eq, Default)]
427pub struct InterceptReq {
428    pub event: u16,
429    pub tool_name: String,
430    pub tool_call_id: String,
431    pub input: Vec<u8>,
432    pub content: String,
433    pub is_error: bool,
434    pub err_text: String,
435    pub prompt: String,
436    pub reason: String,
437    pub target_id: String,
438    pub turn_index: u32,
439}
440
441pub fn encode_intercept_req(r: &InterceptReq) -> Vec<u8> {
442    let mut fw = FieldWriter::new();
443    fw.put_u16(F_IX_REQ_EVENT, r.event);
444    fw.put_string(F_IX_REQ_TOOL_NAME, &r.tool_name);
445    fw.put_string(F_IX_REQ_TOOL_CALL_ID, &r.tool_call_id);
446    fw.put_bytes(F_IX_REQ_INPUT, &r.input);
447    fw.put_string(F_IX_REQ_CONTENT, &r.content);
448    fw.put_bool(F_IX_REQ_IS_ERROR, r.is_error);
449    fw.put_string(F_IX_REQ_ERR_TEXT, &r.err_text);
450    fw.put_string(F_IX_REQ_PROMPT, &r.prompt);
451    fw.put_string(F_IX_REQ_REASON, &r.reason);
452    fw.put_string(F_IX_REQ_TARGET_ID, &r.target_id);
453    fw.put_u32(F_IX_REQ_TURN_INDEX, r.turn_index);
454    fw.into_vec()
455}
456
457pub fn decode_intercept_req(b: &[u8]) -> Result<InterceptReq, Error> {
458    let mut r = InterceptReq::default();
459    walk_fields(b, |tag, kind, fr| {
460        match tag {
461            F_IX_REQ_EVENT => r.event = take_u64(kind, fr)? as u16,
462            F_IX_REQ_TOOL_NAME => r.tool_name = take_string(kind, fr)?,
463            F_IX_REQ_TOOL_CALL_ID => r.tool_call_id = take_string(kind, fr)?,
464            F_IX_REQ_INPUT => r.input = take_bytes(kind, fr)?.to_vec(),
465            F_IX_REQ_CONTENT => r.content = take_string(kind, fr)?,
466            F_IX_REQ_IS_ERROR => r.is_error = take_u64(kind, fr)? != 0,
467            F_IX_REQ_ERR_TEXT => r.err_text = take_string(kind, fr)?,
468            F_IX_REQ_PROMPT => r.prompt = take_string(kind, fr)?,
469            F_IX_REQ_REASON => r.reason = take_string(kind, fr)?,
470            F_IX_REQ_TARGET_ID => r.target_id = take_string(kind, fr)?,
471            F_IX_REQ_TURN_INDEX => r.turn_index = take_u64(kind, fr)? as u32,
472            _ => fr.skip(kind)?,
473        }
474        Ok(())
475    })?;
476    Ok(r)
477}
478
479/// Ext→host intercept reply.
480#[derive(Debug, Clone, PartialEq, Eq, Default)]
481pub struct InterceptResp {
482    pub block: bool,
483    pub stop: bool,
484    pub cancel: bool,
485    pub handled: bool,
486    /// Written as `continue_` to dodge the keyword; wire tag is
487    /// `F_IX_RES_CONTINUE`.
488    pub continue_: bool,
489    pub reason: String,
490    pub input: Vec<u8>,
491    pub content: String,
492    pub context: String,
493    pub system_prompt_append: String,
494    pub toast: String,
495    pub prompt: String,
496}
497
498pub fn encode_intercept_resp(r: &InterceptResp) -> Vec<u8> {
499    let mut fw = FieldWriter::new();
500    fw.put_bool(F_IX_RES_BLOCK, r.block);
501    fw.put_bool(F_IX_RES_STOP, r.stop);
502    fw.put_bool(F_IX_RES_CANCEL, r.cancel);
503    fw.put_string(F_IX_RES_REASON, &r.reason);
504    fw.put_bytes(F_IX_RES_INPUT, &r.input);
505    fw.put_string(F_IX_RES_CONTENT, &r.content);
506    fw.put_string(F_IX_RES_CONTEXT, &r.context);
507    fw.put_string(F_IX_RES_SYS_APPEND, &r.system_prompt_append);
508    fw.put_string(F_IX_RES_TOAST, &r.toast);
509    fw.put_bool(F_IX_RES_HANDLED, r.handled);
510    fw.put_string(F_IX_RES_PROMPT, &r.prompt);
511    fw.put_bool(F_IX_RES_CONTINUE, r.continue_);
512    fw.into_vec()
513}
514
515pub fn decode_intercept_resp(b: &[u8]) -> Result<InterceptResp, Error> {
516    let mut r = InterceptResp::default();
517    walk_fields(b, |tag, kind, fr| {
518        match tag {
519            F_IX_RES_BLOCK => r.block = take_u64(kind, fr)? != 0,
520            F_IX_RES_STOP => r.stop = take_u64(kind, fr)? != 0,
521            F_IX_RES_CANCEL => r.cancel = take_u64(kind, fr)? != 0,
522            F_IX_RES_HANDLED => r.handled = take_u64(kind, fr)? != 0,
523            F_IX_RES_CONTINUE => r.continue_ = take_u64(kind, fr)? != 0,
524            F_IX_RES_REASON => r.reason = take_string(kind, fr)?,
525            F_IX_RES_INPUT => r.input = take_bytes(kind, fr)?.to_vec(),
526            F_IX_RES_CONTENT => r.content = take_string(kind, fr)?,
527            F_IX_RES_CONTEXT => r.context = take_string(kind, fr)?,
528            F_IX_RES_SYS_APPEND => r.system_prompt_append = take_string(kind, fr)?,
529            F_IX_RES_TOAST => r.toast = take_string(kind, fr)?,
530            F_IX_RES_PROMPT => r.prompt = take_string(kind, fr)?,
531            _ => fr.skip(kind)?,
532        }
533        Ok(())
534    })?;
535    Ok(r)
536}
537
538/// Fire-and-forget host→ext lifecycle event.
539#[derive(Debug, Clone, PartialEq, Eq, Default)]
540pub struct EventNotify {
541    pub event: u16,
542    pub tool_name: String,
543    pub tool_call_id: String,
544    pub input: Vec<u8>,
545    pub is_error: bool,
546    pub prompt: String,
547    pub reason: String,
548    pub turn_index: u32,
549    pub session_id: String,
550    pub previous_session_id: String,
551    pub target_session_id: String,
552}
553
554pub fn encode_event_notify(e: &EventNotify) -> Vec<u8> {
555    let mut fw = FieldWriter::new();
556    fw.put_u16(F_EV_EVENT, e.event);
557    fw.put_string(F_EV_TOOL_NAME, &e.tool_name);
558    fw.put_string(F_EV_TOOL_CALL_ID, &e.tool_call_id);
559    fw.put_bytes(F_EV_INPUT, &e.input);
560    fw.put_bool(F_EV_IS_ERROR, e.is_error);
561    fw.put_string(F_EV_PROMPT, &e.prompt);
562    fw.put_string(F_EV_REASON, &e.reason);
563    fw.put_u32(F_EV_TURN_INDEX, e.turn_index);
564    fw.put_string(F_EV_SESSION_ID, &e.session_id);
565    fw.put_string(F_EV_PREVIOUS_SESSION_ID, &e.previous_session_id);
566    fw.put_string(F_EV_TARGET_SESSION_ID, &e.target_session_id);
567    fw.into_vec()
568}
569
570pub fn decode_event_notify(b: &[u8]) -> Result<EventNotify, Error> {
571    let mut e = EventNotify::default();
572    walk_fields(b, |tag, kind, fr| {
573        match tag {
574            F_EV_EVENT => e.event = take_u64(kind, fr)? as u16,
575            F_EV_TOOL_NAME => e.tool_name = take_string(kind, fr)?,
576            F_EV_TOOL_CALL_ID => e.tool_call_id = take_string(kind, fr)?,
577            F_EV_INPUT => e.input = take_bytes(kind, fr)?.to_vec(),
578            F_EV_IS_ERROR => e.is_error = take_u64(kind, fr)? != 0,
579            F_EV_PROMPT => e.prompt = take_string(kind, fr)?,
580            F_EV_REASON => e.reason = take_string(kind, fr)?,
581            F_EV_TURN_INDEX => e.turn_index = take_u64(kind, fr)? as u32,
582            F_EV_SESSION_ID => e.session_id = take_string(kind, fr)?,
583            F_EV_PREVIOUS_SESSION_ID => e.previous_session_id = take_string(kind, fr)?,
584            F_EV_TARGET_SESSION_ID => e.target_session_id = take_string(kind, fr)?,
585            _ => fr.skip(kind)?,
586        }
587        Ok(())
588    })?;
589    Ok(e)
590}
591
592/// Ext→host UI toast / footer status.
593#[derive(Debug, Clone, PartialEq, Eq, Default)]
594pub struct NotifyMsg {
595    pub level: String,
596    pub message: String,
597    pub status: String,
598    pub status_set: bool,
599}
600
601pub fn encode_notify(n: &NotifyMsg) -> Vec<u8> {
602    let mut fw = FieldWriter::new();
603    fw.put_string(F_NOTIFY_LEVEL, &n.level);
604    fw.put_string(F_NOTIFY_MESSAGE, &n.message);
605    fw.put_string(F_NOTIFY_STATUS, &n.status);
606    fw.put_bool(F_NOTIFY_STATUS_SET, n.status_set);
607    fw.into_vec()
608}
609
610pub fn decode_notify(b: &[u8]) -> Result<NotifyMsg, Error> {
611    let mut n = NotifyMsg::default();
612    walk_fields(b, |tag, kind, fr| {
613        match tag {
614            F_NOTIFY_LEVEL => n.level = take_string(kind, fr)?,
615            F_NOTIFY_MESSAGE => n.message = take_string(kind, fr)?,
616            F_NOTIFY_STATUS => n.status = take_string(kind, fr)?,
617            F_NOTIFY_STATUS_SET => n.status_set = take_u64(kind, fr)? != 0,
618            _ => fr.skip(kind)?,
619        }
620        Ok(())
621    })?;
622    Ok(n)
623}
624
625/// Ext→host capability RPC.
626#[derive(Debug, Clone, PartialEq, Eq, Default)]
627pub struct HostRequest {
628    pub method: String, // send_user_message | confirm
629    pub arg: String,
630}
631
632pub fn encode_host_request(r: &HostRequest) -> Vec<u8> {
633    let mut fw = FieldWriter::new();
634    fw.put_string(F_HOST_REQ_METHOD, &r.method);
635    fw.put_string(F_HOST_REQ_ARG, &r.arg);
636    fw.into_vec()
637}
638
639pub fn decode_host_request(b: &[u8]) -> Result<HostRequest, Error> {
640    let mut r = HostRequest::default();
641    walk_fields(b, |tag, kind, fr| {
642        match tag {
643            F_HOST_REQ_METHOD => r.method = take_string(kind, fr)?,
644            F_HOST_REQ_ARG => r.arg = take_string(kind, fr)?,
645            _ => fr.skip(kind)?,
646        }
647        Ok(())
648    })?;
649    Ok(r)
650}
651
652/// Host→ext reply to a `HostRequest`.
653#[derive(Debug, Clone, PartialEq, Eq, Default)]
654pub struct HostResult {
655    pub ok: bool,
656    pub error: String,
657    pub body: String,
658}
659
660pub fn encode_host_result(r: &HostResult) -> Vec<u8> {
661    let mut fw = FieldWriter::new();
662    fw.put_bool(F_HOST_RES_OK, r.ok);
663    fw.put_string(F_HOST_RES_ERROR, &r.error);
664    fw.put_string(F_HOST_RES_BODY, &r.body);
665    fw.into_vec()
666}
667
668pub fn decode_host_result(b: &[u8]) -> Result<HostResult, Error> {
669    let mut r = HostResult::default();
670    walk_fields(b, |tag, kind, fr| {
671        match tag {
672            F_HOST_RES_OK => r.ok = take_u64(kind, fr)? != 0,
673            F_HOST_RES_ERROR => r.error = take_string(kind, fr)?,
674            F_HOST_RES_BODY => r.body = take_string(kind, fr)?,
675            _ => fr.skip(kind)?,
676        }
677        Ok(())
678    })?;
679    Ok(r)
680}
681
682/// Host→ext session identity push.
683#[derive(Debug, Clone, PartialEq, Eq, Default)]
684pub struct SessionMeta {
685    pub session_id: String,
686    pub cwd: String,
687}
688
689pub fn encode_session_meta(m: &SessionMeta) -> Vec<u8> {
690    let mut fw = FieldWriter::new();
691    fw.put_string(F_META_SESSION_ID, &m.session_id);
692    fw.put_string(F_META_CWD, &m.cwd);
693    fw.into_vec()
694}
695
696pub fn decode_session_meta(b: &[u8]) -> Result<SessionMeta, Error> {
697    let mut m = SessionMeta::default();
698    walk_fields(b, |tag, kind, fr| {
699        match tag {
700            F_META_SESSION_ID => m.session_id = take_string(kind, fr)?,
701            F_META_CWD => m.cwd = take_string(kind, fr)?,
702            _ => fr.skip(kind)?,
703        }
704        Ok(())
705    })?;
706    Ok(m)
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    /// Round-trip every message through encode/decode and check that the
714    /// encode is deterministic (same struct, same bytes twice).
715    #[test]
716    fn all_messages_roundtrip() {
717        type Reencode = fn(&[u8]) -> Result<Vec<u8>, Error>;
718        let cases: Vec<(Vec<u8>, Reencode)> = vec![
719            (
720                encode_hello(&Hello {
721                    name: "greet".into(),
722                    version: "1.0.0".into(),
723                    caps: 3,
724                    protocol: 1,
725                }),
726                |b| Ok(encode_hello(&decode_hello(b)?)),
727            ),
728            (
729                encode_hello_ack(&HelloAck {
730                    protocol: 1,
731                    phi_version: "v0.19.0".into(),
732                    cwd: "/tmp".into(),
733                    session_id: "s1".into(),
734                    extension_dir: "/ext".into(),
735                }),
736                |b| Ok(encode_hello_ack(&decode_hello_ack(b)?)),
737            ),
738            (
739                encode_register_command(&RegisterCommand {
740                    name: "hi".into(),
741                    description: "Say hi".into(),
742                }),
743                |b| Ok(encode_register_command(&decode_register_command(b)?)),
744            ),
745            (
746                encode_register_tool(&RegisterTool {
747                    name: "t".into(),
748                    description: "d".into(),
749                    schema_json: br#"{"type":"object"}"#.to_vec(),
750                    timeout_sec: 120,
751                }),
752                |b| Ok(encode_register_tool(&decode_register_tool(b)?)),
753            ),
754            (
755                encode_subscribe(&Subscribe {
756                    events: vec![5, 10],
757                    intercept: vec![1, 2],
758                }),
759                |b| Ok(encode_subscribe(&decode_subscribe(b)?)),
760            ),
761            (
762                encode_command_invoked(&CommandInvoked {
763                    name: "hi".into(),
764                    args: "a b".into(),
765                }),
766                |b| Ok(encode_command_invoked(&decode_command_invoked(b)?)),
767            ),
768            (
769                encode_command_response(&CommandResponse {
770                    ok: false,
771                    error: "boom".into(),
772                    notify: String::new(),
773                    submit: "next".into(),
774                }),
775                |b| Ok(encode_command_response(&decode_command_response(b)?)),
776            ),
777            (
778                encode_tool_invoke(&ToolInvoke {
779                    name: "t".into(),
780                    args: br#"{"k":1}"#.to_vec(),
781                }),
782                |b| Ok(encode_tool_invoke(&decode_tool_invoke(b)?)),
783            ),
784            (
785                encode_tool_result(&ToolResultMsg {
786                    content: "c".into(),
787                    detail: "d".into(),
788                    output: "o".into(),
789                    is_error: true,
790                    error: "e".into(),
791                }),
792                |b| Ok(encode_tool_result(&decode_tool_result(b)?)),
793            ),
794            (
795                encode_intercept_req(&InterceptReq {
796                    event: 1,
797                    tool_name: "bash".into(),
798                    tool_call_id: "c1".into(),
799                    input: br#"{"command":"ls"}"#.to_vec(),
800                    content: "out".into(),
801                    is_error: false,
802                    err_text: String::new(),
803                    prompt: "p".into(),
804                    reason: "r".into(),
805                    target_id: "t2".into(),
806                    turn_index: 3,
807                }),
808                |b| Ok(encode_intercept_req(&decode_intercept_req(b)?)),
809            ),
810            (
811                encode_intercept_resp(&InterceptResp {
812                    block: true,
813                    stop: false,
814                    cancel: false,
815                    handled: true,
816                    continue_: true,
817                    reason: "r".into(),
818                    input: b"in".to_vec(),
819                    content: "c".into(),
820                    context: "ctx".into(),
821                    system_prompt_append: "sys".into(),
822                    toast: "t".into(),
823                    prompt: "p".into(),
824                }),
825                |b| Ok(encode_intercept_resp(&decode_intercept_resp(b)?)),
826            ),
827            (
828                encode_event_notify(&EventNotify {
829                    event: 5,
830                    tool_name: "t".into(),
831                    tool_call_id: "c".into(),
832                    input: b"i".to_vec(),
833                    is_error: true,
834                    prompt: "p".into(),
835                    reason: "r".into(),
836                    turn_index: 2,
837                    session_id: "s".into(),
838                    previous_session_id: "ps".into(),
839                    target_session_id: "ts".into(),
840                }),
841                |b| Ok(encode_event_notify(&decode_event_notify(b)?)),
842            ),
843            (
844                encode_notify(&NotifyMsg {
845                    level: "info".into(),
846                    message: "Hello".into(),
847                    status: "st".into(),
848                    status_set: true,
849                }),
850                |b| Ok(encode_notify(&decode_notify(b)?)),
851            ),
852            (
853                encode_host_request(&HostRequest {
854                    method: "confirm".into(),
855                    arg: r#"{"Title":"t"}"#.into(),
856                }),
857                |b| Ok(encode_host_request(&decode_host_request(b)?)),
858            ),
859            (
860                encode_host_result(&HostResult {
861                    ok: true,
862                    error: String::new(),
863                    body: "b".into(),
864                }),
865                |b| Ok(encode_host_result(&decode_host_result(b)?)),
866            ),
867            (
868                encode_session_meta(&SessionMeta {
869                    session_id: "s2".into(),
870                    cwd: "/x".into(),
871                }),
872                |b| Ok(encode_session_meta(&decode_session_meta(b)?)),
873            ),
874        ];
875
876        for (bytes, reencode) in cases {
877            assert_eq!(reencode(&bytes).unwrap(), bytes);
878        }
879    }
880
881    #[test]
882    fn decode_skips_unknown_tags() {
883        let mut w = FieldWriter::new();
884        w.put_string(1, "name");
885        w.put_string(200, "future field"); // experimental, must be skippable
886        w.put_string(2, "1.0.0");
887        let h = decode_hello(&w.into_vec()).unwrap();
888        assert_eq!(h.name, "name");
889        assert_eq!(h.version, "1.0.0");
890    }
891}