Skip to main content

agentmux_common/
client_events.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Payloads of the broker events `agentmux-bashwrap` publishes through srv's
5//! `POST /agentmux/wps/publish`. srv declares the same types for these events
6//! (`backend::mps_events`), refuses a publish whose `data` isn't one of them, and
7//! generates them for the frontend, so the three can't drift
8//! (docs/specs/SPEC_CODEGEN_REMAINING_GAPS_2026_10_13.md, step 2).
9//!
10//! The TypeScript derives are behind the `ts` feature, which only srv turns on.
11
12use serde::{Deserialize, Serialize};
13
14/// The `tool_chunk` event, published to `block:<id>` while a Bash call runs. The call's
15/// tool_use_id rides in the payload, not the event name, so the pane opens one subscription per
16/// block and routes by `tool_id` (SPEC_STREAMING_BASH_RUNNER_2026_05_11.md ยง6).
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export_to = "../../../frontend/types/rpc/"))]
19#[serde(tag = "op", rename_all = "lowercase")]
20pub enum ToolChunk {
21    /// A line of the call's output.
22    Chunk {
23        tool_id: String,
24        kind: ToolChunkKind,
25        content: String,
26        #[cfg_attr(feature = "ts", ts(type = "number"))]
27        timestamp: u64,
28    },
29    /// The call exited.
30    Terminal {
31        tool_id: String,
32        exit_code: i32,
33        #[cfg_attr(feature = "ts", ts(type = "number"))]
34        timestamp: u64,
35    },
36    /// The wrapper's own pid, once, for a declared-background call
37    /// (SPEC_BACKGROUND_TASK_PID_CAPTURE_2026_08_20.md).
38    Pid {
39        tool_id: String,
40        pid: u32,
41        #[cfg_attr(feature = "ts", ts(type = "number"))]
42        timestamp: u64,
43    },
44    /// Which call the wrapper runs, for Tower's "started by". srv keeps it and doesn't forward it
45    /// (`backend::tool_calls`).
46    Call {
47        tool_id: String,
48        pid: u32,
49        description: String,
50        #[cfg_attr(feature = "ts", ts(type = "number"))]
51        timestamp: u64,
52    },
53}
54
55/// Where a `tool_chunk` line came from.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export_to = "../../../frontend/types/rpc/"))]
58#[serde(rename_all = "lowercase")]
59pub enum ToolChunkKind {
60    Stdout,
61    Stderr,
62    /// A line the wrapper writes itself (a timeout, a kill).
63    System,
64}
65
66/// The `compaction_started` event: the `PreCompact` hook fired, so Claude Code is compacting.
67/// camelCase on the wire, like srv's `AgentEvent`.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export_to = "../../../frontend/types/rpc/"))]
70#[serde(rename_all = "camelCase")]
71pub struct CompactionStarted {
72    pub trigger: CompactionTrigger,
73    pub session_id: String,
74    /// RFC 3339.
75    pub started_at: String,
76}
77
78/// How compaction was triggered. `Auto` = context filled up and
79/// Claude Code compacted automatically; `Manual` = the user (or the
80/// agent itself) ran `/compact`.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export_to = "../../../frontend/types/rpc/"))]
83#[serde(rename_all = "snake_case")]
84pub enum CompactionTrigger {
85    Auto,
86    Manual,
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use serde_json::json;
93
94    #[test]
95    fn tool_chunks_serialize_as_the_wrapper_always_sent_them() {
96        let chunk = ToolChunk::Chunk { tool_id: "toolu_1".into(), kind: ToolChunkKind::Stderr, content: "x\n".into(), timestamp: 5 };
97        assert_eq!(serde_json::to_value(&chunk).unwrap(), json!({"op": "chunk", "tool_id": "toolu_1", "kind": "stderr", "content": "x\n", "timestamp": 5}));
98        let terminal = ToolChunk::Terminal { tool_id: "toolu_1".into(), exit_code: -1, timestamp: 6 };
99        assert_eq!(serde_json::to_value(&terminal).unwrap(), json!({"op": "terminal", "tool_id": "toolu_1", "exit_code": -1, "timestamp": 6}));
100        let pid = ToolChunk::Pid { tool_id: "toolu_1".into(), pid: 42, timestamp: 7 };
101        assert_eq!(serde_json::to_value(&pid).unwrap(), json!({"op": "pid", "tool_id": "toolu_1", "pid": 42, "timestamp": 7}));
102        let call = ToolChunk::Call { tool_id: "toolu_1".into(), pid: 42, description: "Run it".into(), timestamp: 8 };
103        assert_eq!(serde_json::to_value(&call).unwrap(), json!({"op": "call", "tool_id": "toolu_1", "pid": 42, "description": "Run it", "timestamp": 8}));
104        for v in [chunk, terminal, pid, call] {
105            assert_eq!(serde_json::from_value::<ToolChunk>(serde_json::to_value(&v).unwrap()).unwrap(), v);
106        }
107    }
108
109    #[test]
110    fn compaction_started_serializes_camel_case() {
111        let p = CompactionStarted { trigger: CompactionTrigger::Auto, session_id: "s1".into(), started_at: "2026-10-13T00:00:00Z".into() };
112        assert_eq!(serde_json::to_value(&p).unwrap(), json!({"trigger": "auto", "sessionId": "s1", "startedAt": "2026-10-13T00:00:00Z"}));
113    }
114
115    #[test]
116    fn an_unknown_op_or_kind_is_refused() {
117        assert!(serde_json::from_value::<ToolChunk>(json!({"op": "nope", "tool_id": "t"})).is_err());
118        assert!(serde_json::from_value::<ToolChunk>(json!({"op": "chunk", "tool_id": "t", "kind": "tty", "content": "", "timestamp": 0})).is_err());
119        assert!(serde_json::from_value::<CompactionStarted>(json!({"trigger": "sometimes", "sessionId": "", "startedAt": ""})).is_err());
120    }
121}