omni_dev/sessions/stream.rs
1//! The stream-json tracker (Feed 4): the pure state machine behind
2//! `omni-dev claude-wrap`, turning Claude's `--output-format stream-json` stdio
3//! into [`ObserveRequest`]s for the [`SessionsRegistry`].
4//!
5//! Unlike the other three feeds this one is **authoritative**. Hooks and the
6//! transcript watcher observe a session from the outside and infer what it is
7//! doing; the wrapper sits *in* the stream the Claude VS Code extension itself
8//! reads, so it sees the exact protocol events — including `can_use_tool`, the
9//! permission prompt that never reaches a transcript and is therefore invisible
10//! to Feeds 1–3 unless the user has the `Notification` hook installed. See
11//! ADR-0057.
12//!
13//! Both directions matter. A permission request travels **CLI → editor** on the
14//! child's stdout as a `control_request`; its resolution travels **editor → CLI**
15//! on the child's stdin as a `control_response`. Correlating the two by
16//! `request_id` is what makes `waiting_for_permission` exact rather than a guess,
17//! so [`StreamTracker::observe_line`] takes the [`Direction`] a line was seen in.
18//! A model switch can be the same shape the other way around: `set_model` is a
19//! `control_request` an SDK-driven caller can send **to** the CLI, over a
20//! *live* process's stdin, to switch models without waiting for the next
21//! turn's `system`/`init` line (#1448 follow-up). In practice this rarely
22//! fires for the Claude VS Code extension itself: its in-chat `/model` command
23//! is a local UI action that never sends `set_model` at all (it mutates local
24//! state and persists to the user's settings file instead), and the extension
25//! respawns a fresh wrapped process per turn rather than keeping one alive —
26//! so for that caller, a switch is still only observed via the next turn's
27//! `system`/`init` line, same as every other identity field here.
28//!
29//! Nothing here does I/O and nothing here retains conversation content: only the
30//! message `type`/`subtype`, the identity fields (`session_id`, `cwd`, `model`)
31//! and outstanding permission ids are ever read out of a line. Every parse is
32//! best-effort — an unparseable or unrecognized line is ignored, never fatal,
33//! because the wrapper must fail open (see [`crate::cli::claude_wrap`]).
34//!
35//! [`SessionsRegistry`]: super::SessionsRegistry
36
37use std::collections::HashSet;
38use std::path::PathBuf;
39
40use serde::Deserialize;
41
42use super::{ObserveRequest, SessionEvent, SessionState};
43
44/// Ceiling on simultaneously-outstanding permission requests tracked at once.
45///
46/// Claude asks about one tool at a time in practice, so this only bounds memory
47/// against a malformed or adversarial stream; ids past the cap are dropped,
48/// which can only ever make the tracker return to `working` early.
49const MAX_PENDING_PERMISSIONS: usize = 64;
50
51/// Which side of the wrapped process's stdio a line was observed on.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Direction {
54 /// A line the wrapped `claude` wrote to its **stdout** (CLI → editor).
55 FromClaude,
56 /// A line the editor wrote to the wrapped `claude`'s **stdin** (editor → CLI).
57 ToClaude,
58}
59
60/// The subset of a stream-json line this tracker reads.
61///
62/// Deliberately minimal and fully optional: the stream schema is Claude Code's
63/// internal protocol, so anything unrecognized must deserialize successfully and
64/// be ignored rather than break the feed. Conversation content (`message`,
65/// tool inputs, results) is never named here, so it is never even materialized.
66#[derive(Debug, Default, Deserialize)]
67struct StreamLine {
68 /// The message kind: `system`, `assistant`, `user`, `result`, `stream_event`,
69 /// `control_request`, `control_response`, …
70 #[serde(rename = "type", default)]
71 kind: Option<String>,
72 /// The `system` message's discriminator, notably `init`.
73 #[serde(default)]
74 subtype: Option<String>,
75 /// The Claude session id, carried on most messages.
76 #[serde(default)]
77 session_id: Option<String>,
78 /// The session's working directory, carried on `system`/`init`.
79 #[serde(default)]
80 cwd: Option<PathBuf>,
81 /// The model id, carried on `system`/`init`.
82 #[serde(default)]
83 model: Option<String>,
84 /// A control message's correlation id, when carried at the top level.
85 #[serde(default)]
86 request_id: Option<String>,
87 /// A `control_request`'s body.
88 #[serde(default)]
89 request: Option<ControlBody>,
90 /// A `control_response`'s body.
91 #[serde(default)]
92 response: Option<ControlBody>,
93}
94
95/// The shared shape of a `control_request` / `control_response` body.
96#[derive(Debug, Default, Deserialize)]
97struct ControlBody {
98 /// The control kind, e.g. `can_use_tool`, `initialize`, `hook_callback`,
99 /// `set_model`.
100 #[serde(default)]
101 subtype: Option<String>,
102 /// The correlation id, when carried inside the body rather than at the top
103 /// level (a `control_response` echoes it here).
104 #[serde(default)]
105 request_id: Option<String>,
106 /// A `set_model` request's target model id. Per Claude Code's own schema
107 /// this field is optional and simply **absent** when the editor asks to
108 /// reset to the account/session default (a value only the CLI itself can
109 /// resolve) — that case is left for the next `system`/`init` line to
110 /// settle. The literal string `"default"` is guarded against too, purely
111 /// defensively: nothing in the documented protocol sends it, but nothing
112 /// rules out a caller that does.
113 #[serde(default)]
114 model: Option<String>,
115}
116
117/// The authoritative session-state machine over one wrapped `claude` process.
118///
119/// Feed it every line of both stdio directions; it returns an [`ObserveRequest`]
120/// exactly when the session's effective state *changes*, so a long turn costs one
121/// report at its start and one at its end rather than one per streamed token.
122#[derive(Debug)]
123pub struct StreamTracker {
124 /// The session id, learned from the first line that carries one.
125 session_id: Option<String>,
126 /// The session's working directory, learned from `system`/`init`.
127 cwd: Option<PathBuf>,
128 /// The model currently in use: from the most recent `system`/`init` line,
129 /// or — sooner — a `set_model` `control_request` the editor sent. Unlike
130 /// `session_id`/`cwd`, this is **not** fill-once: Claude Code re-emits
131 /// `system`/`init` — model included — at the start of every turn (not just
132 /// session start), and a `set_model` request updates it immediately, at
133 /// the moment of the switch rather than the next turn (#1448 follow-up).
134 model: Option<String>,
135 /// The state implied by the most recent content message, before the
136 /// permission overlay is applied.
137 base: SessionState,
138 /// The `request_id`s of permission prompts asked but not yet answered.
139 pending: HashSet<String>,
140 /// The (state, model) pair most recently returned to the caller, for
141 /// change detection. Both dimensions are tracked because they change
142 /// independently: an ordinary turn changes state without the model, while
143 /// a mid-turn `set_model` changes the model without the state.
144 reported: Option<(SessionState, Option<String>)>,
145}
146
147impl StreamTracker {
148 /// Creates a tracker for one wrapped process, before any line is seen.
149 #[must_use]
150 pub fn new() -> Self {
151 Self {
152 session_id: None,
153 cwd: None,
154 model: None,
155 // A `claude` that has started but has not been prompted is sitting at
156 // the prompt: idle, not "starting". `Starting` stays the hook feed's
157 // (very brief) `SessionStart` state, so both feeds agree that a tab
158 // the user opened and has not typed into is not doing work.
159 base: SessionState::Idle,
160 pending: HashSet::new(),
161 reported: None,
162 }
163 }
164
165 /// The session id, once a line has carried one.
166 #[must_use]
167 pub fn session_id(&self) -> Option<&str> {
168 self.session_id.as_deref()
169 }
170
171 /// The most recently reported model id, once a line has carried one.
172 ///
173 /// Unlike [`Self::session_id`], this can change over a session's
174 /// lifetime (see [`Self::absorb_identity`]) — callers that need to react
175 /// to a model change compare successive reads rather than treating a
176 /// first sighting as final.
177 #[must_use]
178 pub fn model(&self) -> Option<&str> {
179 self.model.as_deref()
180 }
181
182 /// Feeds one stdio line and returns a sighting when the effective state
183 /// changed as a result.
184 ///
185 /// Returns `None` for every line that is unparseable, unrecognized, seen
186 /// before the session id is known, or that leaves the state unchanged.
187 pub fn observe_line(&mut self, direction: Direction, line: &str) -> Option<ObserveRequest> {
188 let line = line.trim();
189 if line.is_empty() {
190 return None;
191 }
192 // The tee's newline-based framing (`tee_chunk`, claude_wrap.rs) does
193 // not know about terminal control sequences: Claude's raw
194 // terminal-init bytes (title-set, mouse tracking, alt-screen — none
195 // of them newline-terminated on their own) can land in the same
196 // read() as the following stream-json line and get accumulated onto
197 // its front before the first `\n` is seen, corrupting what would
198 // otherwise be a valid line — most consequentially the first
199 // `system`/`init` line, which is what carries `model`. Every real
200 // line here is a JSON object, so skipping to the first `{` recovers
201 // it without needing to understand escape-sequence structure at all.
202 let json = line.find('{').map_or(line, |start| &line[start..]);
203 let parsed: StreamLine = serde_json::from_str(json).ok()?;
204 self.absorb_identity(direction, &parsed);
205 self.apply(direction, &parsed);
206 self.emit_if_changed()
207 }
208
209 /// Re-reports the current state, so a session that has been silent for a
210 /// while does not age out of the registry on its TTL.
211 ///
212 /// The wrapper lives exactly as long as the `claude` process does, so this
213 /// is real liveness rather than the activity-based approximation the hook and
214 /// transcript feeds are limited to.
215 #[must_use]
216 pub fn keepalive(&self) -> Option<ObserveRequest> {
217 self.request(self.state())
218 }
219
220 /// Records the identity fields carried on a line: `session_id`/`cwd` are
221 /// fill-once (never overwriting a value already learned with a later
222 /// absent one), but `model` always takes the latest non-empty value seen,
223 /// from either of two shapes — see the field doc on [`Self::model`] for
224 /// why.
225 fn absorb_identity(&mut self, direction: Direction, parsed: &StreamLine) {
226 if self.session_id.is_none() {
227 if let Some(id) = parsed.session_id.as_deref() {
228 if !id.trim().is_empty() {
229 self.session_id = Some(id.to_string());
230 }
231 }
232 }
233 if self.cwd.is_none() {
234 self.cwd.clone_from(&parsed.cwd);
235 }
236 // The top-level `model`, carried on a `system`/`init` line.
237 if let Some(model) = parsed.model.as_deref() {
238 if !model.trim().is_empty() {
239 self.model = Some(model.to_string());
240 }
241 }
242 // A `set_model` control_request, which — unlike every other identity
243 // signal here — travels editor → CLI, so it is only ever trusted on
244 // that direction (the same rule `close_permission` applies to a
245 // permission answer). An unresolved target — the field absent per the
246 // documented schema, or (defensively) the literal string "default" —
247 // cannot be resolved without the CLI's own account/session settings,
248 // so it is left alone rather than guessed; the next `system`/`init`
249 // line settles it instead.
250 //
251 // Note this path is only reachable while a wrapped process is alive:
252 // Claude Code's own in-chat `/model` command does not send this
253 // control_request at all (it mutates local state and persists to the
254 // user's settings file instead), and the VS Code extension respawns a
255 // fresh wrapped process per turn rather than keeping one alive across
256 // a whole conversation. So in practice, a model switch is still only
257 // observed at the start of the *next* turn's `system`/`init` line
258 // (handled above) — this block is a correctness improvement for
259 // whatever caller does send `set_model` to a live process (e.g. an
260 // external SDK-driven consumer), not a guaranteed instant path for
261 // this extension's own in-chat command.
262 if direction == Direction::ToClaude && parsed.kind.as_deref() == Some("control_request") {
263 if let Some(model) = parsed
264 .request
265 .as_ref()
266 .filter(|body| body.subtype.as_deref() == Some("set_model"))
267 .and_then(|body| body.model.as_deref())
268 {
269 if !model.trim().is_empty() && model != "default" {
270 self.model = Some(model.to_string());
271 }
272 }
273 }
274 }
275
276 /// Applies a line's state effect: content messages move [`Self::base`],
277 /// control messages open and close permission prompts.
278 fn apply(&mut self, direction: Direction, parsed: &StreamLine) {
279 match parsed.kind.as_deref() {
280 // The session announced itself but has not been prompted yet.
281 Some("system") if parsed.subtype.as_deref() == Some("init") => {
282 self.base = SessionState::Idle;
283 }
284 // A replayed user prompt, a streamed assistant reply, or a tool
285 // result: the turn is running.
286 Some("assistant" | "user" | "stream_event") => self.base = SessionState::Working,
287 // The turn finished. Also the drift backstop: if the stream ever
288 // stops answering a permission request in a shape this tracker
289 // recognizes, a completed turn unwedges it rather than pinning the
290 // session on `waiting_for_permission` forever.
291 Some("result") => {
292 self.base = SessionState::Idle;
293 self.pending.clear();
294 }
295 Some("control_request") if direction == Direction::FromClaude => {
296 self.open_permission(parsed);
297 }
298 Some("control_response") if direction == Direction::ToClaude => {
299 self.close_permission(parsed);
300 }
301 _ => {}
302 }
303 }
304
305 /// Records a `can_use_tool` request as outstanding; every other control
306 /// subtype (`initialize`, `hook_callback`, `mcp_message`, …) carries no
307 /// state signal and is ignored.
308 fn open_permission(&mut self, parsed: &StreamLine) {
309 let body = parsed.request.as_ref();
310 if body.and_then(|b| b.subtype.as_deref()) != Some("can_use_tool") {
311 return;
312 }
313 let Some(id) = correlation_id(parsed, body) else {
314 return;
315 };
316 if self.pending.len() < MAX_PENDING_PERMISSIONS {
317 self.pending.insert(id);
318 }
319 }
320
321 /// Clears the permission request a `control_response` answers.
322 fn close_permission(&mut self, parsed: &StreamLine) {
323 let body = parsed.response.as_ref();
324 if let Some(id) = correlation_id(parsed, body) {
325 self.pending.remove(&id);
326 }
327 }
328
329 /// The effective state: an outstanding permission prompt outranks whatever
330 /// the content messages last implied, so a reply that keeps streaming while
331 /// the user is being asked to approve a tool cannot mask the prompt.
332 fn state(&self) -> SessionState {
333 if self.pending.is_empty() {
334 self.base
335 } else {
336 SessionState::WaitingForPermission
337 }
338 }
339
340 /// Returns a sighting when the effective state or the model differs from
341 /// what was last reported, and records the new pair as reported. Emitting
342 /// on a model-only change (no state change) is what makes a `set_model`
343 /// control_request reach the daemon immediately rather than waiting for
344 /// the next state transition to carry it along.
345 fn emit_if_changed(&mut self) -> Option<ObserveRequest> {
346 let state = self.state();
347 let signature = (state, self.model.clone());
348 if self.reported.as_ref() == Some(&signature) {
349 return None;
350 }
351 let request = self.request(state)?;
352 self.reported = Some(signature);
353 Some(request)
354 }
355
356 /// Builds the sighting for `state`, or `None` while the session id is still
357 /// unknown (nothing can be keyed without it).
358 fn request(&self, state: SessionState) -> Option<ObserveRequest> {
359 Some(ObserveRequest {
360 session_id: self.session_id.clone()?,
361 cwd: self.cwd.clone(),
362 transcript_path: None,
363 event: SessionEvent::StreamState(state),
364 repo: None,
365 model: self.model.clone(),
366 })
367 }
368}
369
370impl Default for StreamTracker {
371 fn default() -> Self {
372 Self::new()
373 }
374}
375
376/// A control message's correlation id, from the body when present (where a
377/// `control_response` echoes it) and otherwise from the top level.
378fn correlation_id(parsed: &StreamLine, body: Option<&ControlBody>) -> Option<String> {
379 body.and_then(|b| b.request_id.clone())
380 .or_else(|| parsed.request_id.clone())
381}
382
383#[cfg(test)]
384#[allow(clippy::unwrap_used, clippy::expect_used)]
385mod tests {
386 use super::*;
387
388 const INIT: &str = r#"{"type":"system","subtype":"init","session_id":"sess-1","cwd":"/w/repo","model":"claude-opus-5","tools":["Read"]}"#;
389
390 fn tracker_after_init() -> StreamTracker {
391 let mut tracker = StreamTracker::new();
392 let first = tracker
393 .observe_line(Direction::FromClaude, INIT)
394 .expect("init announces the session");
395 assert_eq!(first.session_id, "sess-1");
396 tracker
397 }
398
399 fn state_of(request: &ObserveRequest) -> SessionState {
400 match request.event {
401 SessionEvent::StreamState(state) => state,
402 other => panic!("expected a stream state, got {other:?}"),
403 }
404 }
405
406 #[test]
407 fn init_announces_the_session_as_idle_with_its_identity() {
408 let mut tracker = StreamTracker::new();
409 let request = tracker.observe_line(Direction::FromClaude, INIT).unwrap();
410 assert_eq!(request.session_id, "sess-1");
411 assert_eq!(
412 request.cwd.as_deref(),
413 Some(std::path::Path::new("/w/repo"))
414 );
415 assert_eq!(request.model.as_deref(), Some("claude-opus-5"));
416 // A started-but-unprompted session sits at the prompt.
417 assert_eq!(state_of(&request), SessionState::Idle);
418 assert_eq!(tracker.session_id(), Some("sess-1"));
419 }
420
421 #[test]
422 fn nothing_is_reported_before_a_session_id_is_known() {
423 let mut tracker = StreamTracker::new();
424 // A content message with no session id moves the state but cannot be keyed.
425 assert!(tracker
426 .observe_line(Direction::FromClaude, r#"{"type":"assistant"}"#)
427 .is_none());
428 assert!(tracker.keepalive().is_none());
429 // …and the state it moved to is reported as soon as an id arrives.
430 let request = tracker
431 .observe_line(
432 Direction::FromClaude,
433 r#"{"type":"assistant","session_id":"sess-1"}"#,
434 )
435 .unwrap();
436 assert_eq!(state_of(&request), SessionState::Working);
437 }
438
439 #[test]
440 fn a_turn_reports_working_then_idle_once_each() {
441 let mut tracker = tracker_after_init();
442 let working = tracker
443 .observe_line(
444 Direction::FromClaude,
445 r#"{"type":"user","session_id":"sess-1"}"#,
446 )
447 .unwrap();
448 assert_eq!(state_of(&working), SessionState::Working);
449 // Streaming does not re-report: the state has not changed.
450 assert!(tracker
451 .observe_line(Direction::FromClaude, r#"{"type":"stream_event"}"#)
452 .is_none());
453 assert!(tracker
454 .observe_line(Direction::FromClaude, r#"{"type":"assistant"}"#)
455 .is_none());
456 let idle = tracker
457 .observe_line(
458 Direction::FromClaude,
459 r#"{"type":"result","subtype":"success"}"#,
460 )
461 .unwrap();
462 assert_eq!(state_of(&idle), SessionState::Idle);
463 }
464
465 #[test]
466 fn a_set_model_request_updates_and_reports_the_model_immediately() {
467 // `set_model` travels editor -> CLI at the exact moment the user
468 // switches models — no turn required to observe it (#1448 follow-up).
469 let mut tracker = tracker_after_init(); // model = claude-opus-5
470 let switched = tracker
471 .observe_line(
472 Direction::ToClaude,
473 r#"{"type":"control_request","request_id":"c1","request":{"subtype":"set_model","model":"claude-sonnet-5"}}"#,
474 )
475 .unwrap();
476 assert_eq!(switched.model.as_deref(), Some("claude-sonnet-5"));
477 // The state itself did not change — this session is still idle.
478 assert_eq!(state_of(&switched), SessionState::Idle);
479 }
480
481 #[test]
482 fn a_set_model_with_no_model_field_is_left_for_the_next_init_to_settle() {
483 // Per the documented schema, "reset to the account/session default" is
484 // signaled by omitting `model` entirely, not by a literal value — that
485 // cannot be resolved without the CLI's own settings, so it is not
486 // taken at face value...
487 let mut tracker = tracker_after_init(); // model = claude-opus-5
488 assert!(tracker
489 .observe_line(
490 Direction::ToClaude,
491 r#"{"type":"control_request","request_id":"c1","request":{"subtype":"set_model"}}"#,
492 )
493 .is_none());
494 // ...and the model is unchanged until the next turn's init resolves it.
495 let switched_init = r#"{"type":"system","subtype":"init","session_id":"sess-1","cwd":"/w/repo","model":"claude-haiku-4-5","tools":["Read"]}"#;
496 let resolved = tracker
497 .observe_line(Direction::FromClaude, switched_init)
498 .unwrap();
499 assert_eq!(resolved.model.as_deref(), Some("claude-haiku-4-5"));
500 }
501
502 #[test]
503 fn a_set_model_literal_default_string_is_also_left_unresolved() {
504 // Defensive only: nothing in the documented schema sends the literal
505 // string "default" (the field is simply omitted instead — see the
506 // previous test), but a hypothetical caller that did should not have
507 // that string taken as a real model id either.
508 let mut tracker = tracker_after_init(); // model = claude-opus-5
509 assert!(tracker
510 .observe_line(
511 Direction::ToClaude,
512 r#"{"type":"control_request","request_id":"c1","request":{"subtype":"set_model","model":"default"}}"#,
513 )
514 .is_none());
515 assert_eq!(
516 tracker.keepalive().unwrap().model.as_deref(),
517 Some("claude-opus-5")
518 );
519 }
520
521 #[test]
522 fn a_set_model_request_is_only_honored_from_the_editor() {
523 // The same line seen on the wrong direction (as if Claude itself sent
524 // it) is not trusted, mirroring the permission-response direction rule.
525 let mut tracker = tracker_after_init(); // model = claude-opus-5
526 assert!(tracker
527 .observe_line(
528 Direction::FromClaude,
529 r#"{"type":"control_request","request_id":"c1","request":{"subtype":"set_model","model":"claude-sonnet-5"}}"#,
530 )
531 .is_none());
532 assert_eq!(
533 tracker.keepalive().unwrap().model.as_deref(),
534 Some("claude-opus-5")
535 );
536 }
537
538 #[test]
539 fn a_later_init_confirming_the_already_set_model_does_not_re_emit() {
540 // Claude Code re-announces itself via `system`/`init` at the start of
541 // every turn, not just session start. Once `set_model` has already
542 // moved the tracker to the new model, that turn's init line — which
543 // confirms the same model — is a no-op rather than a redundant report.
544 let mut tracker = tracker_after_init(); // model = claude-opus-5
545 tracker.observe_line(
546 Direction::ToClaude,
547 r#"{"type":"control_request","request_id":"c1","request":{"subtype":"set_model","model":"claude-sonnet-5"}}"#,
548 );
549 let confirming_init = r#"{"type":"system","subtype":"init","session_id":"sess-1","cwd":"/w/repo","model":"claude-sonnet-5","tools":["Read"]}"#;
550 assert!(tracker
551 .observe_line(Direction::FromClaude, confirming_init)
552 .is_none());
553 }
554
555 #[test]
556 fn a_permission_prompt_reports_waiting_until_it_is_answered() {
557 let mut tracker = tracker_after_init();
558 tracker.observe_line(Direction::FromClaude, r#"{"type":"assistant"}"#);
559 let waiting = tracker
560 .observe_line(
561 Direction::FromClaude,
562 r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool","tool_name":"Bash"}}"#,
563 )
564 .unwrap();
565 assert_eq!(state_of(&waiting), SessionState::WaitingForPermission);
566 // Content still streaming while the user is asked must not mask the prompt.
567 assert!(tracker
568 .observe_line(Direction::FromClaude, r#"{"type":"assistant"}"#)
569 .is_none());
570 // The answer arrives on the *other* direction, echoing the id in its body.
571 let resumed = tracker
572 .observe_line(
573 Direction::ToClaude,
574 r#"{"type":"control_response","response":{"subtype":"success","request_id":"req-1"}}"#,
575 )
576 .unwrap();
577 assert_eq!(state_of(&resumed), SessionState::Working);
578 }
579
580 #[test]
581 fn a_permission_response_is_only_honored_from_the_editor() {
582 let mut tracker = tracker_after_init();
583 tracker.observe_line(
584 Direction::FromClaude,
585 r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool"}}"#,
586 );
587 // The same line seen on the wrong direction is not the answer.
588 assert!(tracker
589 .observe_line(
590 Direction::FromClaude,
591 r#"{"type":"control_response","response":{"request_id":"req-1"}}"#,
592 )
593 .is_none());
594 assert_eq!(
595 state_of(&tracker.keepalive().unwrap()),
596 SessionState::WaitingForPermission
597 );
598 }
599
600 #[test]
601 fn other_control_subtypes_carry_no_state_signal() {
602 let mut tracker = tracker_after_init();
603 for subtype in ["initialize", "hook_callback", "mcp_message", "interrupt"] {
604 let line = format!(
605 r#"{{"type":"control_request","request_id":"c","request":{{"subtype":"{subtype}"}}}}"#
606 );
607 assert!(tracker.observe_line(Direction::FromClaude, &line).is_none());
608 }
609 assert_eq!(state_of(&tracker.keepalive().unwrap()), SessionState::Idle);
610 }
611
612 #[test]
613 fn a_finished_turn_unwedges_a_stranded_permission() {
614 let mut tracker = tracker_after_init();
615 tracker.observe_line(
616 Direction::FromClaude,
617 r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool"}}"#,
618 );
619 // No matching response ever arrives (protocol drift); `result` still ends
620 // the turn rather than pinning the session on `waiting_for_permission`.
621 let idle = tracker
622 .observe_line(Direction::FromClaude, r#"{"type":"result"}"#)
623 .unwrap();
624 assert_eq!(state_of(&idle), SessionState::Idle);
625 }
626
627 #[test]
628 fn outstanding_permissions_are_capped() {
629 let mut tracker = tracker_after_init();
630 for i in 0..(MAX_PENDING_PERMISSIONS + 10) {
631 let line = format!(
632 r#"{{"type":"control_request","request_id":"req-{i}","request":{{"subtype":"can_use_tool"}}}}"#
633 );
634 tracker.observe_line(Direction::FromClaude, &line);
635 }
636 assert_eq!(tracker.pending.len(), MAX_PENDING_PERMISSIONS);
637 }
638
639 #[test]
640 fn a_blank_session_id_is_not_taken_as_identity() {
641 let mut tracker = StreamTracker::new();
642 assert!(tracker
643 .observe_line(
644 Direction::FromClaude,
645 r#"{"type":"assistant","session_id":" "}"#,
646 )
647 .is_none());
648 assert_eq!(tracker.session_id(), None);
649 // …and a real id later still lands.
650 tracker.observe_line(Direction::FromClaude, INIT);
651 assert_eq!(tracker.session_id(), Some("sess-1"));
652 }
653
654 #[test]
655 fn a_later_model_change_updates_the_tracked_model() {
656 let mut tracker = tracker_after_init();
657 assert_eq!(tracker.model(), Some("claude-opus-5"));
658 // A `/model` switch mid-session carries a second `system`/`init`
659 // line with a different model; unlike `session_id`/`cwd` this must
660 // not stay latched to the first value seen.
661 tracker.observe_line(
662 Direction::FromClaude,
663 r#"{"type":"system","subtype":"init","session_id":"sess-1","cwd":"/w/repo","model":"claude-sonnet-5","tools":["Read"]}"#,
664 );
665 assert_eq!(tracker.model(), Some("claude-sonnet-5"));
666 // session_id and cwd are unaffected by the same line.
667 assert_eq!(tracker.session_id(), Some("sess-1"));
668 }
669
670 #[test]
671 fn a_blank_model_is_not_taken_as_a_change() {
672 let mut tracker = tracker_after_init();
673 tracker.observe_line(
674 Direction::FromClaude,
675 r#"{"type":"assistant","session_id":"sess-1","model":" "}"#,
676 );
677 assert_eq!(tracker.model(), Some("claude-opus-5"));
678 }
679
680 #[test]
681 fn control_messages_with_no_correlation_id_are_ignored() {
682 let mut tracker = tracker_after_init();
683 // A permission request that cannot be correlated is not tracked, rather
684 // than pinning the session on a prompt nothing can ever answer.
685 assert!(tracker
686 .observe_line(
687 Direction::FromClaude,
688 r#"{"type":"control_request","request":{"subtype":"can_use_tool"}}"#,
689 )
690 .is_none());
691 assert_eq!(state_of(&tracker.keepalive().unwrap()), SessionState::Idle);
692 // Likewise an answer that names no request clears nothing.
693 tracker.observe_line(
694 Direction::FromClaude,
695 r#"{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool"}}"#,
696 );
697 assert!(tracker
698 .observe_line(Direction::ToClaude, r#"{"type":"control_response"}"#)
699 .is_none());
700 assert_eq!(
701 state_of(&tracker.keepalive().unwrap()),
702 SessionState::WaitingForPermission
703 );
704 }
705
706 #[test]
707 fn default_matches_a_fresh_tracker() {
708 let tracker = StreamTracker::default();
709 assert_eq!(tracker.session_id(), None);
710 assert!(tracker.keepalive().is_none());
711 }
712
713 #[test]
714 fn unparseable_and_unknown_lines_are_ignored() {
715 let mut tracker = tracker_after_init();
716 for line in [
717 "",
718 " ",
719 "not json at all",
720 "{",
721 "[]",
722 r#"{"type":"nonsense"}"#,
723 r#"{"no_type":true}"#,
724 ] {
725 assert!(tracker.observe_line(Direction::FromClaude, line).is_none());
726 }
727 assert_eq!(state_of(&tracker.keepalive().unwrap()), SessionState::Idle);
728 }
729
730 #[test]
731 fn a_json_line_preceded_by_raw_terminal_control_bytes_still_parses() {
732 // Reproduces the real-world corruption: Claude's raw terminal-init
733 // bytes (title-set, mouse tracking, …) land in the same tee'd "line"
734 // as the following system/init line when both arrive in one read(),
735 // with no newline of their own to separate them.
736 let corrupted = "\u{1b}]0;2.1.132\u{7}{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s-1\",\"cwd\":\"/w\",\"model\":\"claude-opus-5\"}";
737 let mut tracker = StreamTracker::new();
738 let request = tracker
739 .observe_line(Direction::FromClaude, corrupted)
740 .expect("the JSON object should still be recovered");
741 assert_eq!(request.session_id, "s-1");
742 assert_eq!(request.model.as_deref(), Some("claude-opus-5"));
743 }
744
745 #[test]
746 fn keepalive_re_reports_the_current_state_without_a_change() {
747 let mut tracker = tracker_after_init();
748 tracker.observe_line(Direction::FromClaude, r#"{"type":"assistant"}"#);
749 let first = tracker.keepalive().unwrap();
750 let second = tracker.keepalive().unwrap();
751 assert_eq!(state_of(&first), SessionState::Working);
752 assert_eq!(state_of(&second), SessionState::Working);
753 assert_eq!(first.session_id, "sess-1");
754 }
755}