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(¤t(&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(¤t(&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(¤t(&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(¤t(&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(¤t(&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}