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