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
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
//! Session-Manager endpoint tests for `/api/v1/sessions/chat` (DOC-14 SM-7).
//!
//! Why: SM-7 rewires `POST /api/v1/sessions/chat` to route through the SM
//! agent when enabled + provisioned, adds `/api/v1/session-manager/*` aliases,
//! and preserves the DOC-13 TUI contract. These tests pin every one of those:
//! the SM turn returns reply + cost; degraded maps to 503; `enabled = false`
//! falls back to the legacy path with no regression; the alias route resolves to
//! the same handler; and the old request/response shapes still (de)serialize.
//! All deterministic — a mock resolver, a tempdir, no network.
//! What: drives the `coordinator_chat` handler directly and the router via
//! `oneshot` for the alias/contract assertions.
//! Test: this *is* the test module.
use std::sync::Arc;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::{Json, body::Body, http::Request};
use serde_json::Value;
use tempfile::TempDir;
use tower::ServiceExt;
use super::{CoordinatorChatRequest, coordinator_chat, router};
use crate::core::sm::SessionManagerAgent;
use crate::core::sm::agent::mock::{MockChatProvider, MockResolver};
use crate::core::sm::config::SessionManagerConfig;
use crate::daemon::state::DaemonState;
/// An enabled SM config.
fn enabled_sm() -> SessionManagerConfig {
SessionManagerConfig {
enabled: true,
..SessionManagerConfig::default()
}
}
/// Build a `DaemonState` whose SM agent is enabled and wired to `resolver`,
/// rooted at `tmp`. Returns both so the caller keeps the tempdir alive.
fn state_with_sm(resolver: Arc<MockResolver>, tmp: &TempDir) -> Arc<DaemonState> {
let agent = Arc::new(SessionManagerAgent::for_test(
enabled_sm(),
resolver,
tmp.path().to_path_buf(),
));
Arc::new(DaemonState::with_session_manager_agent(agent))
}
/// Why: the headline SM-7 acceptance at the endpoint — with the SM enabled and a
/// (mock) provider, `coordinator_chat` drives the SM turn and returns the reply
/// PLUS the additive per-call `cost` and `conv_id`.
/// What: posts a plain message; asserts reply, cost, conv_id, and that the
/// routed/output fields stay `None`.
/// Test: this is the test.
#[tokio::test]
async fn sm_chat_returns_reply_and_cost() {
let tmp = TempDir::new().unwrap();
let provider = MockChatProvider::new("SM plan ready", 0.0123);
let state = state_with_sm(Arc::new(MockResolver::with_provider(provider)), &tmp);
let resp = coordinator_chat(
State(state),
HeaderMap::new(),
Json(CoordinatorChatRequest {
message: "plan the migration".into(),
history: Vec::new(),
conv_id: Some("endpoint-conv".into()),
actions: None,
}),
)
.await
.expect("SM chat succeeds with a provider");
assert_eq!(resp.reply, "SM plan ready");
assert_eq!(resp.cost, Some(0.0123));
assert_eq!(resp.conv_id.as_deref(), Some("endpoint-conv"));
assert!(resp.routed_to_session.is_none());
assert!(resp.command_output.is_none());
}
/// Why: degraded mode (§5.3) — with the SM enabled but NO provider (the resolver
/// reports degraded), the endpoint returns the documented 503 fallback rather
/// than a hard error.
/// What: builds an enabled SM over a degraded resolver; asserts 503.
/// Test: this is the test.
#[tokio::test]
async fn sm_chat_degraded_is_503() {
let tmp = TempDir::new().unwrap();
let state = state_with_sm(Arc::new(MockResolver::degraded()), &tmp);
let err = coordinator_chat(
State(state),
HeaderMap::new(),
Json(CoordinatorChatRequest {
message: "anything".into(),
history: Vec::new(),
conv_id: None,
actions: None,
}),
)
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
}
/// Why: NO REGRESSION — with `enabled = false` (the default), the endpoint must
/// behave exactly like today's `LlmOverseer` path. A default daemon has no key,
/// so a non-prefixed message returns 503 (the legacy semantics), NOT the SM path.
/// What: a default `DaemonState` (SM disabled) → non-prefixed chat → 503.
/// Test: this is the test.
#[tokio::test]
async fn disabled_sm_falls_back_to_legacy_503() {
// Build state with an EXPLICITLY-disabled SM agent (hermetic: never depends
// on the operator's real config). Even though its resolver could serve, the
// endpoint must NOT route through it because `enabled = false`.
let tmp = TempDir::new().unwrap();
let disabled_agent = Arc::new(SessionManagerAgent::for_test(
SessionManagerConfig::default(), // enabled = false
Arc::new(MockResolver::with_provider(MockChatProvider::new(
"unused", 0.0,
))),
tmp.path().to_path_buf(),
));
let state = Arc::new(DaemonState::with_session_manager_agent(disabled_agent));
// Sanity: the agent is disabled, so the SM path is never taken.
assert!(!state.session_manager_agent().is_enabled());
let err = coordinator_chat(
State(state),
HeaderMap::new(),
Json(CoordinatorChatRequest {
message: "what is happening?".into(),
history: Vec::new(),
conv_id: None,
actions: None,
}),
)
.await
.unwrap_err();
assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
}
/// Why: Phase 2 (#1283) — `actions: true` routes the SM branch through the
/// ACTION-CAPABLE loop. With the model emitting a `final` answer immediately (no
/// verb), the in-process control is never invoked, so the handler test stays
/// hermetic while still proving the action BRANCH was taken: it parses the action
/// JSON into the `final` message (the text path would echo the raw JSON verbatim)
/// and emits the additive `cost`/`conv_id`. Because no verb ran, `actions_taken`
/// is `None` (an empty `[]` is never serialized — presence means "verbs ran").
/// What: posts with `actions: Some(true)` and a provider whose reply is a `final`
/// action JSON; asserts the action path was taken (parsed `reply`, `cost`,
/// `conv_id`) and that `actions_taken` is `None` when no verb ran.
/// Test: this is the test.
#[tokio::test]
async fn coordinator_chat_action_path_returns_actions_taken() {
let tmp = TempDir::new().unwrap();
let provider = MockChatProvider::new(r#"{"action":"final","message":"nothing to do"}"#, 0.002);
let state = state_with_sm(Arc::new(MockResolver::with_provider(provider)), &tmp);
let resp = coordinator_chat(
State(state),
HeaderMap::new(),
Json(CoordinatorChatRequest {
message: "check sessions".into(),
history: Vec::new(),
conv_id: Some("act-conv".into()),
actions: Some(true),
}),
)
.await
.expect("action path succeeds");
// The action path PARSED the JSON into the `final` message; the text path would
// instead echo the raw JSON verbatim — so this proves the action branch ran.
assert_eq!(resp.reply, "nothing to do");
assert_eq!(resp.cost, Some(0.002));
assert_eq!(resp.conv_id.as_deref(), Some("act-conv"));
// No verb ran this turn, so the audit field is omitted (None), not `Some([])`.
assert!(
resp.actions_taken.is_none(),
"no verb ran: actions_taken must be None, not Some([])"
);
}
/// Why: NO REGRESSION — `actions` absent (or `false`) must preserve the EXACT
/// text-only `sm.chat` path: the raw reply text is returned verbatim (NOT parsed
/// as an action) and `actions_taken` is absent (`None`).
/// What: posts the same provider reply WITHOUT `actions: true`; asserts the raw
/// JSON-looking reply is returned verbatim and `actions_taken` is `None`.
/// Test: this is the test.
#[tokio::test]
async fn coordinator_chat_actions_absent_is_text_only() {
let tmp = TempDir::new().unwrap();
// A reply that WOULD parse as an action — the text path must NOT interpret it.
let provider = MockChatProvider::new(r#"{"action":"final","message":"x"}"#, 0.007);
let state = state_with_sm(Arc::new(MockResolver::with_provider(provider)), &tmp);
let resp = coordinator_chat(
State(state),
HeaderMap::new(),
Json(CoordinatorChatRequest {
message: "plain chat".into(),
history: Vec::new(),
conv_id: Some("text-conv".into()),
actions: None,
}),
)
.await
.expect("text path succeeds");
// The text path returns the raw provider text verbatim, unparsed.
assert_eq!(resp.reply, r#"{"action":"final","message":"x"}"#);
assert_eq!(resp.cost, Some(0.007));
assert!(
resp.actions_taken.is_none(),
"text path must not populate actions_taken"
);
}
/// Why: D0.1 — the `/api/v1/session-manager/chat` alias must resolve to the SAME
/// handler as `/api/v1/sessions/chat`, so identical input yields identical
/// output. Driving both through the router via `oneshot` proves the alias wiring.
/// What: posts the same SM request to both paths; asserts both 200 with equal
/// `reply`/`cost`/`conv_id`.
/// Test: this is the test.
#[tokio::test]
async fn session_manager_chat_alias_matches_coordinator() {
let tmp = TempDir::new().unwrap();
let provider = MockChatProvider::new("aliased reply", 0.005);
// Keep a handle so we can assert the shared provider was invoked once per
// router call (a refactor that dedups the two calls would silently regress).
let provider_handle = provider.clone();
let state = state_with_sm(Arc::new(MockResolver::with_provider(provider)), &tmp);
let body = serde_json::json!({ "message": "hi", "conv_id": "alias-conv" });
let post = |path: &str| {
let app = router(Arc::clone(&state));
let body = body.clone();
let path = path.to_string();
async move {
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap();
let json: Value = serde_json::from_slice(&bytes).unwrap();
(status, json)
}
};
let (coord_status, coord_json) = post("/api/v1/sessions/chat").await;
let (sm_status, sm_json) = post("/api/v1/session-manager/chat").await;
assert_eq!(coord_status, StatusCode::OK);
assert_eq!(sm_status, StatusCode::OK);
assert_eq!(coord_json["reply"], sm_json["reply"]);
assert_eq!(coord_json["reply"], "aliased reply");
assert_eq!(coord_json["cost"], sm_json["cost"]);
assert_eq!(coord_json["conv_id"], sm_json["conv_id"]);
// Both router calls must have actually driven the shared provider exactly once
// each (one orchestration reply per turn; no compaction triggers on a single
// round at the default window). A refactor that dedups the calls fails here.
assert_eq!(
provider_handle.request_count(),
2,
"the shared provider must be invoked once per router call"
);
}
/// Why: DOC-13 TUI contract — a request in the OLD shape (just `message` +
/// `history`, NO `conv_id`) must still deserialize, and the response must still
/// carry the original `reply` field. The additive `cost`/`conv_id` are present
/// only on the SM path and are skipped otherwise.
/// What: deserializes the legacy request JSON; serializes a legacy-style response
/// and asserts the original field is present and the additive ones are absent.
/// Test: this is the test.
#[test]
fn doc13_tui_contract_is_preserved() {
// Old request shape (pre-SM-7) still deserializes — `conv_id` defaults absent.
let legacy: CoordinatorChatRequest =
serde_json::from_str(r#"{ "message": "hello", "history": [] }"#)
.expect("legacy request shape must still deserialize");
assert_eq!(legacy.message, "hello");
assert!(legacy.conv_id.is_none());
// A legacy-style response (no SM additive fields) serializes with the
// original `reply` field and WITHOUT `cost`/`conv_id` (they skip when None).
let resp = super::CoordinatorChatResponse {
reply: "ok".into(),
routed_to_session: None,
command_output: None,
cost: None,
conv_id: None,
actions_taken: None,
};
let json: Value = serde_json::to_value(&resp).unwrap();
assert_eq!(json["reply"], "ok");
assert!(json.get("cost").is_none(), "cost must be absent when None");
assert!(
json.get("conv_id").is_none(),
"conv_id must be absent when None"
);
assert!(json.get("routed_to_session").is_none());
}
/// Why: #1524 — when inference is unavailable (no API key), the action loop must
/// return a clear 200 reply explaining the situation rather than an opaque 503.
/// The operator opted in to actions but the SM cannot reason; the browser/Web
/// adapter must show them WHY rather than rendering a raw HTTP error page.
/// What: posts with `actions: Some(true)` and a DEGRADED resolver; asserts the
/// response is `200` with a reply that names the missing credential and contains
/// the word "inference".
/// Test: this is the test.
#[tokio::test]
async fn coordinator_chat_action_no_creds_returns_explanatory_reply() {
let tmp = TempDir::new().unwrap();
let state = state_with_sm(Arc::new(MockResolver::degraded()), &tmp);
let resp = coordinator_chat(
State(state),
HeaderMap::new(),
Json(CoordinatorChatRequest {
message: "list sessions".into(),
history: Vec::new(),
conv_id: None,
actions: Some(true),
}),
)
.await
.expect("action loop no-creds must return Ok (200), not an error");
// The reply must explain the missing inference, not leave the browser confused.
assert!(
resp.reply.contains("inference"),
"reply must mention 'inference', got: {:?}",
resp.reply
);
assert!(
resp.reply.contains("OPENROUTER_API_KEY")
|| resp.reply.contains("configured")
|| resp.reply.contains("credentials"),
"reply must hint at configuring credentials, got: {:?}",
resp.reply
);
// No cost/conv_id when no turn was completed.
assert!(resp.cost.is_none(), "cost must be absent");
assert!(resp.conv_id.is_none(), "conv_id must be absent");
assert!(resp.actions_taken.is_none(), "no actions were taken");
}
/// Why: #1392 — the cross-session coordinator surface moved under the plural
/// `/api/v1/sessions/*` namespace and the former `/api/v1/coordinator/*` paths
/// were removed. This pins both halves of that contract at the router level so a
/// future re-add of the old mount (or a typo in the new one) fails loudly.
/// What: drives the `router` via `oneshot` and asserts `GET /api/v1/sessions/context`
/// answers `200` while the retired `GET /api/v1/coordinator/context` answers `404`.
/// Test: this is the test.
#[tokio::test]
async fn sessions_context_replaces_coordinator_namespace() {
let get = |path: &str| {
let app = router(DaemonState::shared());
let path = path.to_string();
async move {
app.oneshot(
Request::builder()
.method("GET")
.uri(path)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
.status()
}
};
// New plural namespace responds.
assert_eq!(get("/api/v1/sessions/context").await, StatusCode::OK);
// Retired coordinator namespace is gone.
assert_eq!(
get("/api/v1/coordinator/context").await,
StatusCode::NOT_FOUND,
);
}