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