Skip to main content

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            // `SessionStart` state, which lasts until the first prompt and which
158            // every consumer counts as idle (#1946), so both feeds agree that a
159            // tab the user opened and has not typed into is not doing work.
160            base: SessionState::Idle,
161            pending: HashSet::new(),
162            reported: None,
163        }
164    }
165
166    /// The session id, once a line has carried one.
167    #[must_use]
168    pub fn session_id(&self) -> Option<&str> {
169        self.session_id.as_deref()
170    }
171
172    /// The most recently reported model id, once a line has carried one.
173    ///
174    /// Unlike [`Self::session_id`], this can change over a session's
175    /// lifetime (see [`Self::absorb_identity`]) — callers that need to react
176    /// to a model change compare successive reads rather than treating a
177    /// first sighting as final.
178    #[must_use]
179    pub fn model(&self) -> Option<&str> {
180        self.model.as_deref()
181    }
182
183    /// Feeds one stdio line and returns a sighting when the effective state
184    /// changed as a result.
185    ///
186    /// Returns `None` for every line that is unparseable, unrecognized, seen
187    /// before the session id is known, or that leaves the state unchanged.
188    pub fn observe_line(&mut self, direction: Direction, line: &str) -> Option<ObserveRequest> {
189        let line = line.trim();
190        if line.is_empty() {
191            return None;
192        }
193        // The tee's newline-based framing (`tee_chunk`, claude_wrap.rs) does
194        // not know about terminal control sequences: Claude's raw
195        // terminal-init bytes (title-set, mouse tracking, alt-screen — none
196        // of them newline-terminated on their own) can land in the same
197        // read() as the following stream-json line and get accumulated onto
198        // its front before the first `\n` is seen, corrupting what would
199        // otherwise be a valid line — most consequentially the first
200        // `system`/`init` line, which is what carries `model`. Every real
201        // line here is a JSON object, so skipping to the first `{` recovers
202        // it without needing to understand escape-sequence structure at all.
203        let json = line.find('{').map_or(line, |start| &line[start..]);
204        let parsed: StreamLine = serde_json::from_str(json).ok()?;
205        self.absorb_identity(direction, &parsed);
206        self.apply(direction, &parsed);
207        self.emit_if_changed()
208    }
209
210    /// Re-reports the current state, so a session that has been silent for a
211    /// while does not age out of the registry on its TTL.
212    ///
213    /// The wrapper lives exactly as long as the `claude` process does, so this
214    /// is real liveness rather than the activity-based approximation the hook and
215    /// transcript feeds are limited to.
216    #[must_use]
217    pub fn keepalive(&self) -> Option<ObserveRequest> {
218        self.request(self.state())
219    }
220
221    /// Records the identity fields carried on a line: `session_id`/`cwd` are
222    /// fill-once (never overwriting a value already learned with a later
223    /// absent one), but `model` always takes the latest non-empty value seen,
224    /// from either of two shapes — see the field doc on [`Self::model`] for
225    /// why.
226    fn absorb_identity(&mut self, direction: Direction, parsed: &StreamLine) {
227        if self.session_id.is_none() {
228            if let Some(id) = parsed.session_id.as_deref() {
229                if !id.trim().is_empty() {
230                    self.session_id = Some(id.to_string());
231                }
232            }
233        }
234        if self.cwd.is_none() {
235            self.cwd.clone_from(&parsed.cwd);
236        }
237        // The top-level `model`, carried on a `system`/`init` line.
238        if let Some(model) = parsed.model.as_deref() {
239            if !model.trim().is_empty() {
240                self.model = Some(model.to_string());
241            }
242        }
243        // A `set_model` control_request, which — unlike every other identity
244        // signal here — travels editor → CLI, so it is only ever trusted on
245        // that direction (the same rule `close_permission` applies to a
246        // permission answer). An unresolved target — the field absent per the
247        // documented schema, or (defensively) the literal string "default" —
248        // cannot be resolved without the CLI's own account/session settings,
249        // so it is left alone rather than guessed; the next `system`/`init`
250        // line settles it instead.
251        //
252        // Note this path is only reachable while a wrapped process is alive:
253        // Claude Code's own in-chat `/model` command does not send this
254        // control_request at all (it mutates local state and persists to the
255        // user's settings file instead), and the VS Code extension respawns a
256        // fresh wrapped process per turn rather than keeping one alive across
257        // a whole conversation. So in practice, a model switch is still only
258        // observed at the start of the *next* turn's `system`/`init` line
259        // (handled above) — this block is a correctness improvement for
260        // whatever caller does send `set_model` to a live process (e.g. an
261        // external SDK-driven consumer), not a guaranteed instant path for
262        // this extension's own in-chat command.
263        if direction == Direction::ToClaude && parsed.kind.as_deref() == Some("control_request") {
264            if let Some(model) = parsed
265                .request
266                .as_ref()
267                .filter(|body| body.subtype.as_deref() == Some("set_model"))
268                .and_then(|body| body.model.as_deref())
269            {
270                if !model.trim().is_empty() && model != "default" {
271                    self.model = Some(model.to_string());
272                }
273            }
274        }
275    }
276
277    /// Applies a line's state effect: content messages move [`Self::base`],
278    /// control messages open and close permission prompts.
279    fn apply(&mut self, direction: Direction, parsed: &StreamLine) {
280        match parsed.kind.as_deref() {
281            // The session announced itself but has not been prompted yet.
282            Some("system") if parsed.subtype.as_deref() == Some("init") => {
283                self.base = SessionState::Idle;
284            }
285            // A replayed user prompt, a streamed assistant reply, or a tool
286            // result: the turn is running.
287            Some("assistant" | "user" | "stream_event") => self.base = SessionState::Working,
288            // The turn finished. Also the drift backstop: if the stream ever
289            // stops answering a permission request in a shape this tracker
290            // recognizes, a completed turn unwedges it rather than pinning the
291            // session on `waiting_for_permission` forever.
292            Some("result") => {
293                self.base = SessionState::Idle;
294                self.pending.clear();
295            }
296            Some("control_request") if direction == Direction::FromClaude => {
297                self.open_permission(parsed);
298            }
299            Some("control_response") if direction == Direction::ToClaude => {
300                self.close_permission(parsed);
301            }
302            _ => {}
303        }
304    }
305
306    /// Records a `can_use_tool` request as outstanding; every other control
307    /// subtype (`initialize`, `hook_callback`, `mcp_message`, …) carries no
308    /// state signal and is ignored.
309    fn open_permission(&mut self, parsed: &StreamLine) {
310        let body = parsed.request.as_ref();
311        if body.and_then(|b| b.subtype.as_deref()) != Some("can_use_tool") {
312            return;
313        }
314        let Some(id) = correlation_id(parsed, body) else {
315            return;
316        };
317        if self.pending.len() < MAX_PENDING_PERMISSIONS {
318            self.pending.insert(id);
319        }
320    }
321
322    /// Clears the permission request a `control_response` answers.
323    fn close_permission(&mut self, parsed: &StreamLine) {
324        let body = parsed.response.as_ref();
325        if let Some(id) = correlation_id(parsed, body) {
326            self.pending.remove(&id);
327        }
328    }
329
330    /// The effective state: an outstanding permission prompt outranks whatever
331    /// the content messages last implied, so a reply that keeps streaming while
332    /// the user is being asked to approve a tool cannot mask the prompt.
333    fn state(&self) -> SessionState {
334        if self.pending.is_empty() {
335            self.base
336        } else {
337            SessionState::WaitingForPermission
338        }
339    }
340
341    /// Returns a sighting when the effective state or the model differs from
342    /// what was last reported, and records the new pair as reported. Emitting
343    /// on a model-only change (no state change) is what makes a `set_model`
344    /// control_request reach the daemon immediately rather than waiting for
345    /// the next state transition to carry it along.
346    fn emit_if_changed(&mut self) -> Option<ObserveRequest> {
347        let state = self.state();
348        let signature = (state, self.model.clone());
349        if self.reported.as_ref() == Some(&signature) {
350            return None;
351        }
352        let request = self.request(state)?;
353        self.reported = Some(signature);
354        Some(request)
355    }
356
357    /// Builds the sighting for `state`, or `None` while the session id is still
358    /// unknown (nothing can be keyed without it).
359    fn request(&self, state: SessionState) -> Option<ObserveRequest> {
360        Some(ObserveRequest {
361            pid: None,
362            agent: crate::sessions::Agent::Claude,
363            session_id: self.session_id.clone()?,
364            cwd: self.cwd.clone(),
365            transcript_path: None,
366            event: SessionEvent::StreamState(state),
367            repo: None,
368            model: self.model.clone(),
369        })
370    }
371}
372
373impl Default for StreamTracker {
374    fn default() -> Self {
375        Self::new()
376    }
377}
378
379/// A control message's correlation id, from the body when present (where a
380/// `control_response` echoes it) and otherwise from the top level.
381fn correlation_id(parsed: &StreamLine, body: Option<&ControlBody>) -> Option<String> {
382    body.and_then(|b| b.request_id.clone())
383        .or_else(|| parsed.request_id.clone())
384}
385
386#[cfg(test)]
387#[allow(clippy::unwrap_used, clippy::expect_used)]
388mod tests {
389    use super::*;
390
391    const INIT: &str = r#"{"type":"system","subtype":"init","session_id":"sess-1","cwd":"/w/repo","model":"claude-opus-5","tools":["Read"]}"#;
392
393    fn tracker_after_init() -> StreamTracker {
394        let mut tracker = StreamTracker::new();
395        let first = tracker
396            .observe_line(Direction::FromClaude, INIT)
397            .expect("init announces the session");
398        assert_eq!(first.session_id, "sess-1");
399        tracker
400    }
401
402    fn state_of(request: &ObserveRequest) -> SessionState {
403        match request.event {
404            SessionEvent::StreamState(state) => state,
405            other => panic!("expected a stream state, got {other:?}"),
406        }
407    }
408
409    #[test]
410    fn init_announces_the_session_as_idle_with_its_identity() {
411        let mut tracker = StreamTracker::new();
412        let request = tracker.observe_line(Direction::FromClaude, INIT).unwrap();
413        assert_eq!(request.session_id, "sess-1");
414        assert_eq!(
415            request.cwd.as_deref(),
416            Some(std::path::Path::new("/w/repo"))
417        );
418        assert_eq!(request.model.as_deref(), Some("claude-opus-5"));
419        // A started-but-unprompted session sits at the prompt.
420        assert_eq!(state_of(&request), SessionState::Idle);
421        assert_eq!(tracker.session_id(), Some("sess-1"));
422    }
423
424    #[test]
425    fn nothing_is_reported_before_a_session_id_is_known() {
426        let mut tracker = StreamTracker::new();
427        // A content message with no session id moves the state but cannot be keyed.
428        assert!(tracker
429            .observe_line(Direction::FromClaude, r#"{"type":"assistant"}"#)
430            .is_none());
431        assert!(tracker.keepalive().is_none());
432        // …and the state it moved to is reported as soon as an id arrives.
433        let request = tracker
434            .observe_line(
435                Direction::FromClaude,
436                r#"{"type":"assistant","session_id":"sess-1"}"#,
437            )
438            .unwrap();
439        assert_eq!(state_of(&request), SessionState::Working);
440    }
441
442    #[test]
443    fn a_turn_reports_working_then_idle_once_each() {
444        let mut tracker = tracker_after_init();
445        let working = tracker
446            .observe_line(
447                Direction::FromClaude,
448                r#"{"type":"user","session_id":"sess-1"}"#,
449            )
450            .unwrap();
451        assert_eq!(state_of(&working), SessionState::Working);
452        // Streaming does not re-report: the state has not changed.
453        assert!(tracker
454            .observe_line(Direction::FromClaude, r#"{"type":"stream_event"}"#)
455            .is_none());
456        assert!(tracker
457            .observe_line(Direction::FromClaude, r#"{"type":"assistant"}"#)
458            .is_none());
459        let idle = tracker
460            .observe_line(
461                Direction::FromClaude,
462                r#"{"type":"result","subtype":"success"}"#,
463            )
464            .unwrap();
465        assert_eq!(state_of(&idle), SessionState::Idle);
466    }
467
468    #[test]
469    fn a_set_model_request_updates_and_reports_the_model_immediately() {
470        // `set_model` travels editor -> CLI at the exact moment the user
471        // switches models — no turn required to observe it (#1448 follow-up).
472        let mut tracker = tracker_after_init(); // model = claude-opus-5
473        let switched = tracker
474            .observe_line(
475                Direction::ToClaude,
476                r#"{"type":"control_request","request_id":"c1","request":{"subtype":"set_model","model":"claude-sonnet-5"}}"#,
477            )
478            .unwrap();
479        assert_eq!(switched.model.as_deref(), Some("claude-sonnet-5"));
480        // The state itself did not change — this session is still idle.
481        assert_eq!(state_of(&switched), SessionState::Idle);
482    }
483
484    #[test]
485    fn a_set_model_with_no_model_field_is_left_for_the_next_init_to_settle() {
486        // Per the documented schema, "reset to the account/session default" is
487        // signaled by omitting `model` entirely, not by a literal value — that
488        // cannot be resolved without the CLI's own settings, so it is not
489        // taken at face value...
490        let mut tracker = tracker_after_init(); // model = claude-opus-5
491        assert!(tracker
492            .observe_line(
493                Direction::ToClaude,
494                r#"{"type":"control_request","request_id":"c1","request":{"subtype":"set_model"}}"#,
495            )
496            .is_none());
497        // ...and the model is unchanged until the next turn's init resolves it.
498        let switched_init = r#"{"type":"system","subtype":"init","session_id":"sess-1","cwd":"/w/repo","model":"claude-haiku-4-5","tools":["Read"]}"#;
499        let resolved = tracker
500            .observe_line(Direction::FromClaude, switched_init)
501            .unwrap();
502        assert_eq!(resolved.model.as_deref(), Some("claude-haiku-4-5"));
503    }
504
505    #[test]
506    fn a_set_model_literal_default_string_is_also_left_unresolved() {
507        // Defensive only: nothing in the documented schema sends the literal
508        // string "default" (the field is simply omitted instead — see the
509        // previous test), but a hypothetical caller that did should not have
510        // that string taken as a real model id either.
511        let mut tracker = tracker_after_init(); // model = claude-opus-5
512        assert!(tracker
513            .observe_line(
514                Direction::ToClaude,
515                r#"{"type":"control_request","request_id":"c1","request":{"subtype":"set_model","model":"default"}}"#,
516            )
517            .is_none());
518        assert_eq!(
519            tracker.keepalive().unwrap().model.as_deref(),
520            Some("claude-opus-5")
521        );
522    }
523
524    #[test]
525    fn a_set_model_request_is_only_honored_from_the_editor() {
526        // The same line seen on the wrong direction (as if Claude itself sent
527        // it) is not trusted, mirroring the permission-response direction rule.
528        let mut tracker = tracker_after_init(); // model = claude-opus-5
529        assert!(tracker
530            .observe_line(
531                Direction::FromClaude,
532                r#"{"type":"control_request","request_id":"c1","request":{"subtype":"set_model","model":"claude-sonnet-5"}}"#,
533            )
534            .is_none());
535        assert_eq!(
536            tracker.keepalive().unwrap().model.as_deref(),
537            Some("claude-opus-5")
538        );
539    }
540
541    #[test]
542    fn a_later_init_confirming_the_already_set_model_does_not_re_emit() {
543        // Claude Code re-announces itself via `system`/`init` at the start of
544        // every turn, not just session start. Once `set_model` has already
545        // moved the tracker to the new model, that turn's init line — which
546        // confirms the same model — is a no-op rather than a redundant report.
547        let mut tracker = tracker_after_init(); // model = claude-opus-5
548        tracker.observe_line(
549            Direction::ToClaude,
550            r#"{"type":"control_request","request_id":"c1","request":{"subtype":"set_model","model":"claude-sonnet-5"}}"#,
551        );
552        let confirming_init = r#"{"type":"system","subtype":"init","session_id":"sess-1","cwd":"/w/repo","model":"claude-sonnet-5","tools":["Read"]}"#;
553        assert!(tracker
554            .observe_line(Direction::FromClaude, confirming_init)
555            .is_none());
556    }
557
558    #[test]
559    fn a_permission_prompt_reports_waiting_until_it_is_answered() {
560        let mut tracker = tracker_after_init();
561        tracker.observe_line(Direction::FromClaude, r#"{"type":"assistant"}"#);
562        let waiting = tracker
563            .observe_line(
564                Direction::FromClaude,
565                r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool","tool_name":"Bash"}}"#,
566            )
567            .unwrap();
568        assert_eq!(state_of(&waiting), SessionState::WaitingForPermission);
569        // Content still streaming while the user is asked must not mask the prompt.
570        assert!(tracker
571            .observe_line(Direction::FromClaude, r#"{"type":"assistant"}"#)
572            .is_none());
573        // The answer arrives on the *other* direction, echoing the id in its body.
574        let resumed = tracker
575            .observe_line(
576                Direction::ToClaude,
577                r#"{"type":"control_response","response":{"subtype":"success","request_id":"req-1"}}"#,
578            )
579            .unwrap();
580        assert_eq!(state_of(&resumed), SessionState::Working);
581    }
582
583    #[test]
584    fn a_permission_response_is_only_honored_from_the_editor() {
585        let mut tracker = tracker_after_init();
586        tracker.observe_line(
587            Direction::FromClaude,
588            r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool"}}"#,
589        );
590        // The same line seen on the wrong direction is not the answer.
591        assert!(tracker
592            .observe_line(
593                Direction::FromClaude,
594                r#"{"type":"control_response","response":{"request_id":"req-1"}}"#,
595            )
596            .is_none());
597        assert_eq!(
598            state_of(&tracker.keepalive().unwrap()),
599            SessionState::WaitingForPermission
600        );
601    }
602
603    #[test]
604    fn other_control_subtypes_carry_no_state_signal() {
605        let mut tracker = tracker_after_init();
606        for subtype in ["initialize", "hook_callback", "mcp_message", "interrupt"] {
607            let line = format!(
608                r#"{{"type":"control_request","request_id":"c","request":{{"subtype":"{subtype}"}}}}"#
609            );
610            assert!(tracker.observe_line(Direction::FromClaude, &line).is_none());
611        }
612        assert_eq!(state_of(&tracker.keepalive().unwrap()), SessionState::Idle);
613    }
614
615    #[test]
616    fn a_finished_turn_unwedges_a_stranded_permission() {
617        let mut tracker = tracker_after_init();
618        tracker.observe_line(
619            Direction::FromClaude,
620            r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool"}}"#,
621        );
622        // No matching response ever arrives (protocol drift); `result` still ends
623        // the turn rather than pinning the session on `waiting_for_permission`.
624        let idle = tracker
625            .observe_line(Direction::FromClaude, r#"{"type":"result"}"#)
626            .unwrap();
627        assert_eq!(state_of(&idle), SessionState::Idle);
628    }
629
630    #[test]
631    fn outstanding_permissions_are_capped() {
632        let mut tracker = tracker_after_init();
633        for i in 0..(MAX_PENDING_PERMISSIONS + 10) {
634            let line = format!(
635                r#"{{"type":"control_request","request_id":"req-{i}","request":{{"subtype":"can_use_tool"}}}}"#
636            );
637            tracker.observe_line(Direction::FromClaude, &line);
638        }
639        assert_eq!(tracker.pending.len(), MAX_PENDING_PERMISSIONS);
640    }
641
642    #[test]
643    fn a_blank_session_id_is_not_taken_as_identity() {
644        let mut tracker = StreamTracker::new();
645        assert!(tracker
646            .observe_line(
647                Direction::FromClaude,
648                r#"{"type":"assistant","session_id":"   "}"#,
649            )
650            .is_none());
651        assert_eq!(tracker.session_id(), None);
652        // …and a real id later still lands.
653        tracker.observe_line(Direction::FromClaude, INIT);
654        assert_eq!(tracker.session_id(), Some("sess-1"));
655    }
656
657    #[test]
658    fn a_later_model_change_updates_the_tracked_model() {
659        let mut tracker = tracker_after_init();
660        assert_eq!(tracker.model(), Some("claude-opus-5"));
661        // A `/model` switch mid-session carries a second `system`/`init`
662        // line with a different model; unlike `session_id`/`cwd` this must
663        // not stay latched to the first value seen.
664        tracker.observe_line(
665            Direction::FromClaude,
666            r#"{"type":"system","subtype":"init","session_id":"sess-1","cwd":"/w/repo","model":"claude-sonnet-5","tools":["Read"]}"#,
667        );
668        assert_eq!(tracker.model(), Some("claude-sonnet-5"));
669        // session_id and cwd are unaffected by the same line.
670        assert_eq!(tracker.session_id(), Some("sess-1"));
671    }
672
673    #[test]
674    fn a_blank_model_is_not_taken_as_a_change() {
675        let mut tracker = tracker_after_init();
676        tracker.observe_line(
677            Direction::FromClaude,
678            r#"{"type":"assistant","session_id":"sess-1","model":"   "}"#,
679        );
680        assert_eq!(tracker.model(), Some("claude-opus-5"));
681    }
682
683    #[test]
684    fn control_messages_with_no_correlation_id_are_ignored() {
685        let mut tracker = tracker_after_init();
686        // A permission request that cannot be correlated is not tracked, rather
687        // than pinning the session on a prompt nothing can ever answer.
688        assert!(tracker
689            .observe_line(
690                Direction::FromClaude,
691                r#"{"type":"control_request","request":{"subtype":"can_use_tool"}}"#,
692            )
693            .is_none());
694        assert_eq!(state_of(&tracker.keepalive().unwrap()), SessionState::Idle);
695        // Likewise an answer that names no request clears nothing.
696        tracker.observe_line(
697            Direction::FromClaude,
698            r#"{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool"}}"#,
699        );
700        assert!(tracker
701            .observe_line(Direction::ToClaude, r#"{"type":"control_response"}"#)
702            .is_none());
703        assert_eq!(
704            state_of(&tracker.keepalive().unwrap()),
705            SessionState::WaitingForPermission
706        );
707    }
708
709    #[test]
710    fn default_matches_a_fresh_tracker() {
711        let tracker = StreamTracker::default();
712        assert_eq!(tracker.session_id(), None);
713        assert!(tracker.keepalive().is_none());
714    }
715
716    #[test]
717    fn unparseable_and_unknown_lines_are_ignored() {
718        let mut tracker = tracker_after_init();
719        for line in [
720            "",
721            "   ",
722            "not json at all",
723            "{",
724            "[]",
725            r#"{"type":"nonsense"}"#,
726            r#"{"no_type":true}"#,
727        ] {
728            assert!(tracker.observe_line(Direction::FromClaude, line).is_none());
729        }
730        assert_eq!(state_of(&tracker.keepalive().unwrap()), SessionState::Idle);
731    }
732
733    #[test]
734    fn a_json_line_preceded_by_raw_terminal_control_bytes_still_parses() {
735        // Reproduces the real-world corruption: Claude's raw terminal-init
736        // bytes (title-set, mouse tracking, …) land in the same tee'd "line"
737        // as the following system/init line when both arrive in one read(),
738        // with no newline of their own to separate them.
739        let corrupted = "\u{1b}]0;2.1.132\u{7}{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s-1\",\"cwd\":\"/w\",\"model\":\"claude-opus-5\"}";
740        let mut tracker = StreamTracker::new();
741        let request = tracker
742            .observe_line(Direction::FromClaude, corrupted)
743            .expect("the JSON object should still be recovered");
744        assert_eq!(request.session_id, "s-1");
745        assert_eq!(request.model.as_deref(), Some("claude-opus-5"));
746    }
747
748    #[test]
749    fn keepalive_re_reports_the_current_state_without_a_change() {
750        let mut tracker = tracker_after_init();
751        tracker.observe_line(Direction::FromClaude, r#"{"type":"assistant"}"#);
752        let first = tracker.keepalive().unwrap();
753        let second = tracker.keepalive().unwrap();
754        assert_eq!(state_of(&first), SessionState::Working);
755        assert_eq!(state_of(&second), SessionState::Working);
756        assert_eq!(first.session_id, "sess-1");
757    }
758}