1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
//! Daemon-side implementation of the four session-manager PROXY MCP tools
//! (#2550, a #1440 follow-up).
//!
//! Why: the MCP `StateBackend` (in `mcp_backend.rs`) must service the proxy
//! tools, but inlining their bodies there risks the 500-SLOC cap. More
//! importantly these tools must be THIN WRAPPERS over the SAME
//! [`crate::client::proxy::SessionProxy`] state machine the daemon's local HTTP
//! proxy routes already drive — not a parallel reimplementation — so the MCP,
//! HTTP, and Telegram surfaces behave identically. This module builds the proxy
//! through the shared [`crate::daemon::managed_routes::proxy::local_proxy`]
//! constructor (a [`crate::client::proxy::SessionProxy`] over the in-process
//! [`crate::daemon::managed_routes::DirectManagedBackend`] and the daemon's ONE
//! shared [`crate::daemon::state::DaemonState::proxy_focus_store`]), so a focus
//! set over MCP is visible on the HTTP/Telegram surfaces under the same key and
//! vice versa.
//! What: four free async functions — `session_proxy_focus`,
//! `session_proxy_unfocus`, `session_proxy_message`, `session_proxy_summary` —
//! each taking `&Arc<DaemonState>` plus parsed arguments, calling the matching
//! [`SessionProxy`](crate::client::SessionProxy) verb, and serialising the outcome via the SAME wire response
//! types the HTTP routes return (so the JSON shape is identical across
//! transports). Every proxy outcome is a valid state (never a transport error),
//! so these always return `Ok(_)`, exactly as the HTTP routes always answer 200.
//! `mcp_backend.rs`'s single `impl OrchestratorBackend for StateBackend` block
//! wires these four functions to the trait as one-liner delegates — the SAME
//! pattern `session_new`/`config_read`/`project_list` already use for their
//! sibling modules (a trait CANNOT be split across two `impl` blocks for one
//! type — Rust rejects that as E0119 — so this module holds only the bodies,
//! not a second impl).
//! Test: `tests` below drives all four through a hermetic
//! `DaemonState::with_root_isolated_managed` (no real tmux), asserting the tagged
//! `outcome` — proving each tool reaches the real `SessionProxy` methods.
use std::sync::Arc;
use serde_json::Value;
use crate::daemon::managed_routes::proxy::local_proxy;
use crate::daemon::managed_routes::{
ProxyFocusResponse, ProxyMessageResponse, ProxySummaryResponse, ProxyUnfocusResponse,
};
use crate::daemon::state::DaemonState;
/// Serialise a proxy wire response into a tool-result [`Value`].
///
/// Why: every proxy tool renders its outcome through the SAME `Serialize` wire
/// type the HTTP routes use; centralising the `to_value` + error mapping keeps
/// each wrapper a one-liner and the JSON identical across transports.
/// What: serialises `resp`, mapping the (practically-impossible) serialisation
/// failure to a flat error string rather than a panic.
/// Test: exercised by every `tests` case below via the four wrappers.
fn to_result<T: serde::Serialize>(resp: T) -> Result<Value, String> {
serde_json::to_value(resp).map_err(|e| format!("failed to serialise proxy response: {e}"))
}
/// Focus (or query) a session for a conversation (`session_proxy_focus` tool).
///
/// Why: bind a conversation to ONE managed session so later
/// [`session_proxy_message`] calls route to it without a per-turn id.
/// What: with a non-empty `session_id` validates + records the focus; with an
/// empty `session_id` queries the current focus without touching the backend.
/// Returns the tagged [`ProxyFocusResponse`] as JSON.
/// Test: `tests::mcp_proxy_focus_message_summary_unfocus_round_trip`,
/// `tests::mcp_proxy_focus_empty_queries_current`.
pub async fn session_proxy_focus(
state: &Arc<DaemonState>,
conversation_key: &str,
session_id: &str,
) -> Result<Value, String> {
let proxy = local_proxy(state);
let outcome = proxy.focus(conversation_key, session_id).await;
to_result(ProxyFocusResponse::from(outcome))
}
/// Clear a conversation's focus (`session_proxy_unfocus` tool).
///
/// Why: the "back to fleet chat" action — drop the binding so free text no
/// longer injects.
/// What: clears the focus and reports the cleared session (or `null`); never an
/// error — unfocusing an unfocused conversation is a harmless no-op.
/// Test: `tests::mcp_proxy_focus_message_summary_unfocus_round_trip`.
pub async fn session_proxy_unfocus(
state: &Arc<DaemonState>,
conversation_key: &str,
) -> Result<Value, String> {
let proxy = local_proxy(state);
let cleared = proxy.unfocus(conversation_key).map(Into::into);
to_result(ProxyUnfocusResponse { cleared })
}
/// INJECT free text into the conversation's focused session
/// (`session_proxy_message` tool).
///
/// Why: the payoff of focus — a plain message becomes a `send` at the focused
/// session, addressed by focus instead of an explicit id.
/// What: injects `text` and returns the tagged [`ProxyMessageResponse`]
/// (`sent` / `auto_unfocused` / `failed` / `no_focus`).
/// Test: `tests::mcp_proxy_focus_message_summary_unfocus_round_trip`,
/// `tests::mcp_proxy_message_no_focus`.
pub async fn session_proxy_message(
state: &Arc<DaemonState>,
conversation_key: &str,
text: &str,
) -> Result<Value, String> {
let proxy = local_proxy(state);
let outcome = proxy.inject(conversation_key, text).await;
to_result(ProxyMessageResponse::from(outcome))
}
/// SUMMARIZE the conversation's focused session (`session_proxy_summary` tool).
///
/// Why: the second proxy direction — "what is my focused session doing?" —
/// without attaching to tmux.
/// What: returns a lightweight activity digest as the tagged
/// [`ProxySummaryResponse`] (`summary` / `auto_unfocused` / `failed` /
/// `no_focus`).
/// Test: `tests::mcp_proxy_focus_message_summary_unfocus_round_trip`.
pub async fn session_proxy_summary(
state: &Arc<DaemonState>,
conversation_key: &str,
) -> Result<Value, String> {
let proxy = local_proxy(state);
let outcome = proxy.summarize(conversation_key).await;
to_result(ProxySummaryResponse::from(outcome))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::RuntimeKind;
use crate::session_manager::record::{ManagedSessionId, ManagedSessionState};
/// Build a hermetic `DaemonState` with one seeded managed session.
///
/// Why: every proxy test needs a resolvable session to focus; mirrors
/// `managed_routes::proxy::tests::seeded_state` (a separate module) so the
/// MCP-layer wrappers are exercised over the SAME fake-tmux, isolated store.
/// What: returns the state and the seeded session's friendly `tmux_name`.
/// The record is marked `Active` right after creation (`create_with_id`
/// otherwise leaves it `Provisioning`, #3591): this fixture represents a
/// normal working session a caller focuses and messages, and
/// `SessionManager::send_input`'s state guard (#3591) now correctly
/// refuses `Provisioning` — a real bug in the proxy target, not something
/// these route-wiring tests are exercising.
async fn seeded_state() -> (Arc<DaemonState>, String) {
let root = tempfile::tempdir().unwrap().keep();
let state = Arc::new(DaemonState::with_root_isolated_managed(root).await);
let mgr = state.session_manager().await;
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"mcp proxy task".to_string(),
None,
Some("mcpproxy".to_string()),
None,
None,
None,
RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
{
let mut store = mgr.store.write().await;
let mut r = store.get(&record.id).await.unwrap();
r.state = ManagedSessionState::Active;
store.upsert(r).await.unwrap();
}
(state, record.tmux_name)
}
#[tokio::test]
async fn mcp_proxy_focus_message_summary_unfocus_round_trip() {
// Proves each MCP tool reaches the REAL SessionProxy over the direct
// backend + shared focus store: focus by name → inject → summarize →
// unfocus, asserting the tagged outcome at every step.
let (state, name) = seeded_state().await;
let focus = session_proxy_focus(&state, "conv-1", &name).await.unwrap();
assert_eq!(focus["outcome"], "focused");
assert_eq!(focus["name"], name);
let msg = session_proxy_message(&state, "conv-1", "run the tests")
.await
.unwrap();
assert_eq!(msg["outcome"], "sent");
assert_eq!(msg["target"]["name"], name);
assert_eq!(msg["text"], "run the tests");
let summary = session_proxy_summary(&state, "conv-1").await.unwrap();
assert_eq!(summary["outcome"], "summary");
assert_eq!(summary["target"]["name"], name);
assert!(
summary["summary"]
.as_str()
.unwrap()
.contains("mcp proxy task")
);
let cleared = session_proxy_unfocus(&state, "conv-1").await.unwrap();
assert_eq!(cleared["cleared"]["name"], name);
// Now unfocused: message reports no_focus, never an error.
let after = session_proxy_message(&state, "conv-1", "still there?")
.await
.unwrap();
assert_eq!(after["outcome"], "no_focus");
}
#[tokio::test]
async fn mcp_proxy_focus_empty_queries_current() {
// An empty session_id is a read-only query, not a set.
let (state, _name) = seeded_state().await;
let body = session_proxy_focus(&state, "conv-2", "").await.unwrap();
assert_eq!(body["outcome"], "current");
assert!(body["target"].is_null());
}
#[tokio::test]
async fn mcp_proxy_focus_shares_store_with_http_surface() {
// The MCP focus is keyed into DaemonState::proxy_focus_store — the SAME
// store the HTTP/Telegram surfaces use. Confirm a focus set via the MCP
// wrapper is observable through the shared store under the same key.
let (state, name) = seeded_state().await;
session_proxy_focus(&state, "conv-shared", &name)
.await
.unwrap();
let store = state.proxy_focus_store();
let guard = store.lock().expect("focus store lock");
let focused = guard.get("conv-shared").expect("focus present in store");
assert_eq!(focused.name, name);
}
#[tokio::test]
async fn mcp_proxy_message_no_focus() {
let (state, _name) = seeded_state().await;
let body = session_proxy_message(&state, "conv-never", "hello")
.await
.unwrap();
assert_eq!(body["outcome"], "no_focus");
}
}