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