Skip to main content

forge_workspace/
session_task.rs

1//! Per-session actor. Owns one `Arc<AgentHandle>`'s event drain
2//! (`AgentHandle::take_events`) plus a per-session `Command`
3//! receiver. Translates each [`AgentEvent`] into the matching
4//! [`SessionUpdate`] envelope and emits onto the workspace-wide
5//! fan-out, which hands one update to every subscriber. Mutates
6//! [`DomainSession`] inline before each
7//! emit so workspace-side projections stay current.
8//!
9//! `SessionTask::run` is the sole consumer of the AgentHandle event
10//! stream.
11
12use std::sync::Arc;
13
14use forge_agent::AgentHandle;
15use forge_agent::client::AgentEvent;
16use forge_primitives::SessionId;
17
18use parking_lot::Mutex;
19use tokio::sync::mpsc;
20use tracing::Instrument;
21
22use crate::SessionSlot;
23use crate::domain_session::{DomainSession, PendingAutoContinue};
24use crate::handoff;
25use crate::protocol::{
26    Command, NoticeSeverity, PendingInteractionSlot, PromptSource, SessionUpdate,
27};
28use crate::update_fanout::UpdateFanout;
29
30pub(crate) struct SessionTask {
31    pub(crate) key: SessionSlot,
32    pub(crate) handle: Arc<AgentHandle>,
33    pub(crate) command_rx: mpsc::UnboundedReceiver<Command>,
34    pub(crate) domain: Arc<Mutex<DomainSession>>,
35    pub(crate) update_tx: UpdateFanout,
36    /// Tracks whether the first `Connected` has been emitted. The
37    /// second-and-beyond Connected on the same task drives
38    /// `SessionUpdate::SessionReplaced` instead (covers `/new`,
39    /// login, logout flows).
40    pub(crate) connected_once: bool,
41    /// Weak reference to the parent [`crate::Workspace`]. Used inside
42    /// [`Self::translate_event`]'s `Connected` arm to call back into
43    /// `Workspace::record_connected_session`
44    /// so the project catalog stays current as freshly-spawned
45    /// sessions reach Connected. `Weak` avoids the Workspace<->Task
46    /// reference cycle (Workspace holds Task's command_tx; Task
47    /// holds Workspace).
48    pub(crate) workspace: std::sync::Weak<crate::Workspace>,
49    /// Compactions this run has crossed, for the handoff policy's
50    /// `after_compactions` backstop.
51    pub(crate) compaction_count: u32,
52    /// The handoff this session arrived by, while its first ten turns are
53    /// measured. `None` for a session that arrived another way - the counts
54    /// exist to tune the trigger, and a session nobody handed to has
55    /// nothing to compare against.
56    pub(crate) successor: Option<handoff::SuccessorWatch>,
57    /// Fires as this task drops - after `run`'s exit path has disconnected
58    /// the subprocess (waited it out up to the close-wait budget, then
59    /// SIGKILLed it) - so a despawn can hold its worktree removal until
60    /// the session's own child is reaped. Held for every exit, early
61    /// returns and panics included, so a waiter never hangs on a task that
62    /// ended some other way.
63    pub(crate) teardown_done: Option<tokio::sync::watch::Sender<bool>>,
64}
65
66impl SessionTask {
67    pub(crate) async fn run(mut self) {
68        tracing::debug!(
69            target: "forge_workspace::session_task",
70            slot = %self.key.display(),
71            "session task started"
72        );
73        // Take the agent's event receiver. The bridge seeds it on
74        // spawn; if it's already been taken (future refactor double-
75        // tapping), surface as ConnectionFailed + return.
76        let Some(mut event_rx) = self.handle.take_events() else {
77            tracing::error!(
78                target: "forge_workspace::session_task",
79                slot = %self.key.display(),
80                "AgentHandle::take_events returned None; session task aborting"
81            );
82            self.emit(SessionUpdate::ConnectionFailed {
83                key: self.key.clone(),
84                message: "agent event receiver unavailable".to_owned(),
85                fatal: false,
86            });
87            return;
88        };
89
90        // Forge-side display_name is known the instant the workspace
91        // picks an account. Emit ForgeAccountIdentity now so welcome
92        // rendering shows the right label from the first frame.
93        if let Some(display_name) = self.handle.display_name() {
94            self.emit(SessionUpdate::ForgeAccountIdentity { key: self.key.clone(), display_name });
95        }
96
97        loop {
98            tokio::select! {
99                maybe_event = event_rx.recv() => {
100                    let Some(event) = maybe_event else { break; };
101                    let event = self.declared_models_for_session(event);
102                    if !self.translate_event(event) {
103                        break;
104                    }
105                }
106                maybe_cmd = self.command_rx.recv() => {
107                    let Some(cmd) = maybe_cmd else { break; };
108                    self.execute_command(cmd);
109                }
110            }
111        }
112        tracing::info!(
113            target: "forge_workspace::session_task",
114            slot = %self.key.display(),
115            "session task exiting (agent event channel closed)"
116        );
117        // The agent-event / command channel closed - this session's
118        // subprocess is gone. Release it from the pool + command_senders
119        // so a later cron fire / projects-pane click sees it as
120        // not-running and re-spawns cleanly, instead of dispatching a
121        // `Command::Prompt` to the now-closed channel (which fails with
122        // `SessionClosed` and is silently dropped, quietly stopping
123        // durable crons for the project). Guarded on handle identity so
124        // a superseded task (its session re-spawned under the same key)
125        // doesn't wipe its successor's live entries.
126        if let Some(workspace) = self.workspace.upgrade() {
127            workspace.release_session_if_current(&self.key, &self.handle);
128        }
129        // Take the bridge's client slot and run the SDK's graceful
130        // shutdown. Without this, release / despawn / reader-death left
131        // the `claude` subprocess (and its reader/writer/stderr tasks)
132        // alive until forge exited - the bridge holds the `Client` and
133        // `Client` has no `Drop` of its own.
134        self.handle.disconnect().await;
135    }
136
137    /// Replace a Connected event's CLI-advertised `available_models`
138    /// with the session's org's declared models before `translate_event`
139    /// sees it (covering both the `Connected` and `SessionReplaced`
140    /// emits). Synchronous - the rows are read from config, not fetched.
141    fn declared_models_for_session(&self, event: AgentEvent) -> AgentEvent {
142        let AgentEvent::Connected {
143            session_id,
144            cwd,
145            current_model,
146            available_models: _,
147            mode,
148            history_updates,
149            compaction_count,
150        } = event
151        else {
152            return event;
153        };
154        // Declared models replace discovery: the picker rows are the
155        // org's accounts' declared models, authored in forge.toml.
156        let (available_models, canonical_model) = match self.workspace.upgrade() {
157            Some(workspace) => (
158                workspace.declared_models_for_session(&self.key),
159                workspace.canonical_model_for_session(&self.key),
160            ),
161            None => (Vec::new(), None),
162        };
163        // The project's declared model names the session: it is what
164        // forge stamped into the CLI's model slots and what every
165        // model-name surface must show, rather than the id the CLI
166        // resolved on its own.
167        let current_model = match canonical_model {
168            Some(model) => forge_primitives::CurrentModel {
169                requested_id: Some(model.clone()),
170                display_name_short: model.clone(),
171                display_name_long: model,
172                ..current_model
173            },
174            None => current_model,
175        };
176        AgentEvent::Connected {
177            session_id,
178            cwd,
179            current_model,
180            available_models,
181            mode,
182            history_updates,
183            compaction_count,
184        }
185    }
186
187    /// Translate one `AgentEvent` into the matching `SessionUpdate`.
188    /// Updates `DomainSession` in-place before each emit.
189    ///
190    /// Returns `true` to keep the run loop running; `false` when the
191    /// event is terminal for this task and the loop must exit (so the
192    /// exit path releases the session and disconnects the subprocess).
193    pub(crate) fn translate_event(&mut self, event: AgentEvent) -> bool {
194        // **A frame is stamped as it crosses, before either half reads it.**
195        // The CLI stamps assistant frames and tool results and nothing else -
196        // not the prompt that OPENS a turn, not a system frame, not the result
197        // that ends one - so a page served those rows had no stamp to anchor a
198        // running turn's clock on, and read 0.0s until an assistant frame
199        // landed. The stamp is this task's own receipt: a frame that carries
200        // one keeps it.
201        let mut event = event;
202        if let AgentEvent::SdkMessage { msg, .. } = &mut event {
203            msg.stamp_if_absent(&crate::install::rfc3339_now());
204        }
205        // First, update DomainSession in-place.
206        let nudge_armed;
207        // Whether this event closed a turn normally, which is the one
208        // boundary a handoff may act on. Captured here because the domain
209        // lock is released before the handoff runs.
210        let turn_completed;
211        let turn_cancelled;
212        let turn_ended;
213        let repo_nudge;
214        let lost_prompts;
215        {
216            let mut guard = self.domain.lock();
217            let moved = apply_event_to_domain(&mut guard, &event);
218            nudge_armed = moved.nudge;
219            repo_nudge = moved.repo_nudge;
220            turn_completed = matches!(moved.turn_ended, Some(MovedTurnEnd::Complete { .. }));
221            turn_cancelled = matches!(moved.turn_ended, Some(MovedTurnEnd::Cancelled));
222            turn_ended = moved.turn_ended.is_some();
223            lost_prompts = moved.lost_prompts;
224            // The two sets that move whole are announced from the scope the
225            // fold wrote in, so what goes out is what the core now holds -
226            // and only when the fold moved it.
227            if moved.monitors {
228                self.emit(SessionUpdate::MonitorsChanged {
229                    key: self.key.clone(),
230                    monitors: guard.monitors.clone(),
231                });
232            }
233            if moved.background_tasks {
234                self.emit(SessionUpdate::BackgroundTasksChanged {
235                    key: self.key.clone(),
236                    tasks: guard.task_rows(),
237                });
238            }
239            if moved.commands {
240                self.emit(SessionUpdate::SlashCommandsChanged {
241                    key: self.key.clone(),
242                    commands: guard.available_commands.clone(),
243                });
244            }
245            if moved.agents {
246                self.emit(SessionUpdate::SubagentsChanged {
247                    key: self.key.clone(),
248                    subagents: guard.available_agents.clone(),
249                });
250            }
251            if moved.cards {
252                self.emit(SessionUpdate::SubagentCardsChanged {
253                    key: self.key.clone(),
254                    cards: guard.cards_snapshot.clone(),
255                });
256            }
257            if moved.processes {
258                self.emit(SessionUpdate::ProcessesChanged {
259                    key: self.key.clone(),
260                    // The walk the store now holds, which the clear left
261                    // empty: the shape a client draws no section from.
262                    snapshot: forge_agent::env::processes::ProcessSnapshot {
263                        processes: Vec::new(),
264                        scanned_at: std::time::SystemTime::now(),
265                    },
266                });
267            }
268            if let Some(ended) = &moved.turn_ended {
269                match ended {
270                    MovedTurnEnd::Complete { terminal_reason } => {
271                        self.emit(SessionUpdate::TurnComplete {
272                            key: self.key.clone(),
273                            terminal_reason: *terminal_reason,
274                        });
275                    }
276                    MovedTurnEnd::Cancelled => {
277                        self.emit(SessionUpdate::TurnCancelled { key: self.key.clone() });
278                    }
279                }
280            }
281        }
282        // The turn settled at the fold - completed, failed or cancelled - so
283        // the seat is open with nothing in flight. **Written before the
284        // boundary hooks**: `after_turn_ended` can start the next turn
285        // synchronously (a parked handoff's judgment prompt goes through
286        // `route` and commits `InTurn`), and an Idle write after it would
287        // erase the turn now running.
288        if turn_ended && let Some(workspace) = self.workspace.upgrade() {
289            workspace.record_seat_state(&self.key, crate::store::sessions::SeatState::Idle);
290        }
291        // The CLI reported a repository mutation: re-read the seat's tree on
292        // the frame, and for a published change look the PR up again.
293        if let Some(pr_stale) = repo_nudge
294            && let Some(workspace) = self.workspace.upgrade()
295        {
296            if pr_stale {
297                workspace.note_code_change_published(&self.key);
298            } else {
299                workspace.note_repository_activity(&self.key);
300            }
301        }
302        // A handoff acts only on a turn boundary: never mid-turn, and never
303        // on a transport-died one. A cancelled judgment turn is the reader
304        // stopping the prompt the brief was coming from, which ends the
305        // handoff rather than finishing it.
306        if turn_completed {
307            self.after_turn_ended();
308        }
309        if turn_cancelled {
310            self.abort_cancelled_handoff();
311        }
312
313        // Mirror Connected into the project catalog so the Projects
314        // pane's drilldown reflects newly-spawned sessions without
315        // forcing a full disk re-scan.
316        if let AgentEvent::Connected { session_id, cwd, .. } = &event
317            && !cwd.is_empty()
318            && let Some(workspace) = self.workspace.upgrade()
319        {
320            // Skip the catalog mirror for workers. The projects pane
321            // draws a project's own sessions from this catalog and its
322            // workers from `live_workers`, so a mirrored worker would be
323            // listed as one of the project's sessions - and it arrives
324            // here before its JSONL carries the forge:worker tag that
325            // keeps the boot scan from listing it.
326            if workspace.worker_lookup_for_session(&self.key).is_none() {
327                workspace.record_connected_session(cwd, session_id, None);
328            }
329        }
330
331        match event {
332            AgentEvent::Connected {
333                session_id,
334                cwd,
335                current_model,
336                available_models,
337                mode,
338                history_updates,
339                compaction_count,
340            } => {
341                let read = history_updates.unwrap_or_default();
342                // **The read becomes a window HERE, and the update carries
343                // it.** A resume hands over the whole transcript, and every
344                // consumer of it - the transport's copy seeded from the
345                // update, every subscriber taking a clone of the update -
346                // cuts it to the same window anyway. So the read's own size
347                // (2.5GiB on the seat #2150 was filed from) may not ride the
348                // update or be paid again per consumer: one cut, and the
349                // window is what is handed over.
350                let (mut window, shed) = crate::conversation_window::Window::tail_of(&read);
351                let left_behind = shed.frames;
352                // **The handover has no page to fold into yet.** The debt the
353                // cut took from the newest turn rides the frames, so the
354                // first counter it still carries absorbs it before the update
355                // crosses - a resume of a thought-heavy turn otherwise serves
356                // only the suffix.
357                window.carry_thinking(shed);
358                // **And the cut itself rides along.** A consumer holding the
359                // window alone reads it as the conversation's front, and a
360                // client's walk below the window would stop there rather than
361                // crossing into the transcript - the floor a cursor below the
362                // window is measured against is exactly what the window left
363                // behind.
364                //
365                // The read goes before the window's second copy is taken, so
366                // the peak is the window's rather than the transcript plus it.
367                drop(read);
368                let history = window.messages().to_vec();
369                self.compaction_count = compaction_count;
370                // The slot the task was spawned under, which the CLI's
371                // own id never moves: it names the occupant, this names
372                // the seat.
373                let key = self.key.clone();
374                // The session came up, so whatever failed here last is
375                // over. This arm is where that clears rather than the
376                // spawn entry: production hoists a domain straight into
377                // `domain_handles` and never registers one, so a clear on
378                // registration would never run at all.
379                //
380                // **A death the reader has not been shown does not hold
381                // this record down.** It rides `died` and its own reason on
382                // the row, so the lifecycle goes back to telling the truth
383                // about NOW - a recovered, working seat reading Failed is
384                // the same lie the issue was filed against, mirrored.
385                if let Some(workspace) = self.workspace.upgrade() {
386                    workspace.clear_spawn_failure(&key);
387                    workspace.note_seat_connected(&key);
388                }
389                // Nothing re-keys on `Connected`: the task is
390                // registered under the slot the CLI never moves, so
391                // `pool`, `command_senders` and `domain_handles` are
392                // already under the key every `Command` addresses. The
393                // id is recorded against the slot's row below, which is
394                // what a restart resumes the occupant from.
395                // Worker sessions get their tag JSONL row written by
396                // `apply_worker_tag_or_rollback`, which spawns a
397                // detached tokio task. The helper checks live_workers
398                // for a matching entry; for leads + non-worker
399                // sessions this is a no-op (early return on
400                // `worker_lookup_for_session` returning None).
401                //
402                // Critical: the helper fires on BOTH first-Connected
403                // and subsequent Connecteds (the /new / login /
404                // logout flow that enters via `connected_once`).
405                // Each Connected carries a fresh session_id and the
406                // worker's tag must travel to the new JSONL - a
407                // post-/new JSONL left untagged is listed by the boot
408                // scan as one of the project's own sessions instead of
409                // being hidden as a worker's. Captured before the
410                // if/else because both branches consume the `cwd` field.
411                //
412                // The detached task does an initial retry loop on
413                // `Io(NotFound)` (claude writes the JSONL lazily on
414                // first turn, so an idle-spawned worker has no file
415                // at Connected). If retries exhaust on NotFound, the
416                // worker still transitions to Running with
417                // `needs_tag = true`; the opportunistic retry in
418                // `handle_deliver_worker_prompt` catches the tag
419                // when the first turn arrives. Non-NotFound errors
420                // (permission denied, disk full, etc.) DO roll back:
421                // release session + emit Removed.
422                //
423                // TODO(ved): lead sessions are currently never tagged
424                // with `forge:lead`, which the catalog tolerates but
425                // nobody relies on. Explicit lead tagging is a spec gap
426                // we should close (apply the same retry pattern via a
427                // sibling `apply_lead_tag_or_warn` that does NOT roll
428                // back on failure - just warns).
429                let cwd_for_tag = cwd.clone();
430                // Captured before the branches below set it: a `/new`,
431                // login or logout arrives as a Connected on a task that
432                // has already connected once, and its row predates it.
433                let replacing = self.connected_once;
434                if replacing {
435                    // A successor that is replaced before its ten turns is
436                    // no longer the session the handoff wrote for. A
437                    // reconnect of that same occupant keeps the watch: the
438                    // ten prompts are still its own.
439                    if self.successor.as_ref().is_none_or(|watch| watch.to != session_id) {
440                        self.successor = None;
441                    }
442                    // A running handoff needs no separate end here: the
443                    // connect's own `clear_runtime_identity` drops it with
444                    // the occupant it belonged to, and the gate below hands
445                    // back what the swap held.
446                }
447                if self.connected_once {
448                    // Drop oneshots from the previous identity so parked
449                    // forwarder tasks exit instead of waiting on
450                    // tool_call_ids the new session will never produce.
451                    // Each one is announced: a view drawing a dock from the
452                    // request it folded would otherwise keep offering a
453                    // prompt the core has let go.
454                    let dropped: Vec<(String, Option<u64>)> = self
455                        .domain
456                        .lock()
457                        .pending_interactions
458                        .drain()
459                        .map(|(tool_id, slot)| {
460                            // A question names the round it was parked as, so
461                            // the views dropping it drop the same one.
462                            let question_index = match slot {
463                                PendingInteractionSlot::Question { request, .. } => {
464                                    Some(request.question_index)
465                                }
466                                PendingInteractionSlot::Permission { .. } => None,
467                            };
468                            (tool_id, question_index)
469                        })
470                        .collect();
471                    for (tool_id, question_index) in dropped {
472                        self.emit(SessionUpdate::PendingInteractionResolved {
473                            key: self.key.clone(),
474                            tool_id,
475                            question_index,
476                            answered_with: None,
477                        });
478                    }
479                    // Everything parked for the replaced identity has no
480                    // session left to drain it, so the bucket is dropped
481                    // and any message in it is acknowledged back to its
482                    // sender.
483                    if let Some(workspace) = self.workspace.upgrade() {
484                        workspace.expire_parked_for_slot(
485                            &self.key,
486                            crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
487                        );
488                    }
489                    // Same reason: the replaced identity never produces a
490                    // Result, so the buffer this turn's review actions sit
491                    // in would never be drained.
492                    let caller = self.domain.lock().key.clone();
493                    self.drain_review_activity_for(&caller);
494                    // Session-scoped `/dictate` state dies with the
495                    // identity too: a view mints a blank bucket for
496                    // the replaced session, so an override left here
497                    // would style a session that no longer exists
498                    // while the readout claims the crate defaults.
499                    // The device pick is NOT here - it is workspace
500                    // state shared by every session and outlives the
501                    // replacement.
502                    {
503                        let mut domain = self.domain.lock();
504                        domain.dictate_overrides = crate::dictate::DictateOverrides::default();
505                    }
506                    // The store is not the first-Connected path's alone:
507                    // every replacement adopts an id forge did not choose,
508                    // and a boot resolves this session from its row.
509                    if let Some(workspace) = self.workspace.upgrade() {
510                        workspace.note_running_session_id(&self.key, &session_id);
511                        // The listener set is keyed by seat, so a swap leaves
512                        // it alone: the successor has to be told it is heard.
513                        workspace.voice_occupant_changed(&self.key);
514                    }
515                    self.emit(SessionUpdate::SessionReplaced {
516                        key: key.clone(),
517                        session_id: SessionId::new(session_id.clone()),
518                        cwd,
519                        current_model,
520                        available_models,
521                        mode,
522                        history,
523                        dropped: left_behind,
524                        compaction_count,
525                    });
526                    // After SessionReplaced so the echo lands on the
527                    // bucket it kept: the cleared values re-affirm the
528                    // blank state.
529                    self.emit(SessionUpdate::DictateOverrides {
530                        key: key.clone(),
531                        overrides: crate::dictate::DictateOverrides::default(),
532                    });
533                    // **Only the successor the brief names consumes it.** A
534                    // login or logout can replace the occupant while the swap
535                    // is in flight, and that session is not the one the brief
536                    // was written for - so it neither takes the brief nor
537                    // draws a row it did not join. The gate closes either way:
538                    // the seat is no longer the handoff's, and what it held is
539                    // handed to whoever is here now.
540                    let awaited_successor = self.workspace.upgrade().is_some_and(|workspace| {
541                        workspace
542                            .pending_brief_for(&key)
543                            .is_some_and(|brief| brief.to == session_id)
544                    });
545                    // The swap's gate opens before the brief is delivered, so
546                    // the brief itself is not taken by it - and what waited
547                    // during the swap follows the brief, in arrival order.
548                    let held = self.workspace.upgrade().map(|workspace| workspace.end_swap(&key));
549                    if awaited_successor {
550                        self.deliver_pending_brief(&key, &session_id);
551                    }
552                    // **The successor's measurement is armed HERE**, on the
553                    // connect a handoff actually lands as - the replacement
554                    // branch, not the first connect a boot would take. Its
555                    // brief turn is the next boundary and is not one of the
556                    // ten. A later reconnect of the same occupant keeps the
557                    // watch it already has rather than restarting the ten.
558                    if awaited_successor || self.successor.is_none() {
559                        self.successor = self.workspace.upgrade().and_then(|workspace| {
560                            workspace.handoff_for_successor(&key, &session_id).map(
561                                |(record, predecessor_reads)| handoff::SuccessorWatch {
562                                    to: record.to,
563                                    turns: 0,
564                                    predecessor_reads,
565                                    measure_at_boundary: false,
566                                },
567                            )
568                        });
569                    }
570                    if let (Some(workspace), Some(held)) = (self.workspace.upgrade(), held) {
571                        for prompt in held {
572                            let sender = prompt.sender.clone();
573                            let first = prompt.text.lines().next().unwrap_or_default().to_owned();
574                            // **The id the hold carried, not a fresh one.** The
575                            // delivery frame and the transcript row already
576                            // share it, and a cron's pairing is stored under
577                            // it - a new id here would orphan the row's
578                            // schedule (#1815).
579                            //
580                            // `route`, not `dispatch`: this is a re-delivery of
581                            // words already recorded, not a new press, so it
582                            // takes no dispatch entry's command handling.
583                            let uuid = prompt.uuid;
584                            if let Err(error) = workspace.route(Command::PromptUnder {
585                                key: key.clone(),
586                                text: prompt.text,
587                                attachments: prompt.attachments,
588                                uuid,
589                                source: prompt.source,
590                                dispatch_id: None,
591                            }) {
592                                tracing::warn!(
593                                    target: "forge_workspace::session_task",
594                                    event_name = "handoff_held_prompt_delivery_failed",
595                                    slot = %key.display(),
596                                    %error,
597                                    "a prompt held during the handoff could not be delivered",
598                                );
599                                if let Some(sender) = sender {
600                                    workspace.notice_undelivered_message(
601                                        &sender,
602                                        &key,
603                                        crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
604                                    );
605                                } else {
606                                    self.emit(SessionUpdate::Notice {
607                                        key: key.clone(),
608                                        severity: NoticeSeverity::Error,
609                                        text: format!(
610                                            "a prompt held during the handoff could not be delivered: {first}"
611                                        ),
612                                    });
613                                }
614                            }
615                        }
616                    }
617                } else {
618                    // **A git worker whose worktree is gone is failed BEFORE
619                    // it is kicked, announced or handed its parked prompts.**
620                    // Those are this branch's side effects, and doing them
621                    // first announced a dead worker live and dispatched
622                    // crons, Gotify, Slack and peer prompts into a session
623                    // the failure then released (#1593).
624                    if self
625                        .workspace
626                        .upgrade()
627                        .is_some_and(|workspace| workspace.fail_worker_without_worktree(&self.key))
628                    {
629                        return false;
630                    }
631                    self.connected_once = true;
632                    // When the connecting session is a project's lead and
633                    // persists workers that are not live yet, dispatch
634                    // one SpawnWorker per row. Idempotent via the
635                    // live_workers gate - a reconnect after a transient
636                    // failure skips re-spawn.
637                    if let Some(workspace) = self.workspace.upgrade() {
638                        let force_new = self.domain.lock().spawned_force_new;
639                        maybe_respawn_workers_on_connected(&workspace, &self.key, force_new);
640                        maybe_kick_worker_on_connected(&workspace, &self.key);
641                        maybe_kick_lead_on_connected(&workspace, &self.key, &self.domain);
642                        // A session that arrived by a handoff is measured for
643                        // its first ten turns, which is what says whether the
644                        // brief carried enough. This is the boot/resume
645                        // path: any brief turn is already in the transcript,
646                        // so nothing is skipped here.
647                        self.successor = workspace
648                            .handoff_for_successor(&self.key, &session_id)
649                            .map(|(record, predecessor_reads)| handoff::SuccessorWatch {
650                                to: record.to,
651                                turns: 0,
652                                predecessor_reads,
653                                measure_at_boundary: false,
654                            });
655                    }
656                    // A boot resolves a session from the store, so the id
657                    // the CLI adopted is recorded rather than left to the
658                    // next boot to guess wrong.
659                    if let Some(workspace) = self.workspace.upgrade() {
660                        workspace.note_running_session_id(&self.key, &session_id);
661                        // A `/resume` on a seat with no live task arrives
662                        // here rather than through `SessionReplaced`, so the
663                        // successor is told it is heard on this path too.
664                        workspace.voice_occupant_changed(&self.key);
665                    }
666                    self.emit(SessionUpdate::Connected {
667                        key: key.clone(),
668                        session_id: SessionId::new(session_id.clone()),
669                        cwd,
670                        current_model,
671                        available_models,
672                        mode,
673                        history,
674                        dropped: left_behind,
675                        compaction_count,
676                    });
677                    // Everything parked for this session's slot while
678                    // it was asleep, delivered in arrival order: peer
679                    // prompts, then crons, then Gotify, then Slack. Each
680                    // re-dispatches as a regular `Command::Prompt` so the
681                    // prompt-delivery path handles it uniformly with
682                    // user-typed prompts.
683                    self.drain_parked();
684                }
685                // A connect on a slot whose last occupant died is not a
686                // silent swap: the reader is told which session died and
687                // whether this one resumed it or replaced it under a new id.
688                // Announced once, by the first connect that answers the
689                // death, whichever id it lands under; the record itself
690                // stays until a reader is shown the seat AND this
691                // announcement has gone out.
692                if let Some(workspace) = self.workspace.upgrade()
693                    && let Some(dead) = workspace.unannounced_death(&key)
694                {
695                    let text = if dead.as_str() == session_id.as_str() {
696                        format!("The agent's session ended; forge resumed it ({session_id}).")
697                    } else {
698                        format!(
699                            "The agent's session ended; forge restarted it under a new id ({dead} -> {session_id})."
700                        )
701                    };
702                    self.emit(SessionUpdate::Notice {
703                        key: key.clone(),
704                        severity: NoticeSeverity::Error,
705                        text,
706                    });
707                }
708                // Re-tag must fire on BOTH first-Connected and
709                // post-/new Connected paths: a /new writes a fresh
710                // JSONL, and an untagged one is listed by the boot scan
711                // as one of the project's own sessions.
712                if let Some(workspace) = self.workspace.upgrade() {
713                    // The spawn's row provenance rides the domain, so a
714                    // rollback can tell a row this spawn minted from one
715                    // a resume inherited. A replacement Connected is a
716                    // `/new` re-tag of an established worker, whose row
717                    // predates this connection and is not this one's to
718                    // take.
719                    let minted_row = !replacing && self.domain.lock().spawn_wrote_row;
720                    workspace.apply_worker_tag_or_rollback(
721                        &key,
722                        &session_id,
723                        &cwd_for_tag,
724                        minted_row,
725                    );
726                }
727                // **After the occupant's own frame**: a replacement resets the
728                // page's conversation, so a row drawn before it would be
729                // wiped before the reader saw it.
730                self.settle_lost_queued(lost_prompts);
731            }
732            AgentEvent::ConnectionFailed { message, kind } => {
733                let key = self.key.clone();
734                // Before the release below takes the session with it: a
735                // view reading the slot afterwards has nothing left to say
736                // the attempt happened.
737                if let Some(workspace) = self.workspace.upgrade() {
738                    workspace.record_spawn_failure(&key, &message);
739                    // An occupant that had been up and ended here died; the
740                    // connect that next lands on the slot is told so and says
741                    // which session it answers for. What rides on the row
742                    // until a reader is shown the seat is the DEATH, not this
743                    // failure - the recovering connect clears the failure as
744                    // it always did, and `record_death` keeps the reason for
745                    // the row. A spawn that never connected records none:
746                    // nothing was running.
747                    if self.connected_once
748                        && let Some(occupant) = self.domain.lock().session_id.clone()
749                    {
750                        // WARN, and with the slot on it: this is the one line
751                        // that says a seat's session ended, and a reader
752                        // triaging it needs to know which seat.
753                        tracing::warn!(
754                            target: "forge_workspace::session_task",
755                            event_name = "session_ended",
756                            slot = %key.display(),
757                            session_id = %occupant,
758                            error = %message,
759                            "the session's subprocess ended; the seat keeps the death until a reader is shown it",
760                        );
761                        workspace.note_seat_death(&key, &message);
762                        workspace.record_death(&key, occupant, &message);
763                    }
764                    // **A healing seat's new occupant that never came up is
765                    // a failed attempt, not a first spawn to surface.** The
766                    // heal progress standing on the slot is what tells the
767                    // two apart.
768                    if !self.connected_once && workspace.heal_progress(&key).is_some() {
769                        workspace.note_seat_death(&key, &message);
770                    }
771                }
772                // A `/new` or `/resume` that fails to respawn ends the
773                // live turn without a Result, so flush the turn's own
774                // bookkeeping here.
775                let caller = self.domain.lock().key.clone();
776                self.drain_review_activity_for(&caller);
777                // A spawn that never connected still holds everything
778                // parked for its slot: drop the bucket and acknowledge any
779                // message in it back to its sender, before the
780                // user-visible ConnectionFailed lands.
781                if let Some(workspace) = self.workspace.upgrade() {
782                    workspace.expire_parked_for_slot(
783                        &self.key,
784                        crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
785                    );
786                    // A handoff whose successor never came up has no
787                    // occupant to give its brief to; the row stays and
788                    // `/resume <old-id>` is the recovery. A prompt held for
789                    // that successor is told it did not run rather than
790                    // vanishing with the swap.
791                    workspace.clear_pending_brief(&self.key);
792                    for prompt in workspace.end_swap(&self.key) {
793                        // **A peer's sender hears it, the way the parked
794                        // bucket answers one.** A cron or a person's prompt
795                        // has no sender but the seat, so the seat is told.
796                        if let Some(sender) = &prompt.sender {
797                            workspace.notice_undelivered_message(
798                                sender,
799                                &self.key,
800                                crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
801                            );
802                            continue;
803                        }
804                        let first = prompt.text.lines().next().unwrap_or_default();
805                        // The composer's mark for this press has no row coming
806                        // either, so it is settled here rather than left
807                        // saying "sending" for a prompt that never ran.
808                        self.emit(SessionUpdate::PromptLifecycle {
809                            key: self.key.clone(),
810                            uuid: prompt.uuid,
811                            state: "cancelled".to_owned(),
812                        });
813                        self.emit(SessionUpdate::Notice {
814                            key: self.key.clone(),
815                            severity: NoticeSeverity::Error,
816                            text: format!(
817                                "the handoff failed before this prompt could run: {first}"
818                            ),
819                        });
820                    }
821                    // Worker async spawn failure: classify the
822                    // failure, dispatch a typed
823                    // WorkerSpawnFailedNotice envelope to the lead's
824                    // chat if the classifier identifies a worktree-
825                    // creation failure, and roll back the
826                    // WorkerEntry regardless of classifier outcome
827                    // (parity with the sync rollback in
828                    // handle_spawn_worker). Lead-session and
829                    // non-worker callers see no behavioural change
830                    // - this branch is a no-op for them.
831                    workspace.handle_async_worker_spawn_failure(&key, &message, kind);
832                }
833                // Before the failure frame, while the page's pile is still
834                // standing: the row draws over the pile's own card, and the
835                // card settles with it.
836                self.settle_lost_queued(lost_prompts);
837                self.emit(SessionUpdate::ConnectionFailed {
838                    key: key.clone(),
839                    message,
840                    fatal: false,
841                });
842                // **No machine turn survives the session's death.** A handoff
843                // whose successor never came up and a compaction both end with
844                // the subprocess, and a seat left holding the blocker would be
845                // one nobody can type into. After the failure, so a client
846                // reads the death first and the stand-down second.
847                self.emit(SessionUpdate::MachineTurn { key: self.key.clone(), machine: None });
848                // Terminal for this task: the spawn failed before any
849                // Connected, so the pooled handle is dead and must not
850                // survive it - otherwise the pool fast path hands the
851                // dead handle back to every later click / cron fire /
852                // peer ask and the retry "succeeds" into nothing.
853                // Release both registrations (the resolved key the
854                // workspace maps hold; `release_session_if_current` no-ops
855                // on an absent key. The run loop then exits, so Drop's
856                // expiry backstop fires too.
857                if let Some(workspace) = self.workspace.upgrade() {
858                    workspace.release_session_if_current(&self.key, &self.handle);
859                }
860                return false;
861            }
862            AgentEvent::PermissionRequest { session_id, request } => {
863                let tool_call_id = request.tool_call.tool_call_id.clone();
864                let wire_request = request;
865                let (response_tx, response_rx) =
866                    tokio::sync::oneshot::channel::<forge_primitives::PermissionOutcome>();
867                {
868                    let mut guard = self.domain.lock();
869                    guard.pending_interactions.insert(
870                        tool_call_id.clone(),
871                        PendingInteractionSlot::Permission {
872                            tx: response_tx,
873                            request: Box::new(wire_request.clone()),
874                        },
875                    );
876                }
877                if let Some(workspace) = self.workspace.upgrade() {
878                    workspace.note_ask(&self.key);
879                }
880                let answerable = self.update_tx.send_answering(SessionUpdate::PermissionRequest {
881                    key: self.key.clone(),
882                    tool_id: tool_call_id.clone(),
883                    request: wire_request,
884                });
885                // Spawned for both outcomes: the forwarder is the only
886                // consumer of `response_rx`, so without it a `Cancelled`
887                // sent below is discarded and the bridge waits for ever.
888                spawn_permission_response_forwarder(
889                    Arc::clone(&self.handle),
890                    response_rx,
891                    session_id,
892                    tool_call_id.clone(),
893                );
894                if !answerable {
895                    // No subscriber can answer a permission prompt -
896                    // either none is attached or none renders one.
897                    // Send Cancelled through the forwarder's oneshot so
898                    // the SDK callback unblocks rather than hanging the
899                    // `claude` subprocess turn forever.
900                    if let Some(pending) =
901                        self.domain.lock().pending_interactions.remove(&tool_call_id)
902                        && let PendingInteractionSlot::Permission { tx, .. } = pending
903                    {
904                        let _ = tx.send(forge_primitives::PermissionOutcome::Cancelled);
905                        // An observer that folded the request keeps drawing
906                        // it otherwise, and its answer reaches nothing.
907                        self.emit(SessionUpdate::PendingInteractionResolved {
908                            key: self.key.clone(),
909                            tool_id: tool_call_id.clone(),
910                            question_index: None,
911                            answered_with: None,
912                        });
913                    }
914                    tracing::warn!(
915                        target: "forge_workspace::session_task",
916                        slot = %self.key.display(),
917                        tool_id = %tool_call_id,
918                        "no subscriber can answer the PermissionRequest; orphaned oneshot cancelled"
919                    );
920                }
921            }
922            AgentEvent::QuestionRequest { session_id, request } => {
923                let tool_call_id = request.tool_call.tool_call_id.clone();
924                let wire_request = request;
925                let (response_tx, response_rx) =
926                    tokio::sync::oneshot::channel::<forge_primitives::QuestionOutcome>();
927                {
928                    let mut guard = self.domain.lock();
929                    guard.pending_interactions.insert(
930                        tool_call_id.clone(),
931                        PendingInteractionSlot::Question {
932                            tx: response_tx,
933                            request: Box::new(wire_request.clone()),
934                        },
935                    );
936                }
937                if let Some(workspace) = self.workspace.upgrade() {
938                    workspace.note_ask(&self.key);
939                }
940                let answerable = self.update_tx.send_answering(SessionUpdate::QuestionRequest {
941                    key: self.key.clone(),
942                    tool_id: tool_call_id.clone(),
943                    request: wire_request,
944                });
945                // Spawned for both outcomes: the forwarder is the only
946                // consumer of `response_rx`, so without it a `Cancelled`
947                // sent below is discarded and the bridge waits for ever.
948                spawn_question_response_forwarder(
949                    Arc::clone(&self.handle),
950                    response_rx,
951                    session_id,
952                    tool_call_id.clone(),
953                );
954                if !answerable {
955                    // No subscriber can answer a question - either none
956                    // is attached or none renders one. Send Cancelled
957                    // through the forwarder's oneshot so the SDK callback
958                    // unblocks rather than hanging the turn.
959                    if let Some(pending) =
960                        self.domain.lock().pending_interactions.remove(&tool_call_id)
961                        && let PendingInteractionSlot::Question { tx, request, .. } = pending
962                    {
963                        let _ = tx.send(forge_primitives::QuestionOutcome::Cancelled);
964                        // An observer that folded the request keeps drawing
965                        // it otherwise, and its answer reaches nothing.
966                        self.emit(SessionUpdate::PendingInteractionResolved {
967                            key: self.key.clone(),
968                            tool_id: tool_call_id.clone(),
969                            question_index: Some(request.question_index),
970                            answered_with: None,
971                        });
972                    }
973                    tracing::warn!(
974                        target: "forge_workspace::session_task",
975                        slot = %self.key.display(),
976                        tool_id = %tool_call_id,
977                        "no subscriber can answer the QuestionRequest; orphaned oneshot cancelled"
978                    );
979                }
980            }
981            AgentEvent::McpOperationError { error, .. } => {
982                self.emit(SessionUpdate::McpOperationError { key: self.key.clone(), error });
983            }
984            AgentEvent::PromptCancelResolved { uuid, cancelled } => {
985                // A confirmed cancel drops the row here as well as at the
986                // client: the CLI's own terminal frame for the prompt may be
987                // the only other word, and a reader that asked to drop it must
988                // not be left guessing.
989                if cancelled {
990                    self.domain.lock().drop_queued_prompt(&uuid);
991                }
992                self.emit(SessionUpdate::PromptCancelResolved {
993                    key: self.key.clone(),
994                    uuid: uuid.clone(),
995                    cancelled,
996                });
997                // **A cancelled judgment prompt ends the handoff.** It can be
998                // a real pile row (the client offers Backspace/Delete on it),
999                // and a prompt that never runs never produces the turn whose
1000                // Result would finish the handoff.
1001                let is_judgment = {
1002                    let domain = self.domain.lock();
1003                    domain.handoff.judgment_uuid.as_deref() == Some(uuid.as_str())
1004                };
1005                if cancelled && is_judgment {
1006                    self.abort_handoff("the handoff's brief was cancelled; this session continues");
1007                }
1008            }
1009            AgentEvent::SetModeFailed { mode, message, .. } => {
1010                self.emit(SessionUpdate::SetModeFailed { key: self.key.clone(), mode, message });
1011            }
1012            AgentEvent::SetModelFailed { model, message, .. } => {
1013                self.emit(SessionUpdate::SetModelFailed { key: self.key.clone(), model, message });
1014            }
1015            AgentEvent::TurnError { message, class, .. } => {
1016                self.emit(SessionUpdate::TurnError {
1017                    key: self.key.clone(),
1018                    message,
1019                    class: Some(class),
1020                    terminal_reason: None,
1021                });
1022            }
1023            AgentEvent::ContextUsage {
1024                session_id,
1025                percentage,
1026                max_tokens,
1027                total_tokens,
1028                effective_max_tokens,
1029            } => {
1030                self.emit(SessionUpdate::ContextUsageSnapshot {
1031                    key: self.key.clone(),
1032                    percentage,
1033                    max_tokens,
1034                });
1035                self.evaluate_handoff_trigger(
1036                    &session_id,
1037                    percentage,
1038                    total_tokens,
1039                    effective_max_tokens,
1040                );
1041            }
1042            AgentEvent::McpSnapshot { servers, error, .. } => {
1043                self.emit(SessionUpdate::McpSnapshot { key: self.key.clone(), servers, error });
1044            }
1045            AgentEvent::SdkMessage { msg, .. } => {
1046                // The judgment turn is forge's own prompt, so the last
1047                // assistant text before it ends is the half it wrote.
1048                self.capture_judgment(&msg);
1049                // A rate-limit window that is not `allowed` proves the
1050                // bound account exhausted: report it so the gateway
1051                // cools the account down and drops this session's
1052                // binding - the CLI's next request re-selects. The
1053                // trigger is literal: `allowed_warning` and unknown
1054                // statuses rotate too, because a warning already means
1055                // the window is closing. The binding is keyed by the
1056                // segment the child's base URL was stamped with, which
1057                // is the id the child runs under.
1058                if let forge_primitives::Message::RateLimitEvent { rate_limit_info, .. } = &msg
1059                    && rate_limit_info.status != forge_primitives::RateLimitStatus::Allowed
1060                    && let Some(workspace) = self.workspace.upgrade()
1061                {
1062                    let reset_at = rate_limit_info.resets_at.and_then(|t| u64::try_from(t).ok());
1063                    if let Some(session_id) = self.session_id_string() {
1064                        workspace.gateway.report_rate_limit(
1065                            self.key.org(),
1066                            self.key.project(),
1067                            &session_id,
1068                            reset_at,
1069                        );
1070                    }
1071                }
1072                // Settle ONE turn at the boundary, so the in-flight guards
1073                // stop refusing once the turn ends. `Message::Result` is the
1074                // SDK's signal that the assistant turn has fully completed -
1075                // including a cancelled one, which lands as
1076                // `error_during_execution` - and `after_turn_ended` already
1077                // served it; `Message::Error` is the CLI's last-gasp
1078                // transport failure, after which no Result follows, so it
1079                // serves its own turn here. A prompt committed before this
1080                // frame landed stays outstanding: clearing the flag outright
1081                // would make a running seat read idle.
1082                if matches!(msg, forge_primitives::Message::Error { .. }) {
1083                    let mut guard = self.domain.lock();
1084                    guard.serve_turn();
1085                }
1086                if matches!(
1087                    msg,
1088                    forge_primitives::Message::Result { .. }
1089                        | forge_primitives::Message::Error { .. }
1090                ) {
1091                    let caller = self.domain.lock().key.clone();
1092                    // A delivery held off this turn goes out now, where it
1093                    // starts a turn of its own rather than folding into the
1094                    // one that just ended. Only a `Result` is a turn
1095                    // ended on a live session: an `Error` is the transport
1096                    // dying, and dispatching into it would drop the held
1097                    // prompt where nobody hears - the death path's own
1098                    // expiry tells its sender instead.
1099                    if matches!(msg, forge_primitives::Message::Result { .. })
1100                        && let Some(workspace) = self.workspace.upgrade()
1101                    {
1102                        crate::spawn::drain_turn_held_for_slot(&workspace, &self.key);
1103                    }
1104                    // Turn end: flush this session's accumulated review
1105                    // actions into one batched notice per review, routed to
1106                    // each review's submit origin.
1107                    self.drain_review_activity_for(&caller);
1108                }
1109                // The init frame is where the CLI says what it can do. The
1110                // pile needs `msg_lifecycle_v1`: without it nothing settles a
1111                // queued row, so the seat stops recording them rather than
1112                // birthing cards that can never drain. Read here rather than
1113                // in a view, because what a view then draws follows from what
1114                // the core recorded.
1115                if let forge_primitives::Message::System { subtype, data, .. } = &msg
1116                    && subtype == "init"
1117                {
1118                    let advertised =
1119                        data.get("capabilities").and_then(serde_json::Value::as_array).is_some_and(
1120                            |caps| caps.iter().any(|cap| cap.as_str() == Some("msg_lifecycle_v1")),
1121                        );
1122                    let first_sight = {
1123                        let mut guard = self.domain.lock();
1124                        let first = guard.lifecycle_frames.is_none();
1125                        if first {
1126                            guard.lifecycle_frames = Some(advertised);
1127                        }
1128                        first
1129                    };
1130                    if first_sight && !advertised {
1131                        // **The first prompt can already be waiting.** The
1132                        // capability is only known from this frame, and the
1133                        // CLI's first user message lands before it - so a
1134                        // prompt recorded while the flag was `None` would
1135                        // never be settled by a frame. Draw every person's
1136                        // row now, the only carrier it will get, and settle
1137                        // the pile.
1138                        let held = { self.domain.lock().take_queued_prompts() };
1139                        for prompt in held {
1140                            if prompt.source == PromptSource::You {
1141                                self.emit_prompt_row(
1142                                    &prompt.text,
1143                                    &prompt.attachments,
1144                                    &prompt.uuid,
1145                                    prompt.source,
1146                                );
1147                            }
1148                            // The state only drains the card and releases the
1149                            // held row; no CLI frame follows on this seat.
1150                            self.emit(SessionUpdate::PromptLifecycle {
1151                                key: self.key.clone(),
1152                                uuid: prompt.uuid,
1153                                state: "started".to_owned(),
1154                            });
1155                        }
1156                        tracing::warn!(
1157                            target: "forge_workspace::session_task",
1158                            slot = %self.key.display(),
1159                            event_name = "lifecycle_frames_absent",
1160                            outcome = "pile_disabled",
1161                            "this session's CLI does not advertise msg_lifecycle_v1, so queued \
1162                             prompts cannot be followed here; the pile is not drawn for this seat, \
1163                             its waiting rows drew on this frame and later prompts draw at dispatch",
1164                        );
1165                    }
1166                }
1167                // A prompt's queue state is state, not conversation: the
1168                // prompt's own presence in the record is the transcript's
1169                // enqueue row and the `queued_command` attachment, and
1170                // retaining three frames per prompt would put three rows for
1171                // it into every view's transcript under rule 25. It advances
1172                // the pile and is emitted, then the conversation path below is
1173                // skipped entirely.
1174                if let forge_primitives::Message::CommandLifecycle { command_uuid, state, .. } =
1175                    &msg
1176                {
1177                    // **The judgment prompt's own start is what makes the
1178                    // next turn end its turn.** Without that, the end of any
1179                    // other turn (a continuation, a prompt sent before the
1180                    // decision) would be read as the reply the brief is
1181                    // built from.
1182                    let judgment_settled_short = {
1183                        let mut domain = self.domain.lock();
1184                        let is_judgment =
1185                            domain.handoff.judgment_uuid.as_deref() == Some(command_uuid.as_str());
1186                        let started_before = domain.handoff.judgment_started;
1187                        if is_judgment && matches!(state.as_str(), "started" | "completed") {
1188                            domain.handoff.judgment_started = true;
1189                        }
1190                        is_judgment
1191                            && (matches!(state.as_str(), "cancelled" | "discarded" | "refused")
1192                                // A `completed` with no `started` before it is a
1193                                // prompt the CLI settled without running.
1194                                || (state == "completed" && !started_before))
1195                    };
1196                    // **The page takes the prompt's row here, when the CLI
1197                    // starts it** (#2816): the queue announced the words while
1198                    // they waited, and a row forged at dispatch sat above the
1199                    // answer the running turn was still writing. Read before
1200                    // the advance, because `started` is a settled state and
1201                    // the entry leaves the pile as its row arrives.
1202                    let started_row = (state == "started")
1203                        .then(|| self.domain.lock().queued_prompt(command_uuid).cloned())
1204                        .flatten()
1205                        .filter(|prompt| prompt.source == PromptSource::You);
1206                    let known = { self.domain.lock().advance_queued_prompt(command_uuid, state) };
1207                    // **A judgment prompt the CLI settles without running
1208                    // ends the handoff**, for the same reason a reader's
1209                    // cancel does: no turn will ever finish it.
1210                    if judgment_settled_short {
1211                        self.abort_handoff(&format!(
1212                            "the handoff's brief was {state}; this session continues"
1213                        ));
1214                    }
1215                    // Only the states that IMPLY a row: a prompt's `completed`
1216                    // or `cancelled` follows the row's own removal at
1217                    // `started`, so logging those would be one false alarm per
1218                    // prompt, and an alarm nobody can act on is noise.
1219                    if !known && matches!(state.as_str(), "queued" | "started") {
1220                        tracing::debug!(
1221                            target: "forge_workspace::session_task",
1222                            slot = %self.key.display(),
1223                            uuid = %command_uuid,
1224                            state = %state,
1225                            "lifecycle frame for a prompt this session never recorded",
1226                        );
1227                    }
1228                    // **Before the lifecycle frame**, so a view holds the row
1229                    // and drains it when the state lands rather than drawing
1230                    // the announcement's bare words first.
1231                    if let Some(prompt) = started_row {
1232                        self.emit_prompt_row(
1233                            &prompt.text,
1234                            &prompt.attachments,
1235                            &prompt.uuid,
1236                            prompt.source,
1237                        );
1238                    }
1239                    // **A queued prompt the CLI starts is a turn start like
1240                    // any other.** `route` writes `in_turn` only when its
1241                    // prompt commits a turn; one queued behind a running turn
1242                    // does not, so this frame is the first moment the row can
1243                    // learn that turn is running. Without it a crash in a
1244                    // queued turn leaves `idle`, and the next boot sends no
1245                    // note.
1246                    if state == "started"
1247                        && let Some(workspace) = self.workspace.upgrade()
1248                    {
1249                        workspace.record_seat_state(
1250                            &self.key,
1251                            crate::store::sessions::SeatState::InTurn,
1252                        );
1253                    }
1254                    self.emit(SessionUpdate::PromptLifecycle {
1255                        key: self.key.clone(),
1256                        uuid: command_uuid.clone(),
1257                        state: state.clone(),
1258                    });
1259                } else {
1260                    // **A compaction boundary is a frame like any other.** It
1261                    // stays in the conversation the transport holds; the count
1262                    // is the scalar the handoff policy reads.
1263                    if matches!(&msg, forge_primitives::Message::CompactBoundary { .. }) {
1264                        self.compaction_count = self.compaction_count.saturating_add(1);
1265                    }
1266                    let msg = self.attribute_dispatch(msg);
1267                    // `None` either way: a frame off the wire is the CLI's
1268                    // own, and carries no sender.
1269                    self.emit(SessionUpdate::ChatAppended {
1270                        key: self.key.clone(),
1271                        msg,
1272                        source: None,
1273                    });
1274                }
1275            }
1276            AgentEvent::HookObservation {
1277                tool_use_id,
1278                permission_mode,
1279                effort,
1280                agent_id,
1281                agent_type,
1282                ..
1283            } => {
1284                self.emit(SessionUpdate::HookObservation {
1285                    key: self.key.clone(),
1286                    tool_use_id,
1287                    permission_mode,
1288                    effort,
1289                    agent_id,
1290                    agent_type,
1291                });
1292            }
1293        }
1294        // Last, so the failure's own frames are emitted before the words
1295        // that answer them draw below them: the continuation goes out here
1296        // rather than from a sweep, the seat's next turn starting while
1297        // the failure is still the newest thing a reader has seen.
1298        if nudge_armed && let Some(workspace) = self.workspace.upgrade() {
1299            workspace.fire_auto_continue(&self.key);
1300        }
1301        true
1302    }
1303
1304    /// Record a prompt as waiting and announce it: the row's words and sender
1305    /// ride [`SessionUpdate::PromptQueued`], where the lifecycle frames carry
1306    /// only the id and the state.
1307    ///
1308    /// Returns `false` for a session whose CLI has said it does not carry
1309    /// those frames: a row nothing can settle is worse than no row, and the
1310    /// caller draws the prompt at dispatch instead, the way it did before the
1311    /// pile existed.
1312    fn record_queued(
1313        &self,
1314        uuid: &str,
1315        source: PromptSource,
1316        text: &str,
1317        attachments: &[forge_primitives::ImageAttachment],
1318    ) -> bool {
1319        {
1320            let mut guard = self.domain.lock();
1321            if guard.lifecycle_frames == Some(false) {
1322                return false;
1323            }
1324            guard.record_queued_prompt(uuid, source, text, attachments.to_vec());
1325        }
1326        self.emit(SessionUpdate::PromptQueued {
1327            key: self.key.clone(),
1328            uuid: uuid.to_owned(),
1329            source,
1330            text: text.to_owned(),
1331        });
1332        true
1333    }
1334
1335    /// The row a person's prompt draws.
1336    ///
1337    /// **Emitted at `started` wherever the CLI can follow a queue** (#2816):
1338    /// the CLI takes a prompt sent while a turn runs at its own start - at
1339    /// that turn's end, or absorbed into the running turn - and emits
1340    /// `command_lifecycle: started` then, so a row forged at dispatch drew
1341    /// above the answer still being written. The held copy needs it here for
1342    /// the other half: the CLI never echoes a stdin prompt on the wire, so
1343    /// this frame is the page's only carrier of the reader's own words.
1344    ///
1345    /// **Two fallbacks draw at dispatch**, because no `started` is coming: a
1346    /// CLI that cannot follow a queue, and a dispatch that fails after the
1347    /// prompt was recorded. An occupant that dies or is replaced draws the
1348    /// queued rows on the connect or the failure frame instead.
1349    fn emit_prompt_row(
1350        &self,
1351        text: &str,
1352        attachments: &[forge_primitives::ImageAttachment],
1353        uuid: &str,
1354        source: PromptSource,
1355    ) {
1356        let images = forge_primitives::display_blocks(attachments);
1357        let mut framed = forge_primitives::Message::display_only_user_with_blocks(
1358            text.to_owned(),
1359            images,
1360            uuid.to_owned(),
1361        );
1362        framed.stamp_if_absent(&crate::install::rfc3339_now());
1363        self.emit(SessionUpdate::ChatAppended {
1364            key: self.key.clone(),
1365            msg: framed,
1366            source: Some(source),
1367        });
1368    }
1369
1370    /// Settle the prompts still in the queue when the occupant went.
1371    ///
1372    /// **No row is forged for one that never started.** A live row would have
1373    /// no read carrier: the transcript keeps only the enqueue attachment and
1374    /// no start, so a reload would drop it and the two paths would disagree.
1375    /// The pile's ending line carries the words instead, the same carrier a
1376    /// refused or discarded prompt uses, and the card settles with it.
1377    fn settle_lost_queued(&self, prompts: Vec<crate::protocol::QueuedPrompt>) {
1378        for prompt in prompts {
1379            // **The words are re-announced so the ending line can carry them.**
1380            // A replacement may already have reset the page's queue to an
1381            // empty read; the announcement restores the row the discarded
1382            // state then turns into the one line. A page that still holds the
1383            // row ignores it by uuid.
1384            self.emit(SessionUpdate::PromptQueued {
1385                key: self.key.clone(),
1386                uuid: prompt.uuid.clone(),
1387                source: prompt.source,
1388                text: prompt.text,
1389            });
1390            self.emit(SessionUpdate::PromptLifecycle {
1391                key: self.key.clone(),
1392                uuid: prompt.uuid,
1393                state: "discarded".to_owned(),
1394            });
1395        }
1396    }
1397
1398    fn execute_command(&mut self, cmd: Command) {
1399        // **The judgment prompt is the one the swap's gate lets through.**
1400        // Recognised by the id the handoff recorded for it, so nothing else
1401        // can pass on its word.
1402        let judgment_prompt = matches!(
1403            &cmd,
1404            Command::PromptUnder { uuid, .. }
1405                if self.domain.lock().handoff.judgment_uuid.as_deref() == Some(uuid.as_str())
1406        );
1407        // A person's prompt arriving while the seat swaps its occupant waits
1408        // for the successor, exactly as a peer or cron delivery does. It is
1409        // held before its queued row is recorded, because no session is
1410        // going to run it here.
1411        if !judgment_prompt && held_while_swapping(&cmd, &self.key, &self.workspace) {
1412            return;
1413        }
1414        // A prompt's queued row is recorded here, where its id is minted (or
1415        // is already the caller's) and its source is known: the lifecycle
1416        // frames that follow name only the id and the state, so this is the
1417        // one moment that can say what is waiting and who sent it.
1418        let cmd = match cmd {
1419            Command::Prompt { key, text, attachments } => {
1420                let uuid = forge_sdk::request_id::next_prompt_id();
1421                if !self.record_queued(&uuid, PromptSource::You, &text, &attachments) {
1422                    self.emit_prompt_row(&text, &attachments, &uuid, PromptSource::You);
1423                }
1424                Command::PromptUnder {
1425                    key,
1426                    text,
1427                    attachments,
1428                    uuid,
1429                    source: PromptSource::You,
1430                    dispatch_id: None,
1431                }
1432            }
1433            Command::PromptUnder { key, text, attachments, uuid, source, dispatch_id } => {
1434                // The continuation speaks for the occupant that failed. A
1435                // `/new` or `/resume` the queue drains ahead of it leaves a
1436                // replacement under the words, which point at a history the
1437                // replacement does not carry - so the prompt is dropped
1438                // rather than handed to it.
1439                if self.continuation_lost_its_occupant(&uuid) {
1440                    // The seat's own work, not forge's health: the drop is
1441                    // the intended outcome, so the record stays behind
1442                    // RUST_LOG rather than claiming a problem.
1443                    tracing::debug!(
1444                        target: "forge_workspace::session_task",
1445                        event_name = "auto_continue_dropped_occupant_replaced",
1446                        slot = %self.key.display(),
1447                        uuid = %uuid,
1448                        "a continuation's occupant was replaced before it ran; the prompt is dropped",
1449                    );
1450                    return;
1451                }
1452                let recorded = self.record_queued(&uuid, source, &text, &attachments);
1453                // **A CLI that cannot follow a queue draws its row here.**
1454                // Only a person's prompt: a delivery's own envelope row and
1455                // the machinery's forged row are already its carrier.
1456                if !recorded && source == PromptSource::You {
1457                    self.emit_prompt_row(&text, &attachments, &uuid, source);
1458                }
1459                Command::PromptUnder { key, text, attachments, uuid, source, dispatch_id }
1460            }
1461            other => other,
1462        };
1463        match cmd {
1464            Command::RespondPermission { key: _, tool_id, outcome } => {
1465                // Peek the slot kind first; only remove on a kind
1466                // match so a mismatched response leaves the real
1467                // waiter intact.
1468                let mut guard = self.domain.lock();
1469                let kind_matches = matches!(
1470                    guard.pending_interactions.get(&tool_id),
1471                    Some(PendingInteractionSlot::Permission { .. }),
1472                );
1473                if kind_matches
1474                    && let Some(PendingInteractionSlot::Permission { tx, .. }) =
1475                        guard.pending_interactions.remove(&tool_id)
1476                {
1477                    drop(guard);
1478                    let answered_with = permission_words(&outcome);
1479                    if tx.send(outcome).is_err() {
1480                        tracing::warn!(
1481                            target: "forge_workspace::session_task",
1482                            slot = %self.key.display(),
1483                            tool_id = %tool_id,
1484                            "permission oneshot receiver dropped before response could be sent"
1485                        );
1486                    }
1487                    self.emit(SessionUpdate::PendingInteractionResolved {
1488                        key: self.key.clone(),
1489                        tool_id,
1490                        question_index: None,
1491                        answered_with,
1492                    });
1493                } else if let Some(other) = guard.pending_interactions.get(&tool_id) {
1494                    tracing::warn!(
1495                        target: "forge_workspace::session_task",
1496                        slot = %self.key.display(),
1497                        tool_id = %tool_id,
1498                        slot = ?other,
1499                        "RespondPermission expected Permission slot; got different kind. Outcome dropped, slot preserved."
1500                    );
1501                } else {
1502                    tracing::warn!(
1503                        target: "forge_workspace::session_task",
1504                        slot = %self.key.display(),
1505                        tool_id = %tool_id,
1506                        "RespondPermission found no pending interaction (already responded or expired)"
1507                    );
1508                }
1509            }
1510            Command::RespondQuestion { key: _, tool_id, outcome } => {
1511                // Peek-before-remove - mirror of RespondPermission.
1512                let mut guard = self.domain.lock();
1513                let kind_matches = matches!(
1514                    guard.pending_interactions.get(&tool_id),
1515                    Some(PendingInteractionSlot::Question { .. }),
1516                );
1517                if kind_matches
1518                    && let Some(PendingInteractionSlot::Question { tx, request, .. }) =
1519                        guard.pending_interactions.remove(&tool_id)
1520                {
1521                    drop(guard);
1522                    let answered_with = question_words(&outcome);
1523                    if tx.send(outcome).is_err() {
1524                        tracing::warn!(
1525                            target: "forge_workspace::session_task",
1526                            slot = %self.key.display(),
1527                            tool_id = %tool_id,
1528                            "question oneshot receiver dropped"
1529                        );
1530                    }
1531                    self.emit(SessionUpdate::PendingInteractionResolved {
1532                        key: self.key.clone(),
1533                        tool_id,
1534                        question_index: Some(request.question_index),
1535                        answered_with,
1536                    });
1537                } else if let Some(other) = guard.pending_interactions.get(&tool_id) {
1538                    tracing::warn!(
1539                        target: "forge_workspace::session_task",
1540                        slot = %self.key.display(),
1541                        tool_id = %tool_id,
1542                        slot = ?other,
1543                        "RespondQuestion got non-Question slot. Outcome dropped, slot preserved."
1544                    );
1545                } else {
1546                    tracing::warn!(
1547                        target: "forge_workspace::session_task",
1548                        slot = %self.key.display(),
1549                        tool_id = %tool_id,
1550                        "RespondQuestion found no pending interaction"
1551                    );
1552                }
1553            }
1554            // Answered by this task's own state machine: a handoff asks for
1555            // its brief in a turn of its own and only swaps at a turn end.
1556            // **The entry opens the handoff; the exit completes it.** A call
1557            // with no note is where a handoff starts; a call carrying the
1558            // note is the skill's last act, accepted only while this seat is
1559            // already handing over.
1560            Command::Handoff { key: _, brief: Some(note) } => self.complete_handoff(note),
1561            Command::Handoff { key: _, brief: None } => {
1562                self.request_handoff(forge_primitives::HandoffReason::Manual, None);
1563            }
1564            other => {
1565                let sid = self.session_id_string();
1566                // `/new` starts under an id minted here, so the row this
1567                // session belongs to names the new session from the moment
1568                // it starts rather than the one it replaces.
1569                // A handoff supplies the id it wrote into its row; `/new`
1570                // mints one here.
1571                let fresh = match &other {
1572                    Command::NewSession { session_id, .. } => {
1573                        session_id.clone().or_else(|| self.fresh_session_id())
1574                    }
1575                    _ => None,
1576                };
1577                // The id the respawned child runs under: the one minted
1578                // above for `/new`, the transcript's own for `/resume`.
1579                // Its gateway segment is stamped from it below, so the
1580                // binding names the occupant the CLI actually runs as.
1581                let respawn_id = match &other {
1582                    Command::NewSession { .. } => fresh.clone(),
1583                    Command::ResumeSession { session_id, .. } => Some(session_id.clone()),
1584                    _ => None,
1585                };
1586                // **An occupant swap leaves the seat open with no turn in
1587                // flight.** The successor starts parked; whichever prompt
1588                // it is sent next is the turn that flips the row.
1589                if respawn_id.is_some()
1590                    && let Some(workspace) = self.workspace.upgrade()
1591                {
1592                    workspace.record_seat_state(&self.key, crate::store::sessions::SeatState::Idle);
1593                }
1594                // A worker's mission lives in its conversation, so a
1595                // `/new` that emptied it would leave the worker running
1596                // with no idea what it is for. The launch settings for a
1597                // `/new` are built without it, so re-deliver the charter
1598                // the store holds for this slot.
1599                //
1600                // Workers only here: a lead has no stored row to read one
1601                // from, and `stamp_respawn_overrides` below rebuilds its
1602                // charter and its `LEAD_DELEGATION_PREAMBLE` instead.
1603                let mut other = other;
1604                if let Command::NewSession { launch_settings, .. } = &mut other
1605                    && let Some(charter) =
1606                        self.workspace.upgrade().and_then(|ws| ws.stored_charter_for(&self.key))
1607                {
1608                    launch_settings.charter = Some(charter);
1609                }
1610                if let Some(respawn_id) = respawn_id.as_deref()
1611                    && let Some(workspace) = self.workspace.upgrade()
1612                    && let Command::NewSession { launch_settings, .. }
1613                    | Command::ResumeSession { launch_settings, .. } = &mut other
1614                {
1615                    workspace.stamp_respawn_overrides(&self.key, respawn_id, launch_settings);
1616                }
1617                // A person's send is one of the successor's ten, counted
1618                // where the prompt actually reaches the agent.
1619                let counts_for_successor =
1620                    matches!(&other, Command::PromptUnder { source: PromptSource::You, .. });
1621                // **A prompt that never reaches the CLI leaves the queue.**
1622                // Its card would otherwise wait on a frame no CLI will send,
1623                // and its words would have no row. Kept before the move.
1624                let failed = match &other {
1625                    Command::PromptUnder {
1626                        text,
1627                        attachments,
1628                        uuid,
1629                        source: PromptSource::You,
1630                        ..
1631                    } => Some((text.clone(), attachments.clone(), uuid.clone())),
1632                    _ => None,
1633                };
1634                if let Err(err) = execute_command_via_handle(
1635                    &self.handle,
1636                    &self.key,
1637                    sid.as_deref(),
1638                    fresh,
1639                    other,
1640                ) {
1641                    if let Some((text, attachments, uuid)) = failed {
1642                        let held = {
1643                            let mut guard = self.domain.lock();
1644                            let held = guard.queued_prompt(&uuid).is_some();
1645                            guard.drop_queued_prompt(&uuid);
1646                            held
1647                        };
1648                        if held {
1649                            self.emit_prompt_row(&text, &attachments, &uuid, PromptSource::You);
1650                            self.emit(SessionUpdate::PromptLifecycle {
1651                                key: self.key.clone(),
1652                                uuid,
1653                                state: "started".to_owned(),
1654                            });
1655                        }
1656                    }
1657                    tracing::warn!(
1658                        target: "forge_workspace::session_task",
1659                        slot = %self.key.display(),
1660                        error = %err,
1661                        "agent command dispatch failed; bridge channel closed?"
1662                    );
1663                    // Surface it - a view may already have committed
1664                    // optimistic state (Thinking, a flipped chip) that
1665                    // unwinds only when the failure is visible.
1666                    self.emit(SessionUpdate::TurnError {
1667                        key: self.key.clone(),
1668                        message: err.to_string(),
1669                        class: None,
1670                        terminal_reason: None,
1671                    });
1672                } else if counts_for_successor {
1673                    self.count_successor_prompt();
1674                }
1675            }
1676        }
1677    }
1678
1679    /// Whether `uuid` is the continuation forge sent for this seat and the
1680    /// occupant it was sent for is no longer the one here. Taken either way:
1681    /// the prompt is running now, so the matching is spent.
1682    fn continuation_lost_its_occupant(&self, uuid: &str) -> bool {
1683        let mut guard = self.domain.lock();
1684        let Some((sent, armed_for)) = guard.auto_continue_queued.as_ref() else {
1685            return false;
1686        };
1687        if sent != uuid {
1688            return false;
1689        }
1690        let armed_for = armed_for.clone();
1691        guard.auto_continue_queued = None;
1692        armed_for != guard.session_id
1693    }
1694
1695    /// Snapshot the session id stamped on `DomainSession`, formatted
1696    /// as a `String`. Most agent commands need the wire session_id;
1697    /// returns `None` when the bridge hasn't emitted its first
1698    /// `Connected` yet.
1699    fn session_id_string(&self) -> Option<String> {
1700        self.domain.lock().session_id.as_ref().map(std::string::ToString::to_string)
1701    }
1702
1703    /// Send `update` to the workspace fan-out; log when no subscriber
1704    /// took it, so a regression there leaves a trail.
1705    // TODO(ved): gate emits on a per-task session epoch so a superseded
1706    // task (its slot re-spawned by a resume or an account switch) can't
1707    // emit a stale update onto its successor during the brief
1708    // post-supersession drain. Slot keying neither creates nor widens
1709    // that window - the successor holds the same slot either way - and
1710    // the switch stays idle-gated, so it is low-risk today.
1711    fn emit(&self, update: SessionUpdate) {
1712        if !self.update_tx.send(update) {
1713            tracing::warn!(
1714                target: "forge_workspace::session_task",
1715                slot = %self.key.display(),
1716                "no SessionUpdate subscriber took the event"
1717            );
1718        }
1719    }
1720
1721    /// Flush `caller`'s buffered review activity into its batched
1722    /// notices. Every path a turn can end on calls this, so it has to be
1723    /// idempotent: `drain_review_activity` removes the buffer, and a
1724    /// caller with nothing buffered yields no notices. A turn that ended
1725    /// normally therefore leaves the teardown drains with nothing to do.
1726    ///
1727    /// `caller` is explicit because the replacement path drains a key
1728    /// that `DomainSession.key` is about to move off, and the buffer
1729    /// entry becomes unreachable once it has.
1730    fn drain_review_activity_for(&self, caller: &SessionSlot) {
1731        let Some(workspace) = self.workspace.upgrade() else { return };
1732        for update in workspace.drain_review_activity(caller) {
1733            self.emit(update);
1734        }
1735    }
1736
1737    /// The id a `/new` starts under: a fresh one, recorded against the row
1738    /// this session belongs to, so a restart re-enters the new session
1739    /// rather than the one `/new` replaced. `None` when the session has no
1740    /// row to record against, which leaves the CLI to pick its own id.
1741    fn fresh_session_id(&self) -> Option<String> {
1742        Some(self.workspace.upgrade()?.fresh_session_id_for(&self.key))
1743    }
1744
1745    /// Give a successor the brief its swap wrote, as its first turn. The
1746    /// brief forges its own row, which the client draws as the handoff
1747    /// procedure.
1748    fn deliver_pending_brief(&mut self, key: &SessionSlot, occupant: &str) {
1749        let Some(workspace) = self.workspace.upgrade() else { return };
1750        let Some(pending) = workspace.take_pending_brief(key) else { return };
1751        if pending.to != occupant {
1752            tracing::debug!(
1753                target: "forge_workspace::session_task",
1754                event_name = "handoff_brief_occupant_mismatch",
1755                slot = %key.display(),
1756                written_for = %pending.to,
1757                occupant = %occupant,
1758                "the handoff brief was written for another occupant; it is not given",
1759            );
1760            return;
1761        }
1762        let uuid = forge_sdk::request_id::next_forged_prompt_id();
1763        // The brief's own first line names the pair that handed over; kept
1764        // before the text moves into the dispatch.
1765        let first = pending.text.lines().next().unwrap_or_default().to_owned();
1766        if let Err(error) = workspace.dispatch_handoff_brief(key, pending.text, uuid) {
1767            tracing::warn!(
1768                target: "forge_workspace::session_task",
1769                event_name = "handoff_brief_delivery_failed",
1770                slot = %key.display(),
1771                %error,
1772                "the handoff brief could not be delivered to its successor",
1773            );
1774            // **The successor is told.** A seat that connected and got no
1775            // brief looks like a session that started empty; the brief's first
1776            // line names the session that handed over, and `/resume` is the
1777            // recovery.
1778            self.emit(SessionUpdate::Notice {
1779                key: key.clone(),
1780                severity: NoticeSeverity::Error,
1781                text: format!(
1782                    "the handoff brief could not be delivered to this session ({first}); \
1783                     resume the session that handed over with /resume"
1784                ),
1785            });
1786        }
1787    }
1788
1789    /// The skill's last act: the note the successor's brief carries.
1790    ///
1791    /// **Only while this seat is already handing over.** The entry is
1792    /// `/handoff` or the automatic trigger; a call with no handoff in flight has no
1793    /// turn to belong to and is stated as refused, never turned into a start.
1794    fn complete_handoff(&mut self, note: String) {
1795        let mut domain = self.domain.lock();
1796        // **`parked` alone is not a running handoff.** A request parked for a
1797        // busy turn has no procedure turn yet, so an exit call cannot belong
1798        // to it; the facade's gate refuses that call before it arrives.
1799        if !domain.handoff.awaiting {
1800            drop(domain);
1801            self.emit(SessionUpdate::Notice {
1802                key: self.key.clone(),
1803                severity: NoticeSeverity::Error,
1804                text: "agents__handoff completes a running handoff; a handoff starts at /handoff \
1805                       or at the automatic trigger (fill or compaction)"
1806                    .to_owned(),
1807            });
1808            return;
1809        }
1810        domain.handoff.supplied = Some(note);
1811    }
1812
1813    /// A handoff was asked for. The seat must be otherwise idle - no turn,
1814    /// no queued prompt, nothing parked on an answer - or the request waits
1815    /// for the next turn end: a handoff is never injected into a turn, and
1816    /// its prompt must run as a turn of its own.
1817    fn request_handoff(
1818        &mut self,
1819        reason: forge_primitives::HandoffReason,
1820        fill: Option<handoff::FillReading>,
1821    ) {
1822        let Some(workspace) = self.workspace.upgrade() else { return };
1823        if workspace.swap_in_progress(&self.key) {
1824            tracing::debug!(
1825                target: "forge_workspace::session_task",
1826                event_name = "handoff_requested_during_swap",
1827                slot = %self.key.display(),
1828                "a swap is already building on this seat; this request is dropped",
1829            );
1830            return;
1831        }
1832        {
1833            let mut domain = self.domain.lock();
1834            if domain.handoff.in_progress() {
1835                tracing::debug!(
1836                    target: "forge_workspace::session_task",
1837                    event_name = "handoff_already_running",
1838                    slot = %self.key.display(),
1839                    "a handoff is already running; this request is dropped",
1840                );
1841                return;
1842            }
1843            if !domain.is_idle_for_handoff() {
1844                domain.handoff.reason = Some(reason);
1845                domain.handoff.fill = fill;
1846                domain.handoff.parked = true;
1847                return;
1848            }
1849            // The reason rides to the row even on the immediate path.
1850            domain.handoff.reason = Some(reason);
1851            domain.handoff.fill = fill;
1852        }
1853        self.begin_handoff();
1854    }
1855
1856    /// Start the handoff's own turn.
1857    ///
1858    /// **The swap's gate opens HERE**, at the decision, not when the brief is
1859    /// built: everything the seat is sent from this point waits for the
1860    /// successor, and only the judgment prompt is let through.
1861    fn begin_handoff(&mut self) {
1862        if self.session_id_string().is_none() {
1863            self.emit(SessionUpdate::Notice {
1864                key: self.key.clone(),
1865                severity: NoticeSeverity::Error,
1866                text: "a handoff needs a connected session; this seat has none yet".to_owned(),
1867            });
1868            return;
1869        }
1870        let Some(workspace) = self.workspace.upgrade() else { return };
1871        // **One handoff at a time on a seat.** A swap already building is a
1872        // state in which this task's own request state reads as idle, so
1873        // the gate is the authority, not the domain.
1874        if workspace.swap_in_progress(&self.key) {
1875            tracing::debug!(
1876                target: "forge_workspace::session_task",
1877                event_name = "handoff_begin_during_swap",
1878                slot = %self.key.display(),
1879                "a swap is already building on this seat; this handoff is dropped",
1880            );
1881            return;
1882        }
1883        // **The seat is re-checked here, not only where the request was
1884        // parked.** A prompt can be committed between that park and this
1885        // boundary; beginning on top of it is what folds the judgment prompt
1886        // into someone else's turn.
1887        {
1888            let mut domain = self.domain.lock();
1889            if !domain.is_idle_for_handoff() {
1890                domain.handoff.parked = true;
1891                return;
1892            }
1893        }
1894        let token = workspace.begin_swap(&self.key);
1895        self.domain.lock().handoff.swap_token = Some(token);
1896        // **The procedure's own marker.** The judgment prompt is the handoff
1897        // turn's opener, so a view draws it as the procedure rather than as an
1898        // ordinary machinery nudge; the longer prefix is that marker and still
1899        // reads as the machinery's to every existing check.
1900        let uuid = forge_sdk::request_id::next_handoff_prompt_id();
1901        {
1902            let mut domain = self.domain.lock();
1903            domain.handoff.awaiting = true;
1904            domain.handoff.judgment_uuid = Some(uuid.clone());
1905            domain.handoff.judgment_started = false;
1906        }
1907        // **The seat stands down before its own prompt lands.** A page already
1908        // open learns the handoff here; the record carries the same fact for a
1909        // page that attaches after this frame.
1910        self.emit(SessionUpdate::MachineTurn {
1911            key: self.key.clone(),
1912            machine: Some(forge_primitives::runtime::MachineTurn::Handoff),
1913        });
1914        let text = handoff::judgment_prompt();
1915        let deadline_uuid = uuid.clone();
1916        if let Err(error) = workspace.dispatch_handoff_prompt(&self.key, text, uuid) {
1917            self.domain.lock().handoff = crate::domain_session::HandoffState::default();
1918            self.emit(SessionUpdate::MachineTurn { key: self.key.clone(), machine: None });
1919            // Nothing is coming to hand over to, so the gate must not keep
1920            // holding prompts for it.
1921            let held = workspace.end_swap(&self.key);
1922            if !held.is_empty() {
1923                tracing::warn!(
1924                    target: "forge_workspace::session_task",
1925                    event_name = "handoff_prompts_dropped",
1926                    slot = %self.key.display(),
1927                    held = held.len(),
1928                    "the handoff could not ask for its brief; prompts held for it were dropped",
1929                );
1930            }
1931            for prompt in held {
1932                // The press never ran, so its composer mark settles here.
1933                self.emit(SessionUpdate::PromptLifecycle {
1934                    key: self.key.clone(),
1935                    uuid: prompt.uuid.clone(),
1936                    state: "cancelled".to_owned(),
1937                });
1938                if let Some(sender) = &prompt.sender {
1939                    workspace.notice_undelivered_message(
1940                        sender,
1941                        &self.key,
1942                        crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
1943                    );
1944                } else {
1945                    let first = prompt.text.lines().next().unwrap_or_default();
1946                    self.emit(SessionUpdate::Notice {
1947                        key: self.key.clone(),
1948                        severity: NoticeSeverity::Error,
1949                        text: format!("a prompt held for the failed handoff did not run: {first}"),
1950                    });
1951                }
1952            }
1953            self.emit(SessionUpdate::TurnError {
1954                key: self.key.clone(),
1955                message: format!("the handoff could not ask for its brief: {error}"),
1956                class: None,
1957                terminal_reason: None,
1958            });
1959            return;
1960        }
1961        // **The last backstop.** A prompt that never starts produces no turn
1962        // whose Result would finish the handoff, and the CLI may advertise no
1963        // lifecycle frames at all - so a judgment that has not started by the
1964        // deadline ends the handoff rather than holding the gate for the life
1965        // of the seat.
1966        let workspace = Arc::clone(&workspace);
1967        let domain = Arc::clone(&self.domain);
1968        let update_tx = self.update_tx.clone();
1969        let key = self.key.clone();
1970        tokio::spawn(async move {
1971            tokio::time::sleep(handoff::JUDGMENT_DEADLINE).await;
1972            let wedged = {
1973                let domain = domain.lock();
1974                domain.handoff.awaiting
1975                    && !domain.handoff.judgment_started
1976                    && domain.handoff.judgment_uuid.as_deref() == Some(deadline_uuid.as_str())
1977            };
1978            if wedged {
1979                Self::abort_handoff_state(
1980                    &workspace,
1981                    &domain,
1982                    &update_tx,
1983                    &key,
1984                    "the handoff's brief never started; this session continues",
1985                );
1986            }
1987        });
1988    }
1989
1990    /// The same frame, naming the dispatch that owns it.
1991    ///
1992    /// A sub-agent's frames carry the dispatch their parent fired, and only
1993    /// the outermost dispatch is a card, so a deeper frame names no expansion;
1994    /// a `permission_denied` frame names the agent and no dispatch at all.
1995    /// Both walk the tracker's parent links to the owning card, and a frame
1996    /// whose chain reaches none is left alone.
1997    fn attribute_dispatch(&self, msg: forge_primitives::Message) -> forge_primitives::Message {
1998        let (held, owner) = {
1999            let domain = self.domain.lock();
2000            let tracker = &domain.card_tracker;
2001            match &msg {
2002                forge_primitives::Message::Assistant { parent_tool_use_id, .. }
2003                | forge_primitives::Message::User { parent_tool_use_id, .. }
2004                | forge_primitives::Message::StopHookSummary { parent_tool_use_id, .. } => {
2005                    let held = parent_tool_use_id.as_deref().filter(|id| !id.trim().is_empty());
2006                    (
2007                        held.map(str::to_owned),
2008                        held.and_then(|id| tracker.owning_dispatch(id)).map(str::to_owned),
2009                    )
2010                }
2011                // A refusal the CLI sent names the agent and no dispatch; an
2012                // already-stamped one names the dispatch. The agent is the
2013                // task id the roster opened a dispatch's call with.
2014                forge_primitives::Message::PermissionDenied { extras, .. } => {
2015                    let parent = extras
2016                        .get("parent_tool_use_id")
2017                        .and_then(serde_json::Value::as_str)
2018                        .filter(|id| !id.trim().is_empty());
2019                    let owner = match parent {
2020                        Some(parent) => tracker.owning_dispatch(parent),
2021                        None => extras
2022                            .get("agent_id")
2023                            .and_then(serde_json::Value::as_str)
2024                            .and_then(|agent| tracker.dispatch_of(agent)),
2025                    };
2026                    (parent.map(str::to_owned), owner.map(str::to_owned))
2027                }
2028                _ => return msg,
2029            }
2030        };
2031        let Some(owner) = owner else {
2032            // The frame keeps the parent the wire gave it; a nested id draws
2033            // nowhere, so the tracker's own record is the only trace.
2034            if let Some(held) = held.as_deref() {
2035                tracing::debug!(
2036                    target: "forge_workspace::subagent_cards",
2037                    event_name = "subagent_frame_without_a_card",
2038                    parent = %held,
2039                    "a parented frame resolves to no card; it keeps the parent the wire gave it",
2040                );
2041            }
2042            return msg;
2043        };
2044        if held.as_deref() == Some(owner.as_str()) {
2045            return msg;
2046        }
2047        let mut msg = msg;
2048        match &mut msg {
2049            forge_primitives::Message::Assistant { parent_tool_use_id, .. }
2050            | forge_primitives::Message::User { parent_tool_use_id, .. }
2051            | forge_primitives::Message::StopHookSummary { parent_tool_use_id, .. } => {
2052                *parent_tool_use_id = Some(owner);
2053            }
2054            forge_primitives::Message::PermissionDenied { extras, .. } => {
2055                extras.insert("parent_tool_use_id".to_owned(), serde_json::Value::String(owner));
2056            }
2057            _ => {}
2058        }
2059        msg
2060    }
2061
2062    /// Hold the assistant text of a running judgment turn.
2063    fn capture_judgment(&self, message: &forge_primitives::Message) {
2064        let forge_primitives::Message::Assistant { message: envelope, parent_tool_use_id, .. } =
2065            message
2066        else {
2067            return;
2068        };
2069        // **A sub-agent's text is not the turn's note.** An agentic judgment
2070        // turn spawns Task work, and its frames arrive in the same stream;
2071        // letting one overwrite the note would hand the successor a worker's
2072        // line as the brief.
2073        if forge_primitives::names_a_dispatch(parent_tool_use_id.as_deref()) {
2074            return;
2075        }
2076        let mut domain = self.domain.lock();
2077        if !domain.handoff.awaiting {
2078            return;
2079        }
2080        if let Some(text) = handoff::assistant_text(&envelope.content) {
2081            domain.handoff.judgment = Some(text);
2082        }
2083    }
2084
2085    /// Act on a turn that ended normally: finish a running handoff, or start
2086    /// one that arrived while the turn was running.
2087    fn after_turn_ended(&mut self) {
2088        // **The boundary's own turn is over, and its markers clear later in
2089        // the same event.** The idle gate must not read the turn that just
2090        // ended as still running, or every trigger parks and waits for a
2091        // turn that is never coming - which is exactly what the scratch
2092        // stack found. The `Result` is the freshest word on liveness, so
2093        // this is also where a stale Running mirror goes.
2094        {
2095            let mut domain = self.domain.lock();
2096            domain.serve_turn();
2097            // **Only the ended turn's stamps go.** A prompt committed before
2098            // this Result landed is the NEXT turn; its liveness must stand,
2099            // or a running seat reads idle here and the trigger fires into
2100            // it.
2101            if !domain.turns_outstanding() {
2102                domain.turn_open = false;
2103                if matches!(
2104                    domain.runtime_state,
2105                    Some(
2106                        forge_primitives::RuntimeSessionState::Running
2107                            | forge_primitives::RuntimeSessionState::RequiresAction
2108                    )
2109                ) {
2110                    domain.runtime_state = None;
2111                }
2112            }
2113        }
2114        self.measure_successor_at_boundary();
2115        let (awaiting, started, lifecycle_correlates, parked, reason) = {
2116            let mut domain = self.domain.lock();
2117            let awaiting = domain.handoff.awaiting;
2118            let started = domain.handoff.judgment_started;
2119            // A CLI that never advertised the lifecycle frames cannot say
2120            // which prompt a turn belongs to; the idle gate is then the
2121            // whole of the correlation.
2122            let correlates = domain.lifecycle_frames == Some(true);
2123            let parked = if awaiting { false } else { std::mem::take(&mut domain.handoff.parked) };
2124            (awaiting, started, correlates, parked, domain.handoff.reason.unwrap_or_default())
2125        };
2126        if awaiting {
2127            if lifecycle_correlates && !started {
2128                // Some other turn ended - the continuation a failed turn
2129                // fired, or a prompt sent before the handoff. It is not the
2130                // judgment turn, so its prose is not the brief.
2131                tracing::debug!(
2132                    target: "forge_workspace::session_task",
2133                    event_name = "handoff_judgment_turn_not_ours",
2134                    slot = %self.key.display(),
2135                    "a turn ended without the judgment prompt starting; the handoff waits",
2136                );
2137                return;
2138            }
2139            let judgment = {
2140                let mut domain = self.domain.lock();
2141                // **A note is whatever the turn ended on.** forge prescribes
2142                // no shape for it, so any non-empty note is the brief; the
2143                // exit call's note wins when both sources have one.
2144                let supplied = domain.handoff.supplied.take();
2145                let captured = domain.handoff.judgment.take();
2146                // **Whether the exit call carried a note at all**, which is not
2147                // the same as whether its note carried content: a shell like
2148                // `## Next` parses to nothing but is not an absent call.
2149                let from_tool_call = supplied.is_some();
2150                let from_turn_text = captured.is_some();
2151                domain.handoff.judgment_started = false;
2152                // **The exit call is the deliberate act; a turn that simply
2153                // ended is not.** Both are accepted for their text, but a note
2154                // taken from the closing text is marked so the successor can
2155                // tell it from one its predecessor chose to pass.
2156                if let Some(text) = supplied.filter(|text| handoff::parses_as_note(text)) {
2157                    handoff::parse_judgment(&text)
2158                } else if let Some(text) = captured.filter(|text| handoff::parses_as_note(text)) {
2159                    let why = if from_tool_call {
2160                        "the exit call's note carried no content; this is the session's closing \
2161                         text"
2162                    } else {
2163                        "the exit call carried no note; this is the session's closing text"
2164                    };
2165                    tracing::info!(
2166                        target: "forge_workspace::session_task",
2167                        event_name = "handoff_note_from_turn_text",
2168                        slot = %self.key.display(),
2169                        from_tool_call,
2170                        chars = text.chars().count(),
2171                        "the turn's closing text is the judgment half; the exit call's own note \
2172                         was absent or carried no content",
2173                    );
2174                    let mut sections = handoff::parse_judgment(&text);
2175                    sections.insert(
2176                        0,
2177                        forge_primitives::HandoffSection {
2178                            label: "source".to_owned(),
2179                            lines: vec![why.to_owned()],
2180                        },
2181                    );
2182                    sections
2183                } else {
2184                    // **The successor is told the note is missing; this is the
2185                    // trail that says so.** Either source can be a shell with
2186                    // no content - a headings-only reply parses to nothing -
2187                    // so the two fields name what forge had in hand.
2188                    tracing::warn!(
2189                        target: "forge_workspace::session_task",
2190                        event_name = "handoff_note_rejected",
2191                        slot = %self.key.display(),
2192                        from_tool_call,
2193                        from_turn_text,
2194                        "the handoff turn produced no note; the successor is told the note did \
2195                         not arrive",
2196                    );
2197                    handoff::missing_note()
2198                }
2199            };
2200            self.swap(reason, judgment);
2201            return;
2202        }
2203        if parked {
2204            self.begin_handoff();
2205            return;
2206        }
2207        self.maybe_trigger_handoff();
2208    }
2209
2210    /// A cancelled judgment turn ends the handoff rather than finishing it:
2211    /// the reader stopped the prompt the brief was coming from, so the seat
2212    /// keeps its session and what waited during the swap goes back to it.
2213    fn abort_cancelled_handoff(&mut self) {
2214        let (awaiting, started, correlates) = {
2215            let domain = self.domain.lock();
2216            (
2217                domain.handoff.awaiting,
2218                domain.handoff.judgment_started,
2219                domain.lifecycle_frames == Some(true),
2220            )
2221        };
2222        if !awaiting || (correlates && !started) {
2223            return;
2224        }
2225        self.abort_handoff("the handoff's brief was cancelled; this session continues");
2226    }
2227
2228    /// End a handoff that will not finish: clear its state, close its gate,
2229    /// hand back what the gate held, and tell the seat why.
2230    ///
2231    /// **Every way the judgment prompt can settle without running lands
2232    /// here.** A prompt the reader cancels, or the CLI refuses or discards,
2233    /// never produces the turn whose Result would finish the handoff - and
2234    /// left alone it would hold the gate open and drop every later handoff
2235    /// for the life of the seat.
2236    fn abort_handoff(&mut self, why: &str) {
2237        let Some(workspace) = self.workspace.upgrade() else { return };
2238        Self::abort_handoff_state(&workspace, &self.domain, &self.update_tx, &self.key, why);
2239    }
2240
2241    /// End a running handoff from wherever can reach the seat's state: the
2242    /// task itself for a cancel or a settlement, and the deadline task when
2243    /// the judgment prompt never starts.
2244    fn abort_handoff_state(
2245        workspace: &Arc<crate::Workspace>,
2246        domain: &Arc<parking_lot::Mutex<crate::domain_session::DomainSession>>,
2247        update_tx: &crate::update_fanout::UpdateFanout,
2248        key: &SessionSlot,
2249        why: &str,
2250    ) {
2251        {
2252            let mut guard = domain.lock();
2253            guard.handoff = crate::domain_session::HandoffState::default();
2254            // **The judgment prompt's committed turn settles here.** The
2255            // prompt was routed at `begin_handoff`, so its commit is open;
2256            // these paths end the handoff without the Result that would
2257            // serve it, and a seat left with an outstanding turn bails out
2258            // of every later trigger at the idle gate.
2259            guard.serve_turn();
2260        }
2261        // **The settle the domain just did is the row's too.** These paths
2262        // produce no Result, so no turn end reaches the row; left `in_turn`,
2263        // a restart would hand the seat a note for a turn nothing
2264        // interrupted. Written before the held prompts are re-delivered:
2265        // their `route` commits new turns and must have the last word.
2266        workspace.record_seat_state(key, crate::store::sessions::SeatState::Idle);
2267        // **The seat takes input again.** Every abort lands here - a
2268        // cancelled judgment prompt, one the CLI refused or discarded, and the
2269        // deadline - and a composer left standing down for a handoff that
2270        // ended is a seat nobody can type into.
2271        let _ = update_tx.send(SessionUpdate::MachineTurn { key: key.clone(), machine: None });
2272        let held = workspace.end_swap(key);
2273        if !update_tx.send(SessionUpdate::Notice {
2274            key: key.clone(),
2275            severity: NoticeSeverity::Info,
2276            text: why.to_owned(),
2277        }) {
2278            tracing::warn!(
2279                target: "forge_workspace::session_task",
2280                slot = %key.display(),
2281                "no SessionUpdate subscriber took the handoff-abort notice"
2282            );
2283        }
2284        for prompt in held {
2285            let sender = prompt.sender.clone();
2286            let first = prompt.text.lines().next().unwrap_or_default().to_owned();
2287            let uuid = forge_sdk::request_id::next_forged_prompt_id();
2288            // `route`, not `dispatch`: this is a re-delivery of words already
2289            // recorded, not a new press, so it takes no dispatch entry's
2290            // command handling.
2291            if let Err(error) = workspace.route(Command::PromptUnder {
2292                key: key.clone(),
2293                text: prompt.text,
2294                attachments: prompt.attachments,
2295                uuid,
2296                source: prompt.source,
2297                dispatch_id: None,
2298            }) {
2299                tracing::warn!(
2300                    target: "forge_workspace::session_task",
2301                    event_name = "handoff_held_prompt_redelivery_failed",
2302                    slot = %key.display(),
2303                    %error,
2304                    "a prompt held during an aborted handoff could not be handed back",
2305                );
2306                // The hand-back failed, so the original press never runs and
2307                // its composer mark settles here.
2308                let _ = update_tx.send(SessionUpdate::PromptLifecycle {
2309                    key: key.clone(),
2310                    uuid: prompt.uuid.clone(),
2311                    state: "cancelled".to_owned(),
2312                });
2313                if let Some(sender) = sender {
2314                    workspace.notice_undelivered_message(
2315                        &sender,
2316                        key,
2317                        crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
2318                    );
2319                } else {
2320                    let _ = update_tx.send(SessionUpdate::Notice {
2321                        key: key.clone(),
2322                        severity: NoticeSeverity::Error,
2323                        text: format!(
2324                            "a prompt held during the aborted handoff could not be handed back: {first}"
2325                        ),
2326                    });
2327                }
2328            }
2329        }
2330    }
2331
2332    /// Count the successor's prompts, and mark the tenth for measurement.
2333    ///
2334    /// **Prompts, not turn boundaries.** `successor_counts` reads the
2335    /// transcript's user prompts, so counting boundaries here would compare
2336    /// ten endings against ten prompts that included forge's own brief turn.
2337    /// The brief and every other forge delivery carry `PromptSource::Forge`,
2338    /// so only a person's send counts.
2339    fn count_successor_prompt(&mut self) {
2340        let Some(watch) = self.successor.as_mut() else { return };
2341        watch.turns += 1;
2342        if watch.turns >= handoff::SUCCESSOR_TURNS {
2343            watch.measure_at_boundary = true;
2344        }
2345    }
2346
2347    /// Measure the successor once its tenth prompt's turn has ended, when
2348    /// the transcript holds all ten. A successor that leaves before then
2349    /// goes unmeasured rather than writing half a comparison.
2350    fn measure_successor_at_boundary(&mut self) {
2351        let due = self.successor.as_ref().is_some_and(|watch| watch.measure_at_boundary);
2352        if !due {
2353            return;
2354        }
2355        let Some(watch) = self.successor.take() else { return };
2356        let Some(workspace) = self.workspace.upgrade() else { return };
2357        let cwd = workspace.cwd_for_session(&self.key).unwrap_or_default();
2358        workspace.measure_successor(self.key.clone(), watch.to, cwd, watch.predecessor_reads);
2359    }
2360
2361    /// The automatic trigger: at a turn boundary with no handoff running,
2362    /// ask the CLI for this seat's own fill reading.
2363    ///
2364    /// **Asked at every boundary, page or no page.** A view refreshes the
2365    /// reading, but an unattended worker has no view, so a trigger that read
2366    /// only what a page had asked for would never fire on exactly the
2367    /// sessions that run longest. A failed ask skips this boundary and says
2368    /// so at debug; the denominator is never guessed.
2369    fn maybe_trigger_handoff(&mut self) {
2370        let Some(workspace) = self.workspace.upgrade() else { return };
2371        if !workspace.handoff_policy_for(&self.key).enabled {
2372            return;
2373        }
2374        // A swap already building on this seat is the one this boundary
2375        // must not race: `swap` clears the request state, so an in-flight
2376        // build reads as idle here and would fire a second handoff.
2377        if workspace.swap_in_progress(&self.key) {
2378            return;
2379        }
2380        {
2381            let mut domain = self.domain.lock();
2382            if domain.handoff.in_progress() || !domain.is_idle_for_handoff() {
2383                return;
2384            }
2385            // Re-armed rather than skipped when a probe is already out: a
2386            // reading that never came (the bridge's own timeout swallows it)
2387            // would otherwise leave the trigger stuck off for the life of
2388            // the session.
2389            domain.handoff.probing = true;
2390        }
2391        let Some(session_id) = self.session_id_string() else {
2392            self.domain.lock().handoff.probing = false;
2393            return;
2394        };
2395        if let Err(error) = self.handle.get_context_usage(session_id) {
2396            self.domain.lock().handoff.probing = false;
2397            tracing::debug!(
2398                target: "forge_workspace::session_task",
2399                event_name = "handoff_reading_ask_failed",
2400                slot = %self.key.display(),
2401                %error,
2402                "the fill reading could not be asked for; no handoff trigger this turn",
2403            );
2404        }
2405    }
2406
2407    /// Judge a reading the trigger asked for: a fill at or over the
2408    /// configured share, or a compaction the fill trigger did not stop.
2409    fn evaluate_handoff_trigger(
2410        &mut self,
2411        reading_for: &str,
2412        reported: Option<u8>,
2413        total_tokens: Option<u64>,
2414        effective_max_tokens: Option<u64>,
2415    ) {
2416        // **A reading for another occupant is the previous session's.** The
2417        // probe is asynchronous, so a swap can land while one is out; that
2418        // answer says nothing about the session here and must not judge it
2419        // (nor spend the boundary's probe).
2420        if self.session_id_string().as_deref() != Some(reading_for) {
2421            tracing::debug!(
2422                target: "forge_workspace::session_task",
2423                event_name = "handoff_reading_stale_occupant",
2424                slot = %self.key.display(),
2425                reading_for = %reading_for,
2426                "a fill reading arrived for a session this seat no longer runs; ignored",
2427            );
2428            return;
2429        }
2430        {
2431            let mut domain = self.domain.lock();
2432            if !std::mem::take(&mut domain.handoff.probing) {
2433                return;
2434            }
2435        }
2436        let Some(workspace) = self.workspace.upgrade() else { return };
2437        let policy = workspace.handoff_policy_for(&self.key);
2438        if !policy.enabled {
2439            return;
2440        }
2441        let fill = fill_of(reported, total_tokens, effective_max_tokens);
2442        let compaction_count = self.compaction_count;
2443        let reason = if compaction_count >= policy.after_compactions {
2444            // The backstop: a huge turn crossed the window and the CLI
2445            // compacted before a boundary could carry the handoff.
2446            Some(forge_primitives::HandoffReason::Compaction)
2447        } else if fill.is_some_and(|fill| fill.fires(policy.at_percent)) {
2448            Some(forge_primitives::HandoffReason::Fill)
2449        } else {
2450            None
2451        };
2452        let Some(reason) = reason else { return };
2453        // **Through the request, not straight into `begin_handoff`.** The
2454        // probe is asynchronous: a turn may have opened while it was out,
2455        // and this path is where that turn parks the handoff instead of
2456        // having the judgment prompt folded into it. The reading rides with
2457        // the request so a dropped one cannot overwrite a running handoff's.
2458        self.request_handoff(reason, fill);
2459    }
2460
2461    /// Replace this seat's occupant with a new session that continues from
2462    /// the brief, on the same order `/new` uses.
2463    fn swap(
2464        &mut self,
2465        reason: forge_primitives::HandoffReason,
2466        judgment: Vec<forge_primitives::HandoffSection>,
2467    ) {
2468        let Some(workspace) = self.workspace.upgrade() else { return };
2469        let (from, fill, token) = {
2470            let mut domain = self.domain.lock();
2471            let from = domain.session_id.as_ref().map(std::string::ToString::to_string);
2472            let fill = domain.handoff.fill;
2473            let token = domain.handoff.swap_token;
2474            domain.handoff = crate::domain_session::HandoffState::default();
2475            (from, fill, token)
2476        };
2477        let Some(from) = from else {
2478            // Nothing to hand over: close the gate this beginning opened and
2479            // answer what it was holding, or the seat holds prompts for a
2480            // successor nothing is bringing. **The seat keeps its session**, so
2481            // the machine turn ends here as well - no successor arrives whose
2482            // record would clear it.
2483            self.emit(SessionUpdate::MachineTurn { key: self.key.clone(), machine: None });
2484            let held = workspace.end_swap(&self.key);
2485            for prompt in held {
2486                // The press never ran, so its composer mark settles here.
2487                self.emit(SessionUpdate::PromptLifecycle {
2488                    key: self.key.clone(),
2489                    uuid: prompt.uuid.clone(),
2490                    state: "cancelled".to_owned(),
2491                });
2492                if let Some(sender) = &prompt.sender {
2493                    workspace.notice_undelivered_message(
2494                        sender,
2495                        &self.key,
2496                        crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
2497                    );
2498                } else {
2499                    let first = prompt.text.lines().next().unwrap_or_default();
2500                    self.emit(SessionUpdate::Notice {
2501                        key: self.key.clone(),
2502                        severity: NoticeSeverity::Error,
2503                        text: format!(
2504                            "the handoff did not run; this prompt waits no longer: {first}"
2505                        ),
2506                    });
2507                }
2508            }
2509            self.emit(SessionUpdate::Notice {
2510                key: self.key.clone(),
2511                severity: NoticeSeverity::Error,
2512                text: "a handoff needs a connected session; this seat has none yet".to_owned(),
2513            });
2514            return;
2515        };
2516        // **A successful swap emits no machine-turn frame on purpose.** The
2517        // successor's `SessionReplaced` makes every client re-read the record,
2518        // and the record answers `machine: null` because the handoff state was
2519        // cleared above - so the seat stays down until its new occupant's own
2520        // record lands, drawn under the spawn blocker rather than a stale line.
2521        let policy = workspace.handoff_policy_for(&self.key);
2522        let cwd = workspace.cwd_for_session(&self.key).unwrap_or_default();
2523        // Minted, not recorded: the id reaches the slot's row on Connected.
2524        // A recorded id whose spawn failed is one the next boot resumes into
2525        // an empty session, which a handoff record would then claim to have
2526        // joined.
2527        let to = handoff::mint_session_id();
2528        // The base settings alone: the NewSession arm re-delivers the
2529        // charter, rebuilds the lead's blocks and stamps the new occupant's
2530        // gateway binding, exactly as it does for `/new`.
2531        let launch_settings = workspace.launch_settings_for(&self.key, &cwd);
2532        let charter = workspace.stored_charter_for(&self.key);
2533        let role = if self.key.is_lead() {
2534            handoff::Role::Lead
2535        } else {
2536            handoff::Role::Worker {
2537                charter_opening: charter
2538                    .as_deref()
2539                    .and_then(|charter| charter.lines().next())
2540                    .map(|line| line.chars().take(200).collect()),
2541            }
2542        };
2543        let request = handoff::SwapRequest {
2544            slot: self.key.clone(),
2545            from,
2546            to,
2547            token,
2548            reason,
2549            fill,
2550            at_percent: policy.at_percent,
2551            role,
2552            judgment,
2553            cwd,
2554            launch_settings,
2555        };
2556        workspace.start_handoff(request);
2557    }
2558
2559    /// Drain everything parked for this session's slot on its first
2560    /// `Connected`, in arrival order. The take is the session's own
2561    /// `(org, project, label)` bucket, so a payload parked for a team
2562    /// worker never lands on the project's lead.
2563    fn drain_parked(&self) {
2564        let Some(workspace) = self.workspace.upgrade() else { return };
2565        let parked = workspace.take_parked_for_slot(&self.key);
2566        self.deliver_parked_peers(&workspace, parked.peer);
2567        self.deliver_parked_crons(&workspace, parked.cron);
2568        self.deliver_parked_gotify(&workspace, parked.gotify);
2569        self.deliver_parked_slack(&workspace, parked.slack);
2570    }
2571
2572    /// Deliver the peer prompts parked for this session's slot. Each is
2573    /// re-dispatched as a normal `Command::Prompt` against `self.key`.
2574    /// The existing prompt-delivery path handles it identically to a
2575    /// user-typed prompt - the only difference is the prose body
2576    /// carries the `[Message id=m-…]` wrapper
2577    /// that `forge_server::envelope::detect_inbound` matches, which
2578    /// the chat renders as a styled peer block.
2579    fn deliver_parked_peers(
2580        &self,
2581        workspace: &Arc<crate::Workspace>,
2582        pending: Vec<crate::parked::ParkedPeer>,
2583    ) {
2584        if pending.is_empty() {
2585            return;
2586        }
2587        // Same typed peer-envelope echo the running-target dispatch path
2588        // does. Fire BEFORE the LLM-side dispatch so the user-turn
2589        // ordering is natural. The sender is not used here: it rides the
2590        // parked entry for the expiry path alone.
2591        for entry in pending {
2592            let crate::parked::ParkedPeer { wrapped, .. } = entry;
2593            let uuid = forge_sdk::request_id::next_prompt_id();
2594            crate::spawn::push_peer_user_turn_into_chat(workspace, &self.key, &wrapped, &uuid);
2595            let text = wrapped.to_prose();
2596            if let Err(err) =
2597                workspace.dispatch_workspace_prompt_under(&self.key, text, PromptSource::Peer, uuid)
2598            {
2599                tracing::warn!(
2600                    target: "forge_workspace::session_task",
2601                    slot = %self.key.display(),
2602                    error = ?err,
2603                    "deliver_parked_peers: dispatch failed; prompt dropped"
2604                );
2605                crate::spawn::send_dispatch_turn_error(workspace, self.key.clone(), &err);
2606            }
2607        }
2608    }
2609
2610    /// Deliver the cron prompts parked for this session's slot - the
2611    /// bucket a due cron filled while the slot was asleep. Each is
2612    /// echoed as a cron block (missed-marked when overdue) and
2613    /// re-dispatched as a plain `Command::Prompt`.
2614    fn deliver_parked_crons(
2615        &self,
2616        workspace: &Arc<crate::Workspace>,
2617        pending: Vec<crate::crons::PendingCron>,
2618    ) {
2619        if pending.is_empty() {
2620            return;
2621        }
2622        for cron in pending {
2623            let text = crate::spawn::missed_cron_text(&cron.text, cron.missed);
2624            let uuid = forge_sdk::request_id::next_prompt_id();
2625            crate::spawn::push_cron_prompt_into_chat(
2626                workspace,
2627                &self.key,
2628                &text,
2629                &uuid,
2630                &cron.cron_id,
2631                cron.description.as_deref(),
2632            );
2633            if let Err(err) =
2634                workspace.dispatch_workspace_prompt_under(&self.key, text, PromptSource::Cron, uuid)
2635            {
2636                tracing::warn!(
2637                    target: "forge_workspace::session_task",
2638                    slot = %self.key.display(),
2639                    error = ?err,
2640                    "deliver_parked_crons: dispatch failed; prompt dropped",
2641                );
2642                crate::spawn::send_dispatch_turn_error(workspace, self.key.clone(), &err);
2643            }
2644        }
2645    }
2646
2647    /// Deliver the Gotify notifications parked for this session's slot.
2648    /// Each is echoed into chat as a notification block and re-dispatched
2649    /// as a plain `Command::Prompt`, landing as an ordinary user turn.
2650    /// Mirrors [`Self::deliver_parked_crons`], which echoes its own
2651    /// block before dispatching.
2652    fn deliver_parked_gotify(
2653        &self,
2654        workspace: &Arc<crate::Workspace>,
2655        pending: Vec<crate::mcp::gotify::types::GotifyNotification>,
2656    ) {
2657        if pending.is_empty() {
2658            return;
2659        }
2660        for notification in pending {
2661            // Echo the notification block, then re-dispatch its prose as a
2662            // plain user turn (mirrors the running-target path in
2663            // spawn::deliver_gotify_message).
2664            let uuid = forge_sdk::request_id::next_prompt_id();
2665            crate::spawn::push_gotify_notification_into_chat(
2666                workspace,
2667                &self.key,
2668                &notification,
2669                &uuid,
2670            );
2671            if let Err(err) = workspace.dispatch_workspace_prompt_under(
2672                &self.key,
2673                notification.to_prose(),
2674                PromptSource::Gotify,
2675                uuid,
2676            ) {
2677                tracing::warn!(
2678                    target: "forge_workspace::session_task",
2679                    slot = %self.key.display(),
2680                    error = ?err,
2681                    "deliver_parked_gotify: dispatch failed; prompt dropped"
2682                );
2683                crate::spawn::send_dispatch_turn_error(workspace, self.key.clone(), &err);
2684            }
2685        }
2686    }
2687
2688    /// Deliver the Slack messages parked for this session's slot. Each
2689    /// conversation's is echoed into chat as one notification block and
2690    /// re-dispatched as a plain `Command::Prompt`, landing as an ordinary
2691    /// user turn. Mirrors [`Self::deliver_parked_gotify`].
2692    fn deliver_parked_slack(
2693        &self,
2694        workspace: &Arc<crate::Workspace>,
2695        pending: Vec<forge_primitives::slack::SlackMessage>,
2696    ) {
2697        if pending.is_empty() {
2698            return;
2699        }
2700        // The parked bucket is flat, so regroup it into the per-conversation
2701        // block each batch was read as.
2702        let mut by_conversation: std::collections::BTreeMap<String, Vec<_>> =
2703            std::collections::BTreeMap::new();
2704        for message in pending {
2705            by_conversation.entry(message.conversation.clone()).or_default().push(message);
2706        }
2707        for messages in by_conversation.into_values() {
2708            let Some(first) = messages.first() else { continue };
2709            let prose = crate::spawn::slack_bundle_to_prose(
2710                &messages,
2711                workspace.slack_stale_after(&first.workspace),
2712                std::time::SystemTime::now(),
2713            );
2714            let uuid = forge_sdk::request_id::next_prompt_id();
2715            crate::spawn::push_slack_message_into_chat(workspace, &self.key, &prose, &uuid);
2716            if let Err(err) = workspace.dispatch_workspace_prompt_under(
2717                &self.key,
2718                prose,
2719                PromptSource::Slack,
2720                uuid,
2721            ) {
2722                tracing::warn!(
2723                    target: "forge_workspace::session_task",
2724                    slot = %self.key.display(),
2725                    error = ?err,
2726                    "deliver_parked_slack: dispatch failed; prompt dropped"
2727                );
2728                crate::spawn::send_dispatch_turn_error(workspace, self.key.clone(), &err);
2729            }
2730        }
2731    }
2732}
2733
2734/// Drop hook: on SessionTask exit (any reason - graceful close,
2735/// crash, panic), drop whatever is parked for this session's slot - a
2736/// message waiting on a session that no longer exists is acknowledged
2737/// back to its sender rather than delivered to nobody.
2738///
2739/// Uses the stored `Weak<Workspace>` reference so a Workspace drop
2740/// before the task drops doesn't double-fire or panic.
2741impl Drop for SessionTask {
2742    fn drop(&mut self) {
2743        // Teardown backstop for the routes out of `run` that produce no
2744        // terminal envelope: the command channel closing on a release or
2745        // despawn, and the runtime dropping the task. Child death needs
2746        // no backstop - `reader_loop` emits ConnectionFailed on both of
2747        // its exit arms (stream error and stream close), and that arm
2748        // drains review activity before terminating the task.
2749        //
2750        // The teardown signal fires here rather than at `run`'s tail so
2751        // every exit resolves it - a waiter (the despawn's worktree
2752        // removal) must not hang on a task that ended some other way.
2753        if let Some(done) = self.teardown_done.take() {
2754            let _ = done.send_replace(true);
2755        }
2756        let caller = self.domain.lock().key.clone();
2757        self.drain_review_activity_for(&caller);
2758        if let Some(workspace) = self.workspace.upgrade() {
2759            workspace.expire_parked_for_slot(
2760                &self.key,
2761                crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
2762            );
2763            // A task that ends mid-swap leaves its gate open; closing it
2764            // here is what keeps a later prompt from being held for a
2765            // successor nothing is bringing.
2766            let held = workspace.end_swap(&self.key);
2767            if !held.is_empty() {
2768                tracing::warn!(
2769                    target: "forge_workspace::session_task",
2770                    event_name = "handoff_prompts_dropped_on_teardown",
2771                    slot = %self.key.display(),
2772                    held = held.len(),
2773                    "the session task ended during a handoff; prompts held for its successor were dropped",
2774                );
2775            }
2776            for prompt in &held {
2777                // The press never ran, so its composer mark settles here.
2778                let _ = self.update_tx.send(SessionUpdate::PromptLifecycle {
2779                    key: self.key.clone(),
2780                    uuid: prompt.uuid.clone(),
2781                    state: "cancelled".to_owned(),
2782                });
2783                if let Some(sender) = &prompt.sender {
2784                    workspace.notice_undelivered_message(
2785                        sender,
2786                        &self.key,
2787                        crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
2788                    );
2789                } else {
2790                    let first = prompt.text.lines().next().unwrap_or_default();
2791                    let _ = self.update_tx.send(SessionUpdate::Notice {
2792                        key: self.key.clone(),
2793                        severity: NoticeSeverity::Error,
2794                        text: format!(
2795                            "a prompt held for the handoff did not run; the session ended: {first}"
2796                        ),
2797                    });
2798                }
2799            }
2800            // **Every exit releases the pool, not only `run`'s tail.** A
2801            // task that ends by panic, a dropped runtime or an early return
2802            // never reaches that tail, and its pooled handle outlived it:
2803            // every later spawn fast-pathed a seat with nothing behind it
2804            // (#2683). The identity check keeps a superseded task from
2805            // taking its successor's entry.
2806            workspace.release_session_if_current(&self.key, &self.handle);
2807        }
2808    }
2809}
2810
2811/// Shared worker Connected hook: when the session that connected is a
2812/// project's lead and no live workers exist for the project yet,
2813/// (re-)spawn its workers - one `SpawnWorker` per persisted worker row.
2814/// Called from `SessionTask::translate_event` (production) and
2815/// `on_connected_for_test` (tests). Idempotent; safe to call multiple
2816/// times for the same session - the `live_workers.is_empty()` gate
2817/// guards against double-spawn on `/new` reconnects or transient
2818/// retries, and `respawn_workers_for_lead` no-ops when the project has
2819/// no worker rows.
2820///
2821/// The slot's label carries the role: only a lead (`None`) owns a team.
2822fn maybe_respawn_workers_on_connected(
2823    workspace: &Arc<crate::Workspace>,
2824    slot: &crate::SessionSlot,
2825    force_new: bool,
2826) {
2827    if !slot.is_lead() {
2828        return;
2829    }
2830    // **A closed lead does not bring its workers back.** The boot wave
2831    // never spawns a closed lead, so this hook should not fire for one;
2832    // the check keeps the guarantee local rather than resting on that.
2833    if matches!(workspace.stored_seat_state(slot), Some(crate::store::sessions::SeatState::Closed))
2834    {
2835        return;
2836    }
2837    if workspace.find_project_view_by_name(slot.project()).is_none() {
2838        return;
2839    }
2840    let Some(project_key) = workspace.project_key_for_name(slot.project()) else {
2841        return;
2842    };
2843    if !workspace.list_live_workers(&project_key).is_empty() {
2844        return;
2845    }
2846    // Re-spawn the project's persisted workers on a lead reconnect, so
2847    // each resumes the id the store holds for its label instead of
2848    // starting fresh. Workspace claims a per-project in-flight guard
2849    // synchronously so a fast double-Connected can't slip a second
2850    // worker-spawn through; the guard is released after the SpawnWorker
2851    // commands are dispatched.
2852    workspace.respawn_workers_for_lead(slot, project_key, force_new);
2853}
2854
2855/// Shared worker-kick hook: when the session that connected is a worker,
2856/// dispatch a `Command::Prompt` carrying the live worker's stored kick
2857/// to it. Claude sessions don't act until a
2858/// user-turn arrives, so without this kick a worker would sit idle
2859/// indefinitely after spawn (its charter would shape behaviour IF
2860/// prompted, but nothing prompts it).
2861///
2862/// A worker spawned without a kick gets none - it stays caller-driven
2863/// until its lead sends it something. The re-spawn path decides what a
2864/// resuming worker is kicked with, so there is nothing to decide here.
2865///
2866/// Called from `SessionTask::translate_event` (production) and
2867/// `on_connected_for_test` (tests). The dispatch goes through the
2868/// Workspace command bus to the worker's own SessionTask queue,
2869/// which processes the prompt on its next loop iteration once
2870/// translate_event returns.
2871fn maybe_kick_worker_on_connected(workspace: &Arc<crate::Workspace>, slot: &crate::SessionSlot) {
2872    // The label is the worker's; a lead has none and gets no kick.
2873    if slot.is_lead() {
2874        return;
2875    }
2876    let label = slot.label();
2877    let Some(project_key) = workspace.project_key_for_name(slot.project()) else {
2878        return;
2879    };
2880    // `handle_spawn_worker` inserts the entry as Spawning before the agent
2881    // spawn so this hook always finds it, which makes a miss an invariant
2882    // violation rather than a kick-less worker. Kept apart from `None`
2883    // kick, which is the ordinary silent case.
2884    let Some(entry) =
2885        workspace.list_live_workers(&project_key).into_iter().rev().find(|w| w.label == label)
2886    else {
2887        tracing::warn!(
2888            target: "forge_workspace::workers",
2889            label = %label,
2890            slot = %slot.display(),
2891            "no live worker entry for this label; the worker connects unkicked and idles",
2892        );
2893        return;
2894    };
2895    let Some(kick) = entry.kick else {
2896        return;
2897    };
2898    // #259: kicks route through the workspace-level dispatcher so
2899    // multi-worker boots don't fire N simultaneous Prompts at
2900    // Anthropic's per-IP burst limit. The drainer fires one per
2901    // `KICK_DISPATCH_INTERVAL`; the first kick of an empty queue
2902    // has zero added latency.
2903    workspace.enqueue_kick(crate::workspace::KickRequest { slot: slot.clone(), prompt_body: kick });
2904}
2905
2906/// A lead's first turn after a boot spawn handed it a stored session id
2907/// to resume: the restart note the spawn stamped on the domain when the
2908/// boot read the seat as mid-turn. The hook takes the note, so a reconnect
2909/// repeats nothing, and a parked seat, a first boot or `--new` never
2910/// stamped one.
2911fn maybe_kick_lead_on_connected(
2912    workspace: &Arc<crate::Workspace>,
2913    slot: &crate::SessionSlot,
2914    domain: &Mutex<DomainSession>,
2915) {
2916    if !slot.is_lead() {
2917        return;
2918    }
2919    let Some(note) = domain.lock().pending_lead_resume_note.take() else { return };
2920    workspace.enqueue_kick(crate::workspace::KickRequest { slot: slot.clone(), prompt_body: note });
2921}
2922
2923/// Test-only entry point for the Connected hooks.
2924/// Drives `maybe_respawn_workers_on_connected` (lead path) and
2925/// `maybe_kick_worker_on_connected` (worker path) directly without
2926/// constructing a `SessionTask` or pumping through the actor - the
2927/// `connected_hook_tests` module uses this to assert the trigger logic.
2928/// The slot's label selects: a lead drives the respawn, a worker its
2929/// kick. The lead restart-note hook is exercised by its own test, which
2930/// needs the session's domain.
2931#[cfg(test)]
2932fn on_connected_for_test(
2933    workspace: &Arc<crate::Workspace>,
2934    slot: &crate::SessionSlot,
2935    _real_session_id: &str,
2936) {
2937    // Normal (non-`--new`) Connected simulation; the force-new cascade
2938    // is exercised directly against respawn_workers_for_lead.
2939    maybe_respawn_workers_on_connected(workspace, slot, false);
2940    maybe_kick_worker_on_connected(workspace, slot);
2941}
2942
2943/// Forward a `Command` straight to `handle`. Pure transport - no
2944/// pending-interaction bookkeeping; the only commands that consult
2945/// `PendingInteractionSlot` (RespondPermission / RespondQuestion)
2946/// must be handled by the caller before delegation here.
2947///
2948/// Used by both `SessionTask::execute_command` (production: actor
2949/// path) and `Workspace::dispatch`'s synchronous test fallback when
2950/// no `SessionTask` is running for `key`.
2951///
2952/// Returns `Ok(())` on successful enqueue; `Err(...)` on AgentHandle
2953/// send failure (dispatcher channel closed) or on a command dropped
2954/// for having no `session_id` yet (pre-Connect).
2955pub(crate) fn execute_command_via_handle(
2956    handle: &Arc<AgentHandle>,
2957    key: &SessionSlot,
2958    session_id: Option<&str>,
2959    new_session_id: Option<String>,
2960    cmd: Command,
2961) -> Result<(), forge_agent::AgentError> {
2962    match cmd {
2963        // A handoff runs the task's own state machine, before it reaches here;
2964        // a vault answer is workspace state, handled before the agent path.
2965        Command::Handoff { .. } | Command::RespondVaultAsk { .. } => Ok(()),
2966        Command::Prompt { key: _, text, attachments } => {
2967            let Some(sid) = session_id else {
2968                return Err(warn_no_session(key, "Prompt"));
2969            };
2970            // The live path rewrites `Prompt` into `PromptUnder` before it gets
2971            // here, so this arm is the test fallback's alone: it mints without
2972            // recording, because the fallback holds no session state to record
2973            // into.
2974            let uuid = forge_sdk::request_id::next_prompt_id();
2975            handle.prompt_with_images(sid.to_owned(), text, attachments, uuid)
2976        }
2977        Command::PromptUnder { key: _, text, attachments, uuid, source: _, dispatch_id: _ } => {
2978            let Some(sid) = session_id else {
2979                return Err(warn_no_session(key, "PromptUnder"));
2980            };
2981            handle.prompt_with_images(sid.to_owned(), text, attachments, uuid)
2982        }
2983        Command::Cancel { key: _ } => {
2984            let Some(sid) = session_id else {
2985                return Err(warn_no_session(key, "Cancel"));
2986            };
2987            handle.cancel(sid.to_owned())
2988        }
2989        Command::CancelQueuedPrompt { key: _, uuid } => {
2990            let Some(sid) = session_id else {
2991                return Err(warn_no_session(key, "CancelQueuedPrompt"));
2992            };
2993            handle.cancel_queued(sid.to_owned(), uuid)
2994        }
2995        Command::SetMode { key: _, mode } => {
2996            let Some(sid) = session_id else {
2997                return Err(warn_no_session(key, "SetMode"));
2998            };
2999            handle.set_mode(sid.to_owned(), mode)
3000        }
3001        Command::SetModel { key: _, model } => {
3002            let Some(sid) = session_id else {
3003                return Err(warn_no_session(key, "SetModel"));
3004            };
3005            handle.set_model(sid.to_owned(), model)
3006        }
3007        Command::NewSession { key: _, cwd, launch_settings, .. } => {
3008            handle.new_session(new_session_id, cwd, launch_settings)
3009        }
3010        Command::ResumeSession { key: _, session_id, cwd, launch_settings } => {
3011            handle.resume_session(session_id, cwd, launch_settings)
3012        }
3013        Command::RespondPermission { .. } | Command::RespondQuestion { .. } => {
3014            tracing::error!(
3015                target: "forge_workspace::session_task",
3016                slot = %key.display(),
3017                command = ?cmd,
3018                "Respond* commands must be handled by SessionTask::execute_command before delegation"
3019            );
3020            Ok(())
3021        }
3022        // No per-session route: app-level commands are caught in
3023        // `Workspace::dispatch`'s own branches, and `ReadCallOutput` is
3024        // answered by the transport from its held conversation before any
3025        // dispatch. A command of either kind arriving here is a wiring bug.
3026        misrouted @ (Command::ReadCallOutput { .. }
3027        | Command::SetDictateOverride { .. }
3028        | Command::ResetDictateOverrides { .. }
3029        | Command::DictateStream { .. }
3030        | Command::DictateStop { .. }
3031        | Command::SpawnProject { .. }
3032        | Command::DeliverPeerPrompt { .. }
3033        | Command::SpawnWorker { .. }
3034        | Command::CloseWorker { .. }
3035        | Command::DespawnWorker { .. }
3036        | Command::DeliverWorkerPrompt { .. }
3037        | Command::DeliverWorkerPromptToLead { .. }
3038        | Command::DeliverGotifyMessage { .. }
3039        | Command::RespondSlackPost { .. }
3040        | Command::RespondBrowserHandOff { .. }
3041        | Command::RespondTerminalHandOff { .. }
3042        | Command::AnswerDocumentHandOff { .. }
3043        | Command::DictateCatalogueCheck
3044        | Command::DictateInstall { .. }
3045        | Command::DictateActivate { .. }
3046        | Command::DictateDeactivate { .. }
3047        | Command::DictateBench { .. }
3048        | Command::DictateBenchStop
3049        | Command::VoiceBenchStart
3050        | Command::VoiceBenchStop
3051        | Command::DictateReadAloudStart { .. }
3052        | Command::DictateReadAloudStop { .. }
3053        | Command::DictateReadAloudDelete { .. }
3054        | Command::DictateBenchDelete { .. }
3055        | Command::DictateUninstall { .. }
3056        | Command::SetReviewThreadStatus { .. }
3057        | Command::CloseSession { .. }
3058        | Command::UpsertReviewThread { .. }
3059        | Command::DeleteReviewThread { .. }
3060        | Command::SubmitReview { .. }
3061        | Command::TaskVerdict { .. }
3062        | Command::TaskAnswer { .. }
3063        | Command::TaskRank { .. }
3064        | Command::TaskAssign { .. }
3065        | Command::TaskCreate { .. }
3066        | Command::TaskMove { .. }
3067        | Command::TaskReopen { .. }
3068        | Command::TaskReserve { .. }
3069        | Command::PluginInstall { .. }
3070        | Command::PluginUpdate { .. }
3071        | Command::PluginUpdateAll
3072        | Command::PluginCheckUpdates
3073        | Command::MarketplaceAdd { .. }
3074        | Command::MarketplaceRepair { .. }
3075        | Command::RespondSettingsWrite { .. }
3076        | Command::VoiceMode { .. }
3077        | Command::VoicePreview { .. }
3078        | Command::BiometricEnrol { .. }
3079        | Command::BiometricRewrite { .. }
3080        | Command::BiometricRelease { .. }
3081        | Command::BiometricRevoke { .. }
3082        | Command::VaultLock
3083        | Command::VaultEnrolmentMaterial { .. }) => {
3084            tracing::warn!(
3085                target: "forge_workspace::session_task",
3086                slot = %key.display(),
3087                command = ?misrouted,
3088                "command has no per-session route"
3089            );
3090            Ok(())
3091        }
3092    }
3093}
3094
3095/// The words a permission answer carried, where it carried any: the box's own
3096/// notes, the one field a dictation into the ask lands in (#1360).
3097/// Hold a prompt aimed at a seat that is swapping its occupant, answering
3098/// whether it went into the hold.
3099///
3100/// The machinery's own deliveries are gated in
3101/// `Workspace::dispatch_workspace_prompt_under`; a person's prompt reaches
3102/// the task directly, so this is the door it comes through.
3103fn held_while_swapping(
3104    cmd: &Command,
3105    key: &SessionSlot,
3106    workspace: &std::sync::Weak<crate::Workspace>,
3107) -> bool {
3108    let prompt = match cmd {
3109        Command::Prompt { key: _, text, attachments } => crate::handoff::HeldPrompt {
3110            text: text.clone(),
3111            attachments: attachments.clone(),
3112            source: PromptSource::You,
3113            // A plain prompt carries no id yet; this is the one it keeps
3114            // through the hold and the drain, so the transcript row the
3115            // successor writes carries it.
3116            uuid: forge_sdk::request_id::next_prompt_id(),
3117            sender: None,
3118        },
3119        Command::PromptUnder { key: _, text, attachments, source, uuid, .. } => {
3120            crate::handoff::HeldPrompt {
3121                text: text.clone(),
3122                attachments: attachments.clone(),
3123                source: *source,
3124                uuid: uuid.clone(),
3125                sender: None,
3126            }
3127        }
3128        _ => return false,
3129    };
3130    let Some(workspace) = workspace.upgrade() else { return false };
3131    workspace.hold_prompt(key, prompt)
3132}
3133
3134/// The fill a reading states, against the CLI's own effective window.
3135///
3136/// **Counted from the two numbers rather than taken from the reported
3137/// share** when both are there: the denominator has to be the effective
3138/// window after any lowered auto-compact window, so a reading that names
3139/// only its share is the fallback rather than the truth. `None` when
3140/// neither says anything, so nothing fires on a guess.
3141fn fill_of(
3142    reported: Option<u8>,
3143    total_tokens: Option<u64>,
3144    effective_max_tokens: Option<u64>,
3145) -> Option<crate::handoff::FillReading> {
3146    if let (Some(total), Some(max)) = (total_tokens, effective_max_tokens)
3147        && max > 0
3148    {
3149        let percent = total.saturating_mul(100).saturating_add(max / 2) / max;
3150        return Some(crate::handoff::FillReading {
3151            percent: u8::try_from(percent).unwrap_or(u8::MAX),
3152            total_tokens: total,
3153            max_tokens: max,
3154        });
3155    }
3156    reported.map(|percent| crate::handoff::FillReading { percent, total_tokens: 0, max_tokens: 0 })
3157}
3158
3159fn permission_words(outcome: &forge_primitives::PermissionOutcome) -> Option<String> {
3160    match outcome {
3161        forge_primitives::PermissionOutcome::Selected { notes_text, .. } => notes_text.clone(),
3162        forge_primitives::PermissionOutcome::Cancelled => None,
3163    }
3164}
3165
3166/// The words a question answer carried, where it carried any: its own note,
3167/// for the same reason as [`permission_words`].
3168fn question_words(outcome: &forge_primitives::QuestionOutcome) -> Option<String> {
3169    match outcome {
3170        forge_primitives::QuestionOutcome::Answered { annotation, .. } => {
3171            annotation.as_ref().and_then(|held| held.notes.clone())
3172        }
3173        forge_primitives::QuestionOutcome::Cancelled => None,
3174    }
3175}
3176
3177fn warn_no_session(key: &SessionSlot, command: &'static str) -> forge_agent::AgentError {
3178    tracing::warn!(
3179        target: "forge_workspace::session_task",
3180        slot = %key.display(),
3181        command,
3182        "command dropped: no session_id stamped on DomainSession yet",
3183    );
3184    forge_agent::AgentError::NoSession { command }
3185}
3186
3187/// The wire subtype of the retry frame, where the CLI names an
3188/// authentication failure on the retry path. A view matches the same
3189/// literal.
3190const API_RETRY_SUBTYPE: &str = "api_retry";
3191
3192/// What the failure is called in the nudge: the CLI's own errors when the
3193/// result frame reported any, else its subtype, else a plain fallback.
3194pub(crate) fn failure_reason(errors: Option<&[String]>, subtype: &str) -> String {
3195    if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
3196        return errors.join("; ");
3197    }
3198    if !subtype.is_empty() && subtype != "success" {
3199        return subtype.to_owned();
3200    }
3201    "an error".to_owned()
3202}
3203
3204/// What one event MOVED in the domain's whole sets.
3205///
3206/// **A set that did not change is not news.** Each moves whole and on discrete
3207/// frames, and every frame of a busy seat reaches this fold - so a viewer
3208/// redrawing on each one would pay for nothing, and the answer is taken by
3209/// comparing the sets the fold was handed with the ones it left.
3210///
3211/// The walk joins them on the death only: nothing else in this fold writes it
3212/// (the seat's own loop does), so the flag is the one
3213/// transition this fold makes - a snapshot that was held and is now gone.
3214pub(crate) struct Moved {
3215    pub monitors: bool,
3216    pub background_tasks: bool,
3217    pub processes: bool,
3218    pub commands: bool,
3219    pub agents: bool,
3220    pub cards: bool,
3221    /// The prompts still queued when the occupant went. They died with its
3222    /// process, so the caller draws the row each one will never get.
3223    pub lost_prompts: Vec<crate::protocol::QueuedPrompt>,
3224    /// A failed turn armed the continuation, which the caller fires the
3225    /// moment this fold returns.
3226    pub nudge: bool,
3227    /// The CLI reported a repository mutation. `Some(false)` re-reads the
3228    /// tree, `Some(true)` also re-runs the PR lookup because the held block
3229    /// was looked up before the change existed.
3230    pub repo_nudge: Option<bool>,
3231    /// The seat's turn ended in this event, as the caller announces it.
3232    /// `None` while no turn ended here.
3233    pub turn_ended: Option<MovedTurnEnd>,
3234}
3235
3236/// What a turn's end was, for the caller to announce.
3237pub(crate) enum MovedTurnEnd {
3238    /// The turn finished (or failed) on its own; the CLI's terminal reason
3239    /// rides where it gave one.
3240    Complete { terminal_reason: Option<forge_primitives::runtime::TerminalReason> },
3241    /// The reader's own interrupt: the CLI reports it as a failed `Result`
3242    /// too, so the cancel flag is what tells the two apart.
3243    Cancelled,
3244}
3245
3246/// Apply an [`AgentEvent`] to a [`DomainSession`]. Pure mutation; no
3247/// I/O, no async, no sends. Called from inside
3248/// [`SessionTask::translate_event`] under the domain's lock.
3249///
3250/// Workspace owns the routing metadata every dispatch needs
3251/// (`session_id`) plus the facts a view reads through the view
3252/// surface. Operational state a view renders from the update stream
3253/// itself (lifecycle, cwd, account info) stays on the view.
3254///
3255/// The answer is what the caller announces, so it is the sets as they stand
3256/// AFTER the fold rather than an opinion about the event.
3257pub(crate) fn apply_event_to_domain(domain: &mut DomainSession, event: &AgentEvent) -> Moved {
3258    let held_monitors = domain.monitors.clone();
3259    let held_tasks = domain.background_tasks.clone();
3260    let held_live_tasks = domain.live_tasks.clone();
3261    let held_walk = domain.process_snapshot.is_some();
3262    let held_commands = domain.available_commands.clone();
3263    let held_agents = domain.available_agents.clone();
3264    let held_cards = domain.cards_snapshot.clone();
3265    let mut nudge = false;
3266    let mut turn_ended = None;
3267    let mut lost_prompts = Vec::new();
3268    // **The CLI's own report of a repository mutation is the one signal the
3269    // file watch cannot produce**: a commit or a stage touches only `.git`,
3270    // which the watch filters out, so the tree is re-read on the frame rather
3271    // than at the next staleness window. A published change is the same wake
3272    // plus a PR lookup, because the block the seat holds was looked up before
3273    // the PR existed.
3274    let repo_nudge = match event {
3275        AgentEvent::SdkMessage {
3276            msg: forge_primitives::Message::VcsStateChanged { .. }, ..
3277        } => Some(false),
3278        AgentEvent::SdkMessage {
3279            msg: forge_primitives::Message::CodeChangePublished { .. },
3280            ..
3281        } => Some(true),
3282        _ => None,
3283    };
3284    hold_view_facts(domain, event);
3285    if let AgentEvent::ConnectionFailed { .. } = event {
3286        // The subprocess is gone - drop the runtime/turn mirrors so the
3287        // in-flight guards don't read a stale turn. The failure mark goes
3288        // with them: the row reads its failure from the lifecycle.
3289        domain.runtime_state = None;
3290        domain.reset_turns();
3291        domain.turn_open = false;
3292        domain.pending_cancel = false;
3293        domain.failed_turn_at = None;
3294        domain.auto_continue = None;
3295        domain.auto_continue_spent = false;
3296        // No terminal `background_tasks_changed` follows a dead session,
3297        // so the last snapshot would stand forever - and the registry with
3298        // it, spinning rows over tasks a dead process never finished.
3299        lost_prompts = domain.drop_background_tasks();
3300        // The walk describes the subprocess's tree, so it goes with the
3301        // process: left standing it draws rows for processes that are not
3302        // there, and nothing follows a dead session that would replace it.
3303        domain.process_snapshot = None;
3304    }
3305    if let AgentEvent::SdkMessage {
3306        msg: forge_primitives::Message::Result { is_error, subtype, errors, terminal_reason, .. },
3307        ..
3308    } = event
3309    {
3310        // The stamp is spent on every outcome: a cancel that raced a turn
3311        // the CLI had already finished must not exempt the next failure.
3312        let cancelled = std::mem::take(&mut domain.pending_cancel);
3313        // The turn's end is announced (#1486): the frames carry the
3314        // Result, but a page's live row reads its liveness from the record
3315        // and only this frame asks it to re-read. **Every Result announces**,
3316        // including one covering no turn at all (a `/compact` on an idle
3317        // seat): the frame states the CLI's terminator, and a page whose
3318        // record says nothing is in flight no-ops on it. The cancel flag is
3319        // the one the failure mark already spends, so an interrupt racing a
3320        // finished turn is announced as cancelled and a cancel spent by an
3321        // earlier result reads as a completion - the page settles either
3322        // way, and only its record-consult differs.
3323        turn_ended = Some(if cancelled {
3324            MovedTurnEnd::Cancelled
3325        } else {
3326            MovedTurnEnd::Complete { terminal_reason: *terminal_reason }
3327        });
3328        // A foreground call cannot outlive the turn that ran it, and its own
3329        // ending frame does not always come: the `TaskStop` tool path sends
3330        // none, and a cancelled turn can bury the one it would have sent. A
3331        // backgrounded call outlives its turn and stays in the registry.
3332        domain.settle_foreground_holds();
3333        // An errored `Result` marks the slot for the rail, unless the
3334        // reader asked for the interruption - a cancel ends with the same
3335        // failed result a genuine error does.
3336        if *is_error && !cancelled {
3337            let at = std::time::SystemTime::now();
3338            domain.failed_turn_at = Some(at);
3339            // Every non-cancelled failed turn is this nudge's to answer,
3340            // whatever its class: the caller fires the continuation the
3341            // moment this fold returns, so a seat nobody is watching
3342            // resumes without the person having to type again (#2146).
3343            if !domain.auto_continue_spent {
3344                domain.auto_continue = Some(PendingAutoContinue {
3345                    reason: failure_reason(errors.as_deref(), subtype),
3346                });
3347                nudge = true;
3348            }
3349        } else if !*is_error {
3350            // A turn that finished ends the episode: a later failure is a
3351            // new one and gets its own nudge.
3352            domain.auto_continue = None;
3353            domain.auto_continue_spent = false;
3354        }
3355    }
3356    if let AgentEvent::SdkMessage { msg: forge_primitives::Message::Error { .. }, .. } = event {
3357        // A transport death ends the turn too; nothing marks for it, but
3358        // the stamp must not outlive the turn it was armed for.
3359        domain.pending_cancel = false;
3360    }
3361    // The snapshot carries the whole set, so mirroring it is an
3362    // assignment and an empty one clears.
3363    if let AgentEvent::SdkMessage {
3364        msg: forge_primitives::Message::BackgroundTasksChanged { tasks, .. },
3365        ..
3366    } = event
3367    {
3368        domain.background_work = !tasks.is_empty();
3369        let parsed: Vec<crate::BackgroundTask> = tasks
3370            .iter()
3371            .filter_map(|task| {
3372                let entry = task.as_object()?;
3373                Some(crate::BackgroundTask {
3374                    task_id: entry.get("task_id")?.as_str()?.to_owned(),
3375                    // The registry names the background set by definition,
3376                    // and its entries carry no flag of their own.
3377                    is_backgrounded: true,
3378                    task_type: entry.get("task_type")?.as_str()?.to_owned(),
3379                    description: entry.get("description")?.as_str()?.to_owned(),
3380                    command: None,
3381                    tool_use_id: None,
3382                })
3383            })
3384            .collect();
3385        if parsed.len() != tasks.len() {
3386            tracing::debug!(
3387                target: "forge_workspace::session_task",
3388                event_name = "background_tasks_parse_dropped",
3389                dropped = tasks.len() - parsed.len(),
3390                entry_count = tasks.len(),
3391                "background_tasks_changed dropped unparseable entries; possible wire drift",
3392            );
3393        }
3394        domain.replace_background_tasks(parsed);
3395    }
3396    // The command a card carried, which is what the OS scan adopts a detached
3397    // process by and what tells a view that a rostered task has a row to draw.
3398    //
3399    // ANY card that carries a command, not only one whose input asked to run
3400    // in the background: the CLI backgrounds a bash three ways, and two of
3401    // them (`backgroundedByUser`, `assistantAutoBackgrounded`) cannot show on
3402    // the card. The `task_started` link is what scopes it - a command no
3403    // live task names is never read.
3404    if let AgentEvent::SdkMessage {
3405        msg: forge_primitives::Message::Assistant { message, .. },
3406        ..
3407    } = event
3408    {
3409        for block in &message.content {
3410            let forge_primitives::ContentBlock::ToolUse { id, input, .. } = block else {
3411                continue;
3412            };
3413            if let Some(command) = input.get("command").and_then(serde_json::Value::as_str) {
3414                domain.hold_background_command(id.clone(), command.to_owned());
3415            }
3416        }
3417    }
3418    // `task_started` is what links a task to the card that began it, and so
3419    // to the command. **A `local_bash` task it announces is a call this seat
3420    // is running**, backgrounded or not: the registry names only the
3421    // backgrounded ones, so a long foreground call is held here until the
3422    // `task_notification` that ends it.
3423    if let AgentEvent::SdkMessage {
3424        msg:
3425            forge_primitives::Message::TaskStarted {
3426                task_id,
3427                description,
3428                tool_use_id,
3429                task_type,
3430                extras,
3431                ..
3432            },
3433        ..
3434    } = event
3435    {
3436        if let Some(tool_use_id) = tool_use_id {
3437            domain.hold_task_tool_use(task_id.clone(), tool_use_id.clone());
3438        }
3439        // Two flags ride the frame's unmodelled fields: the dispatch marker
3440        // (a sub-agent's call is not this seat's row) and the CLI's own
3441        // backgrounding, which a consumer reads off the hold. A frame that
3442        // states neither is a session call the CLI did not background.
3443        let dispatched = extras.get("parent_tool_use_id").and_then(serde_json::Value::as_str);
3444        let backgrounded =
3445            extras.get("is_backgrounded").and_then(serde_json::Value::as_bool).unwrap_or(false);
3446        if task_type.as_deref() == Some("local_bash")
3447            && !forge_primitives::names_a_dispatch(dispatched)
3448        {
3449            domain.note_live_task(crate::BackgroundTask {
3450                task_id: task_id.clone(),
3451                is_backgrounded: backgrounded,
3452                task_type: "local_bash".to_owned(),
3453                description: description.clone(),
3454                command: None,
3455                tool_use_id: tool_use_id.clone(),
3456            });
3457        }
3458    }
3459    // A task's own ending withdraws it from the live set. The notification is
3460    // the ending frame - it rides beside a terminal `task_updated` and names
3461    // the output file - and a terminal patch is the same answer a frame
3462    // earlier.
3463    if let AgentEvent::SdkMessage {
3464        msg: forge_primitives::Message::TaskNotification { task_id, tool_use_id, .. },
3465        ..
3466    } = event
3467    {
3468        domain.settle_live_task(task_id, tool_use_id.as_deref());
3469    }
3470    if let AgentEvent::SdkMessage {
3471        msg: forge_primitives::Message::TaskUpdated { task_id, patch, .. },
3472        ..
3473    } = event
3474        && patch.status.as_deref().is_some_and(task_status_is_terminal)
3475    {
3476        // The patch names only the task, so the id is the only match here.
3477        domain.settle_live_task(task_id, None);
3478    }
3479    if let AgentEvent::Connected { session_id, .. } = event {
3480        domain.session_id = Some(SessionId::new(session_id.clone()));
3481        domain.runtime_state = None;
3482        domain.reset_turns();
3483        domain.turn_open = false;
3484        // The failure mark named the last occupant's turn, and so did any
3485        // interrupt it was holding.
3486        domain.pending_cancel = false;
3487        domain.failed_turn_at = None;
3488        domain.auto_continue = None;
3489        domain.auto_continue_spent = false;
3490        // A second Connected is a new occupant in the same slot, and the
3491        // CLI re-sends the whole background set only when it changes: a
3492        // registry left standing would spin a row over a task that went
3493        // with the identity it belonged to.
3494        lost_prompts = domain.drop_background_tasks();
3495        // A new occupant has advertised nothing yet: the CLI sends
3496        // `system/init` at the head of a turn only, so the last one's
3497        // catalogues would stand until this one's first message - and
3498        // after a login swap they came from another account's config dir.
3499        domain.available_commands.clear();
3500        domain.available_agents.clear();
3501        // The agent catalogue is read from the first init of a turn, and
3502        // a swap that ends no turn - a mid-turn `/new`, a reconnect after
3503        // a login - never sees the Result that re-arms it. Left armed it
3504        // would drop the new occupant's own init and keep the list empty
3505        // for a whole turn, which is the state clearing the two above is
3506        // meant to end.
3507        domain.agents_emitted_this_turn = false;
3508        // It connected, so it is not waiting to be let in.
3509        domain.awaiting_login = false;
3510    }
3511    // The login wait, from the two signals the CLI actually sends: it
3512    // attributes the failure to the assistant message, or names it on the
3513    // retry frame. `AssistantMessageError` is typed, so nothing here keys
3514    // on prose.
3515    if let AgentEvent::SdkMessage { msg, .. } = event {
3516        match msg {
3517            forge_primitives::Message::Assistant {
3518                error: Some(forge_primitives::AssistantMessageError::AuthenticationFailed),
3519                ..
3520            } => domain.awaiting_login = true,
3521            forge_primitives::Message::System { subtype, data, .. }
3522                if subtype == API_RETRY_SUBTYPE =>
3523            {
3524                if let Some(update) = data
3525                    .as_object()
3526                    .and_then(forge_agent::translate::state_parsing::build_api_retry_update)
3527                    && update.error == forge_primitives::ApiRetryError::AuthenticationFailed
3528                {
3529                    domain.awaiting_login = true;
3530                }
3531            }
3532            _ => {}
3533        }
3534    }
3535    // A `TurnError` the classifier reads as auth-required holds the session
3536    // on `/login`. The class is derived where the event is built, so it is
3537    // read here rather than searched out of the message again, and a class
3538    // the producer could not name stays unclassified rather than being
3539    // guessed at.
3540    if let AgentEvent::TurnError { class, .. } = event
3541        && *class == forge_primitives::TurnErrorClass::AuthRequired
3542    {
3543        domain.awaiting_login = true;
3544    }
3545    // A turn that finished proves the credential works, so the wait is
3546    // over before any reconnect. Success only: a turn that died on the
3547    // missing credential ends with its own failed `Result`, and clearing
3548    // on that would undo the mark the frames above just set. The same
3549    // two-line test exists on the view surface, and folding the two into
3550    // one is its own piece.
3551    if let AgentEvent::SdkMessage {
3552        msg: forge_primitives::Message::Result { is_error, subtype, .. },
3553        ..
3554    } = event
3555        && !*is_error
3556        && subtype == "success"
3557    {
3558        domain.awaiting_login = false;
3559    }
3560    // Mirror runtime liveness from `session_state_changed` so the
3561    // workspace's in-flight guards see a turn authoritatively,
3562    // independent of a view's gate. Reuse the canonical decoder parser
3563    // rather than re-inlining it.
3564    if let AgentEvent::SdkMessage {
3565        msg: forge_primitives::Message::System { subtype, data, .. },
3566        ..
3567    } = event
3568        && subtype == "session_state_changed"
3569        && let Some(state) =
3570            forge_agent::translate::state_parsing::parse_runtime_session_state(data.get("state"))
3571    {
3572        domain.runtime_state = Some(state);
3573    }
3574    // Mirror the frames' own claim about the turn, which is the one signal
3575    // that covers a turn's OPENING: `turn_pending` is spent by the previous
3576    // Result and `session_state_changed` no longer arrives, so thinking and
3577    // prose alone left the header reading idle while the model worked
3578    // (`DomainSession::turn_open` carries the measured window).
3579    if let AgentEvent::SdkMessage { msg, .. } = event
3580        && let Some(said) = crate::domain_session::liveness_of(msg)
3581    {
3582        domain.note_liveness(said);
3583    }
3584    // The two catalogues the composer's autocomplete reads, as the CLI
3585    // last advertised them. Held on the session so a view arriving after
3586    // the turn started reads one answer rather than waiting for the next
3587    // init frame, which is a whole turn away.
3588    if let AgentEvent::SdkMessage {
3589        msg: forge_primitives::Message::System { subtype, data, .. },
3590        ..
3591    } = event
3592        && subtype == "init"
3593        && let Some(record) = data.as_object()
3594    {
3595        if let Some(entries) = record.get("slash_commands").and_then(serde_json::Value::as_array) {
3596            let commands =
3597                forge_agent::translate::commands::map_available_commands_from_json(entries);
3598            // An init frame that advertises none carries nothing about
3599            // them, and the CLI re-fires one every turn.
3600            if !commands.is_empty() {
3601                domain.available_commands = commands;
3602            }
3603        }
3604        if !domain.agents_emitted_this_turn
3605            && let Some(agents) = record.get("agents")
3606        {
3607            domain.available_agents =
3608                forge_agent::translate::agents::map_available_agents_from_names(Some(agents));
3609            domain.agents_emitted_this_turn = true;
3610        }
3611    }
3612    if let AgentEvent::SdkMessage {
3613        msg: forge_primitives::Message::CommandsChanged { commands, .. },
3614        ..
3615    } = event
3616    {
3617        let parsed = forge_agent::translate::commands::map_available_commands_from_json(commands);
3618        // A payload carrying entries that parse to none means the CLI's
3619        // entry shape changed under us, and storing it would wipe the
3620        // list; a legitimately empty one clears it.
3621        let drift = parsed.is_empty() && !commands.is_empty();
3622        if drift {
3623            // A view reading the surface alone would otherwise lose the
3624            // signal this guard logs.
3625            tracing::warn!(
3626                target: "forge_workspace::session_task",
3627                slot = %domain.key.display(),
3628                event_name = "commands_changed_parse_empty",
3629                message = "commands_changed carried entries but none parsed; likely wire drift, keeping prior list",
3630                outcome = "skipped",
3631                entry_count = commands.len(),
3632            );
3633        } else {
3634            domain.available_commands = parsed;
3635        }
3636    }
3637    // The turn boundary re-arms the agent read.
3638    if let AgentEvent::SdkMessage {
3639        msg: forge_primitives::Message::Result { .. } | forge_primitives::Message::Error { .. },
3640        ..
3641    } = event
3642    {
3643        domain.agents_emitted_this_turn = false;
3644    }
3645    // The instance list folds frame by frame, in this same walk: the frames
3646    // that open and settle a card are matched here either way, so the join
3647    // costs a lookup rather than a second pass over the conversation. The
3648    // push is change-gated on the list itself - a frame that moved nothing
3649    // announces nothing.
3650    if let AgentEvent::SdkMessage { msg, .. } = event {
3651        domain.card_tracker.apply(msg);
3652        let cards = domain.card_tracker.cards();
3653        if cards != domain.cards_snapshot {
3654            domain.cards_snapshot = cards;
3655        }
3656    }
3657    Moved {
3658        monitors: domain.monitors != held_monitors,
3659        background_tasks: domain.background_tasks != held_tasks
3660            || domain.live_tasks != held_live_tasks,
3661        processes: held_walk && domain.process_snapshot.is_none(),
3662        commands: domain.available_commands != held_commands,
3663        agents: domain.available_agents != held_agents,
3664        // Live news only: a connect seeds the list from the history it
3665        // carries and the read on that event answers it.
3666        cards: domain.cards_snapshot != held_cards
3667            && matches!(event, AgentEvent::SdkMessage { .. }),
3668        nudge,
3669        repo_nudge,
3670        turn_ended,
3671        lost_prompts,
3672    }
3673}
3674
3675/// Drop every fact that describes one run of a session: the hook's mode
3676/// and effort, the model it resolved, the two bridge snapshots that
3677/// describe a subprocess tree, and the monitor set that run started.
3678///
3679/// This mirrors the view's own reset on the same events, so what it holds
3680/// is what a view draws. Two facts are deliberately not here, because the
3681/// view's reset does not touch them either: the sub-agent attribution
3682/// outlives the run it came from, and the process walk is cleared on the
3683/// death alone - where the tree it describes goes with the subprocess -
3684/// rather than on every identity this clears.
3685fn clear_runtime_identity(domain: &mut DomainSession) {
3686    domain.observed_permission_mode = None;
3687    domain.observed_effort = None;
3688    domain.current_model = None;
3689    domain.mcp_servers = None;
3690    domain.context_usage = None;
3691    // A handoff belongs to the occupant that was running it: its reply, its
3692    // parked request and its judgment describe a conversation the new one
3693    // does not carry.
3694    domain.handoff = crate::domain_session::HandoffState::default();
3695    domain.monitors.clear();
3696}
3697
3698/// The facts this event carries that a view reads through the view
3699/// surface: the hook observation's mode and effort, the two bridge
3700/// snapshots, the resolved model, and the monitor set.
3701///
3702/// One source, two readers: each fact is folded from the same event the
3703/// `SessionUpdate` for it is built from, so the held copy cannot drift
3704/// from the streamed one.
3705fn hold_view_facts(domain: &mut DomainSession, event: &AgentEvent) {
3706    if let AgentEvent::Connected { current_model, available_models, history_updates, .. } = event {
3707        // A replacement occupant inherits nothing the last one held:
3708        // its hook mirrors describe a session that is gone, and its
3709        // bridge snapshots describe subprocesses that went with it.
3710        clear_runtime_identity(domain);
3711        domain.current_model = Some(current_model.clone());
3712        domain.available_models.clone_from(available_models);
3713        // The instance fold is seeded from the history the connect carries,
3714        // so a resumed seat answers the card list its history holds.
3715        domain.card_tracker = crate::subagent_cards::CardTracker::default();
3716        if let Some(history) = history_updates.as_ref() {
3717            for message in history {
3718                domain.card_tracker.apply(message);
3719            }
3720        }
3721        domain.cards_snapshot = domain.card_tracker.cards();
3722        // A monitor started before this process did is in the transcript
3723        // the connect carries, so the same fold runs over it: a view
3724        // opening the session sees the monitor rather than nothing. A
3725        // sub-agent's Monitor call is the instance's, not the session's.
3726        if let Some(history) = history_updates {
3727            for msg in history {
3728                if forge_primitives::messages::ran_under_a_dispatch(msg) {
3729                    continue;
3730                }
3731                fold_monitor(domain, msg, MonitorOrigin::Transcript);
3732            }
3733            // A transcript's monitors are all settled, so the seed drains in
3734            // the one call: a resumed session shows no section rather than a
3735            // row per monitor it ever ran.
3736            drain_settled_monitors(domain);
3737        }
3738    }
3739    // A connection that died leaves the session with no runtime identity.
3740    // The core blanks what a view reads through it - a mode or a model left
3741    // standing describes a run that is gone.
3742    if matches!(event, AgentEvent::ConnectionFailed { .. }) {
3743        clear_runtime_identity(domain);
3744    }
3745    if let AgentEvent::HookObservation { permission_mode, effort, .. } = event {
3746        if let Some(mode) =
3747            permission_mode.as_deref().and_then(forge_primitives::PermissionMode::from_wire)
3748        {
3749            domain.observed_permission_mode = Some(mode);
3750        }
3751        // An unreadable level is a level forge cannot name, not a reason
3752        // to forget the one already held.
3753        if let Some(level) = effort.as_deref().and_then(forge_primitives::EffortLevel::from_stored)
3754        {
3755            domain.observed_effort = Some(level);
3756        }
3757    }
3758    if let AgentEvent::McpSnapshot { servers, error, .. } = event {
3759        domain.mcp_servers = Some(crate::domain_session::McpServers {
3760            servers: servers.clone(),
3761            error: error.clone(),
3762        });
3763    }
3764    if let AgentEvent::ContextUsage { percentage, max_tokens, .. } = event {
3765        domain.context_usage = Some(crate::domain_session::ContextUsage {
3766            percent: *percentage,
3767            max_tokens: *max_tokens,
3768        });
3769    }
3770    if let AgentEvent::SdkMessage { msg, .. } = event {
3771        fold_monitor(domain, msg, MonitorOrigin::Wire);
3772        if let forge_primitives::Message::System { subtype, data, .. } = msg
3773            && subtype == "init"
3774        {
3775            reconcile_model_from_init(domain, data);
3776        }
3777    }
3778}
3779
3780/// Take the model a turn's `system/init` names, which is how a switch
3781/// reaches a reader: `/model` re-fires the frame with the model the turn
3782/// runs under, and the frame arrives at the head of every turn besides.
3783///
3784/// A frame naming the model the session is already on changes nothing, so
3785/// a session that did not switch keeps the name its connect resolved
3786/// rather than being renamed to the CLI's own spelling of the same model.
3787fn reconcile_model_from_init(domain: &mut DomainSession, data: &serde_json::Value) {
3788    let Some(model_id) = data.get("model").and_then(serde_json::Value::as_str).map(str::trim)
3789    else {
3790        return;
3791    };
3792    if model_id.is_empty()
3793        || domain.current_model.as_ref().is_some_and(|held| held.resolved_id == model_id)
3794    {
3795        return;
3796    }
3797    // No requested id: the pin forge stamped described the model the
3798    // session connected on, and the CLI has just named a different one, so
3799    // the CLI's own answer is what names this model.
3800    domain.current_model = Some(forge_agent::session_lifecycle::resolve_current_model_from_inputs(
3801        model_id,
3802        None,
3803        None,
3804        &domain.available_models,
3805    ));
3806}
3807
3808/// Whether a `task_updated` patch names a status that ends the task.
3809fn task_status_is_terminal(status: &str) -> bool {
3810    monitor_status_from_wire(status).is_some()
3811}
3812
3813/// The status a terminal `task_updated` names, in the vocabulary the
3814/// renderers use. `None` for a status that is not terminal, and for one
3815/// forge does not know: a patch that only stamps an end time carries no
3816/// status at all.
3817fn monitor_status_from_wire(status: &str) -> Option<forge_primitives::MonitorStatus> {
3818    match status {
3819        "completed" => Some(forge_primitives::MonitorStatus::Completed),
3820        "failed" | "killed" | "stopped" => Some(forge_primitives::MonitorStatus::Stopped),
3821        _ => None,
3822    }
3823}
3824
3825/// Which side of the session a monitor was folded from, which decides the
3826/// state it starts in.
3827#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3828enum MonitorOrigin {
3829    /// The live wire, where a `Monitor` tool call means the monitor runs
3830    /// until a command frame settles it.
3831    Wire,
3832    /// The transcript a connect carries. It holds no lifecycle frame - the
3833    /// replay synthesizer emits user and assistant messages only - so a
3834    /// monitor found here is one whose task is over as far as this process
3835    /// can tell, and nothing that follows can settle it, because every
3836    /// settlement is keyed on the task id a transcript cannot carry.
3837    Transcript,
3838}
3839
3840/// Fold one wire message into the session's monitor set: the `Monitor`
3841/// tool call that starts one, the task id the CLI assigns it, and the
3842/// lifecycle message that settles it.
3843///
3844/// The CLI's task ids are what the terminal transitions are keyed by,
3845/// so a record's `task_id` is what lets a later `task_updated` find it
3846/// at all.
3847fn fold_monitor(
3848    domain: &mut DomainSession,
3849    msg: &forge_primitives::Message,
3850    origin: MonitorOrigin,
3851) {
3852    match msg {
3853        forge_primitives::Message::Assistant { message, .. } => {
3854            for block in &message.content {
3855                let forge_primitives::ContentBlock::ToolUse { id, name, input, .. } = block else {
3856                    continue;
3857                };
3858                if name != "Monitor" {
3859                    continue;
3860                }
3861                let Some(parsed) = forge_agent::user_interaction::parse_monitor_input(input) else {
3862                    continue;
3863                };
3864                if domain.monitors.iter().any(|held| &held.tool_use_id == id) {
3865                    continue;
3866                }
3867                let status = match origin {
3868                    MonitorOrigin::Wire => forge_primitives::MonitorStatus::Running,
3869                    MonitorOrigin::Transcript => forge_primitives::MonitorStatus::Completed,
3870                };
3871                domain.monitors.push(forge_primitives::MonitorRecord {
3872                    tool_use_id: id.clone(),
3873                    task_id: None,
3874                    description: parsed.description,
3875                    command: parsed.command,
3876                    persistent: parsed.persistent,
3877                    timeout_ms: parsed.timeout_ms,
3878                    status,
3879                    output_file: None,
3880                    ended_at: None,
3881                });
3882            }
3883        }
3884        forge_primitives::Message::TaskStarted { task_id, tool_use_id: Some(id), .. } => {
3885            let Some(record) = domain.monitors.iter_mut().find(|held| &held.tool_use_id == id)
3886            else {
3887                return;
3888            };
3889            record.task_id.get_or_insert_with(|| task_id.clone());
3890        }
3891        forge_primitives::Message::TaskUpdated { task_id, patch, .. } => {
3892            // A status forge does not classify is one it cannot act on,
3893            // so only the instant is stamped then: it is a fact the frame
3894            // states whether or not this build can name how it ended.
3895            settle_monitor(
3896                domain,
3897                task_id,
3898                patch.status.as_deref().and_then(monitor_status_from_wire),
3899                None,
3900                patch.end_time,
3901            );
3902        }
3903        forge_primitives::Message::TaskNotification { task_id, status, output_file, .. } => {
3904            let settled = match status {
3905                forge_primitives::TaskNotificationStatus::Completed => {
3906                    Some(forge_primitives::MonitorStatus::Completed)
3907                }
3908                forge_primitives::TaskNotificationStatus::Failed
3909                | forge_primitives::TaskNotificationStatus::Stopped => {
3910                    Some(forge_primitives::MonitorStatus::Stopped)
3911                }
3912                // A status forge cannot name settles nothing, but it is
3913                // still a notification and the drain below is unconditional.
3914                forge_primitives::TaskNotificationStatus::Unknown => None,
3915            };
3916            if let Some(status) = settled {
3917                settle_monitor(domain, task_id, Some(status), Some(output_file.clone()), None);
3918            }
3919            // The notification is the last frame a monitor sends, so it is
3920            // where the set drains once nothing in it is running: a session
3921            // that ran a monitor an hour ago draws no section in any view.
3922            drain_settled_monitors(domain);
3923        }
3924        _ => {}
3925    }
3926}
3927
3928/// Drop the monitor set once every entry in it is terminal.
3929fn drain_settled_monitors(domain: &mut DomainSession) {
3930    if !domain.monitors.is_empty()
3931        && domain.monitors.iter().all(|monitor| monitor.status.is_terminal())
3932    {
3933        domain.monitors.clear();
3934    }
3935}
3936
3937fn settle_monitor(
3938    domain: &mut DomainSession,
3939    task_id: &str,
3940    status: Option<forge_primitives::MonitorStatus>,
3941    output_file: Option<String>,
3942    end_time_ms: Option<u64>,
3943) {
3944    let Some(record) =
3945        domain.monitors.iter_mut().find(|held| held.task_id.as_deref() == Some(task_id))
3946    else {
3947        return;
3948    };
3949    if let Some(status) = status {
3950        record.status = status;
3951    }
3952    if let Some(path) = output_file {
3953        record.output_file = Some(path);
3954    }
3955    if let Some(end_ms) = end_time_ms {
3956        record.ended_at =
3957            Some(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(end_ms));
3958    }
3959}
3960
3961/// Forward an awaited permission outcome to the agent so the bridge
3962/// can complete the round-trip with the CLI subprocess.
3963fn spawn_permission_response_forwarder(
3964    agent: Arc<AgentHandle>,
3965    response_rx: tokio::sync::oneshot::Receiver<forge_primitives::PermissionOutcome>,
3966    session_id: String,
3967    tool_call_id: String,
3968) {
3969    let span = tracing::info_span!(
3970        "permission_response_forwarder",
3971        session_id = %session_id,
3972        tool_call_id = %tool_call_id,
3973    );
3974    tokio::task::spawn(
3975        async move {
3976            let Ok(outcome) = response_rx.await else {
3977                tracing::warn!(
3978                    target: "forge_workspace::session_task",
3979                    session_id = %session_id,
3980                    tool_call_id = %tool_call_id,
3981                    "permission response channel closed before forwarding"
3982                );
3983                return;
3984            };
3985            let selected_option = match &outcome {
3986                forge_primitives::PermissionOutcome::Selected { option_id, .. } => {
3987                    option_id.clone()
3988                }
3989                forge_primitives::PermissionOutcome::Cancelled => "cancelled".to_owned(),
3990            };
3991            let session_id_for_log = session_id.clone();
3992            let tool_call_id_for_log = tool_call_id.clone();
3993            match agent.permission_response(session_id, tool_call_id, outcome) {
3994                Ok(()) => {
3995                    tracing::info!(
3996                        target: "forge_workspace::session_task",
3997                        session_id = %session_id_for_log,
3998                        tool_call_id = %tool_call_id_for_log,
3999                        selected_option = %selected_option,
4000                        "permission response forwarded to bridge"
4001                    );
4002                }
4003                Err(err) => {
4004                    tracing::error!(
4005                        target: "forge_workspace::session_task",
4006                        session_id = %session_id_for_log,
4007                        tool_call_id = %tool_call_id_for_log,
4008                        selected_option = %selected_option,
4009                        error = %err,
4010                        "failed to forward permission response to bridge"
4011                    );
4012                }
4013            }
4014        }
4015        .instrument(span),
4016    );
4017}
4018
4019/// Forward an awaited question outcome to the agent so the bridge
4020/// can complete the round-trip with the CLI subprocess.
4021fn spawn_question_response_forwarder(
4022    agent: Arc<AgentHandle>,
4023    response_rx: tokio::sync::oneshot::Receiver<forge_primitives::QuestionOutcome>,
4024    session_id: String,
4025    tool_call_id: String,
4026) {
4027    let span = tracing::info_span!(
4028        "question_response_forwarder",
4029        session_id = %session_id,
4030        tool_call_id = %tool_call_id,
4031    );
4032    tokio::task::spawn(
4033        async move {
4034            let Ok(outcome) = response_rx.await else {
4035                tracing::warn!(
4036                    target: "forge_workspace::session_task",
4037                    session_id = %session_id,
4038                    tool_call_id = %tool_call_id,
4039                    "question response channel closed before forwarding"
4040                );
4041                return;
4042            };
4043            let selected_option_count = match &outcome {
4044                forge_primitives::QuestionOutcome::Answered { selected_option_ids, .. } => {
4045                    selected_option_ids.len()
4046                }
4047                forge_primitives::QuestionOutcome::Cancelled => 0,
4048            };
4049            let session_id_for_log = session_id.clone();
4050            let tool_call_id_for_log = tool_call_id.clone();
4051            match agent.question_response(session_id, tool_call_id, outcome) {
4052                Ok(()) => {
4053                    tracing::info!(
4054                        target: "forge_workspace::session_task",
4055                        session_id = %session_id_for_log,
4056                        tool_call_id = %tool_call_id_for_log,
4057                        selected_option_count,
4058                        "question response forwarded to bridge"
4059                    );
4060                }
4061                Err(err) => {
4062                    tracing::error!(
4063                        target: "forge_workspace::session_task",
4064                        session_id = %session_id_for_log,
4065                        tool_call_id = %tool_call_id_for_log,
4066                        selected_option_count,
4067                        error = %err,
4068                        "failed to forward question response to bridge"
4069                    );
4070                }
4071            }
4072        }
4073        .instrument(span),
4074    );
4075}
4076
4077#[cfg(test)]
4078#[allow(clippy::expect_used, clippy::unwrap_used)]
4079mod tests {
4080    use super::*;
4081    use crate::update_fanout::{SeatUpdate, SubscriberRole};
4082    use forge_agent::Agent;
4083    use forge_agent::client::SpawnFailureKind;
4084    use forge_primitives::{ContentBlock, Message};
4085
4086    fn empty_domain() -> DomainSession {
4087        let (handle, _rx) = Agent::testing_stub();
4088        DomainSession::new(SessionSlot::from_str_for_test("test"), Some(Arc::new(handle)))
4089    }
4090
4091    /// The slot a test task carries. Drains resolve the parked bucket
4092    /// against it, so a test that parks must park under the same triple.
4093    fn test_slot() -> crate::SessionSlot {
4094        crate::SessionSlot::lead("TestOrg", "forge")
4095    }
4096
4097    /// Drive a worker's Connected through `translate_event` with a session
4098    /// id the tag write cannot use, so the write fails non-NotFound and the
4099    /// rollback arm runs, and return the row it left behind.
4100    ///
4101    /// `connected_once` is the shape under test: false is a spawn's first
4102    /// Connected, true is the `/new` re-tag of a worker that is already
4103    /// established. The domain carries the spawn's provenance either way,
4104    /// since a `/new` reuses it.
4105    async fn tag_rollback_leftover_row(connected_once: bool) -> Option<serde_json::Value> {
4106        let cfg_dir = tempfile::tempdir().expect("cfg tempdir");
4107        std::fs::create_dir_all(cfg_dir.path().join("forge")).expect("forge dir");
4108        std::fs::write(
4109            cfg_dir.path().join("forge").join("forge.toml"),
4110            "[[orgs]]\nname = \"Default\"\naccounts = [\"Acct\"]\n\n\
4111             [[orgs.projects]]\nname = \"forge\"\npath = \"/tmp/tag-rollback-row\"\n\n\
4112             [[accounts]]\ndisplay_name = \"Acct\"\ntoken = \"t\"\nmodels = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
4113        )
4114        .expect("write forge.toml");
4115        let config = crate::config::load_from_dir(cfg_dir.path()).expect("load config");
4116        let (workspace, mut update_rx) =
4117            crate::Workspace::testing_stub_with_config(cfg_dir.path().to_owned(), config)
4118                .expect("stub over the fixture config");
4119        let db_dir = tempfile::tempdir().expect("db tempdir");
4120        workspace.install_db_for_test(
4121            crate::store::Db::open(&db_dir.path().join("db.redb")).expect("open db"),
4122        );
4123        let project_key = workspace.project_key_for_name("forge").expect("the fixture project");
4124        let worker_slot = crate::SessionSlot::worker("Default", "forge", "steward");
4125        workspace.insert_live_worker(
4126            &project_key,
4127            crate::mcp::workers::types::WorkerEntry {
4128                label: "steward".to_owned(),
4129                charter: "c".to_owned(),
4130                slot: worker_slot.clone(),
4131                session_id: Some(forge_primitives::SessionId::new("steward-id")),
4132                status: forge_primitives::WorkerLiveness::Spawning,
4133                spawned_at: std::time::SystemTime::UNIX_EPOCH,
4134                spawned_by: crate::SessionSlot::from_str_for_test("lead-uuid"),
4135                // Its tag never landed, so the rollback's second fact holds.
4136                needs_tag: true,
4137                is_git_repo_at_spawn: false,
4138                cwd: None,
4139                diagnostic: None,
4140                kick: None,
4141            },
4142        );
4143        workspace
4144            .record_worker_row(
4145                &project_key,
4146                "steward",
4147                "steward-id",
4148                "charter",
4149                Some("kick"),
4150                None,
4151                false,
4152                crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
4153                None,
4154            )
4155            .expect("seed the row the rollback judges");
4156
4157        let (handle, _agent_cmds) = Agent::testing_stub();
4158        let arc = Arc::new(handle);
4159        let domain = workspace.register_domain_session(worker_slot.clone(), Some(Arc::clone(&arc)));
4160        // The spawn that wrote the row is the one this task runs.
4161        domain.lock().spawn_wrote_row = true;
4162        let (_cmd_tx, command_rx) =
4163            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
4164        let mut task = SessionTask {
4165            key: worker_slot.clone(),
4166            handle: arc,
4167            command_rx,
4168            domain,
4169            update_tx: workspace.update_sender(),
4170            connected_once,
4171            workspace: Arc::downgrade(&workspace),
4172            compaction_count: 0,
4173            successor: None,
4174            teardown_done: None,
4175        };
4176
4177        task.translate_event(AgentEvent::Connected {
4178            session_id: "not-a-uuid".to_owned(),
4179            cwd: "/tmp/tag-rollback-row".to_owned(),
4180            current_model: forge_primitives::CurrentModel {
4181                resolved_id: "claude".to_owned(),
4182                display_name_short: "claude".to_owned(),
4183                display_name_long: "claude".to_owned(),
4184                requested_id: None,
4185                catalog_id: None,
4186                supports_effort: false,
4187                supported_effort_levels: Vec::new(),
4188                supports_auto_mode: None,
4189                supports_adaptive_thinking: None,
4190                is_authoritative: true,
4191            },
4192            available_models: Vec::new(),
4193            mode: None,
4194            history_updates: None,
4195            compaction_count: 0,
4196        });
4197
4198        // The rollback runs in a detached task, so wait for the Removed
4199        // event that says it ran rather than for a wall clock.
4200        tokio::time::timeout(std::time::Duration::from_secs(5), async {
4201            while let Some(frame) = update_rx.recv().await {
4202                if let crate::protocol::SessionUpdate::WorkerStatusChanged { action, .. } =
4203                    std::sync::Arc::unwrap_or_clone(frame.update)
4204                    && action == crate::protocol::WorkerStatusAction::Removed
4205                {
4206                    return;
4207                }
4208            }
4209        })
4210        .await
4211        .expect("the rollback emits a Removed event");
4212
4213        let db = workspace.db.lock();
4214        crate::store::sessions::get(db.as_ref().expect("db"), "Default", "forge", "steward")
4215            .expect("read the row the rollback left")
4216            .map(|row| serde_json::json!({ "charter": row.charter, "kick": row.kick }))
4217    }
4218
4219    /// A spawn's first Connected owns the row it minted: the rollback
4220    /// discards the spawn, and a row left behind re-spawns a worker the
4221    /// caller was told had failed.
4222    #[tokio::test]
4223    async fn a_first_connected_rolls_back_the_row_its_spawn_minted() {
4224        assert!(
4225            tag_rollback_leftover_row(false).await.is_none(),
4226            "the first Connected of a minting spawn takes the row with the rollback",
4227        );
4228    }
4229
4230    /// A `/new` re-tag runs the same arm over a row that predates the
4231    /// connection, and the spawn's provenance is still stamped on the
4232    /// domain the `/new` reuses. The first-connect count is the only thing
4233    /// separating the two, so dropping it deletes a live worker's row.
4234    #[tokio::test]
4235    async fn a_new_session_retag_keeps_the_workers_row() {
4236        assert!(
4237            tag_rollback_leftover_row(true).await.is_some(),
4238            "a `/new` re-tag must not take the row of a worker that is already established",
4239        );
4240    }
4241
4242    fn workspace_with_account_config_dir(
4243        _config_dir: &str,
4244    ) -> (tempfile::TempDir, Arc<crate::Workspace>) {
4245        let dir = tempfile::tempdir().expect("tempdir");
4246        let forge = dir.path().join("forge");
4247        std::fs::create_dir_all(&forge).expect("forge dir");
4248        std::fs::write(
4249            forge.join("forge.toml"),
4250            "[[orgs]]\nname = \"Default\"\naccounts = [\"Acct\"]\n\n\
4251             [[orgs.projects]]\nname = \"forge\"\npath = \"~/Projects/forge\"\n\n\
4252             [[accounts]]\ndisplay_name = \"Acct\"\ntoken = \"t\"\nmodels = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
4253        )
4254        .expect("write forge.toml");
4255        let workspace =
4256            Arc::new(crate::Workspace::new_for_test(dir.path().to_owned()).expect("new"));
4257        (dir, workspace)
4258    }
4259
4260    /// A workspace holding one submitted review plus one un-drained
4261    /// reply from `worker`, i.e. a turn that has touched a review and
4262    /// not yet ended. Returns the tempdir (kept alive for the db), the
4263    /// workspace, the review's submit origin, and the replying session.
4264    fn workspace_with_pending_review_activity()
4265    -> (tempfile::TempDir, Arc<crate::Workspace>, SessionSlot, SessionSlot) {
4266        use forge_primitives::review::{
4267            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
4268        };
4269        let dir = tempfile::tempdir().expect("tempdir");
4270        let (workspace, _rx) =
4271            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4272        workspace.install_db_for_test(
4273            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4274        );
4275        workspace.upsert_review_thread(
4276            "forge",
4277            "feat",
4278            ReviewThread {
4279                id: "a".to_owned(),
4280                anchor: ReviewAnchor {
4281                    path: "src/x.rs".to_owned(),
4282                    side: ReviewSide::New,
4283                    line: 1,
4284                    content_hash: 1,
4285                    context: vec!["ctx".to_owned()],
4286                    base_ref: "main".to_owned(),
4287                },
4288                comments: vec![ReviewComment {
4289                    author: ReviewAuthor::User,
4290                    text: "look".to_owned(),
4291                    at: "t".to_owned(),
4292                    review_id: None,
4293                }],
4294                status: ReviewStatus::Open,
4295                created_at: "t".to_owned(),
4296                updated_at: "t".to_owned(),
4297                commit: None,
4298            },
4299        );
4300        let reviewer = SessionSlot::from_str_for_test("reviewer");
4301        let worker = SessionSlot::from_str_for_test("worker");
4302        workspace.submit_review("forge", "feat", None, &["a".to_owned()], reviewer.clone());
4303        workspace
4304            .review_reply(&worker, "forge", "feat", "a", "implementer", "fixed", "t")
4305            .expect("reply recorded as this turn's activity");
4306        (dir, workspace, reviewer, worker)
4307    }
4308
4309    /// A `SessionTask` bound to `key`, plus the receiver for what it emits.
4310    fn review_task_for(
4311        workspace: &Arc<crate::Workspace>,
4312        key: &SessionSlot,
4313    ) -> (SessionTask, mpsc::UnboundedReceiver<SeatUpdate>) {
4314        let (handle, _cmds) = Agent::testing_stub();
4315        let handle = Arc::new(handle);
4316        let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
4317        let update_tx = UpdateFanout::default();
4318        let update_rx = update_tx.subscribe(SubscriberRole::Answering);
4319        let domain = Arc::new(Mutex::new(DomainSession::new(key.clone(), Some(handle.clone()))));
4320        let task = SessionTask {
4321            key: key.clone(),
4322            handle,
4323            command_rx,
4324            domain,
4325            update_tx,
4326            connected_once: false,
4327            workspace: Arc::downgrade(workspace),
4328            compaction_count: 0,
4329            successor: None,
4330            teardown_done: None,
4331        };
4332        (task, update_rx)
4333    }
4334
4335    /// A `SessionTask` bound to `key`, plus the receiver for the agent
4336    /// commands it forwards. `review_task_for` drops that receiver;
4337    /// this keeps it for a test that asserts what the task dispatched.
4338    fn command_task_for(
4339        workspace: &Arc<crate::Workspace>,
4340        key: &SessionSlot,
4341    ) -> (SessionTask, mpsc::UnboundedReceiver<forge_primitives::AgentCommand>) {
4342        let (handle, cmds) = Agent::testing_stub();
4343        let handle = Arc::new(handle);
4344        let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
4345        let update_tx = UpdateFanout::default();
4346        let _update_rx = update_tx.subscribe(SubscriberRole::Answering);
4347        let domain = Arc::new(Mutex::new(DomainSession::new(key.clone(), Some(handle.clone()))));
4348        let task = SessionTask {
4349            key: key.clone(),
4350            handle,
4351            command_rx,
4352            domain,
4353            update_tx,
4354            connected_once: false,
4355            workspace: Arc::downgrade(workspace),
4356            compaction_count: 0,
4357            successor: None,
4358            teardown_done: None,
4359        };
4360        (task, cmds)
4361    }
4362
4363    /// A task that ends any way but `run`'s own tail - a panic, or a
4364    /// dropped runtime - takes its pool entry with it. The tail alone left
4365    /// a handle pooled with nothing behind it, and every later spawn
4366    /// fast-pathed the dead seat (#2683).
4367    #[tokio::test]
4368    async fn dropping_a_session_task_releases_its_pooled_handle() {
4369        let (workspace, _updates) = crate::Workspace::testing_stub();
4370        let key = SessionSlot::from_str_for_test("drop-task-key");
4371        let (task, _update_rx) = review_task_for(&workspace, &key);
4372        let handle = Arc::clone(&task.handle);
4373        workspace.pool.lock().insert(
4374            key.clone(),
4375            crate::workspace::PooledAgent {
4376                handle: Arc::clone(&handle),
4377                account: forge_gateway::AccountKey("acct-a".to_owned()),
4378                permission_mode: None,
4379                registration: None,
4380                session_id: "drop-task".to_owned(),
4381            },
4382        );
4383        let (cmd_tx, _cmd_rx) = mpsc::unbounded_channel();
4384        workspace.command_senders.lock().insert(key.clone(), cmd_tx);
4385        workspace.register_domain_session(key.clone(), Some(Arc::clone(&handle)));
4386
4387        drop(task);
4388
4389        assert!(!workspace.pool.lock().contains_key(&key), "the pool entry goes with its task");
4390        assert!(
4391            !workspace.command_senders.lock().contains_key(&key),
4392            "and the command sender the seat was addressed by",
4393        );
4394        assert!(
4395            !workspace.domain_handles.lock().contains_key(&key),
4396            "and the domain handle that pointed at it",
4397        );
4398    }
4399
4400    /// A handoff swaps under the id its row names, leaves the brief where
4401    /// the successor takes it, and dispatches a spawn under that same id -
4402    /// the pair on the row and the occupant that runs are one.
4403    #[tokio::test]
4404    async fn a_handoff_swaps_under_the_id_its_row_names() {
4405        let dir = tempfile::tempdir().expect("tempdir");
4406        let (workspace, mut update_rx) =
4407            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4408        workspace.enable_test_dispatch_intercept();
4409        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
4410        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
4411        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
4412        workspace.install_db_for_test(
4413            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4414        );
4415        // **A live task must reach PART 1.** The build reads the seat's own
4416        // domain, so the domain is registered and a background task seeded;
4417        // deleting `sections.extend(live)` fails this.
4418        workspace.domain_handles.lock().insert(slot.clone(), Arc::clone(&task.domain));
4419        task.domain.lock().background_tasks.push(forge_primitives::background::BackgroundTask {
4420            task_id: "b1".to_owned(),
4421            is_backgrounded: true,
4422            task_type: "local_bash".to_owned(),
4423            description: "cargo watch".to_owned(),
4424            command: Some("cargo watch".to_owned()),
4425            tool_use_id: None,
4426        });
4427
4428        task.execute_command(Command::Handoff { key: slot.clone(), brief: None });
4429        task.domain.lock().handoff.judgment = Some("Now: the cut is half in.".to_owned());
4430        task.after_turn_ended();
4431
4432        let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
4433            while let Some(frame) = update_rx.recv().await {
4434                if let SessionUpdate::Handoff { handoff, .. } =
4435                    std::sync::Arc::unwrap_or_clone(frame.update)
4436                {
4437                    return handoff;
4438                }
4439            }
4440            panic!("the handoff row was never emitted");
4441        })
4442        .await
4443        .expect("the handoff lands");
4444
4445        assert_eq!(record.from, "old-session-id", "the row names the session that handed over");
4446        assert_eq!(
4447            record.reason,
4448            forge_primitives::HandoffReason::Manual,
4449            "a manual handoff is a handoff asked for",
4450        );
4451        assert!(
4452            record.brief.ground_truth.iter().any(|section| section.label == "record"),
4453            "the ground truth is built at the swap: {:?}",
4454            record.brief.ground_truth,
4455        );
4456        let live = record
4457            .brief
4458            .ground_truth
4459            .iter()
4460            .find(|section| section.label == "background")
4461            .expect("the live background task reaches PART 1");
4462        assert!(
4463            live.lines.iter().any(|line| line.contains("local_bash: cargo watch [b1]")),
4464            "with what it is and its id: {:?}",
4465            live.lines,
4466        );
4467        assert!(
4468            record.brief.judgment.iter().any(|section| section.label == "Now"),
4469            "the caller's brief is the judgment half: {:?}",
4470            record.brief.judgment,
4471        );
4472
4473        let dispatched = workspace.drain_test_dispatch_buffer();
4474        let spawn = dispatched.iter().find_map(|command| match command {
4475            Command::NewSession { session_id, .. } => Some(session_id.clone()),
4476            _ => None,
4477        });
4478        assert_eq!(
4479            spawn,
4480            Some(Some(record.to.clone())),
4481            "the spawn runs under the id the row names",
4482        );
4483
4484        let pending =
4485            workspace.take_pending_brief(&slot).expect("the brief waits for its successor");
4486        assert_eq!(pending.to, record.to, "and it names the same occupant");
4487        assert!(pending.text.starts_with("HANDOFF - "), "the successor is given the brief");
4488        assert!(
4489            workspace.handoff_for_successor(&slot, &record.to).is_some(),
4490            "and the record is written, so the successor is measurable",
4491        );
4492    }
4493
4494    /// A prompt that arrives while the seat swaps its occupant waits for the
4495    /// successor: the session on its way out is never handed a turn the new
4496    /// one would not carry, and what waited follows the brief.
4497    #[tokio::test]
4498    async fn a_prompt_sent_mid_swap_waits_for_the_successor() {
4499        let dir = tempfile::tempdir().expect("tempdir");
4500        let (workspace, _rx) =
4501            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4502        workspace.enable_test_dispatch_intercept();
4503        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
4504        let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
4505        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
4506        task.connected_once = true;
4507        workspace.begin_swap(&slot);
4508
4509        task.execute_command(Command::Prompt {
4510            key: slot.clone(),
4511            text: "sent mid swap".to_owned(),
4512            attachments: Vec::new(),
4513        });
4514        assert!(
4515            agent_rx.try_recv().is_err(),
4516            "the session on its way out is not handed the prompt",
4517        );
4518
4519        task.translate_event(connected_event("new-session-id", "/tmp/forge"));
4520
4521        let dispatched = workspace.drain_test_dispatch_buffer();
4522        assert!(
4523            dispatched.iter().any(|command| matches!(
4524                command,
4525                Command::PromptUnder { text, .. } if text == "sent mid swap"
4526            )),
4527            "the successor is handed it once it is here: {dispatched:?}",
4528        );
4529        assert!(!workspace.swap_in_progress(&slot), "and the gate is closed");
4530    }
4531
4532    /// A replacement that is not the successor a brief names does not take
4533    /// it: a login or logout can land mid-swap, and the brief belongs to the
4534    /// session it was written for.
4535    #[tokio::test]
4536    async fn a_non_matching_replacement_leaves_the_brief_for_its_successor() {
4537        let dir = tempfile::tempdir().expect("tempdir");
4538        let (workspace, _rx) =
4539            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4540        workspace.enable_test_dispatch_intercept();
4541        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
4542        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
4543        task.connected_once = true;
4544        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
4545        workspace.set_pending_brief(
4546            &slot,
4547            crate::handoff::PendingBrief {
4548                to: "new-session-id".to_owned(),
4549                text: "HANDOFF - old -> new".to_owned(),
4550            },
4551        );
4552
4553        task.translate_event(connected_event("a-login-session", "/tmp/forge"));
4554
4555        assert!(
4556            workspace.pending_brief_for(&slot).is_some(),
4557            "another replacement leaves the brief for the successor it names",
4558        );
4559        assert!(
4560            workspace.drain_test_dispatch_buffer().is_empty(),
4561            "and it is given to nobody else",
4562        );
4563
4564        task.translate_event(connected_event("new-session-id", "/tmp/forge"));
4565        let dispatched = workspace.drain_test_dispatch_buffer();
4566        assert!(
4567            dispatched.iter().any(|command| matches!(
4568                command,
4569                Command::PromptUnder { text, .. } if text.starts_with("HANDOFF - old -> new")
4570            )),
4571            "the named successor is the one given it: {dispatched:?}",
4572        );
4573    }
4574
4575    /// A handoff mints its successor without recording it: the id reaches the
4576    /// slot's row on Connected. A recorded id whose spawn failed is one the
4577    /// next boot resumes into an empty session, which the handoff record
4578    /// would then claim to have joined.
4579    #[tokio::test]
4580    async fn a_handoff_does_not_persist_its_successor_before_it_connects() {
4581        let dir = tempfile::tempdir().expect("tempdir");
4582        let (workspace, mut update_rx) =
4583            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4584        workspace.install_db_for_test(
4585            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4586        );
4587        workspace.enable_test_dispatch_intercept();
4588        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
4589        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
4590        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
4591        workspace.note_running_session_id(&slot, "old-session-id");
4592
4593        task.execute_command(Command::Handoff { key: slot.clone(), brief: None });
4594        task.domain.lock().handoff.judgment = Some("Now: the cut is half in.".to_owned());
4595        task.after_turn_ended();
4596        let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
4597            while let Some(frame) = update_rx.recv().await {
4598                if let SessionUpdate::Handoff { handoff, .. } =
4599                    std::sync::Arc::unwrap_or_clone(frame.update)
4600                {
4601                    return handoff;
4602                }
4603            }
4604            panic!("the handoff row was never emitted");
4605        })
4606        .await
4607        .expect("the handoff lands");
4608
4609        assert_eq!(
4610            task.domain.lock().session_id.as_ref().map(forge_primitives::SessionId::as_str),
4611            Some("old-session-id")
4612        );
4613        let stored = {
4614            let guard = workspace.db.lock();
4615            let db = guard.as_ref().expect("a db");
4616            crate::store::sessions::get(db, slot.org(), slot.project(), slot.label())
4617                .expect("read")
4618                .and_then(|row| row.session_id)
4619        };
4620        assert_eq!(
4621            stored.as_deref(),
4622            Some("old-session-id"),
4623            "the successor id is not on the slot's row before it has run: {record:?}",
4624        );
4625    }
4626
4627    /// A swap that finds no session on the seat closes the gate its beginning
4628    /// opened: a hold left open would keep every later prompt for a successor
4629    /// nothing is bringing.
4630    #[tokio::test]
4631    async fn a_handoff_that_loses_its_session_closes_the_gate() {
4632        let dir = tempfile::tempdir().expect("tempdir");
4633        let (workspace, _rx) =
4634            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4635        workspace.enable_test_dispatch_intercept();
4636        let key = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
4637        let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &key);
4638        workspace.begin_swap(&key);
4639        workspace.hold_prompt(
4640            &key,
4641            crate::handoff::HeldPrompt {
4642                text: "held".to_owned(),
4643                attachments: Vec::new(),
4644                source: crate::protocol::PromptSource::You,
4645                uuid: "held-1".to_owned(),
4646                sender: None,
4647            },
4648        );
4649
4650        task.swap(forge_primitives::HandoffReason::Manual, Vec::new());
4651
4652        assert!(!workspace.swap_in_progress(&key), "the gate is closed with the swap");
4653        let mut said = Vec::new();
4654        let mut turns = Vec::new();
4655        let mut settled = Vec::new();
4656        while let Ok(frame) = update_rx.try_recv() {
4657            match std::sync::Arc::unwrap_or_clone(frame.update) {
4658                SessionUpdate::Notice { text, .. } => said.push(text),
4659                SessionUpdate::MachineTurn { machine, .. } => turns.push(machine),
4660                SessionUpdate::PromptLifecycle { uuid, state, .. } => settled.push((uuid, state)),
4661                _ => {}
4662            }
4663        }
4664        assert!(
4665            said.iter().any(|text| text.contains("needs a connected session")),
4666            "the seat is told why: {said:?}",
4667        );
4668        assert!(
4669            said.iter().any(|text| text.contains("this prompt waits no longer")),
4670            "and the held prompt is accounted for: {said:?}",
4671        );
4672        assert!(
4673            settled.iter().any(|(uuid, state)| uuid == "held-1" && state == "cancelled"),
4674            "and the press's composer mark settles: {settled:?}",
4675        );
4676        // **The seat keeps its session, so it keeps its composer.** No
4677        // successor arrives whose record read would clear the machine turn.
4678        assert_eq!(turns, vec![None], "the machine turn ends with the handoff");
4679    }
4680
4681    /// One handoff at a time: a swap already building takes the seat, so a
4682    /// handoff asked for then is dropped rather than racing it.
4683    #[tokio::test]
4684    async fn a_second_handoff_is_dropped_while_one_builds() {
4685        let dir = tempfile::tempdir().expect("tempdir");
4686        let (workspace, _rx) =
4687            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4688        workspace.enable_test_dispatch_intercept();
4689        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
4690        workspace.begin_swap(&task.key);
4691
4692        task.begin_handoff();
4693
4694        assert!(
4695            workspace.drain_test_dispatch_buffer().is_empty(),
4696            "no judgment prompt is sent by a handoff that cannot own the seat",
4697        );
4698        assert!(!task.domain.lock().handoff.awaiting, "and nothing is left waiting");
4699    }
4700
4701    /// A peer message held by the swap answers ITS SENDER when the handoff
4702    /// never reaches a successor, the way the parked bucket does.
4703    #[tokio::test]
4704    async fn a_failed_handoff_answers_a_held_peers_sender() {
4705        let dir = tempfile::tempdir().expect("tempdir");
4706        let (workspace, mut update_rx) =
4707            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4708        workspace.enable_test_dispatch_intercept();
4709        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
4710        let sender = SessionSlot::from_str_for_test("sender-proj");
4711        workspace.begin_swap(&task.key);
4712        workspace.hold_prompt(
4713            &task.key,
4714            crate::handoff::HeldPrompt {
4715                text: "[Message id=m-1] are you up?".to_owned(),
4716                attachments: Vec::new(),
4717                source: crate::protocol::PromptSource::Peer,
4718                uuid: "peer-1".to_owned(),
4719                sender: Some(sender.clone()),
4720            },
4721        );
4722
4723        task.translate_event(AgentEvent::ConnectionFailed {
4724            message: "the child exited".to_owned(),
4725            kind: forge_agent::client::SpawnFailureKind::Unclassified,
4726        });
4727
4728        let mut told = None;
4729        while let Ok(frame) = update_rx.try_recv() {
4730            if let SessionUpdate::PeerEnvelopeAppended { key, wrapped, .. } =
4731                std::sync::Arc::unwrap_or_clone(frame.update)
4732                && wrapped.kind == crate::mcp::peers::types::WrappedKind::DeliveryFailureNotice
4733            {
4734                told = Some((key, wrapped));
4735            }
4736        }
4737        let (key, notice) = told.expect("the sender is told its message never landed");
4738        assert_eq!(key, sender, "on the sender's own seat");
4739        assert_eq!(
4740            notice.sender_name, "forge/handoff-cut",
4741            "and the notice names the seat that never took it",
4742        );
4743    }
4744
4745    /// **Every Result settles the seat, not only a clean one.** A success, a
4746    /// failure and the reader's cancel all end the turn the row names; a
4747    /// settle narrowed to the success arm would leave a failed seat looking
4748    /// mid-turn until the next boot kicked it.
4749    #[tokio::test]
4750    async fn every_result_settles_the_seat_to_idle() {
4751        use crate::store::sessions::SeatState;
4752
4753        let dir = tempfile::tempdir().expect("tempdir");
4754        let (workspace, _rx) =
4755            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4756        workspace.install_db_for_test(
4757            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4758        );
4759        let key = SessionSlot::worker("TestOrg", "forge", "settling");
4760        workspace.record_session_id("TestOrg", "forge", "settling", "id-1");
4761        let (mut task, _agent_rx) = command_task_for(&workspace, &key);
4762
4763        for (label, subtype, cancelled, is_error) in [
4764            ("a success", "success", false, false),
4765            ("a failure", "error_during_execution", false, true),
4766            ("a cancel", "error_during_execution", true, true),
4767        ] {
4768            workspace.record_seat_state(&key, SeatState::InTurn);
4769            {
4770                let mut domain = task.domain.lock();
4771                domain.reset_turns();
4772                domain.commit_turn();
4773                // The cancel stamp is armed while the turn runs; the
4774                // commit's own cleanup would spend it first.
4775                domain.pending_cancel = cancelled;
4776            }
4777
4778            task.translate_event(AgentEvent::SdkMessage {
4779                session_id: "id-1".to_owned(),
4780                msg: result_message(subtype, is_error),
4781            });
4782
4783            assert_eq!(
4784                workspace.stored_seat_state(&key),
4785                Some(SeatState::Idle),
4786                "{label} settles the turn, so the seat is open with nothing in flight",
4787            );
4788        }
4789    }
4790
4791    /// **A settle must not erase the turn the boundary itself starts.** A
4792    /// parked handoff begins its judgment turn from `after_turn_ended`; that
4793    /// prompt routes through the workspace and writes `in_turn`, so the
4794    /// settle's own `idle` has to land first. Written after the hooks, a
4795    /// handoff running for minutes would leave the row reading idle, and a
4796    /// restart in that window would park the lead with no restart note.
4797    #[tokio::test]
4798    async fn a_settle_does_not_erase_the_handoff_turn_it_starts() {
4799        use crate::store::sessions::SeatState;
4800
4801        let dir = tempfile::tempdir().expect("tempdir");
4802        let (workspace, _rx) =
4803            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4804        workspace.install_db_for_test(
4805            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4806        );
4807        let key = SessionSlot::lead("TestOrg", "forge");
4808        workspace.record_session_id("TestOrg", "forge", "lead", "id-1");
4809        workspace.record_seat_state(&key, SeatState::InTurn);
4810        let (mut task, _agent_rx) = command_task_for(&workspace, &key);
4811        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("id-1".to_owned()));
4812        // The handoff was asked for while the turn ran; this boundary is the
4813        // idle one it waited for.
4814        task.domain.lock().handoff.parked = true;
4815        task.domain.lock().commit_turn();
4816        // The judgment prompt routes through the workspace, so the seat has
4817        // to be one the workspace can route to, and the task has to hold its
4818        // own domain there for the commit to be seen.
4819        workspace.domain_handles.lock().insert(key.clone(), Arc::clone(&task.domain));
4820        let (command_tx, _command_rx) = tokio::sync::mpsc::unbounded_channel();
4821        workspace.command_senders.lock().insert(key.clone(), command_tx);
4822        let _updates = workspace.subscribe_mirror();
4823
4824        task.translate_event(AgentEvent::SdkMessage {
4825            session_id: "id-1".to_owned(),
4826            msg: result_message("success", false),
4827        });
4828
4829        assert!(task.domain.lock().handoff.awaiting, "the boundary starts the handoff");
4830        assert_eq!(
4831            workspace.stored_seat_state(&key),
4832            Some(SeatState::InTurn),
4833            "the judgment turn the boundary started is the state the row holds",
4834        );
4835    }
4836
4837    /// **A turn a transport death cut off stays on the row.** The death is
4838    /// forge's to heal, and the state it interrupted is what the next boot
4839    /// reads to decide whether the seat was mid-work; a death write here
4840    /// would turn a stuck turn into a parked seat.
4841    #[tokio::test]
4842    async fn a_transport_death_leaves_an_interrupted_turn_in_turn() {
4843        use crate::store::sessions::SeatState;
4844
4845        let dir = tempfile::tempdir().expect("tempdir");
4846        let (workspace, _rx) =
4847            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4848        workspace.install_db_for_test(
4849            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4850        );
4851        let key = SessionSlot::worker("TestOrg", "forge", "dying");
4852        workspace.record_session_id("TestOrg", "forge", "dying", "id-1");
4853        workspace.record_seat_state(&key, SeatState::InTurn);
4854        let (mut task, _agent_rx) = command_task_for(&workspace, &key);
4855        task.domain.lock().commit_turn();
4856
4857        task.translate_event(AgentEvent::ConnectionFailed {
4858            message: "the child exited".to_owned(),
4859            kind: forge_agent::client::SpawnFailureKind::Unclassified,
4860        });
4861
4862        assert_eq!(
4863            workspace.stored_seat_state(&key),
4864            Some(SeatState::InTurn),
4865            "the interrupted turn stays on the row for the next boot",
4866        );
4867    }
4868
4869    /// **A `/resume` swap leaves the seat open with nothing in flight.**
4870    /// The successor starts parked on the id it names, whatever the seat
4871    /// it replaced was doing; the next prompt is the turn that flips it.
4872    #[tokio::test]
4873    async fn a_resume_swap_leaves_the_seat_idle() {
4874        use crate::store::sessions::SeatState;
4875
4876        let dir = tempfile::tempdir().expect("tempdir");
4877        let (workspace, _rx) =
4878            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4879        workspace.install_db_for_test(
4880            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4881        );
4882        let key = SessionSlot::lead("TestOrg", "forge");
4883        workspace.record_session_id("TestOrg", "forge", "lead", "old-id");
4884        workspace.record_seat_state(&key, SeatState::InTurn);
4885        let (mut task, _agent_rx) = command_task_for(&workspace, &key);
4886        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-id".to_owned()));
4887
4888        task.execute_command(Command::ResumeSession {
4889            key: key.clone(),
4890            session_id: "successor-id".to_owned(),
4891            cwd: "/tmp".to_owned(),
4892            launch_settings: forge_agent::client::SessionLaunchSettings::default(),
4893        });
4894
4895        assert_eq!(
4896            workspace.stored_seat_state(&key),
4897            Some(SeatState::Idle),
4898            "the successor starts parked",
4899        );
4900    }
4901
4902    /// **A queued prompt's start is a turn start too.** `route` writes
4903    /// `in_turn` only for a prompt that commits a turn; one queued behind a
4904    /// running turn gets its own Result, so the CLI's `started` frame is the
4905    /// first moment the row can learn a turn is running. A crash in that
4906    /// turn must not leave the row reading parked.
4907    #[tokio::test]
4908    async fn a_queued_prompt_start_marks_the_seat_in_turn() {
4909        use crate::store::sessions::SeatState;
4910
4911        let dir = tempfile::tempdir().expect("tempdir");
4912        let (workspace, _rx) =
4913            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4914        workspace.install_db_for_test(
4915            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4916        );
4917        let key = SessionSlot::worker("TestOrg", "forge", "queued-start");
4918        workspace.record_session_id(key.org(), key.project(), key.label(), "id-1");
4919        workspace.record_seat_state(&key, SeatState::Idle);
4920        let (mut task, _agent_rx) = command_task_for(&workspace, &key);
4921
4922        task.translate_event(AgentEvent::SdkMessage {
4923            session_id: "id-1".to_owned(),
4924            msg: forge_primitives::Message::CommandLifecycle {
4925                command_uuid: "queued-id".to_owned(),
4926                state: "started".to_owned(),
4927                uuid: "lifecycle-1".to_owned(),
4928                session_id: "id-1".to_owned(),
4929                extras: forge_primitives::messages::Extras::new(),
4930            },
4931        });
4932
4933        assert_eq!(
4934            workspace.stored_seat_state(&key),
4935            Some(SeatState::InTurn),
4936            "the CLI starting a queued prompt is a turn start",
4937        );
4938    }
4939
4940    /// A cancelled judgment turn ends the handoff rather than finishing it,
4941    /// and what the swap's gate held goes back to the session that stays.
4942    #[tokio::test]
4943    async fn a_cancelled_judgment_turn_ends_the_handoff_and_returns_its_prompts() {
4944        let dir = tempfile::tempdir().expect("tempdir");
4945        let (workspace, _rx) =
4946            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
4947        workspace.enable_test_dispatch_intercept();
4948        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
4949        {
4950            let mut domain = task.domain.lock();
4951            domain.lifecycle_frames = Some(true);
4952            domain.handoff.awaiting = true;
4953            domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
4954            domain.handoff.judgment_started = true;
4955            domain.pending_cancel = true;
4956        }
4957        workspace.begin_swap(&task.key);
4958        workspace.hold_prompt(
4959            &task.key,
4960            crate::handoff::HeldPrompt {
4961                text: "sent during the swap".to_owned(),
4962                attachments: Vec::new(),
4963                source: crate::protocol::PromptSource::You,
4964                uuid: "held-swap".to_owned(),
4965                sender: None,
4966            },
4967        );
4968
4969        task.translate_event(AgentEvent::SdkMessage {
4970            session_id: "old-session-id".to_owned(),
4971            msg: result_message("error_during_execution", true),
4972        });
4973
4974        assert!(!workspace.swap_in_progress(&task.key), "the handoff is over");
4975        assert!(
4976            !task.domain.lock().handoff.awaiting,
4977            "and nothing is left waiting on a judgment turn",
4978        );
4979        let dispatched = workspace.drain_test_dispatch_buffer();
4980        assert!(
4981            dispatched.iter().any(|command| matches!(
4982                command,
4983                Command::PromptUnder { text, .. } if text == "sent during the swap"
4984            )),
4985            "the held prompt goes back to the session that continues, under the id it was held \
4986             with: {dispatched:?}",
4987        );
4988    }
4989
4990    /// The machine-turn frames this run emitted, in order.
4991    fn drain_machine_turns(
4992        rx: &mut mpsc::UnboundedReceiver<SeatUpdate>,
4993    ) -> Vec<Option<forge_primitives::runtime::MachineTurn>> {
4994        let mut out = Vec::new();
4995        while let Ok(frame) = rx.try_recv() {
4996            if let SessionUpdate::MachineTurn { machine, .. } =
4997                std::sync::Arc::unwrap_or_clone(frame.update)
4998            {
4999                out.push(machine);
5000            }
5001        }
5002        out
5003    }
5004
5005    /// **A handoff announces its machine turn both ways.** The record carries
5006    /// the same fact for a page that attaches mid-turn, but a read is not
5007    /// pushed - so a page already open hears the begin and the end here, and
5008    /// without them the composer stays live through the handoff and locked
5009    /// after an abort.
5010    #[tokio::test]
5011    async fn a_handoff_announces_its_machine_turn_both_ways() {
5012        use forge_primitives::runtime::MachineTurn;
5013
5014        let dir = tempfile::tempdir().expect("tempdir");
5015        let (workspace, _workspace_rx) =
5016            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5017        workspace.enable_test_dispatch_intercept();
5018        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5019        // The fixture's task carries a fanout of its own, so this is the
5020        // subscriber the run's frames reach.
5021        let mut rx = task.update_tx.subscribe(SubscriberRole::Answering);
5022
5023        task.begin_handoff();
5024        assert_eq!(
5025            drain_machine_turns(&mut rx),
5026            vec![Some(MachineTurn::Handoff)],
5027            "the seat stands down as the judgment prompt goes out",
5028        );
5029
5030        {
5031            let mut domain = task.domain.lock();
5032            domain.lifecycle_frames = Some(true);
5033            domain.handoff.judgment_started = true;
5034            domain.pending_cancel = true;
5035        }
5036        task.translate_event(AgentEvent::SdkMessage {
5037            session_id: "old-session-id".to_owned(),
5038            msg: result_message("error_during_execution", true),
5039        });
5040        assert_eq!(
5041            drain_machine_turns(&mut rx),
5042            vec![None],
5043            "the cancelled handoff hands the seat back",
5044        );
5045    }
5046
5047    /// **A judgment prompt the core refuses hands the seat straight back.**
5048    /// The begin announced the handoff, so without the failure's own clear a
5049    /// composer would stay down for a handoff that never ran.
5050    #[tokio::test]
5051    async fn a_refused_judgment_prompt_clears_the_machine_turn() {
5052        use forge_primitives::runtime::MachineTurn;
5053
5054        let dir = tempfile::tempdir().expect("tempdir");
5055        let (workspace, _workspace_rx) =
5056            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5057        // No dispatch intercept and no seat route: the prompt cannot be sent.
5058        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5059        let mut rx = task.update_tx.subscribe(SubscriberRole::Answering);
5060
5061        task.begin_handoff();
5062
5063        assert_eq!(
5064            drain_machine_turns(&mut rx),
5065            vec![Some(MachineTurn::Handoff), None],
5066            "the begin announces the turn and the refused prompt undoes it",
5067        );
5068    }
5069
5070    /// A judgment prompt cancelled before it ever started is the same end
5071    /// as a cancelled turn: no Result will come, so the handoff must close
5072    /// and hand back what it was holding.
5073    #[tokio::test]
5074    async fn a_judgment_prompt_cancelled_before_it_starts_ends_the_handoff() {
5075        let dir = tempfile::tempdir().expect("tempdir");
5076        let (workspace, _rx) =
5077            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5078        workspace.enable_test_dispatch_intercept();
5079        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5080        {
5081            let mut domain = task.domain.lock();
5082            domain.lifecycle_frames = Some(true);
5083            domain.handoff.awaiting = true;
5084            domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
5085            domain.handoff.judgment_started = false;
5086        }
5087        workspace.begin_swap(&task.key);
5088        workspace.hold_prompt(
5089            &task.key,
5090            crate::handoff::HeldPrompt {
5091                text: "sent during the swap".to_owned(),
5092                attachments: Vec::new(),
5093                source: crate::protocol::PromptSource::You,
5094                uuid: "held-swap".to_owned(),
5095                sender: None,
5096            },
5097        );
5098
5099        task.translate_event(AgentEvent::PromptCancelResolved {
5100            uuid: "judgment-id".to_owned(),
5101            cancelled: true,
5102        });
5103
5104        assert!(!workspace.swap_in_progress(&task.key), "the handoff is over");
5105        assert!(
5106            !task.domain.lock().handoff.awaiting,
5107            "and nothing waits on a brief that will never be written",
5108        );
5109        let dispatched = workspace.drain_test_dispatch_buffer();
5110        assert!(
5111            dispatched.iter().any(|command| matches!(
5112                command,
5113                Command::PromptUnder { text, .. } if text == "sent during the swap"
5114            )),
5115            "the held prompt goes back to the session that continues, under the id it was held \
5116             with: {dispatched:?}",
5117        );
5118    }
5119
5120    /// **The judgment prompt's committed turn settles on the abort.** The
5121    /// real sender path commits it, so a test that arms the handoff state by
5122    /// hand cannot see the leak; this one routes the prompt through a
5123    /// registered sender and cancels it before it starts.
5124    #[tokio::test]
5125    async fn a_cancelled_judgment_short_settles_the_committed_turn() {
5126        use crate::store::sessions::SeatState;
5127
5128        let dir = tempfile::tempdir().expect("tempdir");
5129        let (workspace, _rx) =
5130            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5131        workspace.install_db_for_test(
5132            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
5133        );
5134        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5135        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5136        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5137        workspace.domain_handles.lock().insert(slot.clone(), Arc::clone(&task.domain));
5138        workspace.record_session_id(slot.org(), slot.project(), slot.label(), "old-session-id");
5139        let (commands, mut routed) = mpsc::unbounded_channel();
5140        workspace.command_senders.lock().insert(slot.clone(), commands);
5141
5142        task.begin_handoff();
5143        assert_eq!(
5144            workspace.stored_seat_state(&slot),
5145            Some(SeatState::InTurn),
5146            "routing the judgment prompt commits its turn on the row",
5147        );
5148
5149        assert!(
5150            matches!(
5151                routed.try_recv(),
5152                Ok(Command::PromptUnder { text, .. }) if text == crate::handoff::judgment_prompt()
5153            ),
5154            "the judgment prompt reached the seat's sender",
5155        );
5156        assert!(task.domain.lock().turn_pending, "and its turn is committed");
5157        let judgment_uuid = task
5158            .domain
5159            .lock()
5160            .handoff
5161            .judgment_uuid
5162            .clone()
5163            .expect("the handoff recorded its prompt id");
5164
5165        task.translate_event(AgentEvent::PromptCancelResolved {
5166            uuid: judgment_uuid,
5167            cancelled: true,
5168        });
5169
5170        let domain = task.domain.lock();
5171        assert!(!domain.turn_pending, "the abort settles the judgment prompt's turn");
5172        assert!(domain.is_idle_for_handoff(), "so the seat is idle again");
5173        assert!(!domain.handoff.awaiting, "and the handoff is over");
5174        drop(domain);
5175        assert_eq!(
5176            workspace.stored_seat_state(&slot),
5177            Some(SeatState::Idle),
5178            "and the row settles with it, so no boot kicks a turn nothing interrupted",
5179        );
5180    }
5181
5182    /// A judgment prompt the CLI refuses or discards without running is the
5183    /// same dead end, whichever state names it.
5184    #[tokio::test]
5185    async fn a_refused_judgment_prompt_ends_the_handoff() {
5186        let dir = tempfile::tempdir().expect("tempdir");
5187        let (workspace, _rx) =
5188            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5189        workspace.enable_test_dispatch_intercept();
5190        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5191        {
5192            let mut domain = task.domain.lock();
5193            domain.lifecycle_frames = Some(true);
5194            domain.handoff.awaiting = true;
5195            domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
5196        }
5197        workspace.begin_swap(&task.key);
5198
5199        task.translate_event(AgentEvent::SdkMessage {
5200            session_id: "old-session-id".to_owned(),
5201            msg: forge_primitives::Message::CommandLifecycle {
5202                command_uuid: "judgment-id".to_owned(),
5203                state: "refused".to_owned(),
5204                uuid: "lifecycle-1".to_owned(),
5205                session_id: "old-session-id".to_owned(),
5206                extras: forge_primitives::messages::Extras::new(),
5207            },
5208        });
5209
5210        assert!(!workspace.swap_in_progress(&task.key), "the handoff is over");
5211        assert!(
5212            !task.domain.lock().handoff.awaiting,
5213            "and the seat is free to continue on its own",
5214        );
5215        assert!(
5216            workspace.drain_test_dispatch_buffer().is_empty(),
5217            "nothing is swapped for a brief that was refused",
5218        );
5219    }
5220
5221    /// A `completed` with no `started` before it is a prompt the CLI settled
5222    /// without running; no Result will finish that handoff.
5223    #[tokio::test]
5224    async fn a_completed_judgment_with_no_start_ends_the_handoff() {
5225        let dir = tempfile::tempdir().expect("tempdir");
5226        let (workspace, _rx) =
5227            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5228        workspace.enable_test_dispatch_intercept();
5229        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5230        {
5231            let mut domain = task.domain.lock();
5232            domain.lifecycle_frames = Some(true);
5233            domain.handoff.awaiting = true;
5234            domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
5235            domain.handoff.judgment_started = false;
5236        }
5237        workspace.begin_swap(&task.key);
5238
5239        task.translate_event(AgentEvent::SdkMessage {
5240            session_id: "old-session-id".to_owned(),
5241            msg: forge_primitives::Message::CommandLifecycle {
5242                command_uuid: "judgment-id".to_owned(),
5243                state: "completed".to_owned(),
5244                uuid: "lifecycle-1".to_owned(),
5245                session_id: "old-session-id".to_owned(),
5246                extras: forge_primitives::messages::Extras::new(),
5247            },
5248        });
5249
5250        assert!(!workspace.swap_in_progress(&task.key), "the handoff is over");
5251        assert!(!task.domain.lock().handoff.awaiting, "nothing waits on a turn that never started");
5252    }
5253
5254    /// **A replacement in the judgment window leaves the handoff ownerless.**
5255    /// The old occupant's judgment turn can never produce a Result here, so
5256    /// the new occupant must not be swapped by it or have its trigger held
5257    /// off by it.
5258    #[tokio::test]
5259    async fn a_replacement_during_the_judgment_window_ends_the_handoff() {
5260        let dir = tempfile::tempdir().expect("tempdir");
5261        let (workspace, _rx) =
5262            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5263        workspace.enable_test_dispatch_intercept();
5264        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5265        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5266        task.connected_once = true;
5267        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5268        {
5269            let mut domain = task.domain.lock();
5270            domain.handoff.awaiting = true;
5271            domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
5272            domain.handoff.judgment_started = true;
5273        }
5274        workspace.begin_swap(&task.key);
5275        workspace.hold_prompt(
5276            &task.key,
5277            crate::handoff::HeldPrompt {
5278                text: "sent during the swap".to_owned(),
5279                attachments: Vec::new(),
5280                source: crate::protocol::PromptSource::You,
5281                uuid: "held-swap".to_owned(),
5282                sender: None,
5283            },
5284        );
5285
5286        task.translate_event(connected_event("replacement-id", "/tmp/forge"));
5287
5288        assert!(!workspace.swap_in_progress(&task.key), "the gate is closed");
5289        assert!(
5290            !task.domain.lock().handoff.awaiting,
5291            "nothing waits on the previous occupant's judgment",
5292        );
5293        let dispatched = workspace.drain_test_dispatch_buffer();
5294        let held = dispatched
5295            .iter()
5296            .find_map(|command| match command {
5297                Command::PromptUnder { text, uuid, .. } if text == "sent during the swap" => {
5298                    Some(uuid.clone())
5299                }
5300                _ => None,
5301            })
5302            .expect("what the swap held goes to the occupant that is here");
5303        assert_eq!(
5304            held, "held-swap",
5305            "the drain keeps the id the hold carried, and draws no second turn: a fresh id \
5306             would orphan a cron row's schedule pairing (#1815)",
5307        );
5308    }
5309
5310    /// The last backstop: a judgment prompt that never starts is abandoned at
5311    /// the deadline rather than holding the seat's gate forever.
5312    #[tokio::test(start_paused = true)]
5313    async fn a_judgment_prompt_that_never_starts_is_abandoned_at_the_deadline() {
5314        let dir = tempfile::tempdir().expect("tempdir");
5315        let (workspace, _rx) =
5316            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5317        workspace.enable_test_dispatch_intercept();
5318        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5319
5320        task.begin_handoff();
5321        assert!(workspace.swap_in_progress(&task.key), "precondition: the gate is open");
5322        // The spawned deadline sleeps from the clock it was first polled at,
5323        // so it has to register before the clock moves.
5324        tokio::task::yield_now().await;
5325
5326        tokio::time::advance(crate::handoff::JUDGMENT_DEADLINE + std::time::Duration::from_secs(1))
5327            .await;
5328        // The woken deadline task runs on the scheduler, not inside advance.
5329        for _ in 0..4 {
5330            tokio::task::yield_now().await;
5331        }
5332
5333        assert!(!workspace.swap_in_progress(&task.key), "the deadline ends the handoff");
5334        assert!(
5335            !task.domain.lock().handoff.awaiting,
5336            "and the seat is free to continue on its own",
5337        );
5338    }
5339
5340    /// A handoff's successor lands as a REPLACEMENT connect, and that is
5341    /// where its measurement must be armed; arming only on a first connect
5342    /// left every live handoff unmeasured.
5343    #[tokio::test]
5344    async fn a_replacement_connect_arms_the_successor_measurement() {
5345        let dir = tempfile::tempdir().expect("tempdir");
5346        let (workspace, _rx) =
5347            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5348        workspace.install_db_for_test(
5349            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
5350        );
5351        workspace.enable_test_dispatch_intercept();
5352        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5353        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5354        task.connected_once = true;
5355        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5356        workspace.record_handoff(
5357            &forge_primitives::HandoffRecord {
5358                slot: slot.clone(),
5359                from: "old-session-id".to_owned(),
5360                to: "new-session-id".to_owned(),
5361                reason: forge_primitives::HandoffReason::Fill,
5362                fill_percent: Some(85),
5363                total_tokens: Some(850_000),
5364                max_tokens: Some(1_000_000),
5365                at_percent: 85,
5366                brief: forge_primitives::HandoffBrief::default(),
5367                transcript_path: "/tmp/old.jsonl".to_owned(),
5368                at: "2026-10-24T08:00:00Z".to_owned(),
5369                cycle: forge_primitives::HandoffCycle::default(),
5370                successor: None,
5371            },
5372            &[],
5373        );
5374
5375        task.translate_event(connected_event("new-session-id", "/tmp/forge"));
5376
5377        let watch = task.successor.as_ref().expect("the replacement connect arms it");
5378        assert_eq!(watch.to, "new-session-id", "for the occupant the handoff named");
5379        assert_eq!(watch.turns, 0, "and no prompt is counted before one runs");
5380        assert!(!watch.measure_at_boundary, "nor is anything measured yet");
5381
5382        // The brief's own turn ending is not one of the successor's ten: the
5383        // brief is forge's own prompt, and the count is of a person's sends.
5384        task.after_turn_ended();
5385        assert_eq!(
5386            task.successor.as_ref().map(|watch| watch.turns),
5387            Some(0),
5388            "a turn boundary alone counts nothing",
5389        );
5390        assert!(
5391            task.successor.as_ref().is_some_and(|watch| !watch.measure_at_boundary),
5392            "and no measurement is armed by it",
5393        );
5394    }
5395
5396    /// A reconnect of the same successor keeps its watch: the ten prompts are
5397    /// still that occupant's, and restarting would measure a later set
5398    /// against the brief.
5399    #[tokio::test]
5400    async fn a_reconnect_of_the_same_successor_keeps_its_count() {
5401        let dir = tempfile::tempdir().expect("tempdir");
5402        let (workspace, _rx) =
5403            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5404        workspace.install_db_for_test(
5405            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
5406        );
5407        workspace.enable_test_dispatch_intercept();
5408        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5409        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5410        task.connected_once = true;
5411        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5412        workspace.record_handoff(
5413            &forge_primitives::HandoffRecord {
5414                slot: slot.clone(),
5415                from: "old-session-id".to_owned(),
5416                to: "new-session-id".to_owned(),
5417                reason: forge_primitives::HandoffReason::Fill,
5418                fill_percent: Some(85),
5419                total_tokens: Some(850_000),
5420                max_tokens: Some(1_000_000),
5421                at_percent: 85,
5422                brief: forge_primitives::HandoffBrief::default(),
5423                transcript_path: "/tmp/old.jsonl".to_owned(),
5424                at: "2026-10-24T08:00:00Z".to_owned(),
5425                cycle: forge_primitives::HandoffCycle::default(),
5426                successor: None,
5427            },
5428            &[],
5429        );
5430        task.translate_event(connected_event("new-session-id", "/tmp/forge"));
5431        task.count_successor_prompt();
5432        task.count_successor_prompt();
5433
5434        task.translate_event(connected_event("new-session-id", "/tmp/forge"));
5435
5436        assert_eq!(
5437            task.successor.as_ref().map(|watch| watch.turns),
5438            Some(2),
5439            "a reconnect of the same occupant does not restart the ten",
5440        );
5441    }
5442
5443    /// The successor's window closes at its tenth turn: a row that kept
5444    /// counting would measure the seat's later work against a brief that
5445    /// had nothing to do with it.
5446    #[tokio::test]
5447    async fn the_successor_is_measured_at_its_tenth_turn() {
5448        let dir = tempfile::tempdir().expect("tempdir");
5449        let (workspace, _rx) =
5450            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5451        workspace.enable_test_dispatch_intercept();
5452        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5453        task.successor = Some(crate::handoff::SuccessorWatch {
5454            to: "old-session-id".to_owned(),
5455            turns: 0,
5456            predecessor_reads: Vec::new(),
5457            measure_at_boundary: false,
5458        });
5459
5460        for _ in 0..(crate::handoff::SUCCESSOR_TURNS - 1) {
5461            task.count_successor_prompt();
5462        }
5463        assert_eq!(
5464            task.successor.as_ref().map(|watch| watch.turns),
5465            Some(crate::handoff::SUCCESSOR_TURNS - 1),
5466            "nine prompts are counted and the watch is still open",
5467        );
5468        assert!(
5469            task.successor.as_ref().is_some_and(|watch| !watch.measure_at_boundary),
5470            "nine prompts do not measure anything",
5471        );
5472
5473        task.count_successor_prompt();
5474        assert!(
5475            task.successor.as_ref().is_some_and(|watch| watch.measure_at_boundary),
5476            "the tenth prompt arms the measurement",
5477        );
5478        assert!(task.successor.is_some(), "but it waits for that prompt's turn to end");
5479        task.after_turn_ended();
5480        assert!(task.successor.is_none(), "the tenth turn closes the measurement");
5481    }
5482
5483    /// The successor's count is of a person's prompts, the same thing the
5484    /// transcript fold counts. A forge delivery - the brief, a kick, an
5485    /// auto-continue - is not one of the ten.
5486    #[tokio::test]
5487    async fn only_a_persons_prompt_counts_toward_the_successor() {
5488        let dir = tempfile::tempdir().expect("tempdir");
5489        let (workspace, _rx) =
5490            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5491        workspace.enable_test_dispatch_intercept();
5492        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5493        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5494        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("new-session-id"));
5495        task.successor = Some(crate::handoff::SuccessorWatch {
5496            to: "new-session-id".to_owned(),
5497            turns: 0,
5498            predecessor_reads: Vec::new(),
5499            measure_at_boundary: false,
5500        });
5501
5502        task.execute_command(Command::Prompt {
5503            key: slot.clone(),
5504            text: "do the thing".to_owned(),
5505            attachments: Vec::new(),
5506        });
5507        assert_eq!(
5508            task.successor.as_ref().map(|watch| watch.turns),
5509            Some(1),
5510            "a person's prompt is one of the ten",
5511        );
5512
5513        task.execute_command(Command::PromptUnder {
5514            key: slot.clone(),
5515            text: "HANDOFF - old -> new".to_owned(),
5516            attachments: Vec::new(),
5517            uuid: forge_sdk::request_id::next_forged_prompt_id(),
5518            source: crate::protocol::PromptSource::Forge,
5519            dispatch_id: None,
5520        });
5521        assert_eq!(
5522            task.successor.as_ref().map(|watch| watch.turns),
5523            Some(1),
5524            "forge's own delivery is not one of them",
5525        );
5526    }
5527
5528    /// **A prompt held by a swap draws no row while it waits.** Its row lands
5529    /// when the successor's CLI starts it; the frame this used to forge before
5530    /// the hold drew words the successor had not taken yet (#2816).
5531    #[tokio::test]
5532    async fn a_prompt_held_by_a_swap_draws_no_row_while_it_waits() {
5533        let dir = tempfile::tempdir().expect("tempdir");
5534        let (workspace, _rx) =
5535            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5536        let slot = SessionSlot::worker("TestOrg", "forge", "held-frame");
5537        let (mut task, mut update_rx, mut agent_rx) = nudge_task_for(&workspace, &slot);
5538        workspace.begin_swap(&slot);
5539        assert!(workspace.swap_in_progress(&slot), "premise: the swap gate holds prompts");
5540
5541        task.execute_command(Command::PromptUnder {
5542            key: slot.clone(),
5543            text: "held words".to_owned(),
5544            attachments: Vec::new(),
5545            uuid: "held-frame-uuid".to_owned(),
5546            source: crate::protocol::PromptSource::You,
5547            dispatch_id: None,
5548        });
5549
5550        let mut drawn = Vec::new();
5551        while let Ok(frame) = update_rx.try_recv() {
5552            if let SessionUpdate::ChatAppended { msg, .. } = &*frame.update {
5553                drawn.push(msg.clone());
5554            }
5555        }
5556        assert!(drawn.is_empty(), "the held prompt draws no row: {drawn:?}");
5557        assert!(
5558            agent_rx.try_recv().is_err(),
5559            "and nothing was handed to the agent: the swap is holding it",
5560        );
5561        let held = workspace.end_swap(&slot);
5562        assert_eq!(held.len(), 1, "the prompt waits for the successor");
5563        assert_eq!(held[0].uuid, "held-frame-uuid", "under the id its row will carry");
5564    }
5565
5566    /// **A dispatched prompt draws no row of its own** (#2816).
5567    ///
5568    /// The queue's announcement carries the words, and the row lands when the
5569    /// CLI starts the prompt. The frame this used to forge here was stamped at
5570    /// dispatch, so a prompt sent while a turn ran drew above the assistant
5571    /// text the running turn wrote after the press.
5572    #[tokio::test]
5573    async fn a_dispatched_prompt_emits_no_row_before_the_cli_starts_it() {
5574        let dir = tempfile::tempdir().expect("tempdir");
5575        let (workspace, _rx) =
5576            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5577        let slot = SessionSlot::worker("TestOrg", "forge", "send-row");
5578        let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
5579        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("send-row-session"));
5580
5581        task.execute_command(Command::PromptUnder {
5582            key: slot.clone(),
5583            text: "sent mid turn".to_owned(),
5584            // Two the sender drops before the model sees them: the live row
5585            // must draw only what a reload would carry.
5586            attachments: vec![
5587                forge_primitives::ImageAttachment {
5588                    data: "iVBORw0KGgo=".to_owned(),
5589                    mime_type: "image/png".to_owned(),
5590                },
5591                forge_primitives::ImageAttachment {
5592                    data: "iVBORw0KGgo=".to_owned(),
5593                    mime_type: "text/plain".to_owned(),
5594                },
5595                forge_primitives::ImageAttachment {
5596                    data: "not base64!".to_owned(),
5597                    mime_type: "image/png".to_owned(),
5598                },
5599            ],
5600            uuid: "send-row-uuid".to_owned(),
5601            source: crate::protocol::PromptSource::You,
5602            dispatch_id: None,
5603        });
5604
5605        let mut queued = false;
5606        while let Ok(frame) = update_rx.try_recv() {
5607            match &*frame.update {
5608                SessionUpdate::ChatAppended { msg, .. } => {
5609                    panic!("a prompt that has not started draws no row: {msg:?}");
5610                }
5611                SessionUpdate::PromptQueued { uuid, text, .. } if uuid == "send-row-uuid" => {
5612                    assert_eq!(text, "sent mid turn", "the queue carries the words meanwhile");
5613                    queued = true;
5614                }
5615                _ => {}
5616            }
5617        }
5618        assert!(queued, "the queue announces the prompt until the CLI takes it");
5619
5620        // The CLI takes it: the row lands now, with the words the queue
5621        // carried and the attachment the dispatch held.
5622        let before = time::OffsetDateTime::now_utc();
5623        task.translate_event(AgentEvent::SdkMessage {
5624            session_id: "send-row-session".to_owned(),
5625            msg: forge_primitives::Message::CommandLifecycle {
5626                command_uuid: "send-row-uuid".to_owned(),
5627                state: "started".to_owned(),
5628                uuid: "lifecycle-1".to_owned(),
5629                session_id: "send-row-session".to_owned(),
5630                extras: forge_primitives::messages::Extras::new(),
5631            },
5632        });
5633        let after = time::OffsetDateTime::now_utc();
5634
5635        let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
5636            .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
5637            .collect();
5638        let drawn: Vec<(&forge_primitives::Message, Option<crate::protocol::PromptSource>)> =
5639            announced
5640                .iter()
5641                .filter_map(|update| match update {
5642                    SessionUpdate::ChatAppended { msg, source, .. } => Some((msg, *source)),
5643                    _ => None,
5644                })
5645                .collect();
5646        let [(msg, source)] = drawn.as_slice() else {
5647            panic!("the start draws one row: {drawn:?}");
5648        };
5649        // **The row lands before the state that drains it.** A view holds the
5650        // row on the frame and draws it when the state arrives; the other
5651        // order draws the announcement's bare words and loses the attachment.
5652        let row_at = announced
5653            .iter()
5654            .position(|update| matches!(update, SessionUpdate::ChatAppended { .. }))
5655            .expect("the row is announced");
5656        let lifecycle_at = announced
5657            .iter()
5658            .position(|update| {
5659                matches!(
5660                    update,
5661                    SessionUpdate::PromptLifecycle { state, .. } if state == "started"
5662                )
5663            })
5664            .expect("the state is announced");
5665        assert!(
5666            row_at < lifecycle_at,
5667            "the row is emitted before the lifecycle frame that drains it: {announced:?}",
5668        );
5669        assert_eq!(*source, Some(crate::protocol::PromptSource::You), "the reader's own send");
5670        let forge_primitives::Message::User { message, uuid, .. } = msg else {
5671            panic!("the row is the user turn it draws: {msg:?}");
5672        };
5673        assert_eq!(uuid.as_deref(), Some("send-row-uuid"), "under the prompt's own id");
5674        assert!(
5675            message.content.iter().any(|block| matches!(
5676                block,
5677                forge_primitives::ContentBlock::Text { text, .. } if text == "sent mid turn"
5678            )),
5679            "carrying the words the queue announced: {message:?}",
5680        );
5681        let Some(forge_primitives::ContentBlock::Image { source, extras }) = message
5682            .content
5683            .iter()
5684            .find(|block| matches!(block, forge_primitives::ContentBlock::Image { .. }))
5685        else {
5686            panic!("and the attachment the dispatch held: {message:?}");
5687        };
5688        assert_eq!(
5689            source.get("media_type").and_then(serde_json::Value::as_str),
5690            Some("image/png"),
5691            "the row names the mime",
5692        );
5693        assert_eq!(
5694            extras.get("forge_bytes").and_then(serde_json::Value::as_u64),
5695            Some(8),
5696            "and the eight bytes the payload weighed, never the payload",
5697        );
5698        assert_eq!(
5699            message.content.len(),
5700            2,
5701            "only the attachment the sender kept draws, beside the words: {message:?}",
5702        );
5703        // **The row carries the clock a mid-turn page reads.** Stamped where
5704        // the CLI's start is handled, not at dispatch.
5705        let stamp =
5706            serde_json::to_value(msg).expect("encode")["timestamp"].as_str().map(str::to_owned);
5707        let stamp = stamp.expect("the row carries a stamp");
5708        let at =
5709            time::OffsetDateTime::parse(&stamp, &time::format_description::well_known::Rfc3339)
5710                .unwrap_or_else(|error| panic!("a client cannot read the stamp {stamp}: {error}"));
5711        assert!(
5712            at >= before - time::Duration::seconds(5) && at <= after + time::Duration::seconds(5),
5713            "the row carries the task's own clock, not a constant: {stamp} sits outside \
5714             {before}..{after}",
5715        );
5716    }
5717
5718    /// A judgment turn that ends swaps the seat, and the row's judgment is
5719    /// the reply that turn wrote.
5720    #[tokio::test]
5721    async fn the_judgment_turn_swaps_when_it_ends() {
5722        let dir = tempfile::tempdir().expect("tempdir");
5723        let (workspace, mut update_rx) =
5724            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5725        workspace.enable_test_dispatch_intercept();
5726        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5727        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5728        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5729        let token = workspace.begin_swap(&task.key);
5730        {
5731            let mut domain = task.domain.lock();
5732            domain.handoff.awaiting = true;
5733            domain.handoff.judgment = Some("Next: the client row.".to_owned());
5734            domain.handoff.swap_token = Some(token);
5735        }
5736
5737        task.after_turn_ended();
5738
5739        let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
5740            while let Some(frame) = update_rx.recv().await {
5741                if let SessionUpdate::Handoff { handoff, .. } =
5742                    std::sync::Arc::unwrap_or_clone(frame.update)
5743                {
5744                    return handoff;
5745                }
5746            }
5747            panic!("the handoff row was never emitted");
5748        })
5749        .await
5750        .expect("the handoff lands");
5751        assert!(
5752            record.brief.judgment.iter().any(|section| section.label == "Next"),
5753            "the judgment is the reply, parsed by its heading: {:?}",
5754            record.brief.judgment,
5755        );
5756    }
5757
5758    /// **The note is the turn's last text, not its bookkeeping.** The turn
5759    /// now records memory with tool calls before it writes the note, so the
5760    /// capture must keep the newest assistant text: keeping the first would
5761    /// hand the successor a progress line instead of the brief.
5762    #[tokio::test]
5763    async fn an_agentic_judgment_turn_captures_its_last_text() {
5764        let dir = tempfile::tempdir().expect("tempdir");
5765        let (workspace, _rx) =
5766            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5767        workspace.enable_test_dispatch_intercept();
5768        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
5769        task.domain.lock().handoff.awaiting = true;
5770
5771        for frame in [
5772            serde_json::json!({
5773                "type": "assistant",
5774                "session_id": "old-session-id",
5775                "message": {
5776                    "id": "m1",
5777                    "role": "assistant",
5778                    "model": "a-model",
5779                    "content": [{"type": "text", "text": "recording what this session learned"}],
5780                },
5781            }),
5782            serde_json::json!({
5783                "type": "assistant",
5784                "session_id": "old-session-id",
5785                "message": {
5786                    "id": "m2",
5787                    "role": "assistant",
5788                    "model": "a-model",
5789                    "content": [{
5790                        "type": "tool_use",
5791                        "id": "t1",
5792                        "name": "Write",
5793                        "input": {"file_path": "memory.md", "content": "the lesson"},
5794                    }],
5795                },
5796            }),
5797            serde_json::json!({
5798                "type": "assistant",
5799                "session_id": "old-session-id",
5800                "message": {
5801                    "id": "m3",
5802                    "role": "assistant",
5803                    "model": "a-model",
5804                    "content": [{"type": "text", "text": "Now: the cut is in."}],
5805                },
5806            }),
5807            // A sub-agent's frame arrives in the same stream and after the
5808            // note; it must not become the brief.
5809            serde_json::json!({
5810                "type": "assistant",
5811                "session_id": "old-session-id",
5812                "parent_tool_use_id": "task-1",
5813                "message": {
5814                    "id": "m4",
5815                    "role": "assistant",
5816                    "model": "a-model",
5817                    "content": [{"type": "text", "text": "a sub-agent line that must not win"}],
5818                },
5819            }),
5820        ] {
5821            task.translate_event(AgentEvent::SdkMessage {
5822                session_id: "old-session-id".to_owned(),
5823                msg: serde_json::from_value(frame).expect("an assistant frame"),
5824            });
5825        }
5826
5827        assert_eq!(
5828            task.domain.lock().handoff.judgment.as_deref(),
5829            Some("Now: the cut is in."),
5830            "the note the turn ended on, not the bookkeeping before it",
5831        );
5832    }
5833
5834    /// **The exit call's note is the brief's judgment half.** The skill ends
5835    /// by calling `agents__handoff` with the note; that note, not the turn's
5836    /// other prose, is what the successor is given.
5837    #[tokio::test]
5838    async fn the_exit_calls_note_is_the_judgment_half() {
5839        let dir = tempfile::tempdir().expect("tempdir");
5840        let (workspace, mut update_rx) =
5841            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5842        workspace.enable_test_dispatch_intercept();
5843        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5844        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5845        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5846        let token = workspace.begin_swap(&task.key);
5847        {
5848            let mut domain = task.domain.lock();
5849            domain.handoff.awaiting = true;
5850            domain.handoff.swap_token = Some(token);
5851            // The turn's closing text, which the exit call's note outranks.
5852            domain.handoff.judgment = Some("I am still working".to_owned());
5853        }
5854
5855        task.execute_command(Command::Handoff {
5856            key: slot.clone(),
5857            brief: Some("Now: the cut is in.".to_owned()),
5858        });
5859        task.after_turn_ended();
5860
5861        let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
5862            while let Some(frame) = update_rx.recv().await {
5863                if let SessionUpdate::Handoff { handoff, .. } =
5864                    std::sync::Arc::unwrap_or_clone(frame.update)
5865                {
5866                    return handoff;
5867                }
5868            }
5869            panic!("the handoff row was never emitted");
5870        })
5871        .await
5872        .expect("the handoff lands");
5873        assert_eq!(
5874            record.brief.judgment.first().map(|section| section.label.as_str()),
5875            Some("Now"),
5876            "the exit call's note is PART 2: {:?}",
5877            record.brief.judgment,
5878        );
5879        assert!(
5880            record.brief.judgment[0].lines.iter().any(|line| line.contains("the cut is in.")),
5881            "and its text, not the captured prose",
5882        );
5883        assert!(
5884            record.brief.judgment.iter().all(|section| section.label != "source"),
5885            "an exit-call note carries no source row: {:?}",
5886            record.brief.judgment,
5887        );
5888
5889        // **The successor's own text carries PART 2.** A record that holds
5890        // the judgment while the delivered brief drops it is the regression
5891        // this pins.
5892        let delivered =
5893            workspace.pending_brief_for(&slot).expect("the brief waits for the successor");
5894        assert!(
5895            delivered.text.contains("PART 2: JUDGMENT"),
5896            "the delivered brief carries PART 2: {}",
5897            delivered.text,
5898        );
5899        assert!(
5900            delivered.text.contains("Now: the cut is in."),
5901            "and the note's first heading: {}",
5902            delivered.text,
5903        );
5904    }
5905
5906    /// **No note means no note.** A turn that ends on nothing hands the
5907    /// successor an explicit "the note did not arrive" section, never filler
5908    /// dressed as the brief.
5909    #[tokio::test]
5910    async fn a_handoff_without_a_note_states_it() {
5911        let dir = tempfile::tempdir().expect("tempdir");
5912        let (workspace, mut update_rx) =
5913            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5914        workspace.enable_test_dispatch_intercept();
5915        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5916        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5917        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5918        let token = workspace.begin_swap(&task.key);
5919        {
5920            let mut domain = task.domain.lock();
5921            domain.handoff.awaiting = true;
5922            domain.handoff.swap_token = Some(token);
5923            domain.handoff.judgment = Some("   \n".to_owned());
5924        }
5925
5926        task.after_turn_ended();
5927
5928        let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
5929            while let Some(frame) = update_rx.recv().await {
5930                if let SessionUpdate::Handoff { handoff, .. } =
5931                    std::sync::Arc::unwrap_or_clone(frame.update)
5932                {
5933                    return handoff;
5934                }
5935            }
5936            panic!("the handoff row was never emitted");
5937        })
5938        .await
5939        .expect("the handoff lands");
5940        let section = record.brief.judgment.first().expect("a stated section");
5941        assert_eq!(section.label, "note", "the missing note is its own section");
5942        assert!(
5943            section.lines[0].contains("the handoff note did not arrive"),
5944            "and says so: {:?}",
5945            section.lines,
5946        );
5947        assert_eq!(
5948            record.brief.judgment.len(),
5949            1,
5950            "an empty turn states the note once: {:?}",
5951            record.brief.judgment,
5952        );
5953    }
5954
5955    /// **A note taken from the turn's closing text says so.** Nothing in a
5956    /// capture distinguishes prose the predecessor chose to pass from a turn
5957    /// that simply ended, so the judgment carries a `source` row when the exit
5958    /// call had no note.
5959    #[tokio::test]
5960    async fn a_note_taken_from_the_turn_text_is_marked() {
5961        let dir = tempfile::tempdir().expect("tempdir");
5962        let (workspace, mut update_rx) =
5963            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
5964        workspace.enable_test_dispatch_intercept();
5965        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
5966        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
5967        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
5968        let token = workspace.begin_swap(&task.key);
5969        {
5970            let mut domain = task.domain.lock();
5971            domain.handoff.awaiting = true;
5972            domain.handoff.swap_token = Some(token);
5973            domain.handoff.judgment =
5974                Some("Memory is updated and the monitor is stopped.".to_owned());
5975        }
5976
5977        task.after_turn_ended();
5978
5979        let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
5980            while let Some(frame) = update_rx.recv().await {
5981                if let SessionUpdate::Handoff { handoff, .. } =
5982                    std::sync::Arc::unwrap_or_clone(frame.update)
5983                {
5984                    return handoff;
5985                }
5986            }
5987            panic!("the handoff row was never emitted");
5988        })
5989        .await
5990        .expect("the handoff lands");
5991        assert_eq!(
5992            record.brief.judgment.iter().map(|section| section.label.as_str()).collect::<Vec<_>>(),
5993            ["source", "note"],
5994            "the capture is marked, then drawn as the note it is: {:?}",
5995            record.brief.judgment,
5996        );
5997        assert!(
5998            record.brief.judgment[0]
5999                .lines
6000                .iter()
6001                .any(|line| line.contains("the exit call carried no note")),
6002            "the source row says where it came from: {:?}",
6003            record.brief.judgment[0].lines,
6004        );
6005        assert!(
6006            record.brief.judgment[1].lines.iter().any(|line| line.contains("Memory is updated")),
6007            "and the turn's text is the note: {:?}",
6008            record.brief.judgment[1].lines,
6009        );
6010    }
6011
6012    /// **A note that carried no content is not an absent call.** An exit call
6013    /// whose brief was a shell like `## Next` was made; the source row must
6014    /// say the note had no content rather than that no call arrived.
6015    #[tokio::test]
6016    async fn a_content_free_exit_note_is_named_as_such() {
6017        let dir = tempfile::tempdir().expect("tempdir");
6018        let (workspace, mut update_rx) =
6019            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6020        workspace.enable_test_dispatch_intercept();
6021        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6022        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
6023        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
6024        let token = workspace.begin_swap(&task.key);
6025        {
6026            let mut domain = task.domain.lock();
6027            domain.handoff.awaiting = true;
6028            domain.handoff.swap_token = Some(token);
6029            domain.handoff.judgment =
6030                Some("Memory is updated and the monitor is stopped.".to_owned());
6031        }
6032
6033        task.execute_command(Command::Handoff {
6034            key: slot.clone(),
6035            brief: Some("## Next".to_owned()),
6036        });
6037        task.after_turn_ended();
6038
6039        let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
6040            while let Some(frame) = update_rx.recv().await {
6041                if let SessionUpdate::Handoff { handoff, .. } =
6042                    std::sync::Arc::unwrap_or_clone(frame.update)
6043                {
6044                    return handoff;
6045                }
6046            }
6047            panic!("the handoff row was never emitted");
6048        })
6049        .await
6050        .expect("the handoff lands");
6051        assert_eq!(
6052            record.brief.judgment.iter().map(|section| section.label.as_str()).collect::<Vec<_>>(),
6053            ["source", "note"],
6054            "the capture is marked: {:?}",
6055            record.brief.judgment,
6056        );
6057        assert!(
6058            record.brief.judgment[0]
6059                .lines
6060                .iter()
6061                .any(|line| line.contains("the exit call's note carried no content")),
6062            "the row names the call that was made: {:?}",
6063            record.brief.judgment[0].lines,
6064        );
6065    }
6066
6067    /// The task's own refusal: an exit with no handoff turn running states
6068    /// where a handoff starts and stores nothing.
6069    #[tokio::test]
6070    async fn an_exit_with_no_handoff_turn_is_stated() {
6071        let (workspace, _rx) = crate::Workspace::testing_stub();
6072        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6073        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
6074        let mut updates = task.update_tx.subscribe(crate::update_fanout::SubscriberRole::Answering);
6075
6076        task.execute_command(Command::Handoff {
6077            key: slot.clone(),
6078            brief: Some("Now: x".to_owned()),
6079        });
6080
6081        assert!(task.domain.lock().handoff.supplied.is_none(), "no note is stored");
6082        let mut stated = false;
6083        while let Ok(frame) = updates.try_recv() {
6084            if let SessionUpdate::Notice { text, .. } =
6085                std::sync::Arc::unwrap_or_clone(frame.update)
6086                && text.contains("/handoff")
6087                && text.contains("automatic trigger")
6088            {
6089                stated = true;
6090            }
6091        }
6092        assert!(stated, "the seat is told where a handoff starts");
6093    }
6094
6095    /// **An empty exit does not discard a valid captured note.** The exit
6096    /// call's note is tried first; when it carries nothing, the capture is.
6097    #[tokio::test]
6098    async fn an_empty_exit_falls_back_to_the_captured_note() {
6099        let dir = tempfile::tempdir().expect("tempdir");
6100        let (workspace, mut update_rx) =
6101            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6102        workspace.enable_test_dispatch_intercept();
6103        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6104        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
6105        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
6106        let token = workspace.begin_swap(&task.key);
6107        {
6108            let mut domain = task.domain.lock();
6109            domain.handoff.awaiting = true;
6110            domain.handoff.swap_token = Some(token);
6111            domain.handoff.judgment = Some("Now: the captured note.".to_owned());
6112        }
6113
6114        task.execute_command(Command::Handoff { key: slot.clone(), brief: Some("   ".to_owned()) });
6115        task.after_turn_ended();
6116
6117        let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
6118            while let Some(frame) = update_rx.recv().await {
6119                if let SessionUpdate::Handoff { handoff, .. } =
6120                    std::sync::Arc::unwrap_or_clone(frame.update)
6121                {
6122                    return handoff;
6123                }
6124            }
6125            panic!("the handoff row was never emitted");
6126        })
6127        .await
6128        .expect("the handoff lands");
6129        assert_eq!(
6130            record.brief.judgment.iter().map(|section| section.label.as_str()).collect::<Vec<_>>(),
6131            ["source", "Now"],
6132            "the captured note is PART 2, marked as a capture: {:?}",
6133            record.brief.judgment,
6134        );
6135        assert!(
6136            record.brief.judgment[1].lines.iter().any(|line| line.contains("the captured note")),
6137            "and its text, not the empty exit",
6138        );
6139    }
6140
6141    /// A handoff asked for while a turn runs sends nothing into that turn:
6142    /// it waits, and the judgment prompt goes out as its own turn after.
6143    #[tokio::test]
6144    async fn a_handoff_asked_mid_turn_waits_for_the_turn_to_end() {
6145        let (workspace, _rx) = crate::Workspace::testing_stub();
6146        workspace.enable_test_dispatch_intercept();
6147        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6148        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
6149        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
6150        task.domain.lock().turn_pending = true;
6151
6152        task.execute_command(Command::Handoff { key: slot.clone(), brief: None });
6153
6154        assert!(
6155            workspace.drain_test_dispatch_buffer().is_empty(),
6156            "a running turn is never handed a prompt in the middle",
6157        );
6158        assert!(task.domain.lock().handoff.parked, "the request waits for the boundary instead");
6159
6160        task.domain.lock().turn_pending = false;
6161        task.after_turn_ended();
6162
6163        let dispatched = workspace.drain_test_dispatch_buffer();
6164        let Some(Command::PromptUnder { text, source, .. }) = dispatched.first() else {
6165            panic!("the judgment prompt was never sent: {dispatched:?}");
6166        };
6167        assert_eq!(
6168            text,
6169            &crate::handoff::judgment_prompt(),
6170            "forge's own text, which names no skill"
6171        );
6172        assert_eq!(*source, PromptSource::Forge, "and it is forge's turn, not the user's");
6173        assert!(task.domain.lock().handoff.awaiting, "the reply is waited for");
6174    }
6175
6176    /// A turn boundary in a fresh task with a session id, a compaction count
6177    /// and the stub workspace: the shape the trigger reads.
6178    fn trigger_task(
6179        workspace: &Arc<crate::Workspace>,
6180        compaction_count: u32,
6181    ) -> (SessionTask, mpsc::UnboundedReceiver<forge_primitives::AgentCommand>) {
6182        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6183        let (mut task, agent_rx) = command_task_for(workspace, &slot);
6184        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-session-id"));
6185        task.compaction_count = compaction_count;
6186        (task, agent_rx)
6187    }
6188
6189    fn reading(reported: Option<u8>, total: Option<u64>, effective: Option<u64>) -> AgentEvent {
6190        reading_for("old-session-id", reported, total, effective)
6191    }
6192
6193    fn reading_for(
6194        session_id: &str,
6195        reported: Option<u8>,
6196        total: Option<u64>,
6197        effective: Option<u64>,
6198    ) -> AgentEvent {
6199        AgentEvent::ContextUsage {
6200            session_id: session_id.to_owned(),
6201            percentage: reported,
6202            max_tokens: Some(1_000_000),
6203            total_tokens: total,
6204            effective_max_tokens: effective,
6205        }
6206    }
6207
6208    /// **The trigger must fire at a REAL turn boundary.** The turn's
6209    /// `turn_pending` marker is cleared later in the same event than
6210    /// `after_turn_ended` runs, so a gate that read it would park every
6211    /// handoff and wait for a turn that never comes; this drives a `Result`
6212    /// through the actor and then its reading.
6213    #[tokio::test]
6214    async fn the_trigger_measures_at_a_real_turn_boundary() {
6215        let dir = tempfile::tempdir().expect("tempdir");
6216        let (workspace, _rx) =
6217            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6218        workspace.enable_test_dispatch_intercept();
6219        let (mut task, mut agent_rx) = trigger_task(&workspace, 0);
6220        // The state the actor is in when a `Result` lands.
6221        task.domain.lock().turn_pending = true;
6222
6223        task.translate_event(AgentEvent::SdkMessage {
6224            session_id: "old-session-id".to_owned(),
6225            msg: result_message("success", false),
6226        });
6227
6228        assert!(
6229            matches!(
6230                agent_rx.try_recv(),
6231                Ok(forge_primitives::AgentCommand::GetContextUsage { .. })
6232            ),
6233            "the boundary asks for the reading",
6234        );
6235        assert!(
6236            !task.domain.lock().handoff.parked,
6237            "and does not park on the turn that just ended",
6238        );
6239
6240        task.translate_event(reading(Some(90), Some(180_000), Some(200_000)));
6241
6242        assert!(
6243            workspace.drain_test_dispatch_buffer().iter().any(|command| matches!(
6244                command,
6245                Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6246            )),
6247            "the handoff begins at the boundary",
6248        );
6249        assert!(workspace.swap_in_progress(&task.key), "with its gate open");
6250    }
6251
6252    /// **A handoff parked by a prompt queued inside a running turn begins at
6253    /// the shared Result.** One turn ended, so the seat is idle and the
6254    /// judgment prompt goes out as its own turn rather than the handoff
6255    /// sitting parked forever.
6256    #[tokio::test]
6257    async fn a_handoff_parked_by_a_queued_prompt_begins_at_the_shared_result() {
6258        let dir = tempfile::tempdir().expect("tempdir");
6259        let (workspace, _rx) =
6260            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6261        workspace.enable_test_dispatch_intercept();
6262        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6263        {
6264            let mut domain = task.domain.lock();
6265            domain.commit_turn();
6266            domain.commit_turn();
6267            domain.handoff.reason = Some(forge_primitives::HandoffReason::Manual);
6268            domain.handoff.parked = true;
6269        }
6270
6271        task.after_turn_ended();
6272
6273        let dispatched = workspace.drain_test_dispatch_buffer();
6274        assert!(
6275            dispatched.iter().any(|command| matches!(
6276                command,
6277                Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6278            )),
6279            "the judgment prompt goes out once the shared turn ends: {dispatched:?}",
6280        );
6281        assert!(workspace.swap_in_progress(&task.key), "and the swap gate opens with it");
6282    }
6283
6284    /// A prompt queued behind the boundary is not a second turn, and the
6285    /// seat stays busy while it waits: no handoff fires into it, and the
6286    /// reading is asked only once the queue is clear.
6287    #[tokio::test]
6288    async fn a_prompt_queued_behind_the_boundary_keeps_the_seat_busy() {
6289        let dir = tempfile::tempdir().expect("tempdir");
6290        let (workspace, _rx) =
6291            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6292        workspace.enable_test_dispatch_intercept();
6293        let (mut task, mut agent_rx) = trigger_task(&workspace, 0);
6294        {
6295            let mut domain = task.domain.lock();
6296            domain.commit_turn();
6297            // The prompt the CLI queued inside that turn, before its Result.
6298            domain.commit_turn();
6299            domain.record_queued_prompt(
6300                "queued-2",
6301                crate::protocol::PromptSource::You,
6302                "the next ask",
6303                Vec::new(),
6304            );
6305        }
6306
6307        task.translate_event(AgentEvent::SdkMessage {
6308            session_id: "old-session-id".to_owned(),
6309            msg: result_message("success", false),
6310        });
6311
6312        assert!(!task.domain.lock().turn_in_flight(), "the shared Result ended the turn");
6313        assert!(
6314            !task.domain.lock().is_idle_for_handoff(),
6315            "but the queued prompt still holds the seat",
6316        );
6317        assert!(agent_rx.try_recv().is_err(), "so no reading is asked for at this boundary");
6318
6319        // The CLI ran it; its lifecycle settles and its own Result serves the
6320        // boundary.
6321        task.domain.lock().advance_queued_prompt("queued-2", "completed");
6322        task.translate_event(AgentEvent::SdkMessage {
6323            session_id: "old-session-id".to_owned(),
6324            msg: result_message("success", false),
6325        });
6326        assert!(
6327            matches!(
6328                agent_rx.try_recv(),
6329                Ok(forge_primitives::AgentCommand::GetContextUsage { .. })
6330            ),
6331            "the clear boundary is the one that asks",
6332        );
6333    }
6334
6335    /// The probe is asynchronous: a turn can open while it is out (the
6336    /// continuation a failed turn fires is the near case). The reading then
6337    /// parks the handoff instead of writing its prompt into that turn.
6338    #[tokio::test]
6339    async fn the_trigger_parks_when_a_turn_started_while_the_probe_was_out() {
6340        let dir = tempfile::tempdir().expect("tempdir");
6341        let (workspace, _rx) =
6342            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6343        workspace.enable_test_dispatch_intercept();
6344        let (mut task, mut agent_rx) = trigger_task(&workspace, 0);
6345
6346        task.maybe_trigger_handoff();
6347        assert!(
6348            matches!(
6349                agent_rx.try_recv(),
6350                Ok(forge_primitives::AgentCommand::GetContextUsage { .. })
6351            ),
6352            "precondition: the reading was asked for",
6353        );
6354        // The failed turn's continuation opens a turn before the answer lands.
6355        task.domain.lock().turn_pending = true;
6356
6357        task.translate_event(reading(Some(90), Some(900_000), Some(1_000_000)));
6358
6359        assert!(
6360            workspace.drain_test_dispatch_buffer().is_empty(),
6361            "no judgment prompt is written into a running turn",
6362        );
6363        let parked = task.domain.lock().handoff.parked;
6364        assert!(parked, "the handoff waits for the boundary instead");
6365        assert_eq!(
6366            task.domain.lock().handoff.reason,
6367            Some(forge_primitives::HandoffReason::Fill),
6368            "and the reason it will fire for is kept",
6369        );
6370
6371        // That turn ends: the handoff now begins, and its gate opens with it.
6372        task.domain.lock().turn_pending = false;
6373        task.after_turn_ended();
6374        assert!(
6375            workspace.drain_test_dispatch_buffer().iter().any(|command| matches!(
6376                command,
6377                Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6378            )),
6379            "the judgment prompt goes out as its own turn once the seat is idle",
6380        );
6381        assert!(workspace.swap_in_progress(&task.key), "and the swap gate is open");
6382    }
6383
6384    /// A parked handoff re-checks the seat before it begins: a prompt
6385    /// committed after the park is a turn the judgment prompt must not be
6386    /// folded into.
6387    #[tokio::test]
6388    async fn a_parked_handoff_rechecks_the_seat_before_it_begins() {
6389        let dir = tempfile::tempdir().expect("tempdir");
6390        let (workspace, _rx) =
6391            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6392        workspace.enable_test_dispatch_intercept();
6393        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6394        {
6395            let mut domain = task.domain.lock();
6396            domain.handoff.reason = Some(forge_primitives::HandoffReason::Fill);
6397            domain.handoff.parked = true;
6398            domain.commit_turn();
6399        }
6400
6401        task.begin_handoff();
6402
6403        assert!(
6404            workspace.drain_test_dispatch_buffer().is_empty(),
6405            "the judgment prompt is not written into a running turn",
6406        );
6407        assert!(task.domain.lock().handoff.parked, "the handoff stays parked for its own boundary");
6408        assert!(!workspace.swap_in_progress(&task.key), "and its gate never opened");
6409    }
6410
6411    /// An unanswered judgment turn waits for ITS turn: a boundary from any
6412    /// other turn is not the reply the brief is built from.
6413    #[tokio::test]
6414    async fn a_turn_that_is_not_the_judgment_turn_does_not_swap() {
6415        let dir = tempfile::tempdir().expect("tempdir");
6416        let (workspace, mut update_rx) =
6417            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6418        workspace.enable_test_dispatch_intercept();
6419        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6420        {
6421            let mut domain = task.domain.lock();
6422            domain.lifecycle_frames = Some(true);
6423            domain.handoff.awaiting = true;
6424            domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
6425            domain.handoff.judgment_started = false;
6426            domain.handoff.judgment = Some("Next: not the judgment turn".to_owned());
6427        }
6428
6429        task.after_turn_ended();
6430
6431        assert!(
6432            task.domain.lock().handoff.awaiting,
6433            "the handoff is still waiting for its own prompt's turn",
6434        );
6435        assert!(
6436            update_rx.try_recv().is_err(),
6437            "and no handoff row was emitted for another turn's prose",
6438        );
6439    }
6440
6441    /// The CLI's lifecycle frame is what names the judgment turn, and its
6442    /// end is what swaps.
6443    #[tokio::test]
6444    async fn the_lifecycle_frame_names_the_judgment_turn() {
6445        let dir = tempfile::tempdir().expect("tempdir");
6446        let (workspace, mut update_rx) =
6447            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6448        workspace.enable_test_dispatch_intercept();
6449        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6450        let token = workspace.begin_swap(&task.key);
6451        {
6452            let mut domain = task.domain.lock();
6453            domain.lifecycle_frames = Some(true);
6454            domain.handoff.awaiting = true;
6455            domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
6456            domain.handoff.swap_token = Some(token);
6457        }
6458        task.translate_event(AgentEvent::SdkMessage {
6459            session_id: "old-session-id".to_owned(),
6460            msg: forge_primitives::Message::CommandLifecycle {
6461                command_uuid: "judgment-id".to_owned(),
6462                state: "started".to_owned(),
6463                uuid: "lifecycle-1".to_owned(),
6464                session_id: "old-session-id".to_owned(),
6465                extras: forge_primitives::messages::Extras::new(),
6466            },
6467        });
6468        assert!(task.domain.lock().handoff.judgment_started, "the frame names the turn");
6469        task.domain.lock().handoff.judgment = Some("Next: the client row.".to_owned());
6470
6471        task.after_turn_ended();
6472
6473        let record = tokio::time::timeout(std::time::Duration::from_secs(10), async {
6474            while let Some(frame) = update_rx.recv().await {
6475                if let SessionUpdate::Handoff { handoff, .. } =
6476                    std::sync::Arc::unwrap_or_clone(frame.update)
6477                {
6478                    return handoff;
6479                }
6480            }
6481            panic!("the handoff row was never emitted");
6482        })
6483        .await
6484        .expect("the handoff lands");
6485        assert!(
6486            record.brief.judgment.iter().any(|section| section.label == "Next"),
6487            "the judgment is that turn's reply: {:?}",
6488            record.brief.judgment,
6489        );
6490    }
6491
6492    /// A late reading for the previous occupant cannot judge this one, and it
6493    /// does not spend the boundary's probe.
6494    #[tokio::test]
6495    async fn a_reading_for_another_occupant_does_not_judge_this_one() {
6496        let dir = tempfile::tempdir().expect("tempdir");
6497        let (workspace, _rx) =
6498            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6499        workspace.enable_test_dispatch_intercept();
6500        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6501        task.maybe_trigger_handoff();
6502
6503        task.translate_event(reading_for(
6504            "an-older-session",
6505            Some(99),
6506            Some(990_000),
6507            Some(1_000_000),
6508        ));
6509
6510        assert!(
6511            task.domain.lock().handoff.probing,
6512            "the boundary's probe is still owed to this occupant's reading",
6513        );
6514        assert!(
6515            workspace.drain_test_dispatch_buffer().is_empty(),
6516            "and the previous session's fill asks for nothing here",
6517        );
6518    }
6519
6520    /// A boundary during a swap's build does not fire a second handoff: the
6521    /// state it would read as idle is the swap's own.
6522    #[tokio::test]
6523    async fn no_second_handoff_fires_while_a_swap_builds() {
6524        let dir = tempfile::tempdir().expect("tempdir");
6525        let (workspace, _rx) =
6526            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6527        workspace.enable_test_dispatch_intercept();
6528        let (mut task, mut agent_rx) = trigger_task(&workspace, 0);
6529        workspace.begin_swap(&task.key);
6530
6531        task.maybe_trigger_handoff();
6532
6533        assert!(agent_rx.try_recv().is_err(), "no reading is even asked for while the swap builds");
6534        assert!(!task.domain.lock().handoff.probing, "and nothing is armed");
6535    }
6536
6537    /// At the configured share the trigger asks for the judgment half in a
6538    /// turn of its own, and the row later says why it fired.
6539    #[tokio::test]
6540    async fn the_trigger_fires_at_the_configured_share() {
6541        let dir = tempfile::tempdir().expect("tempdir");
6542        let (workspace, _rx) =
6543            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6544        workspace.enable_test_dispatch_intercept();
6545        let (mut task, mut agent_rx) = trigger_task(&workspace, 0);
6546
6547        task.maybe_trigger_handoff();
6548        assert!(
6549            matches!(
6550                agent_rx.try_recv(),
6551                Ok(forge_primitives::AgentCommand::GetContextUsage { .. })
6552            ),
6553            "the reading is asked of the CLI at the boundary",
6554        );
6555
6556        task.translate_event(reading(Some(85), Some(850_000), Some(1_000_000)));
6557
6558        let dispatched = workspace.drain_test_dispatch_buffer();
6559        assert!(
6560            dispatched.iter().any(|command| matches!(
6561                command,
6562                Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6563            )),
6564            "at 85% the judgment half is asked for: {dispatched:?}",
6565        );
6566        assert_eq!(
6567            task.domain.lock().handoff.reason,
6568            Some(forge_primitives::HandoffReason::Fill),
6569            "and the reason rides to the row",
6570        );
6571        let fill = task.domain.lock().handoff.fill.expect("the reading that fired");
6572        assert_eq!(fill.percent, 85);
6573        assert_eq!((fill.total_tokens, fill.max_tokens), (850_000, 1_000_000));
6574    }
6575
6576    /// A reading that was already out when a handoff began belongs to no
6577    /// handoff: the running one keeps the reading it fired on, which is what
6578    /// the row will carry.
6579    #[tokio::test]
6580    async fn a_late_reading_does_not_overwrite_a_running_handoffs_fill() {
6581        let dir = tempfile::tempdir().expect("tempdir");
6582        let (workspace, _rx) =
6583            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6584        workspace.enable_test_dispatch_intercept();
6585        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6586        {
6587            let mut domain = task.domain.lock();
6588            domain.handoff.awaiting = true;
6589            domain.handoff.judgment_uuid = Some("judgment-id".to_owned());
6590            domain.handoff.fill = Some(crate::handoff::FillReading {
6591                percent: 86,
6592                total_tokens: 860_000,
6593                max_tokens: 1_000_000,
6594            });
6595            // The probe was asked before the handoff began.
6596            domain.handoff.probing = true;
6597        }
6598
6599        task.translate_event(reading(Some(99), Some(990_000), Some(1_000_000)));
6600
6601        let fill = task.domain.lock().handoff.fill.expect("the running handoff keeps its reading");
6602        assert_eq!(
6603            fill.percent, 86,
6604            "the late reading is dropped, not written over the running handoff's",
6605        );
6606    }
6607
6608    /// One point under the share is not a handoff: the trigger fires at the
6609    /// line, not near it.
6610    #[tokio::test]
6611    async fn a_reading_under_the_share_does_not_fire() {
6612        let dir = tempfile::tempdir().expect("tempdir");
6613        let (workspace, _rx) =
6614            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6615        workspace.enable_test_dispatch_intercept();
6616        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6617
6618        task.maybe_trigger_handoff();
6619        task.translate_event(reading(Some(84), Some(840_000), Some(1_000_000)));
6620
6621        assert!(
6622            workspace.drain_test_dispatch_buffer().is_empty(),
6623            "84% is under the default share and asks for nothing",
6624        );
6625        assert!(!task.domain.lock().handoff.probing, "and the boundary is spent");
6626    }
6627
6628    /// The denominator is the CLI's effective window, not the share it
6629    /// reports: a window lowered for auto-compact fires earlier, which is
6630    /// the whole reason the trigger is a percentage rather than a count.
6631    #[tokio::test]
6632    async fn the_effective_window_is_the_denominator() {
6633        let dir = tempfile::tempdir().expect("tempdir");
6634        let (workspace, _rx) =
6635            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6636        workspace.enable_test_dispatch_intercept();
6637        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6638
6639        task.maybe_trigger_handoff();
6640        // 900k of an effective 1M is 90%, though the CLI's share of the raw
6641        // 2M window reads 45%.
6642        task.translate_event(reading(Some(45), Some(900_000), Some(1_000_000)));
6643
6644        assert!(
6645            workspace.drain_test_dispatch_buffer().iter().any(|command| matches!(
6646                command,
6647                Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6648            )),
6649            "the effective window's 90% fires even though the reported share does not",
6650        );
6651    }
6652
6653    /// A compaction the fill trigger did not stop arms the backstop: the
6654    /// session hands over at the next boundary whatever its fill reads.
6655    #[tokio::test]
6656    async fn the_count_backstop_fires_when_a_compaction_came_first() {
6657        let dir = tempfile::tempdir().expect("tempdir");
6658        let (workspace, _rx) =
6659            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6660        workspace.enable_test_dispatch_intercept();
6661        let (mut task, mut agent_rx) = trigger_task(&workspace, 1);
6662
6663        task.maybe_trigger_handoff();
6664        assert!(
6665            matches!(
6666                agent_rx.try_recv(),
6667                Ok(forge_primitives::AgentCommand::GetContextUsage { .. })
6668            ),
6669            "the boundary asks for the reading",
6670        );
6671        task.translate_event(reading(Some(10), Some(100_000), Some(1_000_000)));
6672
6673        assert!(
6674            workspace.drain_test_dispatch_buffer().iter().any(|command| matches!(
6675                command,
6676                Command::PromptUnder { text, .. } if text == &crate::handoff::judgment_prompt()
6677            )),
6678            "a session that compacted hands over even at a low fill",
6679        );
6680        assert_eq!(
6681            task.domain.lock().handoff.reason,
6682            Some(forge_primitives::HandoffReason::Compaction),
6683            "and the row says a compaction came first",
6684        );
6685        assert!(workspace.swap_in_progress(&task.key), "with its gate open");
6686    }
6687
6688    /// `enabled = false` is the one off switch: not even a reading is asked
6689    /// for, so a project that must not be interrupted pays nothing.
6690    #[tokio::test]
6691    async fn the_trigger_is_off_when_the_table_says_so() {
6692        let dir = tempfile::tempdir().expect("tempdir");
6693        let mut config = crate::config::LoadedConfig::empty_for_test();
6694        config.handoff = crate::config::HandoffSettings {
6695            enabled: Some(false),
6696            ..crate::config::HandoffSettings::default()
6697        };
6698        let (workspace, _rx) =
6699            crate::Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
6700                .expect("the stub builds");
6701        workspace.enable_test_dispatch_intercept();
6702        let (mut task, mut agent_rx) = trigger_task(&workspace, 3);
6703
6704        task.maybe_trigger_handoff();
6705        assert!(
6706            agent_rx.try_recv().is_err(),
6707            "a disabled trigger does not even ask for the reading",
6708        );
6709        assert!(!task.domain.lock().handoff.probing, "and nothing is left half-armed");
6710    }
6711
6712    /// A reading that says nothing fires nothing: the trigger never guesses
6713    /// a denominator.
6714    #[tokio::test]
6715    async fn a_reading_with_no_numbers_does_not_fire() {
6716        let dir = tempfile::tempdir().expect("tempdir");
6717        let (workspace, _rx) =
6718            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6719        workspace.enable_test_dispatch_intercept();
6720        let (mut task, _agent_rx) = trigger_task(&workspace, 0);
6721
6722        task.maybe_trigger_handoff();
6723        task.translate_event(reading(None, None, None));
6724
6725        assert!(
6726            workspace.drain_test_dispatch_buffer().is_empty(),
6727            "an empty reading is not a full one",
6728        );
6729    }
6730
6731    /// The brief is given only to the occupant it was written for: a connect
6732    /// under any other id is a different session, and the brief describes a
6733    /// history it does not carry.
6734    #[test]
6735    fn the_brief_goes_to_the_occupant_it_was_written_for() {
6736        let (workspace, _rx) = crate::Workspace::testing_stub();
6737        workspace.enable_test_dispatch_intercept();
6738        let slot = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
6739        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
6740
6741        let brief = || crate::handoff::PendingBrief {
6742            to: "new-id".to_owned(),
6743            text: "HANDOFF - 9cfe315c -> 4b21e0a7".to_owned(),
6744        };
6745        workspace.set_pending_brief(&slot, brief());
6746        task.deliver_pending_brief(&slot, "someone-else");
6747        assert!(
6748            workspace.drain_test_dispatch_buffer().is_empty(),
6749            "another occupant is not given a brief written for this one",
6750        );
6751
6752        workspace.set_pending_brief(&slot, brief());
6753        task.deliver_pending_brief(&slot, "new-id");
6754        let dispatched = workspace.drain_test_dispatch_buffer();
6755        let Some(Command::PromptUnder { text, source, .. }) = dispatched.first() else {
6756            panic!("the brief was never delivered: {dispatched:?}");
6757        };
6758        assert!(text.starts_with("HANDOFF - "), "the successor is given the brief itself");
6759        assert_eq!(*source, PromptSource::Forge, "as forge's own first turn");
6760    }
6761
6762    /// A worker's mission lives in its conversation, so `/new` has to
6763    /// re-deliver the charter the store holds for its slot. A view
6764    /// builds the launch settings for a `/new` and knows nothing of the
6765    /// charter, so without the re-delivery a worker that ran `/new`
6766    /// comes back as a session that is still its slot but no longer its
6767    /// worker - alive-looking and doing nothing.
6768    #[test]
6769    fn a_worker_that_runs_new_is_re_delivered_its_charter() {
6770        let dir = tempfile::tempdir().expect("tempdir");
6771        let (workspace, _rx) =
6772            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6773        workspace.install_db_for_test(
6774            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
6775        );
6776        let slot = SessionSlot::worker("TestOrg", "forge", "reviewer");
6777        workspace.seed_test_session_charter(&slot, "mind the queues", false);
6778        let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
6779
6780        task.execute_command(Command::NewSession {
6781            key: slot.clone(),
6782            cwd: "/tmp/forge".to_owned(),
6783            launch_settings: forge_agent::client::SessionLaunchSettings::default(),
6784            session_id: None,
6785        });
6786
6787        let Some(forge_primitives::AgentCommand::NewSession { launch_settings, .. }) =
6788            agent_rx.try_recv().ok()
6789        else {
6790            panic!("the task forwards the new-session command to the handle");
6791        };
6792        assert_eq!(
6793            launch_settings.get("charter").and_then(serde_json::Value::as_str),
6794            Some("mind the queues"),
6795            "the worker comes back with the charter its row holds",
6796        );
6797    }
6798
6799    /// A respawn moves the id the child runs under, so its gateway
6800    /// binding moves with it. `/new` stamps the id it mints and
6801    /// `/resume` the transcript it re-enters, the segment the session
6802    /// left behind keeps no binding, and `bound_account_for` - what the
6803    /// projects pane reads - follows the child that is actually running
6804    /// rather than the one it replaced.
6805    #[test]
6806    fn a_respawn_moves_the_gateway_binding_to_the_id_the_child_runs_under() {
6807        let (workspace, _rx) = crate::Workspace::testing_stub();
6808        let slot = SessionSlot::worker("Busytools", "forge", "reviewer");
6809        let account = forge_gateway::AccountKey("OpenRouter".to_owned());
6810        let (handle, _cmds) = Agent::testing_stub();
6811        workspace.pool.lock().insert(
6812            slot.clone(),
6813            crate::workspace::PooledAgent {
6814                handle: Arc::new(handle),
6815                account: account.clone(),
6816                permission_mode: None,
6817                registration: Some(forge_gateway::binding::Registration {
6818                    org: "Busytools".to_owned(),
6819                    project: "forge".to_owned(),
6820                    session: "spawn-id".to_owned(),
6821                    account: account.clone(),
6822                    provider: forge_primitives::account::Provider::Anthropic,
6823                }),
6824                session_id: "spawn-id".to_owned(),
6825            },
6826        );
6827        workspace.gateway.bindings.bind("Busytools", "forge", "spawn-id", account.clone());
6828        let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
6829
6830        let stamped_base_url = |launch_settings: &serde_json::Value| -> Option<String> {
6831            launch_settings
6832                .get("env_overrides")
6833                .and_then(|overrides| overrides.get("ANTHROPIC_BASE_URL"))
6834                .and_then(serde_json::Value::as_str)
6835                .map(str::to_owned)
6836        };
6837        let listener_base = format!("http://127.0.0.1:{}", workspace.gateway_port());
6838        // A respawn's settings are built by a view, which knows nothing of
6839        // the spawn-time flags, so the tools forge has replaced have to be
6840        // re-denied here or the new occupant gets them back.
6841        let denied = |launch_settings: &serde_json::Value| -> String {
6842            launch_settings
6843                .get("extra_args")
6844                .and_then(serde_json::Value::as_array)
6845                .and_then(|pairs| {
6846                    pairs.iter().find(|pair| {
6847                        pair.get(0).and_then(serde_json::Value::as_str) == Some("disallowedTools")
6848                    })
6849                })
6850                .and_then(|pair| pair.get(1).and_then(serde_json::Value::as_str))
6851                .unwrap_or_default()
6852                .to_owned()
6853        };
6854
6855        task.execute_command(Command::NewSession {
6856            key: slot.clone(),
6857            cwd: "/tmp/forge".to_owned(),
6858            launch_settings: forge_agent::client::SessionLaunchSettings::default(),
6859            session_id: None,
6860        });
6861
6862        let Some(forge_primitives::AgentCommand::NewSession {
6863            session_id, launch_settings, ..
6864        }) = agent_rx.try_recv().ok()
6865        else {
6866            panic!("the task forwards the new-session command to the handle");
6867        };
6868        let minted = session_id.expect("`/new` starts under a minted id");
6869        assert_eq!(
6870            stamped_base_url(&launch_settings).as_deref(),
6871            Some(format!("{listener_base}/Busytools/forge/{minted}").as_str()),
6872            "`/new` stamps the base URL with the id it mints for the child",
6873        );
6874        assert_eq!(
6875            workspace.bound_account_for(&slot),
6876            Some(account.clone()),
6877            "the account read for the slot follows the child that is running",
6878        );
6879        for tool in [
6880            "CronCreate",
6881            "CronDelete",
6882            "CronList",
6883            "TaskCreate",
6884            "TaskGet",
6885            "TaskList",
6886            "TaskUpdate",
6887            "SendMessage",
6888            "ListAgents",
6889            "Workflow",
6890            "RemoteTrigger",
6891        ] {
6892            assert!(
6893                denied(&launch_settings).split(',').any(|name| name == tool),
6894                "{tool} must reach a respawned session's launch; got {:?}",
6895                denied(&launch_settings),
6896            );
6897        }
6898        assert_eq!(
6899            workspace.gateway.bindings.binding_for("Busytools", "forge", "spawn-id"),
6900            None,
6901            "the id the session left behind keeps no binding",
6902        );
6903
6904        // The same stamp on the routed `/resume`, whose id comes from the
6905        // command rather than a mint.
6906        task.execute_command(Command::ResumeSession {
6907            key: slot.clone(),
6908            session_id: "resumed-id".to_owned(),
6909            cwd: "/tmp/forge".to_owned(),
6910            launch_settings: forge_agent::client::SessionLaunchSettings::default(),
6911        });
6912
6913        let Some(forge_primitives::AgentCommand::ResumeSession { launch_settings, .. }) =
6914            agent_rx.try_recv().ok()
6915        else {
6916            panic!("the task forwards the resume command to the handle");
6917        };
6918        assert_eq!(
6919            stamped_base_url(&launch_settings).as_deref(),
6920            Some(format!("{listener_base}/Busytools/forge/resumed-id").as_str()),
6921            "`/resume` stamps the base URL with the transcript it re-enters",
6922        );
6923        assert_eq!(
6924            workspace.bound_account_for(&slot),
6925            Some(account),
6926            "the binding follows the resumed occupant",
6927        );
6928        assert_eq!(
6929            workspace.gateway.bindings.binding_for("Busytools", "forge", &minted),
6930            None,
6931            "and the segment the new session left behind is dropped",
6932        );
6933    }
6934
6935    /// A respawn rebuilds the launch from view-built settings, so every denial
6936    /// a worker's own spawn wrote has to come back from the row that holds
6937    /// its flags. `AskUserQuestion` is the one the respawn stamp used to
6938    /// omit: a non-interactive worker that regained it would put a question
6939    /// in a row nobody is watching, and block there unseen.
6940    #[test]
6941    fn a_respawned_worker_keeps_its_own_denials() {
6942        let dir = tempfile::tempdir().expect("tempdir");
6943        let (workspace, _rx) =
6944            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6945        workspace.install_db_for_test(
6946            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
6947        );
6948        let slot = SessionSlot::worker("TestOrg", "forge", "reviewer");
6949        workspace.seed_test_session_charter(&slot, "review the diff", false);
6950        let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
6951
6952        task.execute_command(Command::NewSession {
6953            key: slot.clone(),
6954            cwd: "/tmp/forge".to_owned(),
6955            launch_settings: forge_agent::client::SessionLaunchSettings::default(),
6956            session_id: None,
6957        });
6958
6959        let Some(forge_primitives::AgentCommand::NewSession { launch_settings, .. }) =
6960            agent_rx.try_recv().ok()
6961        else {
6962            panic!("the task forwards the new-session command to the handle");
6963        };
6964        let denied = respawn_denials(&launch_settings);
6965        let names: Vec<&str> = denied.split(',').collect();
6966        for tool in ["AskUserQuestion", "EnterWorktree", "ExitWorktree"] {
6967            assert!(
6968                names.contains(&tool),
6969                "{tool} must come back on a non-interactive worker's respawn; got {denied:?}",
6970            );
6971        }
6972    }
6973
6974    /// The question denial belongs to the spawn, not to the kind: a worker
6975    /// the user asked to talk to directly keeps `AskUserQuestion` across a
6976    /// respawn, and still keeps the worktree pins that make it a worker.
6977    #[test]
6978    fn a_respawned_interactive_worker_keeps_the_question() {
6979        let dir = tempfile::tempdir().expect("tempdir");
6980        let (workspace, _rx) =
6981            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
6982        workspace.install_db_for_test(
6983            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
6984        );
6985        let slot = SessionSlot::worker("TestOrg", "forge", "reviewer");
6986        workspace.seed_test_session_charter(&slot, "review the diff", true);
6987        let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
6988
6989        task.execute_command(Command::NewSession {
6990            key: slot.clone(),
6991            cwd: "/tmp/forge".to_owned(),
6992            launch_settings: forge_agent::client::SessionLaunchSettings::default(),
6993            session_id: None,
6994        });
6995
6996        let Some(forge_primitives::AgentCommand::NewSession { launch_settings, .. }) =
6997            agent_rx.try_recv().ok()
6998        else {
6999            panic!("the task forwards the new-session command to the handle");
7000        };
7001        let denied = respawn_denials(&launch_settings);
7002        let names: Vec<&str> = denied.split(',').collect();
7003        assert!(
7004            !names.contains(&"AskUserQuestion"),
7005            "an interactive worker asks its own row's user; got {denied:?}",
7006        );
7007        assert!(
7008            names.contains(&"EnterWorktree"),
7009            "and it is still a worker, pinned to its spawn location; got {denied:?}",
7010        );
7011    }
7012
7013    /// The kind half of the same stamp: a lead respawns without the
7014    /// worktree pins because a lead still hops worktrees, and keeps the
7015    /// question because a lead's row is where the user is.
7016    #[test]
7017    fn a_respawned_lead_keeps_its_own_surface() {
7018        let (workspace, _rx) = crate::Workspace::testing_stub();
7019        let slot = SessionSlot::lead("TestOrg", "forge");
7020        let (mut task, mut agent_rx) = command_task_for(&workspace, &slot);
7021
7022        task.execute_command(Command::NewSession {
7023            key: slot.clone(),
7024            cwd: "/tmp/forge".to_owned(),
7025            launch_settings: forge_agent::client::SessionLaunchSettings::default(),
7026            session_id: None,
7027        });
7028
7029        let Some(forge_primitives::AgentCommand::NewSession { launch_settings, .. }) =
7030            agent_rx.try_recv().ok()
7031        else {
7032            panic!("the task forwards the new-session command to the handle");
7033        };
7034        let denied = respawn_denials(&launch_settings);
7035        let names: Vec<&str> = denied.split(',').collect();
7036        for tool in ["EnterWorktree", "ExitWorktree", "AskUserQuestion"] {
7037            assert!(!names.contains(&tool), "a lead keeps {tool} across a respawn; got {denied:?}");
7038        }
7039        assert!(
7040            names.contains(&"Workflow"),
7041            "the replaced set still reaches a lead's respawn; got {denied:?}",
7042        );
7043    }
7044
7045    /// The `--disallowedTools` value a respawn's launch carries.
7046    fn respawn_denials(launch_settings: &serde_json::Value) -> String {
7047        launch_settings
7048            .get("extra_args")
7049            .and_then(serde_json::Value::as_array)
7050            .and_then(|pairs| {
7051                pairs.iter().find(|pair| {
7052                    pair.get(0).and_then(serde_json::Value::as_str) == Some("disallowedTools")
7053                })
7054            })
7055            .and_then(|pair| pair.get(1).and_then(serde_json::Value::as_str))
7056            .unwrap_or_default()
7057            .to_owned()
7058    }
7059
7060    /// First-Connected drains the session's buffered Slack messages: each
7061    /// dispatches a plain `Command::Prompt` AND echoes a
7062    /// `SlackMessageAppended` so a message that arrived while the project was
7063    /// asleep shows its block once the session connects. Driven through
7064    /// Connected rather than the private drain so the ordering is pinned -
7065    /// the drain runs after the Connected emit, and both carry the task's
7066    /// own slot, which is the bucket the message was parked under.
7067    #[test]
7068    fn first_connected_drains_parked_slack_and_echoes_block() {
7069        let (workspace, mut update_rx) = crate::Workspace::testing_stub();
7070        workspace.seed_test_project("slack-drain", "/tmp/slack-drain");
7071
7072        // The slot production derives for the project this fixture seeds,
7073        // and the bucket a delivery for that project's lead parks on.
7074        let session_key = SessionSlot::lead("TestOrg", "slack-drain");
7075        let domain =
7076            Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
7077        workspace.park_slack(
7078            &session_key,
7079            vec![forge_primitives::slack::SlackMessage {
7080                workspace: "acme".to_owned(),
7081                conversation: "D1".to_owned(),
7082                conversation_label: "U9".to_owned(),
7083                ts: "100.000001".to_owned(),
7084                thread_ts: None,
7085                user: Some("U9".to_owned()),
7086                author: None,
7087                text: "the buffered text".to_owned(),
7088                parent_user_id: None,
7089                latest_reply: None,
7090                edited: None,
7091                files: Vec::new(),
7092            }],
7093        );
7094
7095        let (handle, _agent_cmd_rx) = Agent::testing_stub();
7096        let (_cmd_tx, command_rx) =
7097            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
7098        let update_tx = workspace.update_sender();
7099        let mut task = SessionTask {
7100            key: session_key.clone(),
7101            handle: Arc::new(handle),
7102            command_rx,
7103            domain: Arc::clone(&domain),
7104            update_tx,
7105            connected_once: false,
7106            workspace: Arc::downgrade(&workspace),
7107            compaction_count: 0,
7108            successor: None,
7109            teardown_done: None,
7110        };
7111
7112        workspace.enable_test_dispatch_intercept();
7113        task.translate_event(connected_event(&session_key.display(), "/tmp/slack-drain"));
7114
7115        let dispatched = workspace.drain_test_dispatch_buffer();
7116        assert!(
7117            dispatched.iter().any(|c| matches!(
7118                c, crate::protocol::Command::Prompt { key, text, .. }
7119                    | crate::protocol::Command::PromptUnder { key, text, .. }
7120                    if *key == session_key && text.contains("the buffered text")
7121            )),
7122            "the buffered message arrives as the session's own prompt: {dispatched:?}",
7123        );
7124
7125        let mut echoed = None;
7126        while let Ok(frame) = update_rx.try_recv() {
7127            if let SessionUpdate::SlackMessageAppended { key, prose, .. } =
7128                std::sync::Arc::unwrap_or_clone(frame.update)
7129                && key == session_key
7130                && prose.contains("the buffered text")
7131            {
7132                echoed = Some(prose);
7133            }
7134        }
7135        let prose = echoed
7136            .expect("an asleep-buffered Slack message echoes a SlackMessageAppended on drain");
7137        assert!(
7138            prose.contains("d old"),
7139            "the drain reads the workspace's stale threshold: {prose}",
7140        );
7141
7142        assert!(
7143            workspace.take_parked_for_slot(&session_key).slack.is_empty(),
7144            "the buffer drains once flushed",
7145        );
7146    }
7147
7148    /// The drain runs after the Connected emit, so the session's own
7149    /// `Connected` update lands before the drained Slack echo - a drain
7150    /// hoisted above the emit paints the block before the session the block
7151    /// belongs to. Driven through Connected rather than the private drain so
7152    /// the ordering is observable on the update channel.
7153    #[test]
7154    fn first_connected_emits_connected_before_draining_slack() {
7155        let (workspace, mut update_rx) = crate::Workspace::testing_stub();
7156        workspace.seed_test_project("slack-rekey", "/tmp/slack-rekey");
7157        let slot = SessionSlot::lead("TestOrg", "slack-rekey");
7158        let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(slot.clone(), None)));
7159        workspace.park_slack(&slot, vec![buffered_slack("buffered while asleep")]);
7160
7161        let (handle, _agent_cmd_rx) = Agent::testing_stub();
7162        let (_cmd_tx, command_rx) =
7163            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
7164        let update_tx = workspace.update_sender();
7165        let mut task = SessionTask {
7166            key: slot.clone(),
7167            handle: Arc::new(handle),
7168            command_rx,
7169            domain: Arc::clone(&domain),
7170            update_tx,
7171            connected_once: false,
7172            workspace: Arc::downgrade(&workspace),
7173            compaction_count: 0,
7174            successor: None,
7175            teardown_done: None,
7176        };
7177
7178        workspace.enable_test_dispatch_intercept();
7179        task.translate_event(connected_event(&slot.display(), "/tmp/slack-rekey"));
7180
7181        let dispatched = workspace.drain_test_dispatch_buffer();
7182        assert!(
7183            dispatched.iter().any(|c| matches!(
7184                c, crate::protocol::Command::Prompt { key, text, .. }
7185                    | crate::protocol::Command::PromptUnder { key, text, .. }
7186                    if *key == slot && text.contains("buffered while asleep")
7187            )),
7188            "the drained prompt rides the task's own slot: {dispatched:?}",
7189        );
7190
7191        let mut connected = false;
7192        let mut echo_after_connected = false;
7193        while let Ok(frame) = update_rx.try_recv() {
7194            match std::sync::Arc::unwrap_or_clone(frame.update) {
7195                SessionUpdate::Connected { .. } => connected = true,
7196                SessionUpdate::SlackMessageAppended { key, .. } if key == slot => {
7197                    assert!(
7198                        connected,
7199                        "the session's Connected lands before its drained Slack echo",
7200                    );
7201                    echo_after_connected = true;
7202                }
7203                _ => {}
7204            }
7205        }
7206        assert!(echo_after_connected, "the drained message still echoes a block");
7207    }
7208
7209    /// The review-activity notice a task emitted, if any.
7210    fn drained_notice(
7211        update_rx: &mut mpsc::UnboundedReceiver<SeatUpdate>,
7212    ) -> Option<(SessionSlot, String, usize, String)> {
7213        let mut notice = None;
7214        while let Ok(frame) = update_rx.try_recv() {
7215            if let SessionUpdate::ReviewActivityNotice { key, branch, waiting, message } =
7216                std::sync::Arc::unwrap_or_clone(frame.update)
7217            {
7218                notice = Some((key, branch, waiting, message));
7219            }
7220        }
7221        notice
7222    }
7223
7224    fn result_message(subtype: &str, is_error: bool) -> forge_primitives::Message {
7225        serde_json::from_value(serde_json::json!({
7226            "type": "result",
7227            "subtype": subtype,
7228            "duration_ms": 1,
7229            "duration_api_ms": 1,
7230            "is_error": is_error,
7231            "num_turns": 1,
7232            "session_id": "worker"
7233        }))
7234        .expect("parse result message")
7235    }
7236
7237    /// The same frame with the CLI's terminal reason aboard.
7238    fn result_message_with_reason(reason: &str) -> forge_primitives::Message {
7239        serde_json::from_value(serde_json::json!({
7240            "type": "result",
7241            "subtype": "success",
7242            "duration_ms": 1,
7243            "duration_api_ms": 1,
7244            "is_error": false,
7245            "num_turns": 1,
7246            "session_id": "worker",
7247            "terminal_reason": reason,
7248        }))
7249        .expect("parse result message")
7250    }
7251
7252    /// A `Result` covering no turn, as a local command (`/compact` on an
7253    /// idle seat) emits: the capture's seventh result of a six-turn run.
7254    fn result_message_for_a_local_command() -> forge_primitives::Message {
7255        serde_json::from_value(serde_json::json!({
7256            "type": "result",
7257            "subtype": "success",
7258            "duration_ms": 1,
7259            "duration_api_ms": 0,
7260            "is_error": false,
7261            "num_turns": 0,
7262            "session_id": "worker",
7263        }))
7264        .expect("parse result message")
7265    }
7266
7267    /// An errored `Result` carrying the CLI's own error strings, which are
7268    /// what the nudge's reason is composed from.
7269    fn result_message_with_errors(errors: &[&str]) -> forge_primitives::Message {
7270        serde_json::from_value(serde_json::json!({
7271            "type": "result",
7272            "subtype": "error_during_execution",
7273            "duration_ms": 1,
7274            "duration_api_ms": 1,
7275            "is_error": true,
7276            "num_turns": 1,
7277            "session_id": "worker",
7278            "errors": errors,
7279        }))
7280        .expect("parse result message")
7281    }
7282
7283    /// A wire `api_retry` frame, where the CLI names a failure on the
7284    /// retry path.
7285    fn api_retry_message(error: &str, status: Option<u16>) -> forge_primitives::Message {
7286        serde_json::from_value(serde_json::json!({
7287            "type": "system",
7288            "subtype": "api_retry",
7289            "session_id": "worker",
7290            "attempt": 1,
7291            "max_retries": 4,
7292            "retry_delay_ms": 500,
7293            "error_status": status,
7294            "error": error,
7295        }))
7296        .expect("parse api_retry message")
7297    }
7298
7299    /// Turn-end wiring: a `Message::Result` on a worker session drains its
7300    /// accumulated review activity into one `ReviewActivityNotice` routed to
7301    /// the submit origin. Guards the seam - a wrong `Message::Result` arm
7302    /// kills the whole notification with every unit test still green.
7303    #[tokio::test]
7304    async fn turn_end_result_drains_review_activity_to_a_notice() {
7305        let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
7306        let (mut task, mut update_rx) = review_task_for(&workspace, &worker);
7307
7308        task.translate_event(AgentEvent::SdkMessage {
7309            session_id: "worker".to_owned(),
7310            msg: result_message("success", false),
7311        });
7312
7313        let (key, branch, waiting, message) = drained_notice(&mut update_rx)
7314            .expect("a ReviewActivityNotice emits on the turn's Result");
7315        assert_eq!(key, reviewer, "the notice routes to the submit origin, not the worker");
7316        assert_eq!(branch, "feat", "the notice names the branch it is about");
7317        assert_eq!(waiting, 1, "the replied-to thread now awaits the reviewer");
7318        assert!(message.contains("review #1"), "the notice names the review: {message}");
7319        assert!(message.contains("1 replied"), "the tally counts the reply: {message}");
7320    }
7321
7322    /// **A turn's end is announced** (#1486): the frames carry a `Result`
7323    /// when a turn ends, but a page's live row reads its liveness from the
7324    /// record - and only this frame asks the page to re-read it. Never
7325    /// sent, every frame-built turn stayed live for the rest of the
7326    /// session.
7327    #[tokio::test]
7328    async fn a_results_end_announces_the_turn_complete() {
7329        let (workspace, _rx) = crate::Workspace::testing_stub();
7330        let key = SessionSlot::from_str_for_test("turn-ends");
7331        let (mut task, mut update_rx) = review_task_for(&workspace, &key);
7332
7333        task.translate_event(AgentEvent::SdkMessage {
7334            session_id: "turn-ends".to_owned(),
7335            msg: result_message("success", false),
7336        });
7337
7338        let mut said = Vec::new();
7339        while let Ok(frame) = update_rx.try_recv() {
7340            match std::sync::Arc::unwrap_or_clone(frame.update) {
7341                SessionUpdate::TurnComplete { key, terminal_reason } => {
7342                    said.push(("complete", key, terminal_reason));
7343                }
7344                SessionUpdate::TurnCancelled { key } => said.push(("cancelled", key, None)),
7345                _ => {}
7346            }
7347        }
7348        assert_eq!(said.len(), 1, "exactly one end frame, saw {said:?}");
7349        assert_eq!(said[0].0, "complete", "an ordinary end, not the cancel's frame");
7350        assert_eq!(said[0].1, key, "announced on the seat its turn ended");
7351        assert_eq!(said[0].2, None, "the test's Result carries no terminal reason");
7352    }
7353
7354    /// The CLI's terminal reason rides the frame when the result carried
7355    /// one, and a local command's result - covering no turn - still
7356    /// announces: the frame states the CLI's terminator, and the page's
7357    /// handlers no-op when nothing is in flight.
7358    #[tokio::test]
7359    async fn a_results_terminal_reason_rides_the_frame() {
7360        let (workspace, _rx) = crate::Workspace::testing_stub();
7361        let key = SessionSlot::from_str_for_test("turn-reason");
7362        let (mut task, mut update_rx) = review_task_for(&workspace, &key);
7363
7364        task.translate_event(AgentEvent::SdkMessage {
7365            session_id: "turn-reason".to_owned(),
7366            msg: result_message_with_reason("completed"),
7367        });
7368        task.translate_event(AgentEvent::SdkMessage {
7369            session_id: "turn-reason".to_owned(),
7370            msg: result_message_for_a_local_command(),
7371        });
7372
7373        let mut said = Vec::new();
7374        while let Ok(frame) = update_rx.try_recv() {
7375            if let SessionUpdate::TurnComplete { terminal_reason, .. } =
7376                std::sync::Arc::unwrap_or_clone(frame.update)
7377            {
7378                said.push(terminal_reason);
7379            }
7380        }
7381        assert_eq!(
7382            said,
7383            [Some(forge_primitives::runtime::TerminalReason::Completed), None],
7384            "the reason rides the frame, and a turn-less result announces without one",
7385        );
7386    }
7387
7388    /// A transport `Error` announces no end of its own: it ends the turn
7389    /// too (`liveness_of` says so), but the page learns that from the
7390    /// lifecycle, and the frame is the `Result`'s.
7391    #[tokio::test]
7392    async fn a_transport_error_announces_no_end() {
7393        let (workspace, _rx) = crate::Workspace::testing_stub();
7394        let key = SessionSlot::from_str_for_test("turn-error");
7395        let (mut task, mut update_rx) = review_task_for(&workspace, &key);
7396
7397        task.translate_event(AgentEvent::SdkMessage {
7398            session_id: "turn-error".to_owned(),
7399            msg: forge_primitives::Message::Error {
7400                error: "stream closed".to_owned(),
7401                extras: serde_json::Map::new(),
7402            },
7403        });
7404
7405        let mut said = Vec::new();
7406        while let Ok(frame) = update_rx.try_recv() {
7407            match std::sync::Arc::unwrap_or_clone(frame.update) {
7408                SessionUpdate::TurnComplete { .. } => said.push("complete"),
7409                SessionUpdate::TurnCancelled { .. } => said.push("cancelled"),
7410                _ => {}
7411            }
7412        }
7413        assert!(said.is_empty(), "a transport error is not the Result's frame: {said:?}");
7414    }
7415
7416    /// The reader's own interrupt is the other end: the CLI reports it as a
7417    /// failed `Result` too, so the cancel flag the arm already spends is
7418    /// what tells the two apart.
7419    #[tokio::test]
7420    async fn a_cancelled_results_end_announces_the_turn_cancelled() {
7421        let (workspace, _rx) = crate::Workspace::testing_stub();
7422        let key = SessionSlot::from_str_for_test("turn-cancelled");
7423        let (mut task, mut update_rx) = review_task_for(&workspace, &key);
7424
7425        task.domain.lock().pending_cancel = true;
7426        task.translate_event(AgentEvent::SdkMessage {
7427            session_id: "turn-cancelled".to_owned(),
7428            msg: result_message("error_during_execution", true),
7429        });
7430
7431        let mut said = Vec::new();
7432        while let Ok(frame) = update_rx.try_recv() {
7433            match std::sync::Arc::unwrap_or_clone(frame.update) {
7434                SessionUpdate::TurnComplete { .. } => said.push("complete"),
7435                SessionUpdate::TurnCancelled { .. } => said.push("cancelled"),
7436                _ => {}
7437            }
7438        }
7439        assert_eq!(said, ["cancelled"], "the reader's cancel is its own frame");
7440    }
7441
7442    /// A cancelled turn reaches the same `Message::Result` arm - the CLI
7443    /// reports the interrupt as `error_during_execution`, not as a
7444    /// separate terminal envelope.
7445    #[tokio::test]
7446    async fn cancelled_turn_result_drains_review_activity() {
7447        let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
7448        let (mut task, mut update_rx) = review_task_for(&workspace, &worker);
7449
7450        task.translate_event(AgentEvent::SdkMessage {
7451            session_id: "worker".to_owned(),
7452            msg: result_message("error_during_execution", true),
7453        });
7454
7455        let (key, ..) = drained_notice(&mut update_rx).expect("a cancelled turn still notifies");
7456        assert_eq!(key, reviewer);
7457    }
7458
7459    /// A turn that ends in error marks the slot - the fact the rail's
7460    /// failure mark reads - and a result the reader asked to interrupt
7461    /// does not: a cancel ends with the same failed `Result`.
7462    #[test]
7463    fn an_errored_result_marks_the_slot_unless_the_reader_cancelled() {
7464        let (workspace, _rx) = crate::Workspace::testing_stub();
7465        let key = SessionSlot::from_str_for_test("failed-rail");
7466        let (mut task, _update_rx) = review_task_for(&workspace, &key);
7467
7468        task.translate_event(AgentEvent::SdkMessage {
7469            session_id: "failed-rail".to_owned(),
7470            msg: result_message("error_during_execution", true),
7471        });
7472        assert!(
7473            task.domain.lock().failed_turn_at.is_some(),
7474            "a genuine error marks the slot for the rail",
7475        );
7476
7477        // The reader cancels the next turn: armed at Cancel routing when
7478        // the turn is in flight, spent at the result, which must not mark.
7479        task.domain.lock().failed_turn_at = None;
7480        task.domain.lock().pending_cancel = true;
7481        task.translate_event(AgentEvent::SdkMessage {
7482            session_id: "failed-rail".to_owned(),
7483            msg: result_message("error_during_execution", true),
7484        });
7485        assert!(
7486            task.domain.lock().failed_turn_at.is_none(),
7487            "a cancelled turn is the reader's own act, not a failure to flag",
7488        );
7489        assert!(!task.domain.lock().pending_cancel, "the stamp is spent at the result");
7490
7491        // And spent when unused: a cancel that raced a turn the CLI had
7492        // already finished must not exempt the next genuine failure.
7493        task.domain.lock().pending_cancel = true;
7494        task.translate_event(AgentEvent::SdkMessage {
7495            session_id: "failed-rail".to_owned(),
7496            msg: result_message("success", false),
7497        });
7498        task.translate_event(AgentEvent::SdkMessage {
7499            session_id: "failed-rail".to_owned(),
7500            msg: result_message("error_during_execution", true),
7501        });
7502        assert!(
7503            task.domain.lock().failed_turn_at.is_some(),
7504            "a success spends the stamp, so the next real failure marks",
7505        );
7506    }
7507
7508    /// A plain failure's reason is the CLI's own error strings, in the order
7509    /// it reported them, carried into the words the model receives.
7510    #[test]
7511    fn a_plain_failure_is_continued_with_the_cli_errors_as_its_reason() {
7512        let (workspace, _rx) = crate::Workspace::testing_stub();
7513        let key = SessionSlot::from_str_for_test("nudge-rail");
7514        let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
7515
7516        task.translate_event(AgentEvent::SdkMessage {
7517            session_id: "nudge-rail".to_owned(),
7518            msg: result_message_with_errors(&["API Error: 400 ...", "request too large"]),
7519        });
7520
7521        let prompts = captured_prompts(&workspace);
7522        assert_eq!(prompts.len(), 1, "the failure's continuation goes out: {prompts:?}");
7523        assert!(
7524            prompts[0].contains("API Error: 400 ...; request too large"),
7525            "the CLI's own errors are the reason, in the order it reported them: {prompts:?}",
7526        );
7527        assert!(task.domain.lock().failed_turn_at.is_some(), "the rail's mark is set with it");
7528    }
7529
7530    /// A turn the reader cancelled ends with the same failed `Result` a
7531    /// genuine error does, and arms nothing - there is no failure to pick
7532    /// back up, only the interruption the reader asked for.
7533    #[test]
7534    fn a_cancelled_turn_arms_no_continuation() {
7535        let (workspace, _rx) = crate::Workspace::testing_stub();
7536        let key = SessionSlot::from_str_for_test("cancelled-rail");
7537        let (mut task, _update_rx) = review_task_for(&workspace, &key);
7538        task.domain.lock().pending_cancel = true;
7539
7540        task.translate_event(AgentEvent::SdkMessage {
7541            session_id: "cancelled-rail".to_owned(),
7542            msg: result_message_with_errors(&["aborted_streaming"]),
7543        });
7544
7545        let domain = task.domain.lock();
7546        assert!(domain.failed_turn_at.is_none(), "a cancelled turn is not a failure to mark");
7547        assert!(domain.auto_continue.is_none(), "and nothing is armed to pick up");
7548    }
7549
7550    /// A result the CLI sent no errors for is named by its subtype in the
7551    /// continuation's words, and an empty list names nothing either.
7552    #[test]
7553    fn a_result_without_errors_is_named_by_its_subtype_in_the_nudge() {
7554        let (workspace, _rx) = crate::Workspace::testing_stub();
7555        let key = SessionSlot::from_str_for_test("subtype-rail");
7556        let (mut task, _update_rx) = review_task_for(&workspace, &key);
7557
7558        task.translate_event(AgentEvent::SdkMessage {
7559            session_id: "subtype-rail".to_owned(),
7560            msg: result_message("error_max_turns", true),
7561        });
7562        let reason = task.domain.lock().auto_continue.clone().expect("armed").reason;
7563        assert_eq!(reason, "error_max_turns", "no errors reported, so the subtype names it");
7564
7565        task.domain.lock().auto_continue = None;
7566        task.translate_event(AgentEvent::SdkMessage {
7567            session_id: "subtype-rail".to_owned(),
7568            msg: result_message_with_errors(&[]),
7569        });
7570        let reason = task.domain.lock().auto_continue.clone().expect("armed").reason;
7571        assert_eq!(reason, "error_during_execution", "an empty list names nothing either");
7572    }
7573
7574    /// The words of the reason, branch by branch: the CLI's errors win when
7575    /// it reported any, then the result's subtype, and a plain wording when
7576    /// neither names anything.
7577    #[test]
7578    fn the_failures_reason_prefers_the_cli_errors_then_the_subtype() {
7579        assert_eq!(
7580            super::failure_reason(Some(&["API Error: 400 ...".to_owned()]), "error_max_turns"),
7581            "API Error: 400 ...",
7582            "the CLI's own errors are the reason",
7583        );
7584        assert_eq!(
7585            super::failure_reason(None, "error_max_turns"),
7586            "error_max_turns",
7587            "a result with no errors is named by its subtype",
7588        );
7589        assert_eq!(
7590            super::failure_reason(None, "success"),
7591            "an error",
7592            "a subtype that names nothing falls back to plain wording",
7593        );
7594        assert_eq!(super::failure_reason(None, ""), "an error", "and so does an empty subtype");
7595    }
7596
7597    /// A turn that finished ends the episode: the spend is dropped, so a later
7598    /// unrelated failure is continued on its own terms.
7599    #[test]
7600    fn a_completed_turn_clears_the_spend_for_the_next_failure() {
7601        let (workspace, _rx) = crate::Workspace::testing_stub();
7602        let key = SessionSlot::from_str_for_test("spent-rail");
7603        let (mut task, _update_rx) = review_task_for(&workspace, &key);
7604        task.domain.lock().auto_continue_spent = true;
7605
7606        task.translate_event(AgentEvent::SdkMessage {
7607            session_id: "spent-rail".to_owned(),
7608            msg: result_message("success", false),
7609        });
7610
7611        let domain = task.domain.lock();
7612        assert!(!domain.auto_continue_spent, "a completed turn ends the episode");
7613    }
7614
7615    /// A task whose domain IS the workspace's own for the slot - the
7616    /// sharing production does - so the fold is what the workspace's fire
7617    /// reads, and a captured dispatch is what the seat's prompt path
7618    /// produced.
7619    fn nudge_task_for(
7620        workspace: &Arc<crate::Workspace>,
7621        key: &SessionSlot,
7622    ) -> (
7623        SessionTask,
7624        mpsc::UnboundedReceiver<SeatUpdate>,
7625        mpsc::UnboundedReceiver<forge_primitives::AgentCommand>,
7626    ) {
7627        workspace.enable_test_dispatch_intercept();
7628        let (handle, to_agent) = Agent::testing_stub();
7629        let handle = Arc::new(handle);
7630        let domain = workspace.register_domain_session(key.clone(), Some(Arc::clone(&handle)));
7631        let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
7632        let update_tx = UpdateFanout::default();
7633        let update_rx = update_tx.subscribe(SubscriberRole::Answering);
7634        let task = SessionTask {
7635            key: key.clone(),
7636            handle,
7637            command_rx,
7638            domain,
7639            update_tx,
7640            connected_once: false,
7641            workspace: Arc::downgrade(workspace),
7642            compaction_count: 0,
7643            successor: None,
7644            teardown_done: None,
7645        };
7646        (task, update_rx, to_agent)
7647    }
7648
7649    /// The prompts `workspace` captured, in order.
7650    fn captured_prompts(workspace: &Arc<crate::Workspace>) -> Vec<String> {
7651        workspace
7652            .drain_test_dispatch_buffer()
7653            .into_iter()
7654            .filter_map(|cmd| match cmd {
7655                crate::protocol::Command::PromptUnder { text, .. } => Some(text),
7656                _ => None,
7657            })
7658            .collect()
7659    }
7660
7661    /// The `PromptUnder` commands `workspace` captured, in order.
7662    fn captured_prompt_commands(
7663        workspace: &Arc<crate::Workspace>,
7664    ) -> Vec<crate::protocol::Command> {
7665        workspace
7666            .drain_test_dispatch_buffer()
7667            .into_iter()
7668            .filter(|cmd| matches!(cmd, crate::protocol::Command::PromptUnder { .. }))
7669            .collect()
7670    }
7671
7672    /// Run a failed turn and then execute the continuation it dispatched,
7673    /// with a replacement's `Connected` landing in between when `replace`.
7674    /// The answer is whether the prompt reached the CLI.
7675    fn continuation_reaches_the_cli(label: &str, replace: bool) -> bool {
7676        let (workspace, _rx) = crate::Workspace::testing_stub();
7677        let key = SessionSlot::from_str_for_test(label);
7678        let (mut task, _update_rx, mut to_agent) = nudge_task_for(&workspace, &key);
7679        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-occupant"));
7680
7681        task.translate_event(AgentEvent::SdkMessage {
7682            session_id: label.to_owned(),
7683            msg: result_message("error_during_execution", true),
7684        });
7685        let captured = captured_prompt_commands(&workspace);
7686        let [crate::protocol::Command::PromptUnder { text, uuid, .. }] = captured.as_slice() else {
7687            panic!("the failure's continuation is dispatched: {captured:?}");
7688        };
7689        let (text, uuid) = (text.clone(), uuid.clone());
7690
7691        if replace {
7692            // What a `/new` or `/resume` drained first leaves behind: a new
7693            // occupant under the queued prompt.
7694            task.translate_event(AgentEvent::Connected {
7695                session_id: "new-occupant".to_owned(),
7696                cwd: "/tmp/continuation-occupant".to_owned(),
7697                current_model: forge_primitives::CurrentModel {
7698                    resolved_id: "claude".to_owned(),
7699                    display_name_short: "claude".to_owned(),
7700                    display_name_long: "claude".to_owned(),
7701                    requested_id: None,
7702                    catalog_id: None,
7703                    supports_effort: false,
7704                    supported_effort_levels: Vec::new(),
7705                    supports_auto_mode: None,
7706                    supports_adaptive_thinking: None,
7707                    is_authoritative: true,
7708                },
7709                available_models: Vec::new(),
7710                mode: None,
7711                history_updates: None,
7712                compaction_count: 0,
7713            });
7714        }
7715
7716        task.execute_command(crate::protocol::Command::PromptUnder {
7717            key: key.clone(),
7718            text,
7719            attachments: Vec::new(),
7720            uuid: uuid.clone(),
7721            source: crate::protocol::PromptSource::Forge,
7722            dispatch_id: None,
7723        });
7724        // **The continuation is the command under test, not whatever arrives
7725        // next.** The turn boundary also asks the CLI for a fill reading, so
7726        // "something was sent" would answer a different question - and a
7727        // dropped continuation would read as delivered on the strength of a
7728        // probe.
7729        loop {
7730            match to_agent.try_recv() {
7731                Ok(forge_primitives::AgentCommand::PromptWithImages { uuid: sent, .. })
7732                    if sent == uuid =>
7733                {
7734                    return true;
7735                }
7736                Ok(_) => {}
7737                Err(_) => return false,
7738            }
7739        }
7740    }
7741
7742    /// The continuation speaks for the occupant that failed. A `/new` or
7743    /// `/resume` the queue drains first leaves a replacement under it, and
7744    /// the replacement carries none of the history the words point at - so
7745    /// the prompt is dropped where it would have run, and still lands when
7746    /// the occupant did not move.
7747    #[test]
7748    fn a_continuation_is_dropped_when_the_occupant_changed_under_it() {
7749        assert!(
7750            continuation_reaches_the_cli("kept-rail", false),
7751            "control: with the occupant unchanged the continuation reaches the CLI",
7752        );
7753        assert!(
7754            !continuation_reaches_the_cli("replaced-rail", true),
7755            "the replacement is not handed words about a history it does not have",
7756        );
7757    }
7758
7759    /// Everything `updates` holds right now.
7760    fn drain_updates(rx: &mut mpsc::UnboundedReceiver<SeatUpdate>) -> Vec<SessionUpdate> {
7761        let mut seen = Vec::new();
7762        while let Ok(frame) = rx.try_recv() {
7763            seen.push(std::sync::Arc::unwrap_or_clone(frame.update));
7764        }
7765        seen
7766    }
7767
7768    /// A turn that dies on a retried 5xx is the nudge's like any other
7769    /// failure: forge's continuation goes out the moment the result lands -
7770    /// no delay, nothing left for a sweep - and it draws as the turn the
7771    /// model received. The terminal's dead-turn path used to own this class
7772    /// and no longer exists, so leaving it alone would leave the seat dead
7773    /// until the person typed again (#2146).
7774    #[test]
7775    fn a_server_error_failure_is_nudged_at_once() {
7776        let (workspace, mut updates) = crate::Workspace::testing_stub();
7777        let key = SessionSlot::from_str_for_test("server-rail");
7778        let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
7779
7780        task.translate_event(AgentEvent::SdkMessage {
7781            session_id: "server-rail".to_owned(),
7782            msg: api_retry_message("server_error", Some(529)),
7783        });
7784        task.translate_event(AgentEvent::SdkMessage {
7785            session_id: "server-rail".to_owned(),
7786            msg: result_message("error_during_execution", true),
7787        });
7788
7789        let prompts = captured_prompts(&workspace);
7790        assert_eq!(
7791            prompts.len(),
7792            1,
7793            "the failed result fires the continuation itself: {prompts:?}"
7794        );
7795        assert!(
7796            prompts[0].contains("error_during_execution"),
7797            "the prompt names the failure it answers: {prompts:?}",
7798        );
7799        let domain = task.domain.lock();
7800        assert!(domain.auto_continue.is_none(), "the fire took the armed continuation");
7801        assert!(domain.auto_continue_spent, "the episode is spent: one nudge per failure");
7802
7803        let drawn = drain_updates(&mut updates);
7804        assert!(
7805            drawn.iter().any(|update| matches!(
7806                update,
7807                SessionUpdate::ChatAppended { msg: forge_primitives::Message::User { .. }, .. }
7808            )),
7809            "the continuation's words draw in every view: {drawn:?}",
7810        );
7811    }
7812
7813    /// **A live frame crosses the task stamped.**
7814    ///
7815    /// The kinds a turn OPENS with are exactly the ones the CLI does not
7816    /// stamp - the prompt frame carries no timestamp at all, and neither do
7817    /// the counter or result frames - so a page opening mid-turn had no row
7818    /// to anchor its clock on: 0.0s until the first assistant frame, whose
7819    /// stamp is the moment it was written rather than the turn's start. The
7820    /// receipt is forge's own record of when the row crossed; a frame the
7821    /// CLI did stamp keeps its own.
7822    #[test]
7823    fn a_live_frame_crosses_stamped_and_keeps_the_stamp_it_came_with() {
7824        let (workspace, _updates) = crate::Workspace::testing_stub();
7825        let key = SessionSlot::from_str_for_test("stamp-crossing");
7826        let (mut task, mut updates, _to_agent) = nudge_task_for(&workspace, &key);
7827
7828        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
7829            task.translate_event(AgentEvent::SdkMessage {
7830                session_id: "stamp-crossing".to_owned(),
7831                msg,
7832            });
7833        };
7834
7835        // The prompt frame as the CLI sends it: no stamp of its own.
7836        let prompt: forge_primitives::Message = serde_json::from_value(serde_json::json!({
7837            "type": "user",
7838            "message": {"role": "user", "content": [{"type": "text", "text": "hi"}]},
7839            "session_id": "stamp-crossing",
7840        }))
7841        .expect("a prompt frame");
7842        send(&mut task, prompt);
7843
7844        // And one the CLI stamped itself.
7845        let spoke: forge_primitives::Message = serde_json::from_value(serde_json::json!({
7846            "type": "assistant",
7847            "uuid": "a1",
7848            "timestamp": "2026-10-15T05:18:15.544Z",
7849            "session_id": "stamp-crossing",
7850            "message": {
7851                "id": "m1",
7852                "role": "assistant",
7853                "model": "claude-opus-5",
7854                "content": [{"type": "text", "text": "working"}],
7855            },
7856        }))
7857        .expect("an assistant frame");
7858        send(&mut task, spoke);
7859
7860        let drawn: Vec<forge_primitives::Message> = drain_updates(&mut updates)
7861            .into_iter()
7862            .filter_map(|update| match update {
7863                SessionUpdate::ChatAppended { msg, .. } => Some(msg),
7864                _ => None,
7865            })
7866            .collect();
7867        assert_eq!(drawn.len(), 2, "both frames are drawn: {drawn:?}");
7868
7869        let prompt_stamp = serde_json::to_value(&drawn[0]).expect("encode")["timestamp"]
7870            .as_str()
7871            .map(str::to_owned)
7872            .expect("the prompt row carries a stamp for a clock to anchor on");
7873        assert!(
7874            time::OffsetDateTime::parse(
7875                &prompt_stamp,
7876                &time::format_description::well_known::Rfc3339,
7877            )
7878            .is_ok(),
7879            "and it is a time the client can read: {prompt_stamp}",
7880        );
7881        assert_eq!(
7882            serde_json::to_value(&drawn[1]).expect("encode")["timestamp"].as_str(),
7883            Some("2026-10-15T05:18:15.544Z"),
7884            "while the frame the CLI stamped keeps its own",
7885        );
7886    }
7887
7888    /// A rate-limited turn gets the continuation too: the gateway answers
7889    /// an exhausted window by rotating the account, and whatever failure
7890    /// still reaches forge is the nudge's to answer (#2146).
7891    #[test]
7892    fn a_rate_limited_failure_is_nudged_at_once() {
7893        let (workspace, _updates) = crate::Workspace::testing_stub();
7894        let key = SessionSlot::from_str_for_test("rate-limit-rail");
7895        let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
7896
7897        task.translate_event(AgentEvent::SdkMessage {
7898            session_id: "rate-limit-rail".to_owned(),
7899            msg: api_retry_message("rate_limit", Some(429)),
7900        });
7901        task.translate_event(AgentEvent::SdkMessage {
7902            session_id: "rate-limit-rail".to_owned(),
7903            msg: result_message("error_during_execution", true),
7904        });
7905
7906        let prompts = captured_prompts(&workspace);
7907        assert_eq!(prompts.len(), 1, "a 429 failure is nudged like any other: {prompts:?}");
7908    }
7909
7910    /// The gateway edge: a rate_limit_event whose status is not
7911    /// `allowed` reports the gateway, which drops the account binding the
7912    /// session's requests were stamped with. The binding is keyed by the id
7913    /// the CLI reported, so the fixture stamps one on the domain. An
7914    /// `allowed` frame reports nothing.
7915    #[test]
7916    fn a_rate_limit_event_not_allowed_reports_the_gateway_for_the_session_key() {
7917        let (workspace, _rx) = crate::Workspace::testing_stub();
7918        let session_id = "w-uuid";
7919        let key = SessionSlot::from_str_for_test(session_id);
7920        let (mut task, _update_rx) = review_task_for(&workspace, &key);
7921        task.domain.lock().session_id = Some(forge_primitives::SessionId::new(session_id));
7922
7923        workspace.gateway.bindings.bind(
7924            key.org(),
7925            key.project(),
7926            session_id,
7927            forge_gateway::AccountKey("A".to_owned()),
7928        );
7929
7930        let rejected = forge_primitives::Message::RateLimitEvent {
7931            rate_limit_info: forge_primitives::RateLimitInfo {
7932                status: forge_primitives::RateLimitStatus::Rejected,
7933                resets_at: Some(1_800_000_000),
7934                rate_limit_type: None,
7935                utilization: None,
7936                overage_status: None,
7937                overage_resets_at: None,
7938                overage_disabled_reason: None,
7939                raw: serde_json::Map::new(),
7940            },
7941            uuid: "u1".to_owned(),
7942            session_id: session_id.to_owned(),
7943            extras: serde_json::Map::new(),
7944        };
7945        task.translate_event(AgentEvent::SdkMessage {
7946            session_id: session_id.to_owned(),
7947            msg: rejected,
7948        });
7949
7950        assert!(
7951            workspace.gateway.bindings.binding_for(key.org(), key.project(), session_id).is_none(),
7952            "the report drops the session's own binding",
7953        );
7954
7955        // The re-bound key must survive the allowed frame: allowed
7956        // frames arrive every turn, so a lost guard here would rotate
7957        // a live session's binding on each one. Re-binding the task's
7958        // OWN key is what makes this assert kill the guard regression.
7959        workspace.gateway.bindings.bind(
7960            key.org(),
7961            key.project(),
7962            session_id,
7963            forge_gateway::AccountKey("C".to_owned()),
7964        );
7965        task.translate_event(AgentEvent::SdkMessage {
7966            session_id: session_id.to_owned(),
7967            msg: forge_primitives::Message::RateLimitEvent {
7968                rate_limit_info: forge_primitives::RateLimitInfo {
7969                    status: forge_primitives::RateLimitStatus::Allowed,
7970                    resets_at: None,
7971                    rate_limit_type: None,
7972                    utilization: None,
7973                    overage_status: None,
7974                    overage_resets_at: None,
7975                    overage_disabled_reason: None,
7976                    raw: serde_json::Map::new(),
7977                },
7978                uuid: "u2".to_owned(),
7979                session_id: session_id.to_owned(),
7980                extras: serde_json::Map::new(),
7981            },
7982        });
7983        assert_eq!(
7984            workspace.gateway.bindings.binding_for(key.org(), key.project(), session_id),
7985            Some(forge_gateway::AccountKey("C".to_owned())),
7986            "an allowed frame rotates nothing",
7987        );
7988    }
7989
7990    /// `Message::Error` is the CLI's last-gasp transport failure; no
7991    /// Result follows it, so it has to drain on its own.
7992    #[tokio::test]
7993    async fn transport_error_drains_review_activity() {
7994        let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
7995        let (mut task, mut update_rx) = review_task_for(&workspace, &worker);
7996
7997        task.translate_event(AgentEvent::SdkMessage {
7998            session_id: "worker".to_owned(),
7999            msg: forge_primitives::Message::Error {
8000                error: "stream closed".to_owned(),
8001                extras: serde_json::Map::new(),
8002            },
8003        });
8004
8005        let (key, ..) =
8006            drained_notice(&mut update_rx).expect("a transport error still notifies the reviewer");
8007        assert_eq!(key, reviewer);
8008    }
8009
8010    /// The session being replaced mid-turn (`/new`, `/clear`, an account
8011    /// switch) never produces a Result, so the drain has to happen here or
8012    /// the activity this turn touched sits until the task exits.
8013    #[tokio::test]
8014    async fn session_replacement_drains_review_activity() {
8015        let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
8016        let (mut task, mut update_rx) = review_task_for(&workspace, &worker);
8017        task.connected_once = true;
8018
8019        task.translate_event(connected_event("worker-replacement", "/tmp/proj"));
8020
8021        let (key, ..) = drained_notice(&mut update_rx)
8022            .expect("the replaced identity flushes what its turn touched");
8023        assert_eq!(key, reviewer);
8024        assert!(
8025            workspace.drain_review_activity(&worker).is_empty(),
8026            "nothing strands under the worker's own slot",
8027        );
8028    }
8029
8030    /// Teardown backstop: the subprocess dying or a despawn closing the
8031    /// command channel drops the task without any terminal envelope.
8032    #[tokio::test]
8033    async fn task_teardown_drains_review_activity() {
8034        let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
8035        let (task, mut update_rx) = review_task_for(&workspace, &worker);
8036
8037        drop(task);
8038
8039        let (key, ..) =
8040            drained_notice(&mut update_rx).expect("teardown flushes the stranded activity");
8041        assert_eq!(key, reviewer);
8042    }
8043
8044    /// `run` exiting its select loop reaches the drain, rather than only
8045    /// a hand-dropped task doing so.
8046    ///
8047    /// This does NOT model subprocess death. `Agent::testing_stub`
8048    /// substitutes an event channel whose sender is already dropped,
8049    /// which production never does - `SessionTask.handle` keeps an `Arc`
8050    /// chain to `BridgeInner.event_tx` alive for the task's whole life,
8051    /// so the event arm's `break` is unreachable there. The fixture also
8052    /// closes the command channel, so this test cannot say which arm
8053    /// broke; what it establishes is that leaving `run` drains.
8054    #[tokio::test]
8055    async fn run_exiting_on_a_closed_channel_drains_review_activity() {
8056        let (_dir, workspace, reviewer, worker) = workspace_with_pending_review_activity();
8057        let (task, mut update_rx) = review_task_for(&workspace, &worker);
8058
8059        task.run().await;
8060
8061        let (key, ..) =
8062            drained_notice(&mut update_rx).expect("the run loop's exit flushes the activity");
8063        assert_eq!(key, reviewer);
8064    }
8065
8066    /// A `ConnectionFailed` event is terminal for the task: the dead
8067    /// spawn's pool entry and command sender are released so the next
8068    /// retry (projects-pane click, cron fire) re-spawns instead of
8069    /// dispatching into the dead handle, a view still receives the
8070    /// user-visible `SessionUpdate::ConnectionFailed`, and
8071    /// `translate_event` signals the run loop to exit so `Drop`'s
8072    /// expiry backstop fires.
8073    #[tokio::test]
8074    async fn connection_failed_releases_registrations_and_terminates() {
8075        let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8076        let key = SessionSlot::from_str_for_test("dead-spawn");
8077        let (handle, _cmds) = Agent::testing_stub();
8078        let handle = Arc::new(handle);
8079        let (cmd_tx, command_rx) = mpsc::unbounded_channel();
8080        let update_tx = UpdateFanout::default();
8081        let mut update_rx = update_tx.subscribe(SubscriberRole::Answering);
8082        workspace.pool.lock().insert(
8083            key.clone(),
8084            crate::workspace::PooledAgent {
8085                handle: Arc::clone(&handle),
8086                account: forge_gateway::AccountKey("Acct".to_owned()),
8087                permission_mode: None,
8088                registration: None,
8089                session_id: "pooled-session".to_owned(),
8090            },
8091        );
8092        workspace.command_senders.lock().insert(key.clone(), cmd_tx);
8093        workspace.register_domain_session(key.clone(), Some(Arc::clone(&handle)));
8094        let mut task = SessionTask {
8095            key: key.clone(),
8096            handle,
8097            command_rx,
8098            domain: Arc::new(Mutex::new(DomainSession::new(key.clone(), None))),
8099            update_tx,
8100            connected_once: false,
8101            workspace: Arc::downgrade(&workspace),
8102            compaction_count: 0,
8103            successor: None,
8104            teardown_done: None,
8105        };
8106
8107        let continues = task.translate_event(AgentEvent::ConnectionFailed {
8108            message: "spawn failed".to_owned(),
8109            kind: SpawnFailureKind::Unclassified,
8110        });
8111
8112        assert!(!continues, "ConnectionFailed must terminate the task");
8113        assert!(
8114            !workspace.pool.lock().contains_key(&key),
8115            "pool entry released so a retry spawns fresh"
8116        );
8117        assert!(!workspace.command_senders.lock().contains_key(&key), "command sender released");
8118        let update = update_rx.try_recv().expect("an update emits");
8119        assert!(
8120            matches!(
8121                &*update.update,
8122                SessionUpdate::ConnectionFailed { key: emitted, .. } if *emitted == key
8123            ),
8124            "the ConnectionFailed envelope still goes out"
8125        );
8126        // **A died session leaves no machine turn.** A handoff whose successor
8127        // never came up and a compaction both end with the subprocess; the
8128        // stand-down follows the death so a client reads it second.
8129        assert!(
8130            matches!(
8131                update_rx.try_recv().map(|frame| std::sync::Arc::unwrap_or_clone(frame.update)),
8132                Ok(SessionUpdate::MachineTurn { machine: None, .. })
8133            ),
8134            "the machine turn ends with the session",
8135        );
8136    }
8137
8138    /// A `ConnectionFailed` releases the task's registrations, so a
8139    /// later click or delivery does not find a dead handle still
8140    /// registered under the key.
8141    #[tokio::test]
8142    async fn connection_failed_releases_the_session_registrations() {
8143        let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8144        let key = SessionSlot::from_str_for_test("real-key");
8145        let (handle, _cmds) = Agent::testing_stub();
8146        let handle = Arc::new(handle);
8147        let (cmd_tx, command_rx) = mpsc::unbounded_channel();
8148        let update_tx = UpdateFanout::default();
8149        let _update_rx = update_tx.subscribe(SubscriberRole::Answering);
8150        workspace.pool.lock().insert(
8151            key.clone(),
8152            crate::workspace::PooledAgent {
8153                handle: Arc::clone(&handle),
8154                account: forge_gateway::AccountKey("Acct".to_owned()),
8155                permission_mode: None,
8156                registration: None,
8157                session_id: "pooled-session".to_owned(),
8158            },
8159        );
8160        workspace.command_senders.lock().insert(key.clone(), cmd_tx);
8161        let mut task = SessionTask {
8162            key: key.clone(),
8163            handle: Arc::clone(&handle),
8164            command_rx,
8165            domain: Arc::new(Mutex::new(DomainSession::new(key.clone(), None))),
8166            update_tx,
8167            connected_once: false,
8168            workspace: Arc::downgrade(&workspace),
8169            compaction_count: 0,
8170            successor: None,
8171            teardown_done: None,
8172        };
8173
8174        let continues = task.translate_event(AgentEvent::ConnectionFailed {
8175            message: "spawn failed".to_owned(),
8176            kind: SpawnFailureKind::Unclassified,
8177        });
8178
8179        assert!(!continues);
8180        assert!(
8181            !workspace.pool.lock().contains_key(&key),
8182            "pool entry under the real key released"
8183        );
8184        assert!(
8185            !workspace.command_senders.lock().contains_key(&key),
8186            "command sender under the real key released"
8187        );
8188    }
8189
8190    /// A spawn that never connected strands whatever was parked for it, and
8191    /// the expiry reaches it by slot rather than by key: one bucket holds
8192    /// it, and the release below drops the domain it was once buffered on.
8193    /// For Slack the delivery was already committed, so nothing re-delivers
8194    /// it.
8195    #[tokio::test]
8196    async fn connection_failed_expires_the_buffers_parked_for_its_slot() {
8197        let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8198        let key = test_slot();
8199        let (handle, _cmds) = Agent::testing_stub();
8200        let handle = Arc::new(handle);
8201        let (cmd_tx, command_rx) = mpsc::unbounded_channel();
8202        let update_tx = UpdateFanout::default();
8203        let _update_rx = update_tx.subscribe(SubscriberRole::Answering);
8204        workspace.pool.lock().insert(
8205            key.clone(),
8206            crate::workspace::PooledAgent {
8207                handle: Arc::clone(&handle),
8208                account: forge_gateway::AccountKey("Acct".to_owned()),
8209                permission_mode: None,
8210                registration: None,
8211                session_id: "pooled-session".to_owned(),
8212            },
8213        );
8214        workspace.command_senders.lock().insert(key.clone(), cmd_tx);
8215        workspace.register_domain_session(key.clone(), Some(Arc::clone(&handle)));
8216        workspace.park_slack(&test_slot(), vec![buffered_slack("parked while spawning")]);
8217
8218        let mut task = SessionTask {
8219            key: key.clone(),
8220            handle: Arc::clone(&handle),
8221            command_rx,
8222            domain: Arc::new(Mutex::new(DomainSession::new(key.clone(), None))),
8223            update_tx,
8224            connected_once: false,
8225            workspace: Arc::downgrade(&workspace),
8226            compaction_count: 0,
8227            successor: None,
8228            teardown_done: None,
8229        };
8230
8231        let continues = task.translate_event(AgentEvent::ConnectionFailed {
8232            message: "spawn failed".to_owned(),
8233            kind: SpawnFailureKind::Unclassified,
8234        });
8235
8236        assert!(!continues);
8237        assert!(
8238            workspace.take_parked_for_slot(&test_slot()).slack.is_empty(),
8239            "the message parked for this slot is expired, not left for a later session",
8240        );
8241    }
8242
8243    /// Everything `updates` holds that is a `Notice`, as its text.
8244    fn notices_of(
8245        updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
8246    ) -> Vec<(NoticeSeverity, String)> {
8247        drain_updates(updates)
8248            .into_iter()
8249            .filter_map(|update| match update {
8250                SessionUpdate::Notice { severity, text, .. } => Some((severity, text)),
8251                _ => None,
8252            })
8253            .collect()
8254    }
8255
8256    /// Record a death on `key`'s seat the way the arm above does, so a test
8257    /// drives the record rather than hand-rolling it.
8258    fn record_death_via_arm(task: &mut SessionTask, occupant: &str, message: &str) {
8259        task.connected_once = true;
8260        task.domain.lock().session_id = Some(forge_primitives::SessionId::new(occupant));
8261        task.translate_event(AgentEvent::ConnectionFailed {
8262            message: message.to_owned(),
8263            kind: SpawnFailureKind::Unclassified,
8264        });
8265    }
8266
8267    /// A session that died is not silently swapped: the recovery that comes
8268    /// up on the slot announces the death and what forge did about it - the
8269    /// same occupant resumed, or a new one under a new id - exactly once,
8270    /// and a connect with no death behind it stays quiet.
8271    ///
8272    /// The verb is "ended" rather than "died" on purpose: the same arm
8273    /// catches a `/new` or `/resume` that failed to respawn, where the
8274    /// occupant was swapped out rather than crashing, and "ended" is true
8275    /// of both.
8276    #[test]
8277    fn a_recovery_after_a_death_announces_what_forge_did() {
8278        let message = "the claude subprocess closed its output stream";
8279
8280        // The death: an occupant that had been up, whose subprocess ended.
8281        let (workspace, _rx) = crate::Workspace::testing_stub();
8282        let key = SessionSlot::from_str_for_test("died-and-restarted");
8283        let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
8284        record_death_via_arm(&mut task, "dead-occupant", message);
8285
8286        // The recovery lands as its own task on the same slot, under a new id.
8287        let (mut revived, mut updates, _to_agent) = nudge_task_for(&workspace, &key);
8288        revived.translate_event(connected_event("fresh-occupant", "/tmp/recovery-announced"));
8289        let notices = notices_of(&mut updates);
8290        assert_eq!(notices.len(), 1, "one line, and only one: {notices:?}");
8291        let (severity, text) = &notices[0];
8292        assert_eq!(
8293            *severity,
8294            NoticeSeverity::Error,
8295            "a death reads at failure level: the client leads the row with the severity word",
8296        );
8297        assert_eq!(
8298            text,
8299            "The agent's session ended; forge restarted it under a new id (dead-occupant -> fresh-occupant).",
8300            "the restart names both ids, in pinned words",
8301        );
8302
8303        // A second connect on the slot stays quiet: the death is announced
8304        // once, not on every connect that follows it.
8305        revived.translate_event(connected_event("second-occupant", "/tmp/recovery-announced"));
8306        assert!(
8307            notices_of(&mut updates).is_empty(),
8308            "a later connect does not re-announce the death",
8309        );
8310
8311        // **The lifecycle tells the truth about now**: the recovering connect
8312        // clears the failure as it always did, so the row reads what the seat
8313        // is - and the death rides on as its own record.
8314        assert_eq!(
8315            workspace.spawn_failure(&key),
8316            None,
8317            "a recovered seat that still read Failed would be the same lie, mirrored",
8318        );
8319        assert!(
8320            workspace.death_stands(&key),
8321            "the death nobody was shown rides until a reader sees it",
8322        );
8323        assert_eq!(
8324            workspace.death_reason(&key).as_deref(),
8325            Some(message),
8326            "and it carries the reason the seat died with",
8327        );
8328        assert!(
8329            workspace.spend_death(&key),
8330            "showing the seat takes the death, and answers that it did",
8331        );
8332        assert!(!workspace.death_stands(&key), "the record goes with the spend");
8333        assert_eq!(workspace.death_reason(&key), None, "and the reason with it");
8334
8335        // And the same id back: the session resumed.
8336        let (workspace, _rx) = crate::Workspace::testing_stub();
8337        let key = SessionSlot::from_str_for_test("died-and-resumed");
8338        let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
8339        record_death_via_arm(&mut task, "kept-occupant", message);
8340        let (mut revived, mut updates, _to_agent) = nudge_task_for(&workspace, &key);
8341        revived.translate_event(connected_event("kept-occupant", "/tmp/recovery-announced"));
8342        let notices = notices_of(&mut updates);
8343        assert_eq!(
8344            notices.first().map(|(_, text)| text.as_str()),
8345            Some("The agent's session ended; forge resumed it (kept-occupant)."),
8346            "the same occupant coming back reads as a resume: {notices:?}",
8347        );
8348
8349        // Control: a connect with no death behind it draws no line.
8350        let (workspace, _rx) = crate::Workspace::testing_stub();
8351        let key = SessionSlot::from_str_for_test("connected-quiet");
8352        let (mut task, mut updates, _to_agent) = nudge_task_for(&workspace, &key);
8353        task.translate_event(connected_event("first-occupant", "/tmp/plain-connect"));
8354        assert!(notices_of(&mut updates).is_empty(), "a plain connect announces nothing");
8355    }
8356
8357    /// **The spend does not eat the announcement.** A reader who opens a
8358    /// crashed seat's page spends the row's trace and then wakes the seat,
8359    /// so the connect that answers their own click is the first one to see
8360    /// the death - and it still owes them the sentence.
8361    #[test]
8362    fn a_spent_death_still_announces_on_the_recovery_that_answers_the_click() {
8363        let (workspace, _rx) = crate::Workspace::testing_stub();
8364        let key = SessionSlot::from_str_for_test("spent-then-recovered");
8365        let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
8366        record_death_via_arm(
8367            &mut task,
8368            "dead-occupant",
8369            "the claude subprocess closed its output stream",
8370        );
8371
8372        // The page opened: the seat was shown, the trace spent, and the
8373        // recovery has not happened yet.
8374        assert!(workspace.spend_death(&key), "the show took the trace");
8375        assert!(!workspace.death_stands(&key), "and the row's mark is gone");
8376
8377        // The wake's spawn lands, under a new id.
8378        let (mut revived, mut updates, _to_agent) = nudge_task_for(&workspace, &key);
8379        revived.translate_event(connected_event("fresh-occupant", "/tmp/recovery-announced"));
8380        let notices = notices_of(&mut updates);
8381        assert_eq!(
8382            notices.first().map(|(_, text)| text.as_str()),
8383            Some(
8384                "The agent's session ended; forge restarted it under a new id (dead-occupant -> fresh-occupant)."
8385            ),
8386            "the click's own recovery is still announced: {notices:?}",
8387        );
8388        assert!(
8389            !workspace.death_stands(&key),
8390            "spent and announced leaves no standing death behind",
8391        );
8392    }
8393
8394    /// One logged event: its level and every field formatted into a string.
8395    /// `record_str` forwards here, so a `%`-formatted field arrives through
8396    /// this arm however the macro wrote it.
8397    #[derive(Default, Clone)]
8398    struct LoggedEvent(std::sync::Arc<std::sync::Mutex<Vec<(tracing::Level, String)>>>);
8399
8400    struct AllFields<'a>(&'a mut String);
8401
8402    impl tracing::field::Visit for AllFields<'_> {
8403        fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
8404            use std::fmt::Write as _;
8405            let _ = write!(self.0, " {}={value:?}", field.name());
8406        }
8407    }
8408
8409    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for LoggedEvent {
8410        fn on_event(
8411            &self,
8412            event: &tracing::Event<'_>,
8413            _ctx: tracing_subscriber::layer::Context<'_, S>,
8414        ) {
8415            let mut fields = String::new();
8416            event.record(&mut AllFields(&mut fields));
8417            self.0.lock().expect("capture").push((*event.metadata().level(), fields));
8418        }
8419    }
8420
8421    /// **The one line that says a seat's session ended.** It is a WARN, and
8422    /// it carries the seat and the occupant: a reader triaging it has to
8423    /// know which seat died, and the agent-side line alone names neither.
8424    #[test]
8425    fn a_death_warns_at_warn_with_the_seat_and_the_occupant() {
8426        use tracing_subscriber::layer::SubscriberExt;
8427
8428        let (workspace, _rx) = crate::Workspace::testing_stub();
8429        let key = SessionSlot::from_str_for_test("death-warns");
8430        let (mut task, _update_rx, _to_agent) = nudge_task_for(&workspace, &key);
8431
8432        let capture = LoggedEvent::default();
8433        let subscriber = tracing_subscriber::registry().with(capture.clone());
8434        tracing::subscriber::with_default(subscriber, || {
8435            record_death_via_arm(
8436                &mut task,
8437                "dead-occupant",
8438                "the claude subprocess closed its output stream",
8439            );
8440        });
8441
8442        let events = capture.0.lock().expect("capture");
8443        let (level, fields) = events
8444            .iter()
8445            .find(|(_, fields)| fields.contains("event_name=\"session_ended\""))
8446            .unwrap_or_else(|| panic!("no session_ended event; saw {events:?}"));
8447        assert_eq!(*level, tracing::Level::WARN, "a session's end reads at WARN: {fields}");
8448        for needle in [
8449            "slot=TestOrg/test-project/death-warns",
8450            "session_id=dead-occupant",
8451            "error=the claude subprocess closed its output stream",
8452        ] {
8453            assert!(fields.contains(needle), "the line carries `{needle}`: {fields}");
8454        }
8455    }
8456
8457    /// A `PermissionRequest` registers a pending slot; `RespondPermission`
8458    /// consumes it and the outcome reaches the agent round-trip. The
8459    /// happy path of the can_use_tool parking lot.
8460    #[tokio::test]
8461    async fn respond_permission_round_trips_to_the_agent() {
8462        let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8463        let (handle, mut agent_rx) = Agent::testing_stub();
8464        let handle = Arc::new(handle);
8465        let key = SessionSlot::from_str_for_test("perm");
8466        let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
8467        let update_tx = UpdateFanout::default();
8468        let _update_rx = update_tx.subscribe(SubscriberRole::Answering);
8469        let mut task = SessionTask {
8470            key: key.clone(),
8471            handle: Arc::clone(&handle),
8472            command_rx,
8473            domain: Arc::new(Mutex::new(DomainSession::new(
8474                key.clone(),
8475                Some(Arc::clone(&handle)),
8476            ))),
8477            update_tx,
8478            connected_once: false,
8479            workspace: Arc::downgrade(&workspace),
8480            compaction_count: 0,
8481            successor: None,
8482            teardown_done: None,
8483        };
8484
8485        task.translate_event(AgentEvent::PermissionRequest {
8486            session_id: key.display(),
8487            request: permission_request_fixture("tu-1"),
8488        });
8489        assert!(
8490            task.domain.lock().pending_interactions.contains_key("tu-1"),
8491            "the request parks a pending permission slot"
8492        );
8493        assert_eq!(
8494            workspace.pending_request_seq(&key),
8495            1,
8496            "the permission has its own identity on the wire",
8497        );
8498
8499        task.execute_command(Command::RespondPermission {
8500            key: key.clone(),
8501            tool_id: "tu-1".to_owned(),
8502            outcome: forge_primitives::PermissionOutcome::Cancelled,
8503        });
8504
8505        let cmd = tokio::time::timeout(std::time::Duration::from_secs(2), agent_rx.recv())
8506            .await
8507            .expect("the forwarded outcome reaches the agent promptly")
8508            .expect("command channel open");
8509        assert!(
8510            matches!(
8511                cmd,
8512                forge_primitives::AgentCommand::PermissionResponse { tool_call_id, .. }
8513                    if tool_call_id == "tu-1"
8514            ),
8515            "the outcome forwards to the bridge with the right tool id"
8516        );
8517        assert!(
8518            !task.domain.lock().pending_interactions.contains_key("tu-1"),
8519            "the slot is consumed"
8520        );
8521    }
8522
8523    /// A subscriber that only reads must not keep a permission request
8524    /// parked. With no subscriber that can answer it, the guard resolves
8525    /// the slot `Cancelled` rather than leaving the turn waiting on a
8526    /// reply nobody will send. Catches the guard keying on "somebody
8527    /// took the update" instead of "somebody can answer it", which the
8528    /// observer's arrival would otherwise turn into a hang.
8529    #[tokio::test]
8530    async fn a_permission_request_with_only_an_observer_fails_closed() {
8531        let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8532        let (handle, mut agent_rx) = Agent::testing_stub();
8533        let handle = Arc::new(handle);
8534        let key = SessionSlot::from_str_for_test("perm-observer");
8535        let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
8536        let update_tx = UpdateFanout::default();
8537        let mut observer = update_tx.subscribe(SubscriberRole::Observing);
8538        let mut task = SessionTask {
8539            key: key.clone(),
8540            handle: Arc::clone(&handle),
8541            command_rx,
8542            domain: Arc::new(Mutex::new(DomainSession::new(
8543                key.clone(),
8544                Some(Arc::clone(&handle)),
8545            ))),
8546            update_tx,
8547            connected_once: false,
8548            workspace: Arc::downgrade(&workspace),
8549            compaction_count: 0,
8550            successor: None,
8551            teardown_done: None,
8552        };
8553
8554        task.translate_event(AgentEvent::PermissionRequest {
8555            session_id: key.display(),
8556            request: permission_request_fixture("tu-obs"),
8557        });
8558
8559        assert!(
8560            !task.domain.lock().pending_interactions.contains_key("tu-obs"),
8561            "the slot is resolved rather than parked, so the turn cannot hang",
8562        );
8563        assert!(
8564            matches!(
8565                observer.try_recv(),
8566                Ok(SeatUpdate { update, .. })
8567                    if matches!(&*update, SessionUpdate::PermissionRequest { tool_id, .. } if tool_id == "tu-obs")
8568            ),
8569            "the observer is still delivered the request it cannot answer",
8570        );
8571        let cmd = tokio::time::timeout(std::time::Duration::from_secs(2), agent_rx.recv())
8572            .await
8573            .expect("the cancelled outcome reaches the agent")
8574            .expect("command channel open");
8575        assert!(
8576            matches!(
8577                &cmd,
8578                forge_primitives::AgentCommand::PermissionResponse {
8579                    tool_call_id,
8580                    outcome: forge_primitives::PermissionOutcome::Cancelled,
8581                    ..
8582                } if tool_call_id == "tu-obs"
8583            ),
8584            "the orphaned permission answers the bridge Cancelled, got {cmd:?}",
8585        );
8586        assert!(
8587            tokio::time::timeout(std::time::Duration::from_millis(200), agent_rx.recv())
8588                .await
8589                .is_err(),
8590            "a second permission response is the duplicate this catches",
8591        );
8592    }
8593
8594    /// The question guard keys on an answering subscriber the same way
8595    /// the permission one does. Catches pointing this site back at
8596    /// `send`: the permission test drives a different event, so an
8597    /// observer's arrival here would park the question on a reply nobody
8598    /// will send and no other test would notice.
8599    #[tokio::test]
8600    async fn a_question_request_with_only_an_observer_fails_closed() {
8601        let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8602        let (handle, mut agent_rx) = Agent::testing_stub();
8603        let handle = Arc::new(handle);
8604        let key = SessionSlot::from_str_for_test("question-observer");
8605        let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
8606        let update_tx = UpdateFanout::default();
8607        let mut observer = update_tx.subscribe(SubscriberRole::Observing);
8608        let mut task = SessionTask {
8609            key: key.clone(),
8610            handle: Arc::clone(&handle),
8611            command_rx,
8612            domain: Arc::new(Mutex::new(DomainSession::new(
8613                key.clone(),
8614                Some(Arc::clone(&handle)),
8615            ))),
8616            update_tx,
8617            connected_once: false,
8618            workspace: Arc::downgrade(&workspace),
8619            compaction_count: 0,
8620            successor: None,
8621            teardown_done: None,
8622        };
8623
8624        task.translate_event(AgentEvent::QuestionRequest {
8625            session_id: key.display(),
8626            request: question_request_fixture("tu-q-obs"),
8627        });
8628
8629        assert!(
8630            !task.domain.lock().pending_interactions.contains_key("tu-q-obs"),
8631            "the question slot is resolved rather than parked, so the turn cannot hang",
8632        );
8633        assert!(
8634            matches!(
8635                observer.try_recv(),
8636                Ok(frame)
8637                    if matches!(&*frame.update, SessionUpdate::QuestionRequest { tool_id, .. } if tool_id == "tu-q-obs")
8638            ),
8639            "the observer is still delivered the question it cannot answer",
8640        );
8641        let cmd = tokio::time::timeout(std::time::Duration::from_secs(2), agent_rx.recv())
8642            .await
8643            .expect("the cancelled outcome reaches the agent")
8644            .expect("command channel open");
8645        assert!(
8646            matches!(
8647                &cmd,
8648                forge_primitives::AgentCommand::QuestionResponse {
8649                    tool_call_id,
8650                    outcome: forge_primitives::QuestionOutcome::Cancelled,
8651                    ..
8652                } if tool_call_id == "tu-q-obs"
8653            ),
8654            "the orphaned question answers the bridge Cancelled, got {cmd:?}",
8655        );
8656        assert!(
8657            tokio::time::timeout(std::time::Duration::from_millis(200), agent_rx.recv())
8658                .await
8659                .is_err(),
8660            "a second question response is the duplicate this catches",
8661        );
8662    }
8663
8664    /// The cross-kind guard: `AskUserQuestion` reuses the can_use_tool
8665    /// wire, so a `RespondPermission` can arrive with a tool id whose
8666    /// slot is a Question. The mismatched outcome must be dropped and
8667    /// the REAL waiter (the question's oneshot) preserved.
8668    #[tokio::test]
8669    async fn respond_permission_against_a_question_slot_is_dropped() {
8670        let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8671        let (handle, mut agent_rx) = Agent::testing_stub();
8672        let handle = Arc::new(handle);
8673        let key = SessionSlot::from_str_for_test("xkind");
8674        let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
8675        let update_tx = UpdateFanout::default();
8676        let _update_rx = update_tx.subscribe(SubscriberRole::Answering);
8677        let mut task = SessionTask {
8678            key: key.clone(),
8679            handle: Arc::clone(&handle),
8680            command_rx,
8681            domain: Arc::new(Mutex::new(DomainSession::new(
8682                key.clone(),
8683                Some(Arc::clone(&handle)),
8684            ))),
8685            update_tx,
8686            connected_once: false,
8687            workspace: Arc::downgrade(&workspace),
8688            compaction_count: 0,
8689            successor: None,
8690            teardown_done: None,
8691        };
8692
8693        task.translate_event(AgentEvent::QuestionRequest {
8694            session_id: key.display(),
8695            request: question_request_fixture("tu-q1"),
8696        });
8697        assert_eq!(
8698            workspace.pending_request_seq(&key),
8699            1,
8700            "the question has its own identity on the wire",
8701        );
8702
8703        task.execute_command(Command::RespondPermission {
8704            key: key.clone(),
8705            tool_id: "tu-q1".to_owned(),
8706            outcome: forge_primitives::PermissionOutcome::Cancelled,
8707        });
8708
8709        assert!(
8710            task.domain.lock().pending_interactions.contains_key("tu-q1"),
8711            "the question slot survives the mismatched permission response"
8712        );
8713        assert!(agent_rx.try_recv().is_err(), "nothing forwards to the agent on a kind mismatch");
8714    }
8715
8716    /// The round rides the resolution: a batch reuses one tool id and
8717    /// advances the question index, and a view's clear needs the pair to
8718    /// dequeue a batch's rounds one at a time (the loss itself is the
8719    /// record's single ask slot - #1717's queue, its own piece).
8720    ///
8721    /// **This pins the ANSWER path** - one of the sites that emits the
8722    /// frame; the identity drain and the orphan arms carry the same field,
8723    /// and the compiler holds every site to naming it.
8724    #[tokio::test]
8725    async fn an_answered_questions_resolution_names_its_round() {
8726        let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8727        let (handle, _agent_rx) = Agent::testing_stub();
8728        let handle = Arc::new(handle);
8729        let key = SessionSlot::from_str_for_test("ask-round");
8730        let (_cmd_tx, command_rx) = mpsc::unbounded_channel();
8731        let update_tx = UpdateFanout::default();
8732        let mut observer = update_tx.subscribe(SubscriberRole::Answering);
8733        let mut task = SessionTask {
8734            key: key.clone(),
8735            handle: Arc::clone(&handle),
8736            command_rx,
8737            domain: Arc::new(Mutex::new(DomainSession::new(
8738                key.clone(),
8739                Some(Arc::clone(&handle)),
8740            ))),
8741            update_tx,
8742            connected_once: false,
8743            workspace: Arc::downgrade(&workspace),
8744            compaction_count: 0,
8745            successor: None,
8746            teardown_done: None,
8747        };
8748
8749        let mut request = question_request_fixture("tu-round");
8750        request.question_index = 3;
8751        task.translate_event(AgentEvent::QuestionRequest { session_id: key.display(), request });
8752
8753        task.execute_command(Command::RespondQuestion {
8754            key: key.clone(),
8755            tool_id: "tu-round".to_owned(),
8756            outcome: forge_primitives::QuestionOutcome::Cancelled,
8757        });
8758
8759        let mut resolved = None;
8760        while let Ok(frame) = observer.try_recv() {
8761            if let SessionUpdate::PendingInteractionResolved { question_index, .. } =
8762                std::sync::Arc::unwrap_or_clone(frame.update)
8763            {
8764                resolved = Some(question_index);
8765            }
8766        }
8767        assert_eq!(resolved, Some(Some(3)), "the resolution names the round it ends");
8768    }
8769
8770    fn permission_request_fixture(tool_id: &str) -> forge_primitives::PermissionRequest {
8771        forge_primitives::PermissionRequest {
8772            tool_call: forge_primitives::ToolCall {
8773                tool_call_id: tool_id.to_owned(),
8774                title: "Read".to_owned(),
8775                kind: forge_primitives::ToolKind::Read,
8776                status: forge_primitives::ToolCallStatus::Pending,
8777                content: Vec::new(),
8778                raw_input: None,
8779                raw_output: None,
8780                output_metadata: None,
8781                task_metadata: None,
8782                locations: Vec::new(),
8783                meta: None,
8784            },
8785            options: Vec::new(),
8786            display: None,
8787        }
8788    }
8789
8790    fn question_request_fixture(tool_id: &str) -> forge_primitives::QuestionRequest {
8791        forge_primitives::QuestionRequest {
8792            tool_call: forge_primitives::ToolCall {
8793                tool_call_id: tool_id.to_owned(),
8794                title: "AskUserQuestion".to_owned(),
8795                kind: forge_primitives::ToolKind::Other,
8796                status: forge_primitives::ToolCallStatus::Pending,
8797                content: Vec::new(),
8798                raw_input: None,
8799                raw_output: None,
8800                output_metadata: None,
8801                task_metadata: None,
8802                locations: Vec::new(),
8803                meta: None,
8804            },
8805            prompt: forge_primitives::QuestionPrompt {
8806                question: "Which?".to_owned(),
8807                header: "Pick".to_owned(),
8808                multi_select: false,
8809                options: Vec::new(),
8810            },
8811            question_index: 0,
8812            total_questions: 1,
8813        }
8814    }
8815
8816    /// The typed dispatch-failure events map onto their session-keyed
8817    /// envelopes: `SetModelFailed` (the /model rollback trigger) and
8818    /// `TurnError` (the committed-turn unwind).
8819    #[tokio::test]
8820    async fn set_model_failed_and_turn_error_map_to_keyed_updates() {
8821        let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8822        let (mut task, mut update_rx) =
8823            review_task_for(&workspace, &SessionSlot::from_str_for_test("m"));
8824
8825        task.translate_event(AgentEvent::SetModelFailed {
8826            session_id: "m".to_owned(),
8827            model: "claude-attempted".to_owned(),
8828            message: "not available".to_owned(),
8829        });
8830        assert!(
8831            matches!(
8832                update_rx.try_recv(),
8833                Ok(frame)
8834                    if matches!(&*frame.update, SessionUpdate::SetModelFailed { model, message, .. } if model == "claude-attempted" && message == "not available")
8835            ),
8836            "SetModelFailed keeps model + message for the rollback reducer"
8837        );
8838
8839        task.translate_event(AgentEvent::TurnError {
8840            session_id: "m".to_owned(),
8841            message: "stdin write failed".to_owned(),
8842            class: forge_primitives::TurnErrorClass::Other,
8843        });
8844        assert!(
8845            matches!(
8846                update_rx.try_recv(),
8847                Ok(frame)
8848                    if matches!(&*frame.update, SessionUpdate::TurnError { message, .. } if message == "stdin write failed")
8849            ),
8850            "TurnError carries the failure text so the spinner unwinds"
8851        );
8852    }
8853
8854    /// The class an event was built with rides the update stream, so a
8855    /// view that reads only `SessionUpdate` can tell an auth failure from
8856    /// any other error instead of searching the message itself.
8857    #[tokio::test]
8858    async fn turn_error_carries_its_class_onto_the_update_stream() {
8859        let (_dir, workspace) = workspace_with_account_config_dir("/tmp/forge-testing-stub");
8860        let (mut task, mut update_rx) =
8861            review_task_for(&workspace, &SessionSlot::from_str_for_test("m"));
8862
8863        for class in [
8864            forge_primitives::TurnErrorClass::AuthRequired,
8865            forge_primitives::TurnErrorClass::Other,
8866        ] {
8867            task.translate_event(AgentEvent::TurnError {
8868                session_id: "m".to_owned(),
8869                message: "interrupt not acknowledged by the CLI".to_owned(),
8870                class,
8871            });
8872            assert!(
8873                matches!(
8874                    update_rx.try_recv(),
8875                    Ok(frame)
8876                        if matches!(&*frame.update, SessionUpdate::TurnError { class: Some(on_wire), .. } if *on_wire == class)
8877                ),
8878                "the class has to reach the stream as {class:?}, not as None a reader must guess past",
8879            );
8880        }
8881    }
8882
8883    /// The teardown drain must not double-notify a turn that already
8884    /// ended cleanly - every site shares one idempotent drain.
8885    #[tokio::test]
8886    async fn teardown_after_a_normal_turn_end_emits_nothing_further() {
8887        let (_dir, workspace, _reviewer, worker) = workspace_with_pending_review_activity();
8888        let (mut task, mut update_rx) = review_task_for(&workspace, &worker);
8889
8890        task.translate_event(AgentEvent::SdkMessage {
8891            session_id: "worker".to_owned(),
8892            msg: result_message("success", false),
8893        });
8894        assert!(drained_notice(&mut update_rx).is_some(), "the turn's own notice fired");
8895
8896        drop(task);
8897
8898        assert!(drained_notice(&mut update_rx).is_none(), "teardown adds no second notice");
8899    }
8900
8901    /// The store follows the id the CLI reports when it connects. A
8902    /// `/resume`, a `/clear`, a login or a logout move a session's id
8903    /// without forge choosing it, and a boot resolves a session from this
8904    /// row - so the row has to move with every Connected, not only the
8905    /// first.
8906    #[test]
8907    fn connected_records_the_id_the_cli_adopted() {
8908        let dir = tempfile::tempdir().expect("tempdir");
8909        let root = dir.path().join("solo");
8910        std::fs::create_dir_all(&root).expect("root");
8911        let forge_dir = crate::config::ensure_forge_data_dir(dir.path()).expect("forge dir");
8912        std::fs::write(
8913            forge_dir.join("forge.toml"),
8914            format!(
8915                r#"
8916[[orgs]]
8917name = "TestOrg"
8918accounts = ["Stargate"]
8919[[orgs.projects]]
8920name = "forge"
8921path = "{root}"
8922[[accounts]]
8923display_name = "Stargate"
8924token = "t"
8925models = ["claude-sonnet-5"]
8926provider = "anthropic"
8927"#,
8928                root = root.display()
8929            ),
8930        )
8931        .expect("write forge.toml");
8932        let workspace =
8933            Arc::new(crate::Workspace::new_for_test(dir.path().to_owned()).expect("workspace"));
8934        let key = SessionSlot::lead("TestOrg", "forge");
8935        workspace.seed_test_bound_session(&key, "Stargate");
8936        {
8937            let db = workspace.db.lock();
8938            let db = db.as_ref().expect("db");
8939            crate::store::sessions::put(
8940                db,
8941                &crate::store::sessions::SessionRecord {
8942                    org: "TestOrg".to_owned(),
8943                    project: "forge".to_owned(),
8944                    label: "lead".to_owned(),
8945                    session_id: None,
8946                    charter: None,
8947                    kick: None,
8948                    resume_kick: None,
8949                    interactive: None,
8950                    seat_state: None,
8951                    is_git_repo: None,
8952                    mcp_families: None,
8953                    cwd: None,
8954                },
8955            )
8956            .expect("seed the row");
8957        }
8958
8959        let stored = || {
8960            let db = workspace.db.lock();
8961            let db = db.as_ref().expect("db");
8962            crate::store::sessions::get(db, "TestOrg", "forge", "lead")
8963                .expect("read")
8964                .and_then(|row| row.session_id)
8965        };
8966
8967        let (mut task, _updates) = review_task_for(&workspace, &key);
8968        task.translate_event(connected_event("first-id", "/proj"));
8969        assert_eq!(
8970            stored().as_deref(),
8971            Some("first-id"),
8972            "the first Connected records the id the CLI adopted",
8973        );
8974
8975        // `/resume`, `/clear`, `/login` and `/logout` all arrive as a
8976        // second Connected on this same task, which is the arm that used
8977        // to leave the row naming the session the user left.
8978        task.translate_event(connected_event("second-id", "/proj"));
8979        assert_eq!(
8980            stored().as_deref(),
8981            Some("second-id"),
8982            "so does a replacement, which is the id the next boot has to resume",
8983        );
8984    }
8985
8986    /// `apply_event_to_domain` on `AgentEvent::Connected` stamps (or
8987    /// overwrites) `session_id` so subsequent `AgentHandle` calls
8988    /// route to the live identity. See
8989    /// `translate_second_connected_overwrites_session_id` for the
8990    /// `/new`-flow overwrite case.
8991    #[test]
8992    fn translate_connected_stamps_session_id() {
8993        let mut domain = empty_domain();
8994        assert!(domain.session_id.is_none());
8995
8996        apply_event_to_domain(
8997            &mut domain,
8998            &AgentEvent::Connected {
8999                session_id: "real-uuid-1".to_owned(),
9000                cwd: "/proj".to_owned(),
9001                current_model: forge_primitives::CurrentModel {
9002                    resolved_id: "claude".to_owned(),
9003                    display_name_short: "claude".to_owned(),
9004                    display_name_long: "claude".to_owned(),
9005                    requested_id: None,
9006                    catalog_id: None,
9007                    supports_effort: false,
9008                    supported_effort_levels: Vec::new(),
9009                    supports_auto_mode: None,
9010                    supports_adaptive_thinking: None,
9011                    is_authoritative: true,
9012                },
9013                available_models: Vec::new(),
9014                mode: None,
9015                history_updates: None,
9016                compaction_count: 0,
9017            },
9018        );
9019
9020        assert_eq!(
9021            domain.session_id.as_ref().map(std::string::ToString::to_string),
9022            Some("real-uuid-1".to_owned())
9023        );
9024    }
9025
9026    /// **The whole chain, not just its two ends.**
9027    ///
9028    /// `liveness_of` and `note_liveness` are each pinned, but nothing pinned the
9029    /// FOLD that joins them - so an edit that called `liveness_of` and dropped
9030    /// its answer would leave every test green while the header went back to
9031    /// reading idle through a turn's opening, which is the 41-second window the
9032    /// mirror exists to close. This drives a real frame through the real fold.
9033    #[test]
9034    fn a_thinking_frame_on_the_wire_opens_the_turn() {
9035        let mut domain = empty_domain();
9036        assert!(!domain.turn_in_flight(), "precondition: nothing is running");
9037
9038        apply_event_to_domain(
9039            &mut domain,
9040            &AgentEvent::SdkMessage {
9041                session_id: "s".to_owned(),
9042                msg: forge_primitives::Message::ThinkingTokens {
9043                    estimated_tokens: 40,
9044                    estimated_tokens_delta: 40,
9045                    uuid: "tokens-1".to_owned(),
9046                    session_id: "s".to_owned(),
9047                    extras: serde_json::Map::new(),
9048                },
9049            },
9050        );
9051
9052        assert!(
9053            domain.turn_in_flight(),
9054            "the frame did not reach the liveness mirror, so the header reads idle",
9055        );
9056    }
9057
9058    /// `apply_event_to_domain` on `AgentEvent::ConnectionFailed`
9059    /// clears the runtime/turn mirrors: the subprocess is gone, so the
9060    /// in-flight guards must not read a stale "turn in flight".
9061    #[test]
9062    fn connection_failed_clears_domain_turn_state() {
9063        let mut domain = empty_domain();
9064        domain.runtime_state = Some(forge_primitives::RuntimeSessionState::Running);
9065        domain.turn_pending = true;
9066
9067        apply_event_to_domain(
9068            &mut domain,
9069            &AgentEvent::ConnectionFailed {
9070                message: "reader died".to_owned(),
9071                kind: SpawnFailureKind::Unclassified,
9072            },
9073        );
9074
9075        assert_eq!(domain.runtime_state, None, "runtime_state cleared on ConnectionFailed");
9076        assert!(!domain.turn_pending, "turn_pending cleared on ConnectionFailed");
9077    }
9078
9079    /// The monitor sets these updates announced, in the order they went out.
9080    fn announced_monitors(
9081        updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9082    ) -> Vec<Vec<forge_primitives::MonitorRecord>> {
9083        let mut announced = Vec::new();
9084        while let Ok(frame) = updates.try_recv() {
9085            if let SessionUpdate::MonitorsChanged { monitors, .. } =
9086                std::sync::Arc::unwrap_or_clone(frame.update)
9087            {
9088                announced.push(monitors);
9089            }
9090        }
9091        announced
9092    }
9093
9094    /// The task sets these updates announced, in the order they went out.
9095    fn announced_tasks(
9096        updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9097    ) -> Vec<Vec<forge_workspace::BackgroundTask>> {
9098        let mut announced = Vec::new();
9099        while let Ok(frame) = updates.try_recv() {
9100            if let SessionUpdate::BackgroundTasksChanged { tasks, .. } =
9101                std::sync::Arc::unwrap_or_clone(frame.update)
9102            {
9103                announced.push(tasks);
9104            }
9105        }
9106        announced
9107    }
9108
9109    /// An assistant frame carrying one tool call, under `parent_tool_use_id`.
9110    fn assistant_tool_use(
9111        tool: &str,
9112        parent_tool_use_id: Option<&str>,
9113    ) -> forge_primitives::Message {
9114        serde_json::from_value(serde_json::json!({
9115            "type": "assistant",
9116            "message": {
9117                "id": "msg-dispatch",
9118                "role": "assistant",
9119                "model": "claude-sonnet-5",
9120                "content": [{
9121                    "type": "tool_use",
9122                    "id": "tu-dispatch",
9123                    "name": tool,
9124                    "input": {"description": "investigate"},
9125                }],
9126            },
9127            "session_id": "s",
9128            "parent_tool_use_id": parent_tool_use_id,
9129        }))
9130        .expect("parse an assistant tool_use")
9131    }
9132
9133    /// An assistant frame carrying a `Task` call, which is a dispatch.
9134    fn dispatch_frame(parent_tool_use_id: Option<&str>) -> forge_primitives::Message {
9135        assistant_tool_use("Task", parent_tool_use_id)
9136    }
9137
9138    /// The card lists a task announced, in order.
9139    fn announced_cards(
9140        updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9141    ) -> Vec<Vec<forge_primitives::runtime::SubagentCard>> {
9142        let mut announced = Vec::new();
9143        while let Ok(frame) = updates.try_recv() {
9144            if let SessionUpdate::SubagentCardsChanged { cards, .. } =
9145                std::sync::Arc::unwrap_or_clone(frame.update)
9146            {
9147                announced.push(cards);
9148            }
9149        }
9150        announced
9151    }
9152
9153    /// **The instance list is pushed as it moves.** The card fold rides the
9154    /// same frame walk that raises the dispatch flag, and the push is
9155    /// change-gated on the list: the dispatch announces a running card, a
9156    /// roster frame that moves nothing announces nothing, and the terminal
9157    /// row announces the settled list.
9158    #[test]
9159    fn a_dispatch_announces_a_card_and_the_roster_settles_it() {
9160        let dir = tempfile::tempdir().expect("tempdir");
9161        let (workspace, _rx) =
9162            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9163        let slot = SessionSlot::lead("TestOrg", "forge");
9164        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9165        let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9166
9167        task.translate_event(sdk_message(dispatch_frame(None)));
9168        let announced = announced_cards(&mut updates);
9169        assert_eq!(announced.len(), 1, "the dispatch announces the list once");
9170        assert_eq!(announced[0].len(), 1, "with one instance");
9171        assert!(announced[0][0].running, "running, because nothing has said it ended");
9172        assert_eq!(announced[0][0].name, "investigate", "named by the dispatch's description");
9173
9174        // The roster opening the task moves no fact the card draws - it was
9175        // already running - so the list is equal and nothing is announced.
9176        task.translate_event(sdk_message(forge_primitives::Message::TaskStarted {
9177            task_id: "t-a".to_owned(),
9178            description: "investigate".to_owned(),
9179            uuid: "u-start".to_owned(),
9180            session_id: "s".to_owned(),
9181            tool_use_id: Some("tu-dispatch".to_owned()),
9182            task_type: Some("local_agent".to_owned()),
9183            extras: serde_json::Map::new(),
9184        }));
9185        assert!(
9186            announced_cards(&mut updates).is_empty(),
9187            "a frame that moves no fact on the list announces nothing",
9188        );
9189
9190        // The terminal row settles the card, and that IS news.
9191        task.translate_event(sdk_message(forge_primitives::Message::TaskUpdated {
9192            task_id: "t-a".to_owned(),
9193            patch: forge_primitives::messages::TaskUpdatePatch {
9194                status: Some("completed".to_owned()),
9195                end_time: Some(1_700_000_000_123),
9196                extras: serde_json::Map::new(),
9197            },
9198            uuid: "u-upd".to_owned(),
9199            session_id: "s".to_owned(),
9200            extras: serde_json::Map::new(),
9201        }));
9202        let announced = announced_cards(&mut updates);
9203        assert_eq!(announced.len(), 1, "the ending announces the list once");
9204        assert!(!announced[0][0].running, "and the card is settled");
9205        assert_eq!(announced[0][0].ended_at_ms, Some(1_700_000_000_123));
9206    }
9207
9208    /// **A sub-agent's refusal names the dispatch it belongs under.**
9209    ///
9210    /// The CLI's `permission_denied` frame is session-scoped: it names the
9211    /// refused call and the sub-agent that made it (`agent_id`, which is the
9212    /// `task_id` the roster opened the dispatch with) and carries no
9213    /// `parent_tool_use_id` at all - so the chat drew a sub-agent's refusal as
9214    /// a row the session itself had made. The transcript keeps no such row, so
9215    /// a resume draws the refusal from the sub-agent's own sidecar instead,
9216    /// where it survives as the call's errored result.
9217    #[test]
9218    fn a_subagents_refusal_names_the_dispatch_it_belongs_under() {
9219        let dir = tempfile::tempdir().expect("tempdir");
9220        let (workspace, _rx) =
9221            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9222        let slot = SessionSlot::lead("TestOrg", "forge");
9223        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9224        let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9225
9226        task.translate_event(sdk_message(agent_call("call-dispatch", None)));
9227        task.translate_event(sdk_message(forge_primitives::Message::TaskStarted {
9228            task_id: "agent-1".to_owned(),
9229            description: "review the fold".to_owned(),
9230            uuid: "u-start".to_owned(),
9231            session_id: "s".to_owned(),
9232            tool_use_id: Some("call-dispatch".to_owned()),
9233            task_type: Some("local_agent".to_owned()),
9234            extras: serde_json::Map::new(),
9235        }));
9236        task.translate_event(sdk_message(refusal(Some("agent-1"))));
9237
9238        let drawn = drawn_refusals(&mut updates);
9239        assert_eq!(drawn.len(), 1, "the refusal is drawn: {drawn:?}");
9240        assert_eq!(
9241            drawn[0].get("parent_tool_use_id").and_then(serde_json::Value::as_str),
9242            Some("call-dispatch"),
9243            "under the Task call whose instance it refused",
9244        );
9245
9246        // **A nested dispatch has no card of its own.** Its `Task` call is a
9247        // child line of the instance that fired it, so a refusal the nested
9248        // instance made belongs on that outer row: stamped with the child id
9249        // it would draw nowhere (measured shape: `spawn_depth: 2`, an `Agent`
9250        // call under the outer dispatch).
9251        task.translate_event(sdk_message(agent_call("call-inner", Some("call-dispatch"))));
9252        task.translate_event(sdk_message(forge_primitives::Message::TaskStarted {
9253            task_id: "agent-2".to_owned(),
9254            description: "inner".to_owned(),
9255            uuid: "u-start-2".to_owned(),
9256            session_id: "s".to_owned(),
9257            tool_use_id: Some("call-inner".to_owned()),
9258            task_type: Some("local_agent".to_owned()),
9259            extras: {
9260                let mut extras = serde_json::Map::new();
9261                extras.insert("spawn_depth".to_owned(), serde_json::Value::from(2));
9262                extras
9263            },
9264        }));
9265        task.translate_event(sdk_message(refusal(Some("agent-2"))));
9266
9267        let drawn = drawn_refusals(&mut updates);
9268        assert_eq!(drawn.len(), 1, "the nested refusal is drawn: {drawn:?}");
9269        assert_eq!(
9270            drawn[0].get("parent_tool_use_id").and_then(serde_json::Value::as_str),
9271            Some("call-dispatch"),
9272            "on the owning instance's row, not the cardless dispatch it ran under",
9273        );
9274
9275        // A task the roster never linked leaves the refusal where it was:
9276        // over the session, which is a row rather than the silence a stamp
9277        // naming no card would give it (rule 25).
9278        task.translate_event(sdk_message(refusal(Some("agent-unknown"))));
9279        let drawn = drawn_refusals(&mut updates);
9280        assert_eq!(drawn.len(), 1, "an unplaceable refusal is drawn too");
9281        assert_eq!(
9282            drawn[0].get("parent_tool_use_id"),
9283            None,
9284            "and an unlinked task's refusal stays the session's own row",
9285        );
9286
9287        // A task linked to a call that opened no row at either level is
9288        // unplaceable too: a stamp naming that call would draw it nowhere.
9289        task.translate_event(sdk_message(forge_primitives::Message::TaskStarted {
9290            task_id: "agent-orphan".to_owned(),
9291            description: "orphan".to_owned(),
9292            uuid: "u-start-orphan".to_owned(),
9293            session_id: "s".to_owned(),
9294            tool_use_id: Some("call-orphan".to_owned()),
9295            task_type: Some("local_agent".to_owned()),
9296            extras: serde_json::Map::new(),
9297        }));
9298        task.translate_event(sdk_message(refusal(Some("agent-orphan"))));
9299        let drawn = drawn_refusals(&mut updates);
9300        assert_eq!(drawn.len(), 1, "an orphaned task's refusal is drawn too");
9301        assert_eq!(
9302            drawn[0].get("parent_tool_use_id"),
9303            None,
9304            "and it stays the session's own row: no row owns that call",
9305        );
9306
9307        task.translate_event(sdk_message(refusal(None)));
9308        let drawn = drawn_refusals(&mut updates);
9309        assert_eq!(drawn.len(), 1, "a session's own refusal is drawn too");
9310        assert_eq!(
9311            drawn[0].get("parent_tool_use_id"),
9312            None,
9313            "and the session's own refusal names no dispatch",
9314        );
9315    }
9316
9317    /// **A frame from an agent three levels down names the card that owns
9318    /// it.** The wire parents such a frame to the `Agent` call its own parent
9319    /// fired; that call is a child line one level below the card, and a level
9320    /// further the frame names that child line rather than a card - so the
9321    /// frame drew nowhere and the refusal drew at the session's level. The
9322    /// tracker's parent links walk the chain back to the top-level dispatch on
9323    /// every frame, which is the expansion that draws it.
9324    #[test]
9325    fn a_third_level_agents_frames_name_the_card_that_owns_them() {
9326        let dir = tempfile::tempdir().expect("tempdir");
9327        let (workspace, _rx) =
9328            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9329        let slot = SessionSlot::lead("TestOrg", "forge");
9330        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9331        let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9332
9333        // The session's dispatch, the dispatch the first-level agent made
9334        // under it, and the one the second-level agent made under that.
9335        task.translate_event(sdk_message(agent_call("call-dispatch", None)));
9336        task.translate_event(sdk_message(task_started("agent-1", Some("call-dispatch"))));
9337        task.translate_event(sdk_message(agent_call("call-inner", Some("call-dispatch"))));
9338        task.translate_event(sdk_message(task_started("agent-2", Some("call-inner"))));
9339        task.translate_event(sdk_message(agent_call("call-deeper", Some("call-inner"))));
9340        task.translate_event(sdk_message(task_started("agent-3", Some("call-deeper"))));
9341        let _ = drain_updates(&mut updates);
9342
9343        task.translate_event(sdk_message(agent_said(Some("call-deeper"))));
9344        assert_eq!(
9345            drawn_frame_parents(&mut updates),
9346            [Some("call-dispatch".to_owned())],
9347            "the third-level agent's frame names the card that owns its dispatch",
9348        );
9349
9350        task.translate_event(sdk_message(user_result(Some("call-deeper"))));
9351        task.translate_event(sdk_message(hook_summary(Some("call-deeper"))));
9352        assert_eq!(
9353            drawn_frame_parents(&mut updates),
9354            [Some("call-dispatch".to_owned()), Some("call-dispatch".to_owned())],
9355            "its tool result and hook summary name the same card",
9356        );
9357
9358        task.translate_event(sdk_message(refusal(Some("agent-3"))));
9359        let drawn = drawn_refusals(&mut updates);
9360        assert_eq!(drawn.len(), 1, "the third-level refusal is drawn: {drawn:?}");
9361        assert_eq!(
9362            drawn[0].get("parent_tool_use_id").and_then(serde_json::Value::as_str),
9363            Some("call-dispatch"),
9364            "and it names the card, not the dispatch two levels down",
9365        );
9366    }
9367
9368    /// An assistant frame the agent named by `parent` produced.
9369    fn agent_said(parent: Option<&str>) -> forge_primitives::Message {
9370        serde_json::from_value(serde_json::json!({
9371            "type": "assistant",
9372            "message": {
9373                "id": "msg-said",
9374                "role": "assistant",
9375                "model": "claude-sonnet-5",
9376                "content": [{"type": "text", "text": "working"}],
9377            },
9378            "session_id": "s",
9379            "parent_tool_use_id": parent,
9380        }))
9381        .expect("an assistant frame")
9382    }
9383
9384    /// A tool result the agent named by `parent` produced.
9385    fn user_result(parent: Option<&str>) -> forge_primitives::Message {
9386        serde_json::from_value(serde_json::json!({
9387            "type": "user",
9388            "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call", "content": "done"}]},
9389            "session_id": "s",
9390            "parent_tool_use_id": parent,
9391        }))
9392        .expect("a user frame")
9393    }
9394
9395    /// A hook summary the agent named by `parent` fired.
9396    fn hook_summary(parent: Option<&str>) -> forge_primitives::Message {
9397        forge_primitives::Message::StopHookSummary {
9398            actions: 1,
9399            hook_infos: Vec::new(),
9400            hook_errors: Vec::new(),
9401            has_output: false,
9402            level: "suggestion".to_owned(),
9403            prevented_continuation: false,
9404            stop_reason: String::new(),
9405            tool_use_id: String::new(),
9406            parent_tool_use_id: parent.map(str::to_owned),
9407            session_id: "s".to_owned(),
9408            uuid: "u-hook".to_owned(),
9409            extras: serde_json::Map::new(),
9410        }
9411    }
9412
9413    /// The parent each frame the task drew carries.
9414    fn drawn_frame_parents(
9415        updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9416    ) -> Vec<Option<String>> {
9417        drain_updates(updates)
9418            .into_iter()
9419            .filter_map(|update| match update {
9420                SessionUpdate::ChatAppended {
9421                    msg:
9422                        forge_primitives::Message::Assistant { parent_tool_use_id, .. }
9423                        | forge_primitives::Message::User { parent_tool_use_id, .. }
9424                        | forge_primitives::Message::StopHookSummary { parent_tool_use_id, .. },
9425                    ..
9426                } => Some(parent_tool_use_id),
9427                _ => None,
9428            })
9429            .collect()
9430    }
9431
9432    /// An `Agent` call: a top-level dispatch opens a card, and one under
9433    /// `parent` is a child line of the instance that fired it.
9434    fn agent_call(call: &str, parent: Option<&str>) -> forge_primitives::Message {
9435        serde_json::from_value(serde_json::json!({
9436            "type": "assistant",
9437            "message": {
9438                "id": "msg-call",
9439                "role": "assistant",
9440                "model": "claude-sonnet-5",
9441                "content": [{
9442                    "type": "tool_use",
9443                    "id": call,
9444                    "name": "Agent",
9445                    "input": {"description": "dispatch"},
9446                }],
9447            },
9448            "session_id": "s",
9449            "parent_tool_use_id": parent,
9450        }))
9451        .expect("an agent call")
9452    }
9453
9454    /// A `permission_denied` frame as the CLI sends it: the refused call, the
9455    /// agent that made it when a sub-agent did, and never a dispatch.
9456    fn refusal(agent: Option<&str>) -> forge_primitives::Message {
9457        let mut frame = serde_json::json!({
9458            "type": "system",
9459            "subtype": "permission_denied",
9460            "tool_name": "Bash",
9461            "tool_use_id": "call-refused",
9462            "decision_reason_type": "subcommandResults",
9463            "message": "Permission to use Bash with command rm -rf /tmp/x has been denied.",
9464            "uuid": "u-denied",
9465            "session_id": "s",
9466        });
9467        if let Some(agent) = agent {
9468            frame["agent_id"] = serde_json::Value::String(agent.to_owned());
9469        }
9470        serde_json::from_value(frame).expect("a permission_denied frame")
9471    }
9472
9473    /// The refusals the task drew, as the wire spells them.
9474    fn drawn_refusals(updates: &mut mpsc::UnboundedReceiver<SeatUpdate>) -> Vec<serde_json::Value> {
9475        drain_updates(updates)
9476            .into_iter()
9477            .filter_map(|update| match update {
9478                SessionUpdate::ChatAppended { msg, .. } => Some(msg),
9479                _ => None,
9480            })
9481            .filter(|msg| matches!(msg, forge_primitives::Message::PermissionDenied { .. }))
9482            .map(|msg| serde_json::to_value(&msg).expect("encode a refusal"))
9483            .collect()
9484    }
9485
9486    /// **A connect seeds the list from its history and announces nothing**,
9487    /// the same rule the dispatch flag follows: the read on that event
9488    /// answers it, and a fresh `/new` carries none.
9489    #[test]
9490    fn a_connect_seeds_the_cards_without_announcing() {
9491        let dir = tempfile::tempdir().expect("tempdir");
9492        let (workspace, _rx) =
9493            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9494        let slot = SessionSlot::lead("TestOrg", "forge");
9495        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9496        let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9497
9498        task.translate_event(AgentEvent::Connected {
9499            session_id: "resumed-uuid".to_owned(),
9500            cwd: "/proj".to_owned(),
9501            current_model: forge_primitives::CurrentModel::new("claude-opus-5", "Opus", "Claude"),
9502            available_models: Vec::new(),
9503            mode: None,
9504            history_updates: Some(vec![dispatch_frame(None)]),
9505            compaction_count: 0,
9506        });
9507
9508        assert_eq!(
9509            task.domain.lock().cards_snapshot.len(),
9510            1,
9511            "a history that dispatched seeds one card",
9512        );
9513        assert!(
9514            announced_cards(&mut updates).is_empty(),
9515            "and the seed is not announced: the read on this event answers it",
9516        );
9517
9518        task.translate_event(AgentEvent::Connected {
9519            session_id: "new-uuid".to_owned(),
9520            cwd: "/proj".to_owned(),
9521            current_model: forge_primitives::CurrentModel::new("claude-opus-5", "Opus", "Claude"),
9522            available_models: Vec::new(),
9523            mode: None,
9524            history_updates: None,
9525            compaction_count: 0,
9526        });
9527
9528        assert!(task.domain.lock().cards_snapshot.is_empty(), "a fresh /new leaves none");
9529        assert!(announced_cards(&mut updates).is_empty(), "and the clear is not announced");
9530    }
9531
9532    /// The command catalogues these updates announced, in the order they went out.
9533    fn announced_commands(
9534        updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9535    ) -> Vec<Vec<forge_primitives::runtime::AvailableCommand>> {
9536        let mut announced = Vec::new();
9537        while let Ok(frame) = updates.try_recv() {
9538            if let SessionUpdate::SlashCommandsChanged { commands, .. } =
9539                std::sync::Arc::unwrap_or_clone(frame.update)
9540            {
9541                announced.push(commands);
9542            }
9543        }
9544        announced
9545    }
9546
9547    /// The agent catalogues these updates announced, in the order they went out.
9548    fn announced_agents(
9549        updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9550    ) -> Vec<Vec<forge_primitives::runtime::AvailableAgent>> {
9551        let mut announced = Vec::new();
9552        while let Ok(frame) = updates.try_recv() {
9553            if let SessionUpdate::SubagentsChanged { subagents, .. } =
9554                std::sync::Arc::unwrap_or_clone(frame.update)
9555            {
9556                announced.push(subagents);
9557            }
9558        }
9559        announced
9560    }
9561
9562    /// The `/` menu's catalogue moves on the frames that can move it - a turn's
9563    /// init and a plugin reload's `commands_changed` - and a frame that leaves
9564    /// it as it was says nothing.
9565    ///
9566    /// **The no-move half is what a turn's init makes necessary.** The CLI
9567    /// re-fires init every turn, so the case the composer would pay for most
9568    /// often is the same list again.
9569    #[test]
9570    fn a_command_catalogue_that_moved_is_announced() {
9571        let dir = tempfile::tempdir().expect("tempdir");
9572        let (workspace, _rx) =
9573            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9574        let slot = SessionSlot::lead("TestOrg", "forge");
9575        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9576        let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9577
9578        task.translate_event(sdk_message(init_frame(&["/help"], &[])));
9579        let announced = announced_commands(&mut updates);
9580        assert_eq!(announced.len(), 1, "the init that fills the catalogue announces it");
9581        assert_eq!(
9582            announced[0],
9583            task.domain.lock().available_commands,
9584            "and what it announces is what the core holds",
9585        );
9586
9587        task.translate_event(sdk_message(result_message("success", false)));
9588        task.translate_event(sdk_message(init_frame(&["/help"], &[])));
9589        assert_eq!(
9590            announced_commands(&mut updates).len(),
9591            0,
9592            "a turn's init repeating the same list is not a move, so nothing is announced",
9593        );
9594
9595        task.translate_event(sdk_message(forge_primitives::Message::CommandsChanged {
9596            commands: vec![serde_json::json!({"name": "/reload", "description": "Reloaded"})],
9597            uuid: "cmd-uuid".to_owned(),
9598            session_id: "s".to_owned(),
9599            extras: serde_json::Map::new(),
9600        }));
9601        let reloaded = announced_commands(&mut updates);
9602        assert_eq!(reloaded.len(), 1, "the reload's list is a move");
9603        assert_eq!(reloaded[0], task.domain.lock().available_commands);
9604    }
9605
9606    /// The agent catalogue moves on the one frame that carries it, and an init
9607    /// that advertises what the last turn did says nothing.
9608    #[test]
9609    fn an_agent_catalogue_that_moved_is_announced() {
9610        let dir = tempfile::tempdir().expect("tempdir");
9611        let (workspace, _rx) =
9612            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9613        let slot = SessionSlot::lead("TestOrg", "forge");
9614        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9615        let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9616
9617        task.translate_event(sdk_message(init_frame(&[], &["reviewer"])));
9618        let announced = announced_agents(&mut updates);
9619        assert_eq!(announced.len(), 1, "the init that fills the catalogue announces it");
9620        assert_eq!(
9621            announced[0],
9622            task.domain.lock().available_agents,
9623            "and what it announces is what the core holds",
9624        );
9625
9626        task.translate_event(sdk_message(result_message("success", false)));
9627        task.translate_event(sdk_message(init_frame(&[], &["reviewer"])));
9628        assert_eq!(
9629            announced_agents(&mut updates).len(),
9630            0,
9631            "an init repeating the same catalogue is not a move, so nothing is announced",
9632        );
9633
9634        task.translate_event(sdk_message(result_message("success", false)));
9635        task.translate_event(sdk_message(init_frame(&[], &["reviewer", "researcher"])));
9636        let grown = announced_agents(&mut updates);
9637        assert_eq!(grown.len(), 1, "an agent the CLI started advertising is a move");
9638        assert_eq!(grown[0], task.domain.lock().available_agents);
9639    }
9640
9641    /// The monitor set moves on discrete task frames, so the frame that moved
9642    /// it announces the whole set and a frame that moved nothing says nothing.
9643    ///
9644    /// **The no-move half is the one worth having.** Every frame here reaches
9645    /// the fold, and a stamp that repeats an id already stamped must not
9646    /// redraw every viewer of the seat.
9647    #[test]
9648    fn a_monitor_set_that_moved_is_announced() {
9649        let dir = tempfile::tempdir().expect("tempdir");
9650        let (workspace, _rx) =
9651            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9652        let slot = SessionSlot::lead("TestOrg", "forge");
9653        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9654        let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9655
9656        // The call that starts a watch enters the set.
9657        task.translate_event(sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
9658        let announced = announced_monitors(&mut updates);
9659        assert_eq!(announced.len(), 1, "the call that starts a watch announces the set");
9660        assert_eq!(
9661            announced[0],
9662            task.domain.lock().monitors,
9663            "and what it announces is what the core holds",
9664        );
9665
9666        // The task id the CLI stamps is a move; the same stamp again is not.
9667        task.translate_event(sdk_message(task_started("t-mon", Some("tu-mon"))));
9668        assert_eq!(
9669            announced_monitors(&mut updates).len(),
9670            1,
9671            "stamping the task id moves the set, so it is announced",
9672        );
9673        task.translate_event(sdk_message(task_started("t-mon", Some("tu-mon"))));
9674        assert_eq!(
9675            announced_monitors(&mut updates).len(),
9676            0,
9677            "and a frame that moves nothing says nothing",
9678        );
9679
9680        // A STATUS-ONLY change is a move too, and it is the one the shared
9681        // comparison has to catch on its own: the record's id is untouched,
9682        // so nothing but the status separates a settle from a repeat.
9683        task.translate_event(sdk_message(task_updated("t-mon", "completed")));
9684        let settled = announced_monitors(&mut updates);
9685        assert_eq!(settled.len(), 1, "settling a monitor announces the settled set");
9686        assert_eq!(
9687            settled[0][0].status,
9688            forge_primitives::MonitorStatus::Completed,
9689            "with the status the frame gave it",
9690        );
9691        task.translate_event(sdk_message(task_updated("t-mon", "completed")));
9692        assert_eq!(
9693            announced_monitors(&mut updates).len(),
9694            0,
9695            "and the same settle again, on a record already settled, says nothing",
9696        );
9697
9698        // The notification is the last frame a monitor sends, and the drain
9699        // that empties the set is a move like any other.
9700        task.translate_event(sdk_message(task_notification("t-mon")));
9701        let drained = announced_monitors(&mut updates);
9702        assert_eq!(drained.len(), 1, "the drain announces");
9703        assert!(drained[0].is_empty(), "with the empty set, which is what the core now holds");
9704    }
9705
9706    /// The background registry is the CLI's whole set on every change, so a
9707    /// frame that moved it announces it and one that did not says nothing.
9708    #[test]
9709    fn a_background_registry_that_moved_is_announced() {
9710        let dir = tempfile::tempdir().expect("tempdir");
9711        let (workspace, _rx) =
9712            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9713        let slot = SessionSlot::lead("TestOrg", "forge");
9714        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9715        let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9716
9717        task.translate_event(sdk_message(background_tasks(one_live_task())));
9718        let announced = announced_tasks(&mut updates);
9719        assert_eq!(announced.len(), 1, "the frame that fills the registry announces it");
9720        assert_eq!(
9721            announced[0],
9722            task.domain.lock().task_rows(),
9723            "and what it announces is what the core holds",
9724        );
9725
9726        task.translate_event(sdk_message(background_tasks(one_live_task())));
9727        assert_eq!(
9728            announced_tasks(&mut updates).len(),
9729            0,
9730            "the same set again is not a move, so nothing is announced",
9731        );
9732
9733        // A connection that died leaves nothing standing: a registry that
9734        // outlived its process spins rows over tasks nobody is running.
9735        task.translate_event(AgentEvent::ConnectionFailed {
9736            message: "reader died".to_owned(),
9737            kind: SpawnFailureKind::Unclassified,
9738        });
9739        let cleared = announced_tasks(&mut updates);
9740        assert_eq!(cleared.len(), 1, "and the clear is announced");
9741        assert!(cleared[0].is_empty(), "with the empty set the core now holds");
9742    }
9743
9744    /// The walks these updates announced, in the order they went out.
9745    fn announced_walks(
9746        updates: &mut mpsc::UnboundedReceiver<SeatUpdate>,
9747    ) -> Vec<forge_agent::env::processes::ProcessSnapshot> {
9748        let mut announced = Vec::new();
9749        while let Ok(frame) = updates.try_recv() {
9750            if let SessionUpdate::ProcessesChanged { snapshot, .. } =
9751                std::sync::Arc::unwrap_or_clone(frame.update)
9752            {
9753                announced.push(snapshot);
9754            }
9755        }
9756        announced
9757    }
9758
9759    /// **A walk that outlives its process draws a tree that is not there.** The
9760    /// walk describes the subprocess's descendants, and nothing follows a dead
9761    /// session that would replace it - so the clear is announced on the death
9762    /// frame like the registry's, rather than left for the next reader to
9763    /// notice and the next session to overwrite.
9764    #[test]
9765    fn a_dead_sessions_walk_is_cleared_and_announced() {
9766        let dir = tempfile::tempdir().expect("tempdir");
9767        let (workspace, _rx) =
9768            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9769        let slot = SessionSlot::lead("TestOrg", "forge");
9770        let (mut task, _agent_rx) = command_task_for(&workspace, &slot);
9771        let mut updates = task.update_tx.subscribe(SubscriberRole::Answering);
9772
9773        let walk = || forge_agent::env::processes::ProcessSnapshot {
9774            processes: vec![forge_agent::env::processes::ProcessEntry {
9775                pid: 4242,
9776                parent_pid: 1,
9777                name: "claude".to_owned(),
9778                command: "claude".to_owned(),
9779                memory_bytes: 1,
9780            }],
9781            scanned_at: std::time::SystemTime::now(),
9782        };
9783        task.domain.lock().process_snapshot = Some(walk());
9784
9785        // A frame that does not end the run leaves the walk exactly where the
9786        // seat's own loop put it.
9787        task.translate_event(sdk_message(background_tasks(one_live_task())));
9788        assert!(
9789            announced_walks(&mut updates).is_empty(),
9790            "a frame that did not end the run announces no walk",
9791        );
9792        assert!(task.domain.lock().process_snapshot.is_some(), "and clears none");
9793
9794        task.translate_event(AgentEvent::ConnectionFailed {
9795            message: "reader died".to_owned(),
9796            kind: SpawnFailureKind::Unclassified,
9797        });
9798
9799        let cleared = announced_walks(&mut updates);
9800        assert_eq!(cleared.len(), 1, "the death announces the walk's clear");
9801        assert!(cleared[0].processes.is_empty(), "with the empty walk the core now holds");
9802        assert!(
9803            task.domain.lock().process_snapshot.is_none(),
9804            "and the store is cleared, so a read answers null rather than a dead tree",
9805        );
9806    }
9807
9808    /// **The fold is the only place a repository frame becomes a nudge.** The
9809    /// work tests drive `note_repository_activity` and
9810    /// `note_code_change_published` directly, so a missing arm, a swapped
9811    /// flag, or a deleted block would leave them green while the wiring did
9812    /// nothing.
9813    #[test]
9814    fn repository_frames_ask_for_the_nudge_they_need() {
9815        let mut domain = empty_domain();
9816
9817        let vcs = apply_event_to_domain(
9818            &mut domain,
9819            &sdk_message(forge_primitives::Message::VcsStateChanged {
9820                kind: "commit".to_owned(),
9821                branch: Some("main".to_owned()),
9822                cwd: "/tmp/repo".to_owned(),
9823                uuid: "u1".to_owned(),
9824                session_id: "s".to_owned(),
9825                extras: forge_primitives::messages::Extras::new(),
9826            }),
9827        );
9828        assert_eq!(vcs.repo_nudge, Some(false), "a mutation re-reads the tree only");
9829
9830        let published = apply_event_to_domain(
9831            &mut domain,
9832            &sdk_message(forge_primitives::Message::CodeChangePublished {
9833                provider: "github".to_owned(),
9834                url: "https://example.test/pull/1".to_owned(),
9835                repo: "a/b".to_owned(),
9836                identifier: "1".to_owned(),
9837                action: Some("created".to_owned()),
9838                branch: Some("main".to_owned()),
9839                uuid: "u2".to_owned(),
9840                session_id: "s".to_owned(),
9841                extras: forge_primitives::messages::Extras::new(),
9842            }),
9843        );
9844        assert_eq!(published.repo_nudge, Some(true), "a published change also looks the PR up");
9845
9846        let other = apply_event_to_domain(
9847            &mut domain,
9848            &sdk_message(forge_primitives::Message::System {
9849                subtype: "init".to_owned(),
9850                session_id: Some("s".to_owned()),
9851                data: serde_json::json!({}),
9852            }),
9853        );
9854        assert_eq!(other.repo_nudge, None, "every other event asks for neither");
9855    }
9856
9857    /// **The flag is only half the wiring.** The fallback above proves the fold
9858    /// answers *what*; this pins the call that acts on the answer, because
9859    /// deleting the block that reads it leaves every other test green. A
9860    /// published frame clears the attempt stamp on the seat's stored snapshot
9861    /// through the task's own workspace handle.
9862    #[test]
9863    fn a_published_frame_clears_the_stored_pr_attempt_through_the_task() {
9864        let dir = tempfile::tempdir().expect("tempdir");
9865        let (workspace, _rx) =
9866            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
9867        workspace.install_db_for_test(
9868            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
9869        );
9870        let slot = SessionSlot::from_str_for_test("test");
9871        let (handle, _agent_cmds) = Agent::testing_stub();
9872        let arc = Arc::new(handle);
9873        let domain = workspace.register_domain_session(slot.clone(), Some(Arc::clone(&arc)));
9874        domain.lock().work_snapshot = Some(crate::work::WorkSnapshot {
9875            diff: forge_primitives::git_diff::GitDiffSnapshot {
9876                branch: forge_primitives::git::GitBranch::Detached,
9877                pushed_sha: None,
9878                pr_fetched_at: Some(std::time::SystemTime::now()),
9879                default_branch: None,
9880                repo_gate: forge_primitives::git_diff::RepoGate::InRepo,
9881                worktree: forge_primitives::git_diff::LayerState::Clean,
9882                branch_ahead: forge_primitives::git_diff::LayerState::Clean,
9883                pr: None,
9884                closes: Vec::new(),
9885            },
9886            cwd: std::path::PathBuf::from("/tmp/repo"),
9887            read_at: std::time::Instant::now(),
9888            base: None,
9889            base_at: None,
9890            base_error: None,
9891        });
9892
9893        let (_cmd_tx, command_rx) = tokio::sync::mpsc::unbounded_channel();
9894        let mut task = SessionTask {
9895            key: slot.clone(),
9896            handle: arc,
9897            command_rx,
9898            domain: Arc::clone(&domain),
9899            update_tx: workspace.update_sender(),
9900            connected_once: false,
9901            workspace: std::sync::Arc::downgrade(&workspace),
9902            compaction_count: 0,
9903            successor: None,
9904            teardown_done: None,
9905        };
9906
9907        task.translate_event(sdk_message(forge_primitives::Message::CodeChangePublished {
9908            provider: "github".to_owned(),
9909            url: "https://example.test/pull/1".to_owned(),
9910            repo: "a/b".to_owned(),
9911            identifier: "1".to_owned(),
9912            action: Some("created".to_owned()),
9913            branch: Some("main".to_owned()),
9914            uuid: "u2".to_owned(),
9915            session_id: "s".to_owned(),
9916            extras: forge_primitives::messages::Extras::new(),
9917        }));
9918
9919        assert!(
9920            domain
9921                .lock()
9922                .work_snapshot
9923                .as_ref()
9924                .is_some_and(|held| held.diff.pr_fetched_at.is_none()),
9925            "the frame's nudge reached the workspace that clears the attempt",
9926        );
9927    }
9928
9929    /// A monitor still running when the connection dies can never be settled,
9930    /// and the session page reads this set directly through the view surface,
9931    /// so a dead one draws as live on both surfaces until the next connect
9932    /// replaces it.
9933    #[test]
9934    fn connection_failed_drops_the_domain_monitor_set() {
9935        let mut domain = empty_domain();
9936        apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
9937        apply_event_to_domain(&mut domain, &sdk_message(task_started("t-mon", Some("tu-mon"))));
9938        assert_eq!(domain.monitors.len(), 1, "the monitor is seeded before the failure");
9939        assert_eq!(
9940            domain.monitors[0].status,
9941            forge_primitives::MonitorStatus::Running,
9942            "and it is still running, which is what makes it unsettleable",
9943        );
9944
9945        apply_event_to_domain(
9946            &mut domain,
9947            &AgentEvent::ConnectionFailed {
9948                message: "reader died".to_owned(),
9949                kind: SpawnFailureKind::Unclassified,
9950            },
9951        );
9952
9953        assert!(
9954            domain.monitors.is_empty(),
9955            "the run's monitor set does not outlive the run that held it",
9956        );
9957    }
9958
9959    /// A second `Connected` is a new occupant in the same slot, and it
9960    /// inherits nothing the previous one held: `background_tasks_changed`
9961    /// is not re-sent for a session that has not started one, so a
9962    /// registry left standing would have a row spin over a task nobody is
9963    /// running until a real snapshot arrives.
9964    #[test]
9965    fn a_second_connected_drops_background_work() {
9966        let mut domain = empty_domain();
9967        domain.background_work = true;
9968
9969        apply_event_to_domain(&mut domain, &connected_event("new-uuid", "/proj"));
9970
9971        assert!(
9972            !domain.background_work,
9973            "a replaced occupant's background work must not survive it",
9974        );
9975    }
9976
9977    /// A second `Connected` (`/new`, `/login`, `/logout`) overwrites
9978    /// the session_id mirror so subsequent user commands route to the
9979    /// new identity.
9980    #[test]
9981    fn translate_second_connected_overwrites_session_id() {
9982        let mut domain = empty_domain();
9983        domain.session_id = Some(SessionId::new("old-uuid"));
9984
9985        apply_event_to_domain(
9986            &mut domain,
9987            &AgentEvent::Connected {
9988                session_id: "new-uuid".to_owned(),
9989                cwd: "/proj".to_owned(),
9990                current_model: forge_primitives::CurrentModel {
9991                    resolved_id: "claude".to_owned(),
9992                    display_name_short: "claude".to_owned(),
9993                    display_name_long: "claude".to_owned(),
9994                    requested_id: None,
9995                    catalog_id: None,
9996                    supports_effort: false,
9997                    supported_effort_levels: Vec::new(),
9998                    supports_auto_mode: None,
9999                    supports_adaptive_thinking: None,
10000                    is_authoritative: true,
10001                },
10002                available_models: Vec::new(),
10003                mode: None,
10004                history_updates: None,
10005                compaction_count: 0,
10006            },
10007        );
10008
10009        assert_eq!(
10010            domain.session_id.as_ref().map(std::string::ToString::to_string),
10011            Some("new-uuid".to_owned()),
10012            "second Connected must overwrite session_id mirror",
10013        );
10014    }
10015
10016    /// `SessionTask::translate_event` on a second `Connected`
10017    /// (`connected_once = true`) drains `pending_interactions` so
10018    /// forwarder tasks parked on the previous identity's
10019    /// tool_call_ids exit instead of waiting forever.
10020    #[test]
10021    fn translate_second_connected_drains_pending_interactions() {
10022        use tokio::sync::oneshot;
10023
10024        let (handle, _commands_rx) = Agent::testing_stub();
10025        let (_cmd_tx, command_rx) =
10026            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10027        let update_tx = UpdateFanout::default();
10028        let mut update_rx = update_tx.subscribe(SubscriberRole::Answering);
10029        let domain = Arc::new(parking_lot::Mutex::new(empty_domain()));
10030        let (response_tx, mut response_rx) =
10031            oneshot::channel::<forge_primitives::PermissionOutcome>();
10032        domain.lock().pending_interactions.insert(
10033            "stale_tool_id".to_owned(),
10034            crate::workspace::testing::test_permission(response_tx),
10035        );
10036        let mut task = SessionTask {
10037            key: SessionSlot::from_str_for_test("old-uuid"),
10038            handle: Arc::new(handle),
10039            command_rx,
10040            domain: Arc::clone(&domain),
10041            update_tx,
10042            connected_once: true,
10043            workspace: std::sync::Weak::new(),
10044            compaction_count: 0,
10045            successor: None,
10046            teardown_done: None,
10047        };
10048
10049        task.translate_event(AgentEvent::Connected {
10050            session_id: "new-uuid".to_owned(),
10051            cwd: "/proj".to_owned(),
10052            current_model: forge_primitives::CurrentModel {
10053                resolved_id: "claude".to_owned(),
10054                display_name_short: "claude".to_owned(),
10055                display_name_long: "claude".to_owned(),
10056                requested_id: None,
10057                catalog_id: None,
10058                supports_effort: false,
10059                supported_effort_levels: Vec::new(),
10060                supports_auto_mode: None,
10061                supports_adaptive_thinking: None,
10062                is_authoritative: true,
10063            },
10064            available_models: Vec::new(),
10065            mode: None,
10066            history_updates: None,
10067            compaction_count: 0,
10068        });
10069
10070        assert!(
10071            domain.lock().pending_interactions.is_empty(),
10072            "second Connected must clear stale pending_interactions",
10073        );
10074        assert!(
10075            matches!(
10076                response_rx.try_recv(),
10077                Err(tokio::sync::oneshot::error::TryRecvError::Closed)
10078            ),
10079            "forwarder receiver must observe Closed after the sender was dropped"
10080        );
10081        // The drop is announced, or a view drawing a dock from the request
10082        // it folded keeps offering a prompt the core has let go, and every
10083        // click on it reaches nothing.
10084        let announced: Vec<String> = std::iter::from_fn(|| update_rx.try_recv().ok())
10085            .filter_map(|frame| match std::sync::Arc::unwrap_or_clone(frame.update) {
10086                SessionUpdate::PendingInteractionResolved { key, tool_id, .. } => {
10087                    assert_eq!(key, SessionSlot::from_str_for_test("old-uuid"));
10088                    Some(tool_id)
10089                }
10090                _ => None,
10091            })
10092            .collect();
10093        assert_eq!(
10094            announced,
10095            vec!["stale_tool_id".to_owned()],
10096            "a dropped prompt is announced with its own tool id",
10097        );
10098    }
10099
10100    /// First-Connected drains the slot's parked peer prompts in FIFO
10101    /// order, dispatching one `Command::Prompt` per parked entry, then
10102    /// leaves the bucket empty. Pinned via the workspace's
10103    /// command-intercept buffer so the full first-Connected branch of
10104    /// `translate_event` runs end-to-end (no poking the private drain
10105    /// method directly).
10106    #[tokio::test]
10107    async fn first_connected_drains_parked_peer_prompts_in_fifo_order() {
10108        use crate::mcp::peers::types::{MessageId, WrappedKind, WrappedPrompt};
10109
10110        let (workspace, _update_rx) = crate::Workspace::testing_stub();
10111        let sender = SessionSlot::from_str_for_test("sender-proj");
10112
10113        // Park three Messages for the task's slot in known order.
10114        let session_key = test_slot();
10115        let domain =
10116            Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10117        let bodies = ["first", "second", "third"];
10118        for body in bodies {
10119            workspace.park_peer_prompt(
10120                &session_key,
10121                &sender,
10122                WrappedPrompt {
10123                    id: MessageId::mint(),
10124                    kind: WrappedKind::Message,
10125                    sender_name: "forge".to_owned(),
10126                    sender_org: "Default".to_owned(),
10127                    body: body.to_owned(),
10128                },
10129            );
10130        }
10131
10132        // connected_once=false → first-Connected arm that drains the
10133        // parked buckets. The drain resolves against the task's own slot,
10134        // so the test doesn't have to register against the workspace pool.
10135        let (handle, _agent_cmd_rx) = Agent::testing_stub();
10136        let (_cmd_tx, command_rx) =
10137            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10138        let update_tx = workspace.update_sender();
10139        let mut task = SessionTask {
10140            key: session_key.clone(),
10141            handle: Arc::new(handle),
10142            command_rx,
10143            domain: Arc::clone(&domain),
10144            update_tx,
10145            connected_once: false,
10146            workspace: Arc::downgrade(&workspace),
10147            compaction_count: 0,
10148            successor: None,
10149            teardown_done: None,
10150        };
10151
10152        workspace.enable_test_dispatch_intercept();
10153        task.translate_event(AgentEvent::Connected {
10154            session_id: session_key.display(),
10155            cwd: "/tmp/drain".to_owned(),
10156            current_model: forge_primitives::CurrentModel {
10157                resolved_id: "claude".to_owned(),
10158                display_name_short: "claude".to_owned(),
10159                display_name_long: "claude".to_owned(),
10160                requested_id: None,
10161                catalog_id: None,
10162                supports_effort: false,
10163                supported_effort_levels: Vec::new(),
10164                supports_auto_mode: None,
10165                supports_adaptive_thinking: None,
10166                is_authoritative: true,
10167            },
10168            available_models: Vec::new(),
10169            mode: None,
10170            history_updates: None,
10171            compaction_count: 0,
10172        });
10173
10174        // Drain dispatches Command::Prompt for each buffered entry,
10175        // in insertion order. Filter out anything else in case
10176        // translate_event grows side-effects later.
10177        let buffered = workspace.drain_test_dispatch_buffer();
10178        let drained_bodies: Vec<String> = buffered
10179            .into_iter()
10180            .filter_map(|cmd| match cmd {
10181                crate::protocol::Command::Prompt { text, .. }
10182                | crate::protocol::Command::PromptUnder { text, .. } => Some(text),
10183                _ => None,
10184            })
10185            .collect();
10186        assert_eq!(
10187            drained_bodies.len(),
10188            bodies.len(),
10189            "one Command::Prompt per buffered wrapped prompt"
10190        );
10191        for (i, expected_body) in bodies.iter().enumerate() {
10192            assert!(
10193                drained_bodies[i].contains(expected_body),
10194                "drain position {i}: expected body '{expected_body}' in dispatched text '{}'",
10195                drained_bodies[i],
10196            );
10197        }
10198        assert!(
10199            workspace.take_parked_for_slot(&session_key).peer.is_empty(),
10200            "the parked peer prompts are drained after first-Connected"
10201        );
10202    }
10203
10204    /// One Slack message, shaped as a delivery buffers it.
10205    fn buffered_slack(text: &str) -> forge_primitives::slack::SlackMessage {
10206        forge_primitives::slack::SlackMessage {
10207            workspace: "acme".to_owned(),
10208            conversation: "D1".to_owned(),
10209            conversation_label: "U9".to_owned(),
10210            ts: "100.000001".to_owned(),
10211            thread_ts: None,
10212            user: Some("U9".to_owned()),
10213            author: None,
10214            text: text.to_owned(),
10215            parent_user_id: None,
10216            latest_reply: None,
10217            edited: None,
10218            files: Vec::new(),
10219        }
10220    }
10221
10222    fn connected_event(session_id: &str, cwd: &str) -> AgentEvent {
10223        AgentEvent::Connected {
10224            session_id: session_id.to_owned(),
10225            cwd: cwd.to_owned(),
10226            current_model: forge_primitives::CurrentModel {
10227                resolved_id: "claude".to_owned(),
10228                display_name_short: "claude".to_owned(),
10229                display_name_long: "claude".to_owned(),
10230                requested_id: None,
10231                catalog_id: None,
10232                supports_effort: false,
10233                supported_effort_levels: Vec::new(),
10234                supports_auto_mode: None,
10235                supports_adaptive_thinking: None,
10236                is_authoritative: true,
10237            },
10238            available_models: Vec::new(),
10239            mode: None,
10240            history_updates: None,
10241            compaction_count: 0,
10242        }
10243    }
10244
10245    fn cron_worker_entry(
10246        label: &str,
10247        slot: crate::SessionSlot,
10248    ) -> crate::mcp::workers::types::WorkerEntry {
10249        crate::mcp::workers::types::WorkerEntry {
10250            label: label.to_owned(),
10251            charter: "c".to_owned(),
10252            spawned_by: crate::SessionSlot::lead(slot.org(), slot.project()),
10253            slot,
10254            session_id: None,
10255            status: forge_primitives::WorkerLiveness::Running,
10256            spawned_at: std::time::SystemTime::UNIX_EPOCH,
10257            needs_tag: false,
10258            is_git_repo_at_spawn: false,
10259            cwd: None,
10260            diagnostic: None,
10261            kick: None,
10262        }
10263    }
10264
10265    /// A cron entry carrying what a park reads: the id, the prompt and a
10266    /// description.
10267    fn test_cron(id: &str, prompt: &str) -> forge_primitives::cron::CronEntry {
10268        use forge_primitives::cron::{CronEntry, CronId, CronKind};
10269        CronEntry {
10270            id: CronId::from(id),
10271            project_name: "cron-drain".to_owned(),
10272            kind: CronKind::Recurring("0 9 * * *".to_owned()),
10273            prompt: prompt.to_owned(),
10274            created_at: std::time::SystemTime::UNIX_EPOCH,
10275            description: Some(format!("{id} summary")),
10276            last_fire: None,
10277            next_fire: std::time::SystemTime::UNIX_EPOCH,
10278            team_role: None,
10279            wake: true,
10280        }
10281    }
10282
10283    /// First-Connected drains the session slot's buffered cron prompts:
10284    /// each dispatches a plain `Command::Prompt` AND echoes a
10285    /// `CronPromptAppended` so an asleep-fired cron shows its block once the
10286    /// session connects (mirrors the gotify drain echo). Reproduce-first:
10287    /// the echo is absent until the drain calls `push_cron_prompt_into_chat`.
10288    #[tokio::test]
10289    async fn first_connected_drains_pending_cron_prompts_and_echoes_block() {
10290        let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10291        workspace.seed_test_project("cron-drain", "/tmp/cron-drain");
10292        // The slot production derives for the seeded project, and the
10293        // bucket the drain reads.
10294        let session_key = SessionSlot::lead("TestOrg", "cron-drain");
10295        workspace.park_cron(&session_key, &test_cron("c1", "morning reminder"), false);
10296
10297        let domain =
10298            Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10299
10300        let (handle, _agent_cmd_rx) = Agent::testing_stub();
10301        let (_cmd_tx, command_rx) =
10302            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10303        let update_tx = workspace.update_sender();
10304        let mut task = SessionTask {
10305            key: session_key.clone(),
10306            handle: Arc::new(handle),
10307            command_rx,
10308            domain: Arc::clone(&domain),
10309            update_tx,
10310            connected_once: false,
10311            workspace: Arc::downgrade(&workspace),
10312            compaction_count: 0,
10313            successor: None,
10314            teardown_done: None,
10315        };
10316
10317        workspace.enable_test_dispatch_intercept();
10318        task.translate_event(connected_event(&session_key.display(), "/tmp/cron-drain"));
10319
10320        // The buffered cron prompt is dispatched as a plain user turn.
10321        let buffered = workspace.drain_test_dispatch_buffer();
10322        assert!(
10323            buffered.iter().any(|c| matches!(
10324                c, crate::protocol::Command::Prompt { text, .. }
10325                    | crate::protocol::Command::PromptUnder { text, .. }
10326                    if text == "morning reminder"
10327            )),
10328            "the buffered cron prompt is dispatched on first-Connected",
10329        );
10330
10331        // AND an echo lands so the drained prompt shows a cron block, naming
10332        // the entry the parked fire came from - the bucket is the only place
10333        // that identity survives an asleep fire.
10334        let mut echoed = None;
10335        while let Ok(frame) = update_rx.try_recv() {
10336            if let SessionUpdate::CronPromptAppended { key, text, cron_id, description, .. } =
10337                std::sync::Arc::unwrap_or_clone(frame.update)
10338                && key == session_key
10339                && text == "morning reminder"
10340            {
10341                echoed = Some((cron_id, description));
10342            }
10343        }
10344        let (cron_id, description) =
10345            echoed.expect("an asleep-fired cron echoes a CronPromptAppended on drain");
10346        assert_eq!(cron_id, "c1", "the drained echo names the entry that fired");
10347        assert_eq!(description.as_deref(), Some("c1 summary"), "and its description");
10348
10349        assert!(
10350            workspace.take_parked_for_slot(&session_key).cron.is_empty(),
10351            "the slot's cron bucket is drained after first-Connected",
10352        );
10353    }
10354
10355    /// A worker session drains only its OWN `(project, label)` cron bucket
10356    /// on first Connect - the lead's bucket stays buffered - and a missed
10357    /// entry's `[missed cron] ` marker survives the buffer -> drain into the
10358    /// dispatched prompt (the asleep/drain missed path, distinct from the
10359    /// live-fire path).
10360    #[tokio::test]
10361    async fn worker_first_connected_drains_its_own_bucket_with_missed_marker() {
10362        let (workspace, _rx) = crate::Workspace::testing_stub();
10363        workspace.seed_test_project("wdp", "/tmp/wdp");
10364        let key =
10365            workspace.list_projects().into_iter().find(|v| v.name == "wdp").expect("view").key;
10366
10367        // A missed cron for the worker + an on-time lead cron for the project.
10368        let worker_slot = crate::SessionSlot::worker("TestOrg", "wdp", "reviewer");
10369        let lead_slot = crate::SessionSlot::lead("TestOrg", "wdp");
10370        workspace.insert_live_worker(&key, cron_worker_entry("reviewer", worker_slot.clone()));
10371        workspace.park_cron(&worker_slot, &test_cron("c-worker", "worker work"), true);
10372        workspace.park_cron(&lead_slot, &test_cron("c-lead", "lead work"), false);
10373
10374        let session_key = worker_slot.clone();
10375        let domain =
10376            Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10377        let (handle, _agent_cmd_rx) = Agent::testing_stub();
10378        let (_cmd_tx, command_rx) =
10379            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10380        let update_tx = workspace.update_sender();
10381        let mut task = SessionTask {
10382            key: session_key.clone(),
10383            handle: Arc::new(handle),
10384            command_rx,
10385            domain: Arc::clone(&domain),
10386            update_tx,
10387            connected_once: false,
10388            workspace: Arc::downgrade(&workspace),
10389            compaction_count: 0,
10390            successor: None,
10391            teardown_done: None,
10392        };
10393
10394        workspace.enable_test_dispatch_intercept();
10395        task.translate_event(connected_event(&worker_slot.display(), "/tmp/wdp"));
10396
10397        let dispatched = workspace.drain_test_dispatch_buffer();
10398        assert!(
10399            dispatched.iter().any(|c| matches!(
10400                c, crate::protocol::Command::Prompt { key, text, .. }
10401                    | crate::protocol::Command::PromptUnder { key, text, .. }
10402                    if *key == session_key && text == "[missed cron] worker work"
10403            )),
10404            "the worker drains its own missed cron with the marker applied",
10405        );
10406        // The lead's bucket is untouched by the worker's drain.
10407        let lead_bucket = workspace.take_parked_for_slot(&lead_slot).cron;
10408        assert_eq!(lead_bucket.len(), 1, "the lead's cron stays buffered");
10409        assert_eq!(lead_bucket[0].text, "lead work");
10410    }
10411
10412    /// Every echo in a multi-fire bucket names its OWN entry: the bucket
10413    /// holds one entry per due cron, so an identity read once for the
10414    /// bucket - the shape the loop's surrounding arguments invite - would
10415    /// print the first schedule on every row. Sorted rather than
10416    /// positional, so this does not pin drain order.
10417    #[tokio::test]
10418    async fn a_drain_names_each_entry_in_a_multi_fire_bucket() {
10419        let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10420        workspace.seed_test_project("cron-drain", "/tmp/cron-drain");
10421        let session_key = SessionSlot::lead("TestOrg", "cron-drain");
10422        workspace.park_cron(&session_key, &test_cron("c1", "morning reminder"), false);
10423        workspace.park_cron(&session_key, &test_cron("c2", "worker digest"), true);
10424        // A once-off registered without a description: the reply's own
10425        // contract says the key is null then, and the row falls back to the
10426        // prompt's first line.
10427        let mut bare = test_cron("c3", "check the queue");
10428        bare.description = None;
10429        workspace.park_cron(&session_key, &bare, false);
10430
10431        let domain =
10432            Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10433        let (handle, _agent_cmd_rx) = Agent::testing_stub();
10434        let (_cmd_tx, command_rx) =
10435            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10436        let update_tx = workspace.update_sender();
10437        let mut task = SessionTask {
10438            key: session_key.clone(),
10439            handle: Arc::new(handle),
10440            command_rx,
10441            domain: Arc::clone(&domain),
10442            update_tx,
10443            connected_once: false,
10444            workspace: Arc::downgrade(&workspace),
10445            compaction_count: 0,
10446            successor: None,
10447            teardown_done: None,
10448        };
10449
10450        workspace.enable_test_dispatch_intercept();
10451        task.translate_event(connected_event(&session_key.display(), "/tmp/cron-drain"));
10452
10453        let mut heard: Vec<(String, String, Option<String>)> = Vec::new();
10454        while let Ok(frame) = update_rx.try_recv() {
10455            if let SessionUpdate::CronPromptAppended { key, text, cron_id, description, .. } =
10456                std::sync::Arc::unwrap_or_clone(frame.update)
10457                && key == session_key
10458            {
10459                heard.push((text, cron_id, description));
10460            }
10461        }
10462        heard.sort();
10463        assert_eq!(
10464            heard,
10465            vec![
10466                (
10467                    "[missed cron] worker digest".to_owned(),
10468                    "c2".to_owned(),
10469                    Some("c2 summary".to_owned())
10470                ),
10471                ("check the queue".to_owned(), "c3".to_owned(), None),
10472                ("morning reminder".to_owned(), "c1".to_owned(), Some("c1 summary".to_owned())),
10473            ],
10474            "each echo carries its own entry's identity, and a missing description stays null",
10475        );
10476    }
10477
10478    /// **A resumed connect hands the WINDOW on, not the read.**
10479    ///
10480    /// `history_updates` is a resume's whole transcript - 51,274 rows and
10481    /// 2.5GiB on the seat #2150 was filed from - and the connect used to pass
10482    /// it on whole. The update is what the transport's copy is seeded from and
10483    /// what every subscriber takes a clone of, so the read was paid again per
10484    /// subscriber to hand over what each of them cuts to a window anyway. The
10485    /// update carries the cut window on both routes a resumed identity takes
10486    /// in.
10487    #[tokio::test]
10488    async fn a_resumed_connect_hands_the_window_not_the_whole_read() {
10489        let cap = crate::conversation_window::CONVERSATION_CAP;
10490        let slack = crate::conversation_window::CONVERSATION_SLACK;
10491        let read = cap + slack + 1;
10492
10493        // Both routes a connect takes: the first one, and the replacement a
10494        // `/new` or a `/resume` takes on a task that has connected once.
10495        for connected_once in [false, true] {
10496            let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10497            let session_key = SessionSlot::from_str_for_test("handover-window-uuid");
10498            let domain =
10499                Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10500            let (handle, _agent_cmd_rx) = Agent::testing_stub();
10501            let (_cmd_tx, command_rx) =
10502                tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10503            let mut task = SessionTask {
10504                key: session_key.clone(),
10505                handle: Arc::new(handle),
10506                command_rx,
10507                domain,
10508                update_tx: workspace.update_sender(),
10509                connected_once,
10510                workspace: Arc::downgrade(&workspace),
10511                compaction_count: 0,
10512                successor: None,
10513                teardown_done: None,
10514            };
10515            let frame = |at: usize| {
10516                serde_json::from_value::<forge_primitives::Message>(serde_json::json!({
10517                    "type": "user",
10518                    "message": {"role": "user", "content": format!("frame {at}")},
10519                    "session_id": session_key.display(),
10520                }))
10521                .expect("a user frame")
10522            };
10523            let mut event = connected_event(&session_key.display(), "/tmp/handed-over");
10524            if let AgentEvent::Connected { history_updates, .. } = &mut event {
10525                *history_updates = Some((0..read).map(frame).collect());
10526            }
10527            task.translate_event(event);
10528
10529            let mut handed = None;
10530            while let Ok(frame) = update_rx.try_recv() {
10531                match std::sync::Arc::unwrap_or_clone(frame.update) {
10532                    SessionUpdate::Connected { history, dropped, .. }
10533                    | SessionUpdate::SessionReplaced { history, dropped, .. } => {
10534                        handed = Some((history, dropped));
10535                    }
10536                    _ => {}
10537                }
10538            }
10539            let (handed, cut) = handed.expect("a connect crosses as Connected or SessionReplaced");
10540            assert!(
10541                (cap..=cap + slack).contains(&handed.len()),
10542                "the update hands over the window the task keeps, not the read: {} messages of \
10543                 the {read} the connect carried (connected_once: {connected_once})",
10544                handed.len(),
10545            );
10546            assert_eq!(
10547                cut,
10548                read - handed.len(),
10549                "and the cut the window left behind rides with it: a consumer holding the window \
10550                 alone reads its front as the conversation's, so a walk below it would stop \
10551                 there instead of crossing into the transcript (connected_once: {connected_once})",
10552            );
10553            assert!(
10554                handed.last().is_some_and(|message| {
10555                    matches!(message, forge_primitives::Message::User { message, .. }
10556                        if message.content.iter().any(|block| matches!(block,
10557                            forge_primitives::ContentBlock::Text { text, .. }
10558                                if text == &format!("frame {}", read - 1))))
10559                }),
10560                "and it is the NEWEST window rather than the oldest: the transcript's own tail",
10561            );
10562        }
10563    }
10564
10565    /// **A handover's cut rides the frames as thinking debt.** A resume hands
10566    /// the window to the update stream with no page to fold into, so the first
10567    /// counter the newest turn carries absorbs what the cut took - else a
10568    /// thought-heavy turn resumes showing only the surviving suffix.
10569    #[tokio::test]
10570    async fn a_resumed_connect_folds_the_cut_thinking_into_the_window() {
10571        let read = 5_100;
10572        for connected_once in [false, true] {
10573            let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10574            let session_key = SessionSlot::from_str_for_test("handover-thinking-uuid");
10575            let domain =
10576                Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10577            let (handle, _agent_cmd_rx) = Agent::testing_stub();
10578            let (_cmd_tx, command_rx) =
10579                tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10580            let mut task = SessionTask {
10581                key: session_key.clone(),
10582                handle: Arc::new(handle),
10583                command_rx,
10584                domain,
10585                update_tx: workspace.update_sender(),
10586                connected_once,
10587                workspace: Arc::downgrade(&workspace),
10588                compaction_count: 0,
10589                successor: None,
10590                teardown_done: None,
10591            };
10592            let counter = |at: usize| {
10593                serde_json::from_value::<forge_primitives::Message>(serde_json::json!({
10594                    "type": "system",
10595                    "subtype": "thinking_tokens",
10596                    "estimated_tokens": 1,
10597                    "estimated_tokens_delta": 1,
10598                    "uuid": format!("t-{at}"),
10599                    "session_id": session_key.display(),
10600                }))
10601                .expect("a counter frame")
10602            };
10603            let mut history = vec![
10604                serde_json::from_value::<forge_primitives::Message>(serde_json::json!({
10605                    "type": "user",
10606                    "message": {"role": "user", "content": "the thought-heavy turn"},
10607                    "session_id": session_key.display(),
10608                }))
10609                .expect("a user frame"),
10610            ];
10611            history.extend((0..read).map(counter));
10612            let mut event = connected_event(&session_key.display(), "/tmp/handed-over");
10613            if let AgentEvent::Connected { history_updates, .. } = &mut event {
10614                *history_updates = Some(history);
10615            }
10616            task.translate_event(event);
10617
10618            let mut handed = None;
10619            while let Ok(frame) = update_rx.try_recv() {
10620                match std::sync::Arc::unwrap_or_clone(frame.update) {
10621                    SessionUpdate::Connected { history, .. }
10622                    | SessionUpdate::SessionReplaced { history, .. } => {
10623                        handed = Some(history);
10624                    }
10625                    _ => {}
10626                }
10627            }
10628            let handed = handed.expect("a connect crosses as Connected or SessionReplaced");
10629            let sum: i64 = handed
10630                .iter()
10631                .filter_map(|message| match message {
10632                    forge_primitives::Message::ThinkingTokens {
10633                        estimated_tokens_delta, ..
10634                    } => Some(*estimated_tokens_delta),
10635                    _ => None,
10636                })
10637                .sum();
10638            assert_eq!(
10639                sum,
10640                i64::try_from(read).expect("the test's count fits"),
10641                "the handover carries the whole turn, not the suffix (connected_once: \
10642                 {connected_once})",
10643            );
10644        }
10645    }
10646
10647    /// The count has coverage at both ends - the scan produces it, the
10648    /// client reads it - and this is the layer in between. Both emitted
10649    /// arms are checked because a task that has connected before emits
10650    /// `SessionReplaced` instead of `Connected`, and forcing the field to
10651    /// zero on either one is invisible to every other test here.
10652    #[tokio::test]
10653    async fn translate_event_carries_a_non_zero_compaction_count_on_both_arms() {
10654        for (connected_once, arm) in [(false, "Connected"), (true, "SessionReplaced")] {
10655            let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10656            let session_key = SessionSlot::from_str_for_test("count-through-uuid");
10657            let domain =
10658                Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10659            let (handle, _agent_cmd_rx) = Agent::testing_stub();
10660            let (_cmd_tx, command_rx) =
10661                tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10662            let mut task = SessionTask {
10663                key: session_key.clone(),
10664                handle: Arc::new(handle),
10665                command_rx,
10666                domain: Arc::clone(&domain),
10667                update_tx: workspace.update_sender(),
10668                connected_once,
10669                workspace: Arc::downgrade(&workspace),
10670                compaction_count: 0,
10671                successor: None,
10672                teardown_done: None,
10673            };
10674
10675            let mut event = connected_event(&session_key.display(), "/tmp/count");
10676            if let AgentEvent::Connected { compaction_count, .. } = &mut event {
10677                *compaction_count = 7;
10678            }
10679            task.translate_event(event);
10680
10681            let mut seen = None;
10682            while let Ok(frame) = update_rx.try_recv() {
10683                match std::sync::Arc::unwrap_or_clone(frame.update) {
10684                    SessionUpdate::Connected { compaction_count, .. }
10685                    | SessionUpdate::SessionReplaced { compaction_count, .. } => {
10686                        seen = Some(compaction_count);
10687                    }
10688                    _ => {}
10689                }
10690            }
10691            assert_eq!(seen, Some(7), "{arm} must carry the count through unchanged");
10692        }
10693    }
10694
10695    /// A resumed conversation carries its sub-agent frames to the page: they
10696    /// are the dispatch row's expansion, and the card fold must open a card
10697    /// for an instance whose sidecar settled every call even though its only
10698    /// dispatch answer is the launch acknowledgement (#1763).
10699    #[tokio::test]
10700    async fn subagent_frames_ride_the_connected_history() {
10701        let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10702        let session_key = SessionSlot::from_str_for_test("sidecar-chat-uuid");
10703        let domain =
10704            Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10705        let (handle, _agent_cmd_rx) = Agent::testing_stub();
10706        let (_cmd_tx, command_rx) =
10707            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10708        let mut task = SessionTask {
10709            key: session_key.clone(),
10710            handle: Arc::new(handle),
10711            command_rx,
10712            domain: Arc::clone(&domain),
10713            update_tx: workspace.update_sender(),
10714            connected_once: false,
10715            workspace: Arc::downgrade(&workspace),
10716            compaction_count: 0,
10717            successor: None,
10718            teardown_done: None,
10719        };
10720        let dispatch = serde_json::from_value(serde_json::json!({
10721            "type": "assistant",
10722            "message": {"id": "msg_1", "type": "message", "role": "assistant",
10723                "model": "claude-sonnet-5", "content": [
10724                {"type": "tool_use", "id": "toolu_a", "name": "Agent",
10725                 "input": {"prompt": "explore"}}
10726            ]},
10727            "session_id": "sidecar-chat",
10728            "uuid": "a1",
10729        }))
10730        .expect("the dispatch decodes");
10731        let child = serde_json::from_value(serde_json::json!({
10732            "type": "assistant",
10733            "message": {"id": "msg_2", "type": "message", "role": "assistant",
10734                "model": "claude-sonnet-5", "content": [
10735                {"type": "tool_use", "id": "toolu_child", "name": "Bash",
10736                 "input": {"command": "ls"}}
10737            ]},
10738            "parent_tool_use_id": "toolu_a",
10739            "session_id": "sidecar-chat",
10740            "uuid": "c1",
10741        }))
10742        .expect("the sub-agent frame decodes");
10743        let dispatch_ack = serde_json::from_value(serde_json::json!({
10744            "type": "user",
10745            "message": {"role": "user", "content": [
10746                {"type": "tool_result", "tool_use_id": "toolu_a",
10747                 "content": "Async agent launched successfully"}
10748            ]},
10749            "session_id": "sidecar-chat",
10750            "uuid": "u1",
10751        }))
10752        .expect("the dispatch ack decodes");
10753        let child_result = serde_json::from_value(serde_json::json!({
10754            "type": "user",
10755            "message": {"role": "user", "content": [
10756                {"type": "tool_result", "tool_use_id": "toolu_child", "content": "total 0"}
10757            ]},
10758            "session_id": "sidecar-chat",
10759            "uuid": "u2",
10760        }))
10761        .expect("the child result decodes");
10762        let mut event = connected_event("sidecar-chat", "/tmp/sidecar");
10763        if let AgentEvent::Connected { history_updates, .. } = &mut event {
10764            *history_updates = Some(vec![dispatch, dispatch_ack, child, child_result]);
10765        }
10766
10767        task.translate_event(event);
10768
10769        let mut history_len = None;
10770        while let Ok(frame) = update_rx.try_recv() {
10771            if let SessionUpdate::Connected { history, .. } = &*frame.update {
10772                history_len = Some(history.len());
10773            }
10774        }
10775        assert_eq!(history_len, Some(4), "the sub-agent frames ride the page as the row expansion");
10776        let cards = domain.lock().cards_snapshot.clone();
10777        assert_eq!(cards.len(), 1, "the folded instance gets its card");
10778        assert_eq!(cards[0].calls, 1, "and its call is the card's");
10779        assert!(
10780            !cards[0].running,
10781            "every sidecar call settled means the instance is settled, not hidden",
10782        );
10783    }
10784
10785    /// **The worktree check runs before the first connect's side effects.**
10786    /// A git worker whose worktree is gone must not be announced live,
10787    /// kicked, or handed its parked prompts, because the failure then
10788    /// releases the session those prompts were dispatched into (#1593).
10789    ///
10790    /// **The path composes from the row's root, never the connect cwd.** A
10791    /// resumed worker connects with its worktree as cwd, so composing from
10792    /// that asked about `<worktree>/.claude/worktrees/<label>` and named the
10793    /// doubled path in the reason.
10794    #[tokio::test]
10795    async fn a_git_worker_without_a_worktree_is_failed_before_the_first_connect_effects() {
10796        let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10797        let session_key = SessionSlot::worker("TestOrg", "proj-x", "builder");
10798        let project_key = crate::ProjectKey::new("proj-x");
10799        let root = tempfile::tempdir().expect("root");
10800        let worktree = root.path().join(".claude/worktrees/builder");
10801        workspace.insert_live_worker(
10802            &project_key,
10803            crate::mcp::workers::types::WorkerEntry {
10804                label: "builder".to_owned(),
10805                charter: "charter".to_owned(),
10806                slot: session_key.clone(),
10807                session_id: Some(forge_primitives::SessionId::new("builder-session")),
10808                status: forge_primitives::WorkerLiveness::Spawning,
10809                spawned_at: std::time::SystemTime::UNIX_EPOCH,
10810                spawned_by: SessionSlot::lead("TestOrg", "proj-x"),
10811                needs_tag: true,
10812                is_git_repo_at_spawn: true,
10813                cwd: Some(root.path().to_path_buf()),
10814                diagnostic: None,
10815                kick: None,
10816            },
10817        );
10818        let (handle, _commands) = Agent::testing_stub();
10819        let handle = Arc::new(handle);
10820        workspace.pool.lock().insert(
10821            session_key.clone(),
10822            crate::workspace::PooledAgent {
10823                handle: Arc::clone(&handle),
10824                account: forge_gateway::AccountKey("acct-a".to_owned()),
10825                permission_mode: None,
10826                registration: None,
10827                session_id: "builder-session".to_owned(),
10828            },
10829        );
10830        let (commands, mut routed) = tokio::sync::mpsc::unbounded_channel();
10831        workspace.command_senders.lock().insert(session_key.clone(), commands);
10832        let (_task_tx, task_rx) =
10833            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10834        let domain =
10835            Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10836        let mut task = SessionTask {
10837            key: session_key.clone(),
10838            handle,
10839            command_rx: task_rx,
10840            domain: Arc::clone(&domain),
10841            update_tx: workspace.update_sender(),
10842            connected_once: false,
10843            workspace: Arc::downgrade(&workspace),
10844            compaction_count: 0,
10845            successor: None,
10846            teardown_done: None,
10847        };
10848        // A resumed worker's connect cwd is already its worktree.
10849        let alive =
10850            task.translate_event(connected_event("builder-session", &worktree.to_string_lossy()));
10851
10852        assert!(!alive, "the task stops rather than continue on a failed worker");
10853        let mut connected = false;
10854        while let Ok(frame) = update_rx.try_recv() {
10855            if matches!(&*frame.update, SessionUpdate::Connected { .. }) {
10856                connected = true;
10857            }
10858        }
10859        assert!(!connected, "the dead worker is never announced live");
10860        assert!(routed.try_recv().is_err(), "and nothing is dispatched into its session");
10861        let entry = workspace
10862            .list_live_workers(&project_key)
10863            .into_iter()
10864            .find(|entry| entry.label == "builder")
10865            .expect("the row stays as the visible failure");
10866        assert_eq!(entry.status, forge_primitives::WorkerLiveness::Failed);
10867        let reason = entry.diagnostic.expect("the row names the missing worktree");
10868        let worktree = worktree.display().to_string();
10869        assert!(reason.contains(&worktree), "the row names the worktree: {reason}");
10870        assert!(
10871            !reason.contains("builder/.claude/worktrees/builder"),
10872            "the reason is not the connect cwd composed a second time: {reason}",
10873        );
10874        assert!(
10875            !workspace.session_is_pooled(&session_key),
10876            "and the session is released with the failure",
10877        );
10878    }
10879
10880    /// A compaction boundary moves the task's count, which is the handoff
10881    /// policy's `after_compactions` backstop.
10882    ///
10883    /// **The count has two sources and both must land on one scalar**: the
10884    /// connect seeds it from the transcript it resumed, and a boundary frame
10885    /// the session emits after it moves it. A store that kept only the
10886    /// connect's number would report the count from before every compaction
10887    /// since.
10888    #[tokio::test]
10889    async fn a_compaction_boundary_moves_the_tasks_count() {
10890        let (workspace, _update_rx) = crate::Workspace::testing_stub();
10891        let session_key = SessionSlot::from_str_for_test("compaction-count-uuid");
10892        let domain =
10893            Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10894        let (handle, _agent_cmd_rx) = Agent::testing_stub();
10895        let (_cmd_tx, command_rx) =
10896            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10897        let mut task = SessionTask {
10898            key: session_key.clone(),
10899            handle: Arc::new(handle),
10900            command_rx,
10901            domain,
10902            update_tx: workspace.update_sender(),
10903            connected_once: false,
10904            workspace: Arc::downgrade(&workspace),
10905            compaction_count: 0,
10906            successor: None,
10907            teardown_done: None,
10908        };
10909
10910        let mut event = connected_event(&session_key.display(), "/tmp/count");
10911        if let AgentEvent::Connected { compaction_count, .. } = &mut event {
10912            *compaction_count = 4;
10913        }
10914        task.translate_event(event);
10915        assert_eq!(task.compaction_count, 4, "a resumed connect seeds the count it carries");
10916
10917        task.translate_event(AgentEvent::SdkMessage {
10918            session_id: session_key.display(),
10919            msg: forge_primitives::Message::CompactBoundary {
10920                trigger: "auto".to_owned(),
10921                pre_tokens: 1,
10922                post_tokens: 1,
10923                uuid: "c1".to_owned(),
10924                session_id: session_key.display(),
10925                metadata_extras: serde_json::Map::new(),
10926                extras: serde_json::Map::new(),
10927            },
10928        });
10929        assert_eq!(task.compaction_count, 5, "and the boundary the session emits moves it");
10930    }
10931
10932    /// A re-spawn that replaces the session seeds `connected_once =
10933    /// true`, so the new task's first Connected emits `SessionReplaced`
10934    /// (not a fresh Connected) carrying the resumed history. A view's
10935    /// reducer resets the chat then re-seeds it from that history, so
10936    /// the same conversation stays visible across the replacement.
10937    #[tokio::test]
10938    async fn connected_once_seed_emits_session_replaced_with_resumed_history() {
10939        use forge_primitives::Message;
10940
10941        let (workspace, mut update_rx) = crate::Workspace::testing_stub();
10942        let session_key = SessionSlot::from_str_for_test("replacement-visible-uuid");
10943        let domain =
10944            Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
10945
10946        let (handle, _agent_cmd_rx) = Agent::testing_stub();
10947        let (_cmd_tx, command_rx) =
10948            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
10949        let update_tx = workspace.update_sender();
10950        let mut task = SessionTask {
10951            key: session_key.clone(),
10952            handle: Arc::new(handle),
10953            command_rx,
10954            domain: Arc::clone(&domain),
10955            update_tx,
10956            // The seed a session-replacing re-spawn installs.
10957            connected_once: true,
10958            workspace: Arc::downgrade(&workspace),
10959            compaction_count: 0,
10960            successor: None,
10961            teardown_done: None,
10962        };
10963
10964        // A one-message resumed history (the --resume backfill).
10965        let history = vec![Message::System {
10966            subtype: "info".to_owned(),
10967            session_id: Some(session_key.display()),
10968            data: serde_json::json!({ "body": "earlier turn" }),
10969        }];
10970
10971        task.translate_event(AgentEvent::Connected {
10972            session_id: session_key.display(),
10973            cwd: "/tmp/respawn".to_owned(),
10974            current_model: forge_primitives::CurrentModel {
10975                resolved_id: "claude".to_owned(),
10976                display_name_short: "claude".to_owned(),
10977                display_name_long: "claude".to_owned(),
10978                requested_id: None,
10979                catalog_id: None,
10980                supports_effort: false,
10981                supported_effort_levels: Vec::new(),
10982                supports_auto_mode: None,
10983                supports_adaptive_thinking: None,
10984                is_authoritative: true,
10985            },
10986            available_models: Vec::new(),
10987            mode: None,
10988            history_updates: Some(history),
10989            compaction_count: 0,
10990        });
10991
10992        let mut replaced_history_len = None;
10993        let mut saw_plain_connected = false;
10994        while let Ok(frame) = update_rx.try_recv() {
10995            match std::sync::Arc::unwrap_or_clone(frame.update) {
10996                SessionUpdate::SessionReplaced { key, history, .. } => {
10997                    assert_eq!(key, session_key, "SessionReplaced targets the re-spawned session");
10998                    replaced_history_len = Some(history.len());
10999                }
11000                SessionUpdate::Connected { .. } => saw_plain_connected = true,
11001                _ => {}
11002            }
11003        }
11004        assert_eq!(
11005            replaced_history_len,
11006            Some(1),
11007            "connected_once=true emits SessionReplaced carrying the resumed conversation",
11008        );
11009        assert!(
11010            !saw_plain_connected,
11011            "a session-replacing re-spawn must not emit a fresh Connected"
11012        );
11013    }
11014
11015    /// A replacement under a listening seat tells the successor it is heard:
11016    /// `/new` and `/resume` leave the listener set alone, so the note has to
11017    /// fire on the occupant change rather than on a listener transition.
11018    #[tokio::test]
11019    async fn a_replaced_seat_tells_the_successor_it_is_heard() {
11020        struct Silent;
11021        impl forge_tts::Engine for Silent {
11022            fn speak(
11023                &self,
11024                _request: forge_tts::SpeakRequest,
11025            ) -> tokio::sync::mpsc::Receiver<Result<forge_tts::Chunk, forge_tts::Error>>
11026            {
11027                let (sender, receiver) = tokio::sync::mpsc::channel(1);
11028                drop(sender);
11029                receiver
11030            }
11031        }
11032
11033        let (workspace, mut update_rx) = crate::Workspace::testing_stub();
11034        workspace.install_voice_engine_for_test(Arc::new(Silent));
11035        let session_key = SessionSlot::from_str_for_test("voice-respawn-uuid");
11036        workspace.voice_service().set_listening(&session_key, 1, true, false);
11037        // The seat has no command sender yet, so this note cannot be delivered:
11038        // it must forge no row, or the page would draw a note the agent never got.
11039        while let Ok(frame) = update_rx.try_recv() {
11040            assert!(
11041                !matches!(&*frame.update, SessionUpdate::ChatAppended { .. }),
11042                "a note that was not delivered draws no row",
11043            );
11044        }
11045        let (command_tx, mut command_rx) = tokio::sync::mpsc::unbounded_channel();
11046        workspace.command_senders.lock().insert(session_key.clone(), command_tx);
11047
11048        let domain =
11049            Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
11050        let (handle, _agent_cmd_rx) = Agent::testing_stub();
11051        let (_cmd_tx, command_rx_task) =
11052            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
11053        let update_tx = workspace.update_sender();
11054        let mut task = SessionTask {
11055            key: session_key.clone(),
11056            handle: Arc::new(handle),
11057            command_rx: command_rx_task,
11058            domain,
11059            update_tx,
11060            connected_once: true,
11061            workspace: Arc::downgrade(&workspace),
11062            compaction_count: 0,
11063            successor: None,
11064            teardown_done: None,
11065        };
11066
11067        task.translate_event(AgentEvent::Connected {
11068            session_id: session_key.display(),
11069            cwd: "/tmp/respawn".to_owned(),
11070            current_model: forge_primitives::CurrentModel {
11071                resolved_id: "claude".to_owned(),
11072                display_name_short: "claude".to_owned(),
11073                display_name_long: "claude".to_owned(),
11074                requested_id: None,
11075                catalog_id: None,
11076                supports_effort: false,
11077                supported_effort_levels: Vec::new(),
11078                supports_auto_mode: None,
11079                supports_adaptive_thinking: None,
11080                is_authoritative: true,
11081            },
11082            available_models: Vec::new(),
11083            mode: None,
11084            history_updates: Some(Vec::new()),
11085            compaction_count: 0,
11086        });
11087
11088        let mut note = None;
11089        let mut note_uuid = None;
11090        while let Ok(command) = command_rx.try_recv() {
11091            if let crate::protocol::Command::PromptUnder { text, uuid, .. } = command {
11092                note = Some(text);
11093                note_uuid = Some(uuid);
11094            }
11095        }
11096        assert!(
11097            note.as_deref().is_some_and(|text| text.contains("The user is listening")),
11098            "the successor is told it is heard, got: {note:?}",
11099        );
11100
11101        let mut row = None;
11102        while let Ok(frame) = update_rx.try_recv() {
11103            if let SessionUpdate::ChatAppended { key, msg, source, .. } = &*frame.update
11104                && key == &session_key
11105            {
11106                row = Some(((*msg).clone(), *source));
11107            }
11108        }
11109        let (row, source) = row.expect("the delivered note forges the row a page draws");
11110        assert_eq!(source, Some(PromptSource::Forge), "the row is forge's own delivery");
11111        let Message::User { message, uuid, timestamp, .. } = &row else {
11112            panic!("a voice note draws as an information row, not the reader's turn: {row:?}");
11113        };
11114        assert_eq!(uuid, &note_uuid, "the row and the prompt share one id");
11115        assert!(timestamp.is_some(), "the row carries forge's clock");
11116        let text = message
11117            .content
11118            .iter()
11119            .find_map(|block| match block {
11120                ContentBlock::Text { text, .. } => Some(text.as_str()),
11121                _ => None,
11122            })
11123            .expect("the row carries the note's words");
11124        assert_eq!(Some(text), note.as_deref(), "the row carries exactly the prose the agent got");
11125        assert!(
11126            text.starts_with("[Voice]\n\n"),
11127            "the envelope is what makes the view draw it as information, got: {text:?}",
11128        );
11129    }
11130
11131    /// The replaced identity's `/dictate` override axes die with it:
11132    /// a view mints a blank bucket for the new session, so an axis
11133    /// left on the domain would style a session that no longer exists.
11134    #[tokio::test]
11135    async fn a_replaced_identity_drops_its_overrides() {
11136        let (workspace, mut update_rx) = crate::Workspace::testing_stub();
11137        let session_key = SessionSlot::from_str_for_test("dictate-rekey-uuid");
11138        let domain =
11139            Arc::new(parking_lot::Mutex::new(DomainSession::new(session_key.clone(), None)));
11140        domain.lock().dictate_overrides.styling = Some(forge_dictate::normalize::Styling::Formal);
11141
11142        let (handle, _agent_cmd_rx) = Agent::testing_stub();
11143        let (_cmd_tx, command_rx) =
11144            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
11145        let mut task = SessionTask {
11146            key: session_key.clone(),
11147            handle: Arc::new(handle),
11148            command_rx,
11149            domain: Arc::clone(&domain),
11150            update_tx: workspace.update_sender(),
11151            connected_once: true,
11152            workspace: Arc::downgrade(&workspace),
11153            compaction_count: 0,
11154            successor: None,
11155            teardown_done: None,
11156        };
11157
11158        task.translate_event(AgentEvent::Connected {
11159            session_id: session_key.display(),
11160            cwd: "/tmp/respawn".to_owned(),
11161            current_model: forge_primitives::CurrentModel {
11162                resolved_id: "claude".to_owned(),
11163                display_name_short: "claude".to_owned(),
11164                display_name_long: "claude".to_owned(),
11165                requested_id: None,
11166                catalog_id: None,
11167                supports_effort: false,
11168                supported_effort_levels: Vec::new(),
11169                supports_auto_mode: None,
11170                supports_adaptive_thinking: None,
11171                is_authoritative: true,
11172            },
11173            available_models: Vec::new(),
11174            mode: None,
11175            history_updates: None,
11176            compaction_count: 0,
11177        });
11178
11179        assert_eq!(domain.lock().dictate_overrides, crate::dictate::DictateOverrides::default());
11180
11181        let mut replaced_seen = false;
11182        while let Ok(frame) = update_rx.try_recv() {
11183            replaced_seen |= matches!(&*frame.update, SessionUpdate::SessionReplaced { .. });
11184        }
11185        assert!(
11186            replaced_seen,
11187            "the scenario must still be a replacement, or the test proves nothing"
11188        );
11189    }
11190
11191    /// The login wait comes from the typed signals the CLI sends, and a
11192    /// turn that finished clears it.
11193    ///
11194    /// The `TurnError` envelope's text is a fallback rather than the
11195    /// signal: its producers are forge's own failed prompt write and
11196    /// failed cancel, so no CLI output reaches it, and a classifier over
11197    /// its text can fire on ordinary error words.
11198    #[test]
11199    fn a_login_wait_comes_from_the_typed_signals() {
11200        let held = |event: &AgentEvent| {
11201            let mut domain = empty_domain();
11202            apply_event_to_domain(&mut domain, event);
11203            domain.awaiting_login
11204        };
11205
11206        assert!(
11207            held(&sdk_message(assistant_with("authentication_failed"))),
11208            "the CLI attributes the failure to the assistant message, and that is typed",
11209        );
11210        assert!(
11211            held(&sdk_message(api_retry("authentication_failed"))),
11212            "and names it on the retry frame as well",
11213        );
11214        assert!(
11215            !held(&sdk_message(assistant_with("server_error"))),
11216            "another class is not a login wait",
11217        );
11218
11219        // A finished turn proves the credential works. A failed one does
11220        // not, and clearing on it would undo the mark the frames above
11221        // just set.
11222        let mut domain = empty_domain();
11223        apply_event_to_domain(&mut domain, &sdk_message(assistant_with("authentication_failed")));
11224        assert!(domain.awaiting_login, "precondition: held");
11225        apply_event_to_domain(
11226            &mut domain,
11227            &sdk_message(result_message("error_during_execution", true)),
11228        );
11229        assert!(
11230            domain.awaiting_login,
11231            "a turn that died on the missing credential does not clear the wait",
11232        );
11233        apply_event_to_domain(&mut domain, &sdk_message(result_message("success", false)));
11234        assert!(!domain.awaiting_login, "a turn that finished does");
11235    }
11236
11237    fn sdk_message(msg: forge_primitives::Message) -> AgentEvent {
11238        AgentEvent::SdkMessage { session_id: "s".to_owned(), msg }
11239    }
11240
11241    fn hook_observation(
11242        permission_mode: Option<&str>,
11243        effort: Option<&str>,
11244        tool_use_id: Option<&str>,
11245        agent_type: Option<&str>,
11246    ) -> AgentEvent {
11247        AgentEvent::HookObservation {
11248            session_id: "s".to_owned(),
11249            tool_use_id: tool_use_id.map(str::to_owned),
11250            permission_mode: permission_mode.map(str::to_owned),
11251            effort: effort.map(str::to_owned),
11252            agent_id: tool_use_id.map(|_| "agent-1".to_owned()),
11253            agent_type: agent_type.map(str::to_owned),
11254        }
11255    }
11256
11257    fn init_frame_naming_the_model(model: &str) -> forge_primitives::Message {
11258        serde_json::from_value(serde_json::json!({
11259            "type": "system",
11260            "subtype": "init",
11261            "session_id": "s",
11262            "model": model,
11263        }))
11264        .expect("parse an init frame naming a model")
11265    }
11266
11267    fn mcp_server(name: &str) -> forge_primitives::McpServerStatus {
11268        serde_json::from_value(serde_json::json!({ "name": name, "status": "connected" }))
11269            .expect("parse an MCP server status")
11270    }
11271
11272    fn monitor_tool_use(tool_use_id: &str, description: &str) -> forge_primitives::Message {
11273        serde_json::from_value(serde_json::json!({
11274            "type": "assistant",
11275            "message": {
11276                "id": "msg-mon",
11277                "role": "assistant",
11278                "model": "claude-sonnet-5",
11279                "content": [{
11280                    "type": "tool_use",
11281                    "id": tool_use_id,
11282                    "name": "Monitor",
11283                    "input": {
11284                        "description": description,
11285                        "command": "gh run watch 1",
11286                        "persistent": true,
11287                    },
11288                }],
11289            },
11290            "session_id": "s",
11291        }))
11292        .expect("parse a Monitor tool_use")
11293    }
11294
11295    fn task_started(task_id: &str, tool_use_id: Option<&str>) -> forge_primitives::Message {
11296        serde_json::from_value(serde_json::json!({
11297            "type": "system",
11298            "subtype": "task_started",
11299            "task_id": task_id,
11300            "description": "watch CI",
11301            "uuid": "u-task",
11302            "session_id": "s",
11303            "tool_use_id": tool_use_id,
11304        }))
11305        .expect("parse a task_started")
11306    }
11307
11308    fn task_updated(task_id: &str, status: &str) -> forge_primitives::Message {
11309        serde_json::from_value(serde_json::json!({
11310            "type": "system",
11311            "subtype": "task_updated",
11312            "task_id": task_id,
11313            "patch": { "status": status },
11314            "uuid": "u-upd",
11315            "session_id": "s",
11316        }))
11317        .expect("parse a task_updated")
11318    }
11319
11320    /// A terminal patch as the CLI sends one: the status and the instant
11321    /// it ended, in one frame.
11322    fn task_updated_at(task_id: &str, status: &str, end_ms: u64) -> forge_primitives::Message {
11323        serde_json::from_value(serde_json::json!({
11324            "type": "system",
11325            "subtype": "task_updated",
11326            "task_id": task_id,
11327            "patch": { "status": status, "end_time": end_ms },
11328            "uuid": "u-upd",
11329            "session_id": "s",
11330        }))
11331        .expect("parse a task_updated carrying an end time")
11332    }
11333
11334    fn task_notification(task_id: &str) -> forge_primitives::Message {
11335        task_notification_with_status(task_id, "completed")
11336    }
11337
11338    fn task_notification_with_status(task_id: &str, status: &str) -> forge_primitives::Message {
11339        serde_json::from_value(serde_json::json!({
11340            "type": "system",
11341            "subtype": "task_notification",
11342            "task_id": task_id,
11343            "status": status,
11344            "output_file": "/tmp/forge-test-monitor.out",
11345            "summary": "Monitor stream ended",
11346            "uuid": "u-note",
11347            "session_id": "s",
11348        }))
11349        .expect("parse a task_notification")
11350    }
11351
11352    /// A `commands_changed` payload carrying entries that parse to none
11353    /// means the CLI's entry shape changed under us. Storing it would
11354    /// wipe the list, and `/help` with it, for every view reading
11355    /// through this, so the prior one stands.
11356    #[test]
11357    fn an_unparseable_commands_changed_payload_keeps_the_retained_commands() {
11358        let mut domain = empty_domain();
11359        apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/help"], &[])));
11360
11361        apply_event_to_domain(
11362            &mut domain,
11363            &sdk_message(forge_primitives::Message::CommandsChanged {
11364                commands: vec![serde_json::json!({"no_name": "x"}), serde_json::json!(7)],
11365                uuid: "cmd-uuid".to_owned(),
11366                session_id: "s".to_owned(),
11367                extras: serde_json::Map::new(),
11368            }),
11369        );
11370
11371        let commands: Vec<&str> =
11372            domain.available_commands.iter().map(|c| c.name.as_str()).collect();
11373        assert_eq!(commands, vec!["/help"], "the prior list stands rather than being wiped");
11374    }
11375
11376    /// An empty payload is a real answer - a plugin uninstall - and it
11377    /// clears.
11378    #[test]
11379    fn an_empty_commands_changed_payload_clears_the_retained_commands() {
11380        let mut domain = empty_domain();
11381        apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/help"], &[])));
11382
11383        apply_event_to_domain(
11384            &mut domain,
11385            &sdk_message(forge_primitives::Message::CommandsChanged {
11386                commands: Vec::new(),
11387                uuid: "cmd-uuid".to_owned(),
11388                session_id: "s".to_owned(),
11389                extras: serde_json::Map::new(),
11390            }),
11391        );
11392
11393        assert!(domain.available_commands.is_empty(), "an empty payload clears the list");
11394    }
11395
11396    /// A `Connected` in the same seat is a NEW OCCUPANT, and it has
11397    /// advertised nothing yet: the CLI sends `system/init` at the head of
11398    /// a turn only, so the last one's catalogues would stand until the new
11399    /// occupant's first message. The core clears both on the same event,
11400    /// and after a login swap the stale list came from another account's
11401    /// config dir - another session's data, not a late one.
11402    ///
11403    /// The refill is half the property: a swap that ends no turn - a
11404    /// mid-turn `/new`, a reconnect after a login - never sees the
11405    /// `Result` that re-arms the agent read, so the new occupant's own
11406    /// init has to be the thing that fills the catalogue back in.
11407    #[test]
11408    fn a_new_occupant_advertises_no_catalogues_until_its_own_init() {
11409        let mut domain = empty_domain();
11410        apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/help"], &["reviewer"])));
11411
11412        apply_event_to_domain(&mut domain, &connected_event("new-occupant", "/proj"));
11413
11414        assert!(
11415            domain.available_commands.is_empty(),
11416            "the previous occupant's command list does not stand",
11417        );
11418        assert!(domain.available_agents.is_empty(), "nor the agent catalogue it advertised");
11419
11420        apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/newhelp"], &["newagent"])));
11421
11422        let commands: Vec<&str> =
11423            domain.available_commands.iter().map(|c| c.name.as_str()).collect();
11424        let agents: Vec<&str> = domain.available_agents.iter().map(|a| a.name.as_str()).collect();
11425        assert_eq!(commands, vec!["/newhelp"], "the new occupant's own init refills the commands");
11426        assert_eq!(agents, vec!["newagent"], "and the catalogue, without waiting a turn");
11427    }
11428
11429    /// A `system/init` frame, which is where the CLI advertises both
11430    /// catalogues: bare command names and bare agent names.
11431    fn init_frame(slash_commands: &[&str], agents: &[&str]) -> forge_primitives::Message {
11432        serde_json::from_value(serde_json::json!({
11433            "type": "system",
11434            "subtype": "init",
11435            "session_id": "s",
11436            "slash_commands": slash_commands,
11437            "agents": agents,
11438        }))
11439        .expect("an init frame")
11440    }
11441
11442    /// Both lists are facts about the session, so the core keeps them: a
11443    /// view that arrives after the turn started reads them rather than
11444    /// waiting for the next init frame, which may be a whole turn away.
11445    #[test]
11446    fn the_catalogues_the_cli_advertises_are_retained_on_the_domain() {
11447        let mut domain = empty_domain();
11448
11449        apply_event_to_domain(
11450            &mut domain,
11451            &sdk_message(init_frame(&["/help", "memory"], &["reviewer"])),
11452        );
11453
11454        let commands: Vec<&str> =
11455            domain.available_commands.iter().map(|c| c.name.as_str()).collect();
11456        assert_eq!(commands, vec!["/help", "memory"], "the command list is kept whole");
11457        let agents: Vec<&str> = domain.available_agents.iter().map(|a| a.name.as_str()).collect();
11458        assert_eq!(agents, vec!["reviewer"], "and so is the agent catalogue");
11459    }
11460
11461    /// A plugin reload re-sends the command list mid-session, and the
11462    /// retained copy has to follow it or the dropdown goes stale.
11463    #[test]
11464    fn a_commands_changed_frame_refreshes_the_retained_commands() {
11465        let mut domain = empty_domain();
11466        apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/help"], &[])));
11467
11468        let refreshed = forge_primitives::Message::CommandsChanged {
11469            commands: vec![serde_json::json!({"name": "/reload", "description": "Reloaded"})],
11470            uuid: "cmd-uuid".to_owned(),
11471            session_id: "s".to_owned(),
11472            extras: serde_json::Map::new(),
11473        };
11474        apply_event_to_domain(&mut domain, &sdk_message(refreshed));
11475
11476        let commands: Vec<&str> =
11477            domain.available_commands.iter().map(|c| c.name.as_str()).collect();
11478        assert_eq!(commands, vec!["/reload"], "the reload's list replaces the init one");
11479    }
11480
11481    /// The CLI re-fires `system/init` every turn. A frame that advertises
11482    /// no commands carries nothing about them, so it must not wipe what
11483    /// the previous turn established.
11484    #[test]
11485    fn an_init_frame_with_no_commands_keeps_the_retained_ones() {
11486        let mut domain = empty_domain();
11487        apply_event_to_domain(&mut domain, &sdk_message(init_frame(&["/help"], &["reviewer"])));
11488
11489        apply_event_to_domain(&mut domain, &sdk_message(init_frame(&[], &["reviewer"])));
11490
11491        assert_eq!(domain.available_commands.len(), 1, "the earlier list stands");
11492        assert_eq!(domain.available_agents.len(), 1, "and the catalogue with it");
11493    }
11494
11495    /// The agent catalogue is read once per turn, the rule this fold
11496    /// applies: a re-fire inside the same turn is the
11497    /// same list, and the turn boundary is what re-arms the read.
11498    #[test]
11499    fn the_agent_catalogue_is_read_once_per_turn() {
11500        let mut domain = empty_domain();
11501        apply_event_to_domain(&mut domain, &sdk_message(init_frame(&[], &["reviewer"])));
11502
11503        apply_event_to_domain(&mut domain, &sdk_message(init_frame(&[], &["someone-else"])));
11504        assert_eq!(
11505            domain.available_agents.first().map(|a| a.name.as_str()),
11506            Some("reviewer"),
11507            "a re-fire inside the turn does not replace the catalogue",
11508        );
11509
11510        apply_event_to_domain(&mut domain, &sdk_message(result_message("success", false)));
11511        apply_event_to_domain(&mut domain, &sdk_message(init_frame(&[], &["someone-else"])));
11512        assert_eq!(
11513            domain.available_agents.first().map(|a| a.name.as_str()),
11514            Some("someone-else"),
11515            "and the next turn's frame does",
11516        );
11517    }
11518
11519    /// The hook observation carries three facts a view renders: the
11520    /// permission mode, the effort level and
11521    /// the tool_use -> agent-type attribution. All three land on the
11522    /// session so a view reads them instead of folding the same event
11523    /// again.
11524    #[test]
11525    fn a_hook_observation_is_held_on_the_domain() {
11526        let mut domain = empty_domain();
11527
11528        apply_event_to_domain(
11529            &mut domain,
11530            &hook_observation(Some("acceptEdits"), Some("xhigh"), Some("tu-1"), Some("Explore")),
11531        );
11532
11533        assert_eq!(
11534            domain.observed_permission_mode,
11535            Some(forge_primitives::PermissionMode::AcceptEdits),
11536            "the hook's permission mode is kept",
11537        );
11538        assert_eq!(
11539            domain.observed_effort,
11540            Some(forge_primitives::EffortLevel::Xhigh),
11541            "and its effort level",
11542        );
11543    }
11544
11545    /// A level the CLI spells differently is a level forge cannot name,
11546    /// not a reason to forget the one it already has.
11547    #[test]
11548    fn an_unreadable_hook_effort_keeps_the_level_already_held() {
11549        let mut domain = empty_domain();
11550        domain.observed_effort = Some(forge_primitives::EffortLevel::High);
11551
11552        apply_event_to_domain(&mut domain, &hook_observation(None, Some("turbo"), None, None));
11553
11554        assert_eq!(
11555            domain.observed_effort,
11556            Some(forge_primitives::EffortLevel::High),
11557            "an unreadable level leaves the held one standing",
11558        );
11559    }
11560
11561    /// The MCP set is per session, and the core is the one that asks for
11562    /// it, so the answer is kept rather than only passed through.
11563    #[test]
11564    fn the_mcp_snapshot_the_bridge_returns_is_held() {
11565        let mut domain = empty_domain();
11566
11567        apply_event_to_domain(
11568            &mut domain,
11569            &AgentEvent::McpSnapshot {
11570                session_id: "s".to_owned(),
11571                servers: vec![mcp_server("ctx7")],
11572                error: None,
11573            },
11574        );
11575
11576        let held = domain.mcp_servers.as_ref().expect("the snapshot is kept");
11577        assert_eq!(held.servers.len(), 1, "the server list is kept whole");
11578        assert_eq!(held.servers[0].name, "ctx7", "and names the server it carries");
11579        assert_eq!(held.error, None, "with no error standing beside it");
11580    }
11581
11582    /// A later snapshot replaces the one before it, including when the
11583    /// bridge reports a failure: a stale `connected` row is worse than
11584    /// no row.
11585    #[test]
11586    fn a_failed_mcp_snapshot_replaces_the_one_before_it() {
11587        let mut domain = empty_domain();
11588        apply_event_to_domain(
11589            &mut domain,
11590            &AgentEvent::McpSnapshot {
11591                session_id: "s".to_owned(),
11592                servers: vec![mcp_server("ctx7")],
11593                error: None,
11594            },
11595        );
11596
11597        apply_event_to_domain(
11598            &mut domain,
11599            &AgentEvent::McpSnapshot {
11600                session_id: "s".to_owned(),
11601                servers: Vec::new(),
11602                error: Some("the CLI refused".to_owned()),
11603            },
11604        );
11605
11606        let held = domain.mcp_servers.as_ref().expect("the snapshot is kept");
11607        assert!(held.servers.is_empty(), "the failed read's empty set replaces the old one");
11608        assert_eq!(held.error.as_deref(), Some("the CLI refused"), "and carries why");
11609    }
11610
11611    /// Context usage is the header's `ctx` reading, and it survives its
11612    /// own refresh: the poll that fills it lands on the session rather
11613    /// than only on the view that asked.
11614    #[test]
11615    fn the_context_usage_the_bridge_reports_is_held() {
11616        let mut domain = empty_domain();
11617
11618        apply_event_to_domain(
11619            &mut domain,
11620            &AgentEvent::ContextUsage {
11621                session_id: "s".to_owned(),
11622                percentage: Some(62),
11623                max_tokens: Some(1_000_000),
11624                total_tokens: Some(620_000),
11625                effective_max_tokens: Some(1_000_000),
11626            },
11627        );
11628
11629        let held = domain.context_usage.expect("the usage is kept");
11630        assert_eq!(held.percent, Some(62), "the percentage is kept");
11631        assert_eq!(held.max_tokens, Some(1_000_000), "and the window it is a share of");
11632    }
11633
11634    /// The model a session runs is stated at connect, and a view reading
11635    /// later must get the session's own rather than nothing.
11636    #[test]
11637    fn a_connected_session_holds_the_model_it_reported() {
11638        let mut domain = empty_domain();
11639
11640        apply_event_to_domain(&mut domain, &connected_event("uuid-1", "/proj"));
11641
11642        assert_eq!(
11643            domain.current_model.as_ref().map(|m| m.resolved_id.as_str()),
11644            Some("claude"),
11645            "the model the connect reported is kept",
11646        );
11647    }
11648
11649    /// The CLI names the model each turn runs under at the head of the
11650    /// turn, and that frame is how a mid-session switch reaches a reader:
11651    /// after `/model` a view's row moves, and a read that only
11652    /// listened at connect would go on naming the model the session left.
11653    #[test]
11654    fn the_model_follows_a_later_turns_init_frame() {
11655        let mut domain = empty_domain();
11656        apply_event_to_domain(&mut domain, &connected_event("uuid-1", "/proj"));
11657        apply_event_to_domain(
11658            &mut domain,
11659            &sdk_message(init_frame_naming_the_model("claude-opus-5-5")),
11660        );
11661
11662        assert_eq!(
11663            domain.current_model.as_ref().map(|model| model.resolved_id.as_str()),
11664            Some("claude-opus-5-5"),
11665            "the model the turn runs under is the model the session is said to be on",
11666        );
11667    }
11668
11669    /// The frame arrives at the head of every turn, including the turns
11670    /// that changed nothing, so a session that is not switching keeps the
11671    /// name it resolved at connect rather than being renamed to the CLI's
11672    /// own spelling of it.
11673    #[test]
11674    fn an_init_frame_naming_the_same_model_keeps_the_resolved_name() {
11675        let mut domain = empty_domain();
11676        apply_event_to_domain(&mut domain, &connected_event("uuid-1", "/proj"));
11677        domain.current_model = Some(forge_primitives::CurrentModel {
11678            requested_id: Some("Opus (1M context)".to_owned()),
11679            resolved_id: "claude".to_owned(),
11680            display_name_short: "Opus (1M context)".to_owned(),
11681            display_name_long: "Opus (1M context)".to_owned(),
11682            ..forge_primitives::CurrentModel::new("claude", "claude", "claude")
11683        });
11684
11685        apply_event_to_domain(&mut domain, &sdk_message(init_frame_naming_the_model("claude")));
11686
11687        assert_eq!(
11688            domain.current_model.as_ref().map(|model| model.display_name_long.as_str()),
11689            Some("Opus (1M context)"),
11690            "a turn that switched nothing does not rename the session",
11691        );
11692    }
11693
11694    /// A replacement occupant inherits none of the last one's facts: the
11695    /// hook mirrors describe a session that is gone, and the process tree
11696    /// belonged to a subprocess that exited with it.
11697    #[test]
11698    fn a_replaced_occupant_clears_the_held_view_facts() {
11699        let mut domain = empty_domain();
11700        apply_event_to_domain(
11701            &mut domain,
11702            &hook_observation(Some("plan"), Some("max"), Some("tu-1"), Some("Explore")),
11703        );
11704        domain.mcp_servers = Some(crate::domain_session::McpServers::default());
11705        domain.context_usage =
11706            Some(crate::domain_session::ContextUsage { percent: Some(10), max_tokens: None });
11707
11708        apply_event_to_domain(&mut domain, &connected_event("uuid-2", "/proj"));
11709
11710        assert_eq!(domain.observed_permission_mode, None, "the dead run's mode does not stand");
11711        assert_eq!(domain.observed_effort, None, "nor its effort");
11712        assert_eq!(domain.mcp_servers, None, "nor the servers it had connected");
11713        assert_eq!(domain.context_usage, None, "nor the context it had filled");
11714    }
11715
11716    /// A dead connection blanks the session's runtime identity, and a view
11717    /// renders that: what the core holds has to go with the run it
11718    /// described, or the read answers with a mode and a model the session no
11719    /// longer has.
11720    #[test]
11721    fn a_dead_connection_clears_the_held_view_facts() {
11722        let mut domain = seeded_view_facts();
11723
11724        apply_event_to_domain(
11725            &mut domain,
11726            &AgentEvent::ConnectionFailed {
11727                message: "reader died".to_owned(),
11728                kind: SpawnFailureKind::Unclassified,
11729            },
11730        );
11731
11732        assert_view_facts_cleared(&domain, "the subprocess died");
11733    }
11734
11735    fn seeded_view_facts() -> DomainSession {
11736        let mut domain = empty_domain();
11737        apply_event_to_domain(
11738            &mut domain,
11739            &hook_observation(Some("plan"), Some("max"), Some("tu-1"), Some("Explore")),
11740        );
11741        domain.current_model =
11742            Some(forge_primitives::CurrentModel::new("claude-opus-5", "Opus", "Claude Opus 5"));
11743        domain.mcp_servers = Some(crate::domain_session::McpServers::default());
11744        domain.context_usage =
11745            Some(crate::domain_session::ContextUsage { percent: Some(10), max_tokens: None });
11746        domain
11747    }
11748
11749    fn assert_view_facts_cleared(domain: &DomainSession, why: &str) {
11750        assert_eq!(domain.observed_permission_mode, None, "{why} leaves no mode standing");
11751        assert_eq!(domain.observed_effort, None, "{why} leaves no effort standing");
11752        assert_eq!(domain.current_model, None, "{why} leaves no model standing");
11753        assert_eq!(domain.context_usage, None, "{why} leaves no context reading standing");
11754        assert_eq!(domain.mcp_servers, None, "{why} leaves no server snapshot standing");
11755    }
11756
11757    /// The walk describes a subprocess tree, so it goes with the process: left
11758    /// standing it paints rows for programs that are not there, and nothing
11759    /// following a dead session would ever replace it. The clear reaches the
11760    /// stream as an empty walk, which is what the monitors and the background
11761    /// registry already do on the same event.
11762    ///
11763    /// **This reverses an earlier call** that kept the walk readable after a
11764    /// failure, on the view going on painting it - the ageing note said the
11765    /// tree was old, never that it was gone.
11766    #[test]
11767    fn a_dead_connection_clears_the_process_walk() {
11768        let mut domain = empty_domain();
11769        domain.process_snapshot = Some(forge_agent::env::processes::ProcessSnapshot {
11770            processes: Vec::new(),
11771            scanned_at: std::time::SystemTime::UNIX_EPOCH,
11772        });
11773
11774        apply_event_to_domain(
11775            &mut domain,
11776            &AgentEvent::ConnectionFailed {
11777                message: "reader died".to_owned(),
11778                kind: SpawnFailureKind::Unclassified,
11779            },
11780        );
11781
11782        assert!(
11783            domain.process_snapshot.is_none(),
11784            "the tree went with the subprocess, so the read has nothing to serve",
11785        );
11786    }
11787
11788    /// A Monitor tool call is live work, and the core holds it so a view
11789    /// can draw it.
11790    #[test]
11791    fn a_monitor_tool_call_enters_the_held_set() {
11792        let mut domain = empty_domain();
11793
11794        apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
11795
11796        assert_eq!(domain.monitors.len(), 1, "the call enters the set");
11797        let held = &domain.monitors[0];
11798        assert_eq!(held.tool_use_id, "tu-mon", "keyed by the tool_use that started it");
11799        assert_eq!(held.description, "ci-watch", "carrying its headline");
11800        assert_eq!(held.command, "gh run watch 1", "and the command it watches");
11801        assert_eq!(
11802            held.status,
11803            forge_primitives::MonitorStatus::Running,
11804            "and it starts running, which is the only state the tool call proves",
11805        );
11806        assert_eq!(held.task_id, None, "with no task id the wire has not named yet");
11807    }
11808
11809    /// The monitor's terminal transition is keyed by the CLI's task id,
11810    /// so the `task_started` stamp is what lets a later `task_updated`
11811    /// find the record at all.
11812    #[test]
11813    fn a_monitors_task_transition_settles_its_held_record() {
11814        let mut domain = empty_domain();
11815        apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
11816
11817        apply_event_to_domain(&mut domain, &sdk_message(task_started("t-1", Some("tu-mon"))));
11818        assert_eq!(
11819            domain.monitors[0].task_id.as_deref(),
11820            Some("t-1"),
11821            "the task id the CLI assigned is stamped on the record",
11822        );
11823
11824        apply_event_to_domain(&mut domain, &sdk_message(task_updated("t-1", "completed")));
11825
11826        assert_eq!(
11827            domain.monitors[0].status,
11828            forge_primitives::MonitorStatus::Completed,
11829            "a clean exit settles the monitor",
11830        );
11831    }
11832
11833    /// The instant a monitor ended rides the frame that settles it, and a
11834    /// view draws the age from it. Without it the record says how a
11835    /// monitor ended and not when, which is the whole difference between
11836    /// `completed` and `completed 12m`.
11837    #[test]
11838    fn a_monitors_terminal_transition_stamps_the_instant_it_ended() {
11839        let mut domain = empty_domain();
11840        apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
11841        apply_event_to_domain(&mut domain, &sdk_message(task_started("t-1", Some("tu-mon"))));
11842
11843        apply_event_to_domain(
11844            &mut domain,
11845            &sdk_message(task_updated_at("t-1", "completed", 1_700_000_000_123)),
11846        );
11847
11848        assert_eq!(
11849            domain.monitors[0].ended_at,
11850            Some(
11851                std::time::SystemTime::UNIX_EPOCH
11852                    + std::time::Duration::from_millis(1_700_000_000_123)
11853            ),
11854            "the frame that ends the monitor is the frame that says when",
11855        );
11856    }
11857
11858    /// A running monitor has no end instant, and a read that carried one
11859    /// would draw an age on work that is still going.
11860    #[test]
11861    fn a_running_monitor_carries_no_end_instant() {
11862        let mut domain = empty_domain();
11863        apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-mon", "ci-watch")));
11864        apply_event_to_domain(&mut domain, &sdk_message(task_started("t-1", Some("tu-mon"))));
11865        apply_event_to_domain(&mut domain, &sdk_message(task_updated("t-1", "running")));
11866
11867        assert_eq!(
11868            domain.monitors[0].ended_at, None,
11869            "a monitor still watching has not ended, so it has nothing to age",
11870        );
11871    }
11872
11873    /// A monitor that was already running before this process started is
11874    /// in the transcript the connect carries, and the transcript carries
11875    /// no lifecycle frame at all: the replay synthesizer emits user and
11876    /// assistant messages and nothing else. So a seeded entry is settled
11877    /// on arrival - one seeded running could never be settled, because
11878    /// every settlement is keyed on the task id the transcript cannot
11879    /// carry.
11880    #[test]
11881    fn a_monitor_folded_from_a_transcript_is_seeded_settled() {
11882        let mut domain = empty_domain();
11883
11884        fold_monitor(
11885            &mut domain,
11886            &monitor_tool_use("tu-mon", "ci-watch"),
11887            MonitorOrigin::Transcript,
11888        );
11889
11890        assert_eq!(domain.monitors.len(), 1, "the transcript's monitor is seeded");
11891        assert_eq!(
11892            domain.monitors[0].status,
11893            forge_primitives::MonitorStatus::Completed,
11894            "settled rather than left falsely running forever",
11895        );
11896        assert_eq!(
11897            domain.monitors[0].task_id, None,
11898            "with no task id, which is why nothing could ever settle it",
11899        );
11900    }
11901
11902    /// **A sub-agent's Monitor call is not the session's.** The connect-time
11903    /// fold that seeds monitors skips a parented frame, so a resumed
11904    /// conversation does not grow a monitor row from work an instance ran.
11905    #[test]
11906    fn a_subagents_monitor_call_is_not_seeded() {
11907        let mut domain = empty_domain();
11908        let mut framed = monitor_tool_use("tu-mon", "ci-watch");
11909        if let forge_primitives::Message::Assistant { parent_tool_use_id, .. } = &mut framed {
11910            *parent_tool_use_id = Some("toolu_dispatch".to_owned());
11911        }
11912        let mut event = connected_event("s-mon", "/tmp/mon");
11913        if let AgentEvent::Connected { history_updates, .. } = &mut event {
11914            *history_updates = Some(vec![framed]);
11915        }
11916
11917        apply_event_to_domain(&mut domain, &event);
11918
11919        assert!(domain.monitors.is_empty(), "the instance's monitor is not the session's");
11920    }
11921
11922    /// A live tool call is not settled: its task is running and the
11923    /// command frames that settle it are still to come.
11924    #[test]
11925    fn a_monitor_folded_from_the_wire_is_seeded_running() {
11926        let mut domain = empty_domain();
11927
11928        fold_monitor(&mut domain, &monitor_tool_use("tu-mon", "ci-watch"), MonitorOrigin::Wire);
11929
11930        assert_eq!(
11931            domain.monitors[0].status,
11932            forge_primitives::MonitorStatus::Running,
11933            "a call on the live wire is running until the command says otherwise",
11934        );
11935    }
11936
11937    /// The monitor set drains once every entry is terminal: a session that
11938    /// ran a monitor an hour ago shows no section, not a section with
11939    /// nothing to say.
11940    #[test]
11941    fn the_monitor_set_drains_once_every_entry_is_terminal() {
11942        let mut domain = empty_domain();
11943        apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-a", "ci-watch")));
11944        apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-b", "deploy-gate")));
11945        apply_event_to_domain(&mut domain, &sdk_message(task_started("t-a", Some("tu-a"))));
11946        apply_event_to_domain(&mut domain, &sdk_message(task_started("t-b", Some("tu-b"))));
11947
11948        apply_event_to_domain(&mut domain, &sdk_message(task_updated("t-a", "completed")));
11949        assert_eq!(
11950            domain.monitors.len(),
11951            2,
11952            "one settled monitor keeps the section, holding its tail"
11953        );
11954
11955        apply_event_to_domain(&mut domain, &sdk_message(task_notification("t-b")));
11956
11957        assert!(
11958            domain.monitors.is_empty(),
11959            "every entry terminal drains the set rather than leaving an empty section",
11960        );
11961    }
11962
11963    /// A notification whose status forge cannot name is still a
11964    /// notification, and it is the last frame a monitor sends: the
11965    /// set drains on every one of them, so a status the CLI adds
11966    /// later must not leave the core's set standing forever.
11967    #[test]
11968    fn an_unnameable_task_status_still_drains_the_set() {
11969        let mut domain = empty_domain();
11970        apply_event_to_domain(&mut domain, &sdk_message(monitor_tool_use("tu-a", "ci-watch")));
11971        apply_event_to_domain(&mut domain, &sdk_message(task_started("t-a", Some("tu-a"))));
11972        // Settled by the command frame, which deliberately does not drain:
11973        // the notification that follows is what carries the tail.
11974        apply_event_to_domain(&mut domain, &sdk_message(task_updated("t-a", "killed")));
11975
11976        apply_event_to_domain(
11977            &mut domain,
11978            &sdk_message(task_notification_with_status("t-a", "reticulating")),
11979        );
11980
11981        assert!(
11982            domain.monitors.is_empty(),
11983            "a notification drains the set whatever its status says",
11984        );
11985    }
11986
11987    /// The same drain runs over a transcript's seed, so a resumed session
11988    /// whose monitors were all over shows none of them.
11989    #[test]
11990    fn a_resumed_sessions_history_leaves_no_live_monitor() {
11991        let mut domain = empty_domain();
11992        let mut connected = connected_event("uuid-1", "/proj");
11993        if let AgentEvent::Connected { history_updates, .. } = &mut connected {
11994            *history_updates = Some(vec![
11995                monitor_tool_use("tu-mon", "ci-watch"),
11996                monitor_tool_use("tu-other", "deploy-gate"),
11997            ]);
11998        }
11999
12000        apply_event_to_domain(&mut domain, &connected);
12001
12002        assert!(
12003            domain.monitors.is_empty(),
12004            "a transcript cannot say a monitor is still running, so none of them are drawn",
12005        );
12006    }
12007
12008    fn assistant_with(error: &str) -> forge_primitives::Message {
12009        serde_json::from_value(serde_json::json!({
12010            "type": "assistant",
12011            "message": {
12012                "id": "msg-1",
12013                "role": "assistant",
12014                "model": "claude-sonnet-5",
12015                "content": [],
12016            },
12017            "session_id": "s",
12018            "error": error,
12019        }))
12020        .expect("parse an assistant message")
12021    }
12022
12023    fn api_retry(error: &str) -> forge_primitives::Message {
12024        serde_json::from_value(serde_json::json!({
12025            "type": "system",
12026            "subtype": "api_retry",
12027            "session_id": "s",
12028            "attempt": 1,
12029            "max_retries": 3,
12030            "retry_delay_ms": 100,
12031            "error": error,
12032        }))
12033        .expect("parse a retry frame")
12034    }
12035
12036    /// A failed connection is recorded where a view can read it, and a
12037    /// `Connected` clears it.
12038    ///
12039    /// Both halves go through `translate_event`, the dispatcher the run
12040    /// loop uses, and that is what this test is for: the record is in the
12041    /// failure arm and the clear is in the `Connected` arm rather than in
12042    /// `register_domain_session`, which production never calls - the
12043    /// spawn entries hoist a domain straight into `domain_handles` and
12044    /// insert it themselves.
12045    ///
12046    /// Both `Connected` branches are driven, because narrowing the clear
12047    /// into the replacement branch is the plausible mistake and it leaves
12048    /// the common recovery reading failed for the life of the process: a
12049    /// fresh task's FIRST connect after a retry.
12050    #[tokio::test]
12051    async fn a_connection_failure_is_recorded_and_a_connected_clears_it() {
12052        let (workspace, _update_rx) = crate::Workspace::testing_stub();
12053
12054        for (key, connected_once) in [
12055            (SessionSlot::from_str_for_test("record-failure-replaced"), true),
12056            (SessionSlot::from_str_for_test("record-failure-fresh"), false),
12057        ] {
12058            let (handle, _agent_cmds) = Agent::testing_stub();
12059            let arc = Arc::new(handle);
12060            let domain = workspace.register_domain_session(key.clone(), Some(Arc::clone(&arc)));
12061            let (_cmd_tx, command_rx) =
12062                tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12063            let mut task = SessionTask {
12064                key: key.clone(),
12065                handle: arc,
12066                command_rx,
12067                domain,
12068                update_tx: workspace.update_sender(),
12069                connected_once,
12070                workspace: Arc::downgrade(&workspace),
12071                compaction_count: 0,
12072                successor: None,
12073                teardown_done: None,
12074            };
12075
12076            task.translate_event(AgentEvent::ConnectionFailed {
12077                message: "the bucket died".to_owned(),
12078                kind: SpawnFailureKind::Unclassified,
12079            });
12080            assert_eq!(
12081                workspace.spawn_failure(&key).as_deref(),
12082                Some("the bucket died"),
12083                "a failed connection is recorded where a view can read it ({connected_once})",
12084            );
12085
12086            task.translate_event(connected_event("fresh-uuid", "/proj"));
12087
12088            assert_eq!(
12089                workspace.spawn_failure(&key),
12090                None,
12091                "and a session that comes up clears it rather than reading failed for the \
12092                 process ({connected_once})",
12093            );
12094        }
12095    }
12096
12097    /// A session-replacing re-spawn tears the live session down BEFORE
12098    /// re-spawning, so if the re-spawned agent fails to connect the
12099    /// session is momentarily agent-less. That failure must be
12100    /// recoverable (`ConnectionFailed { fatal: false }`), and the task's
12101    /// exit must leave no lingering pooled agent under the key. (The
12102    /// synchronous `get_agent_handle` Err arm is defensive/near-
12103    /// unreachable - `Agent::spawn` is infallible - so this covers the
12104    /// realistic async failure path instead.)
12105    #[tokio::test]
12106    async fn respawn_connection_failure_is_nonfatal_and_releases_the_session() {
12107        let (workspace, mut update_rx) = crate::Workspace::testing_stub();
12108        let key = SessionSlot::from_str_for_test("respawn-fail-uuid");
12109
12110        let (handle, _agent_cmds) = Agent::testing_stub();
12111        let arc = Arc::new(handle);
12112        // Register the re-spawned session under `key` with `arc` pooled -
12113        // the SAME Arc the task holds, so the exit cleanup recognises it.
12114        workspace.pool.lock().insert(
12115            key.clone(),
12116            crate::workspace::PooledAgent {
12117                handle: Arc::clone(&arc),
12118                account: forge_gateway::AccountKey("test".to_owned()),
12119                permission_mode: None,
12120                registration: None,
12121                session_id: "pooled-session".to_owned(),
12122            },
12123        );
12124        let domain = workspace.register_domain_session(key.clone(), Some(Arc::clone(&arc)));
12125        let (_cmd_tx, command_rx) =
12126            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12127
12128        let mut task = SessionTask {
12129            key: key.clone(),
12130            handle: arc,
12131            command_rx,
12132            domain,
12133            update_tx: workspace.update_sender(),
12134            connected_once: true, // a session-replacing re-spawn
12135            workspace: Arc::downgrade(&workspace),
12136            compaction_count: 0,
12137            successor: None,
12138            teardown_done: None,
12139        };
12140
12141        // The re-spawned agent fails to connect.
12142        task.translate_event(AgentEvent::ConnectionFailed {
12143            message: "spawn failed".to_owned(),
12144            kind: SpawnFailureKind::Unclassified,
12145        });
12146
12147        let mut saw_nonfatal = false;
12148        while let Ok(frame) = update_rx.try_recv() {
12149            if let SessionUpdate::ConnectionFailed { key: failed_key, fatal, .. } =
12150                std::sync::Arc::unwrap_or_clone(frame.update)
12151            {
12152                assert!(!fatal, "a failed re-spawn is recoverable, not fatal");
12153                assert_eq!(failed_key, key);
12154                saw_nonfatal = true;
12155            }
12156        }
12157        assert!(saw_nonfatal, "the failed re-spawn emits ConnectionFailed with fatal=false");
12158
12159        // The dead spawn's registrations are released in the arm itself,
12160        // before the task exits - no lingering agent under the key.
12161        assert!(
12162            !workspace.pool.lock().contains_key(&key),
12163            "a failed re-spawn leaves no lingering pooled agent",
12164        );
12165    }
12166
12167    /// The named failure kind has to survive the join. `translate_event`
12168    /// is the only production site that hands it to the worker-failure
12169    /// handler, and the default there would put the message heuristic back
12170    /// in charge: a `CwdNotFound` renders the directory it could not
12171    /// enter, and for a worker that directory runs through
12172    /// `.claude/worktrees/<label>`, so the row would be deleted and the
12173    /// lead told the worktree could not be created.
12174    #[tokio::test]
12175    async fn a_named_spawn_failure_kind_reaches_the_worker_handler() {
12176        let (workspace, _update_rx) = crate::Workspace::testing_stub();
12177        workspace.enable_test_dispatch_intercept();
12178        let db_dir = tempfile::tempdir().expect("tempdir");
12179        workspace.install_db_for_test(
12180            crate::store::Db::open(&db_dir.path().join("db.redb")).expect("open db"),
12181        );
12182        let project_dir = tempfile::tempdir().expect("project dir");
12183        workspace.seed_test_project("proj-x", &project_dir.path().to_string_lossy());
12184        let project_key = workspace.project_key_for_name("proj-x").expect("seeded project");
12185        // The row a worktree-creation verdict would delete.
12186        workspace
12187            .record_worker_row(
12188                &project_key,
12189                "reviewer",
12190                "reviewer-uuid",
12191                "c",
12192                None,
12193                None,
12194                false,
12195                crate::mcp::workers::types::WorkerTree { is_git_repo: true, cwd: None },
12196                None,
12197            )
12198            .expect("seed the worker's row");
12199
12200        let worker_slot = SessionSlot::from_str_for_test("worker-uuid");
12201        workspace.insert_live_worker(
12202            &project_key,
12203            crate::mcp::workers::types::WorkerEntry {
12204                label: "reviewer".to_owned(),
12205                charter: "c".to_owned(),
12206                slot: worker_slot.clone(),
12207                session_id: Some(forge_primitives::SessionId::new("reviewer-uuid")),
12208                status: forge_primitives::WorkerLiveness::Spawning,
12209                spawned_at: std::time::SystemTime::UNIX_EPOCH,
12210                spawned_by: SessionSlot::from_str_for_test("lead-uuid"),
12211                needs_tag: false,
12212                is_git_repo_at_spawn: true,
12213                cwd: None,
12214                diagnostic: None,
12215                kick: None,
12216            },
12217        );
12218
12219        let (handle, _agent_cmds) = Agent::testing_stub();
12220        let arc = Arc::new(handle);
12221        let domain = workspace.register_domain_session(worker_slot.clone(), Some(Arc::clone(&arc)));
12222        let (_cmd_tx, command_rx) =
12223            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12224        let mut task = SessionTask {
12225            key: worker_slot.clone(),
12226            handle: arc,
12227            command_rx,
12228            domain,
12229            update_tx: workspace.update_sender(),
12230            connected_once: false,
12231            workspace: Arc::downgrade(&workspace),
12232            compaction_count: 0,
12233            successor: None,
12234            teardown_done: None,
12235        };
12236
12237        task.translate_event(AgentEvent::ConnectionFailed {
12238            message: "forge-sdk session resume failed: claude subprocess working directory \
12239                      `/x/.claude/worktrees/reviewer` does not exist"
12240                .to_owned(),
12241            kind: SpawnFailureKind::WorkingDirMissing,
12242        });
12243
12244        let rows = workspace.worker_rows_for_project(&project_key);
12245        assert!(
12246            rows.iter().any(|row| row.label == "reviewer"),
12247            "a named missing-working-directory failure keeps the worker's row rather than \
12248             deleting it as a worktree that could not be created; rows left {:?}",
12249            rows.iter().map(|row| row.label.clone()).collect::<Vec<_>>(),
12250        );
12251    }
12252
12253    /// Common harness for the `execute_command_via_handle` tests
12254    /// below: build a fresh stub handle + drain channel, return both.
12255    fn stub_handle_with_rx()
12256    -> (Arc<AgentHandle>, tokio::sync::mpsc::UnboundedReceiver<forge_primitives::AgentCommand>)
12257    {
12258        let (handle, rx) = Agent::testing_stub();
12259        (Arc::new(handle), rx)
12260    }
12261
12262    /// `Command::Prompt` reaches the underlying agent's command
12263    /// dispatcher as `PromptWithImages`.
12264    #[test]
12265    fn execute_prompt_forwards_to_handle() {
12266        let (handle, mut rx) = stub_handle_with_rx();
12267        let key = SessionSlot::from_str_for_test("sess");
12268        execute_command_via_handle(
12269            &handle,
12270            &key,
12271            Some("sess-1"),
12272            None,
12273            Command::Prompt { key: key.clone(), text: "hi".into(), attachments: Vec::new() },
12274        )
12275        .expect("dispatch succeeds");
12276        let cmd = rx.try_recv().expect("command queued");
12277        assert!(matches!(
12278            cmd,
12279            forge_primitives::AgentCommand::PromptWithImages { session_id, .. }
12280                if session_id == "sess-1"
12281        ));
12282    }
12283
12284    /// A prompt is recorded on the seat as waiting, announced with its words,
12285    /// and sent under the SAME id - which is the whole mechanism: the CLI's
12286    /// lifecycle frames carry only the id and the state, so the row and the
12287    /// frames are one thing by id or not at all.
12288    #[tokio::test]
12289    async fn executing_a_prompt_records_and_announces_it() {
12290        let (workspace, mut updates) = crate::Workspace::testing_stub();
12291        workspace.seed_test_project("qp", "/tmp/qp");
12292        let key = SessionSlot::from_str_for_test("qp-lead");
12293        let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(key.clone(), None)));
12294        // A connected seat: the id is what the prompt is addressed to, and a
12295        // task that never connected drops the send rather than recording it.
12296        domain.lock().session_id = Some(forge_primitives::SessionId::new("qp-session"));
12297        let (handle, mut agent_rx) = Agent::testing_stub();
12298        let (_cmd_tx, command_rx) =
12299            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12300        let mut task = SessionTask {
12301            key: key.clone(),
12302            handle: Arc::new(handle),
12303            command_rx,
12304            domain: Arc::clone(&domain),
12305            update_tx: workspace.update_sender(),
12306            connected_once: false,
12307            workspace: Arc::downgrade(&workspace),
12308            compaction_count: 0,
12309            successor: None,
12310            teardown_done: None,
12311        };
12312
12313        task.execute_command(Command::Prompt {
12314            key: key.clone(),
12315            text: "the queued words".to_owned(),
12316            attachments: Vec::new(),
12317        });
12318
12319        let rows = domain.lock().prompt_queue.clone();
12320        assert_eq!(rows.len(), 1, "the prompt is on the seat's queue");
12321        assert_eq!(rows[0].text, "the queued words");
12322        assert_eq!(rows[0].source, PromptSource::You);
12323
12324        let sent = agent_rx.try_recv().expect("the prompt reaches the agent");
12325        let forge_primitives::AgentCommand::PromptWithImages { uuid, .. } = sent else {
12326            panic!("expected a prompt, got {sent:?}");
12327        };
12328        assert_eq!(
12329            uuid, rows[0].uuid,
12330            "the recorded row and the frame share one id, which is what the lifecycle frames resolve against",
12331        );
12332
12333        let announced: Vec<crate::protocol::SessionUpdate> =
12334            std::iter::from_fn(|| updates.try_recv().ok())
12335                .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12336                .collect();
12337        assert!(
12338            announced.iter().any(|update| matches!(
12339                update,
12340                crate::protocol::SessionUpdate::PromptQueued { uuid: announced, text, .. }
12341                    if announced == &rows[0].uuid && text == "the queued words"
12342            )),
12343            "the row's words ride the announcement, because no lifecycle frame carries them: {announced:?}",
12344        );
12345    }
12346
12347    /// A prompt held because it arrived mid-turn goes out when that turn's
12348    /// Result lands: the seat is idle again, so it starts a turn of its own
12349    /// instead of folding into the one that just ended.
12350    #[tokio::test]
12351    async fn a_turn_end_delivers_the_prompt_held_for_it() {
12352        let (workspace, _updates) = crate::Workspace::testing_stub();
12353        workspace.seed_test_project("hp", "/tmp/hp");
12354        let key = SessionSlot::from_str_for_test("hp-worker");
12355        let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(key.clone(), None)));
12356        domain.lock().session_id = Some(forge_primitives::SessionId::new("hp-session"));
12357        domain.lock().turn_pending = true;
12358        let (handle, _agent_rx) = Agent::testing_stub();
12359        let (_cmd_tx, command_rx) =
12360            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12361        let mut task = SessionTask {
12362            key: key.clone(),
12363            handle: Arc::new(handle),
12364            command_rx,
12365            domain: Arc::clone(&domain),
12366            update_tx: workspace.update_sender(),
12367            connected_once: false,
12368            workspace: Arc::downgrade(&workspace),
12369            compaction_count: 0,
12370            successor: None,
12371            teardown_done: None,
12372        };
12373        let held = crate::mcp::peers::types::WrappedPrompt {
12374            id: crate::mcp::peers::types::MessageId::mint(),
12375            kind: crate::mcp::peers::types::WrappedKind::Message,
12376            sender_name: "forge".to_owned(),
12377            sender_org: "Busytools".to_owned(),
12378            body: "the held words".to_owned(),
12379        };
12380        workspace.hold_peer_prompt_unless_turn_ended(
12381            &key,
12382            &SessionSlot::lead("Busytools", "hp"),
12383            held,
12384            || true,
12385        );
12386        workspace.enable_test_dispatch_intercept();
12387
12388        task.translate_event(AgentEvent::SdkMessage {
12389            session_id: "hp-session".to_owned(),
12390            msg: result_message("success", false),
12391        });
12392
12393        let dispatched = workspace.drain_test_dispatch_buffer().into_iter().any(|cmd| {
12394            matches!(
12395                cmd,
12396                crate::protocol::Command::PromptUnder { text, source, .. }
12397                    if source == PromptSource::Peer && text.contains("the held words")
12398            )
12399        });
12400        assert!(dispatched, "the held prompt is delivered as the turn ends");
12401        assert!(
12402            workspace.take_turn_held_for_slot(&key).is_empty(),
12403            "and the hold is spent, not redelivered next turn",
12404        );
12405    }
12406
12407    /// A transport death is NOT a turn end to deliver behind: the seat is
12408    /// gone, so the hold stays where the death path's expiry can tell its
12409    /// sender rather than being dispatched into a dying session, where it
12410    /// would drop unheard.
12411    #[tokio::test]
12412    async fn a_transport_death_does_not_drain_the_hold() {
12413        let (workspace, _updates) = crate::Workspace::testing_stub();
12414        workspace.seed_test_project("hd", "/tmp/hd");
12415        let key = SessionSlot::from_str_for_test("hd-worker");
12416        let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(key.clone(), None)));
12417        domain.lock().session_id = Some(forge_primitives::SessionId::new("hd-session"));
12418        domain.lock().turn_pending = true;
12419        let (handle, _agent_rx) = Agent::testing_stub();
12420        let (_cmd_tx, command_rx) =
12421            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12422        let mut task = SessionTask {
12423            key: key.clone(),
12424            handle: Arc::new(handle),
12425            command_rx,
12426            domain: Arc::clone(&domain),
12427            update_tx: workspace.update_sender(),
12428            connected_once: false,
12429            workspace: Arc::downgrade(&workspace),
12430            compaction_count: 0,
12431            successor: None,
12432            teardown_done: None,
12433        };
12434        let held = crate::mcp::peers::types::WrappedPrompt {
12435            id: crate::mcp::peers::types::MessageId::mint(),
12436            kind: crate::mcp::peers::types::WrappedKind::Message,
12437            sender_name: "forge".to_owned(),
12438            sender_org: "Busytools".to_owned(),
12439            body: "while the transport dies".to_owned(),
12440        };
12441        workspace.hold_peer_prompt_unless_turn_ended(
12442            &key,
12443            &SessionSlot::lead("Busytools", "hd"),
12444            held,
12445            || true,
12446        );
12447        workspace.enable_test_dispatch_intercept();
12448
12449        task.translate_event(AgentEvent::SdkMessage {
12450            session_id: "hd-session".to_owned(),
12451            msg: serde_json::from_value(serde_json::json!({
12452                "type": "error",
12453                "error": "stream closed",
12454            }))
12455            .expect("parse error message"),
12456        });
12457
12458        assert!(
12459            workspace.drain_test_dispatch_buffer().is_empty(),
12460            "nothing is dispatched into a session whose transport just died",
12461        );
12462        assert_eq!(
12463            workspace.take_turn_held_for_slot(&key).len(),
12464            1,
12465            "the hold stays for the death path's expiry to acknowledge",
12466        );
12467    }
12468
12469    /// A session whose CLI says it does not carry the lifecycle frames records
12470    /// NO queued rows: nothing could ever settle one, and a card that can never
12471    /// drain is worse than no card - that session draws its prompt the way it
12472    /// did before the pile existed.
12473    #[tokio::test]
12474    async fn a_cli_without_lifecycle_frames_draws_its_prompts_and_drains_the_pile() {
12475        let (workspace, mut updates) = crate::Workspace::testing_stub();
12476        workspace.seed_test_project("nf", "/tmp/nf");
12477        let key = SessionSlot::from_str_for_test("nf-lead");
12478        let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(key.clone(), None)));
12479        domain.lock().session_id = Some(forge_primitives::SessionId::new("nf-session"));
12480        let (handle, mut agent_rx) = Agent::testing_stub();
12481        let (_cmd_tx, command_rx) =
12482            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
12483        let mut task = SessionTask {
12484            key: key.clone(),
12485            handle: Arc::new(handle),
12486            command_rx,
12487            domain: Arc::clone(&domain),
12488            update_tx: workspace.update_sender(),
12489            connected_once: false,
12490            workspace: Arc::downgrade(&workspace),
12491            compaction_count: 0,
12492            successor: None,
12493            teardown_done: None,
12494        };
12495
12496        // **The first prompt lands before the init frame**, which is the only
12497        // frame that can say the capability is absent - so it is recorded and
12498        // announced while the flag is still unknown.
12499        task.execute_command(Command::Prompt {
12500            key: key.clone(),
12501            text: "the first prompt".to_owned(),
12502            attachments: Vec::new(),
12503        });
12504        assert_eq!(
12505            domain.lock().prompt_queue.len(),
12506            1,
12507            "premise: recorded while the capability was unknown",
12508        );
12509        let announced: Vec<crate::protocol::SessionUpdate> =
12510            std::iter::from_fn(|| updates.try_recv().ok())
12511                .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12512                .collect();
12513        let first = announced
12514            .iter()
12515            .find_map(|update| match update {
12516                crate::protocol::SessionUpdate::PromptQueued { uuid, text, .. }
12517                    if text == "the first prompt" =>
12518                {
12519                    Some(uuid.clone())
12520                }
12521                _ => None,
12522            })
12523            .expect("the first prompt is announced while its capability is unknown");
12524
12525        // The init frame from a CLI that does not advertise the capability is
12526        // what draws the waiting prompt and drains the pile.
12527        let init: forge_primitives::Message = serde_json::from_value(serde_json::json!({
12528            "type": "system",
12529            "subtype": "init",
12530            "session_id": "nf-session",
12531            "capabilities": ["interrupt_receipt_v1"],
12532        }))
12533        .expect("parse an init frame");
12534        task.translate_event(forge_agent::AgentEvent::SdkMessage {
12535            session_id: "nf-session".to_owned(),
12536            msg: init,
12537        });
12538        assert!(
12539            domain.lock().prompt_queue.is_empty(),
12540            "the init frame drains the pile it can no longer settle",
12541        );
12542        assert!(
12543            agent_rx.try_recv().is_ok(),
12544            "the prompt itself still reaches the agent: the pile is a view, not the send",
12545        );
12546        let drained: Vec<crate::protocol::SessionUpdate> =
12547            std::iter::from_fn(|| updates.try_recv().ok())
12548                .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12549                .collect();
12550        let row = drained.iter().find_map(|update| match update {
12551            crate::protocol::SessionUpdate::ChatAppended { msg, source, .. }
12552                if matches!(msg, forge_primitives::Message::User { .. }) =>
12553            {
12554                Some((msg, source))
12555            }
12556            _ => None,
12557        });
12558        let Some((msg, source)) = row else {
12559            panic!("the init frame draws the waiting prompt: {drained:?}");
12560        };
12561        assert_eq!(*source, Some(crate::protocol::PromptSource::You), "the reader's own send");
12562        let forge_primitives::Message::User { message, uuid, .. } = msg else {
12563            panic!("the flushed row is the user turn it draws: {msg:?}");
12564        };
12565        assert_eq!(uuid.as_deref(), Some(first.as_str()), "under the announcement's own id");
12566        assert!(
12567            message.content.iter().any(|block| matches!(
12568                block,
12569                forge_primitives::ContentBlock::Text { text, .. } if text == "the first prompt"
12570            )),
12571            "carrying the words the dispatch held: {message:?}",
12572        );
12573        assert!(
12574            drained.iter().any(|update| matches!(
12575                update,
12576                crate::protocol::SessionUpdate::PromptLifecycle { uuid, state, .. }
12577                    if uuid == &first && state == "started"
12578            )),
12579            "and settles the card it was waiting in: {drained:?}",
12580        );
12581
12582        // **Every later prompt takes the dispatch fallback.** The flag is
12583        // known now, so the prompt is not recorded and the dispatch draws it.
12584        task.execute_command(Command::Prompt {
12585            key: key.clone(),
12586            text: "the second prompt".to_owned(),
12587            attachments: Vec::new(),
12588        });
12589        assert!(
12590            domain.lock().prompt_queue.is_empty(),
12591            "a prompt is recorded only where the CLI can settle it",
12592        );
12593        assert!(agent_rx.try_recv().is_ok(), "and it still reaches the agent");
12594        let later: Vec<crate::protocol::SessionUpdate> =
12595            std::iter::from_fn(|| updates.try_recv().ok())
12596                .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12597                .collect();
12598        assert!(
12599            !later.iter().any(|update| matches!(
12600                update,
12601                crate::protocol::SessionUpdate::PromptQueued { .. }
12602            )),
12603            "nothing is announced for a row that is not kept: {later:?}",
12604        );
12605        let drawn = later.iter().find_map(|update| match update {
12606            crate::protocol::SessionUpdate::ChatAppended { msg, source, .. }
12607                if matches!(msg, forge_primitives::Message::User { .. }) =>
12608            {
12609                Some((msg, source))
12610            }
12611            _ => None,
12612        });
12613        let Some((msg, source)) = drawn else {
12614            panic!("a seat that cannot follow a queue draws its row at dispatch: {later:?}");
12615        };
12616        assert_eq!(*source, Some(crate::protocol::PromptSource::You), "the reader's own send");
12617        let forge_primitives::Message::User { message, .. } = msg else {
12618            panic!("the fallback row is the user turn it draws: {msg:?}");
12619        };
12620        assert!(
12621            message.content.iter().any(|block| matches!(
12622                block,
12623                forge_primitives::ContentBlock::Text { text, .. }
12624                    if text == "the second prompt"
12625            )),
12626            "carrying the words the dispatch held: {message:?}",
12627        );
12628    }
12629
12630    /// **The started row reaches every fanout subscriber, for each send.** The
12631    /// task emits it once; the transport's mirror and a view both get the same
12632    /// frame, and a second queued send draws its own row after the first.
12633    #[tokio::test]
12634    async fn a_started_row_reaches_every_fanout_subscriber_for_each_send() {
12635        let dir = tempfile::tempdir().expect("tempdir");
12636        let (workspace, _rx) =
12637            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12638        let slot = SessionSlot::worker("TestOrg", "forge", "fanout-row");
12639        let (mut task, mut view_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
12640        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("fanout-session"));
12641        let mut mirror_rx = task.update_tx.subscribe(SubscriberRole::Observing);
12642
12643        for (uuid, text) in [("fanout-1", "first send"), ("fanout-2", "second send")] {
12644            task.execute_command(Command::PromptUnder {
12645                key: slot.clone(),
12646                text: text.to_owned(),
12647                attachments: vec![forge_primitives::ImageAttachment {
12648                    data: "iVBORw0KGgo=".to_owned(),
12649                    mime_type: "image/png".to_owned(),
12650                }],
12651                uuid: uuid.to_owned(),
12652                source: crate::protocol::PromptSource::You,
12653                dispatch_id: None,
12654            });
12655        }
12656        for uuid in ["fanout-1", "fanout-2"] {
12657            task.translate_event(AgentEvent::SdkMessage {
12658                session_id: "fanout-session".to_owned(),
12659                msg: forge_primitives::Message::CommandLifecycle {
12660                    command_uuid: uuid.to_owned(),
12661                    state: "started".to_owned(),
12662                    uuid: format!("lifecycle-{uuid}"),
12663                    session_id: "fanout-session".to_owned(),
12664                    extras: forge_primitives::messages::Extras::new(),
12665                },
12666            });
12667        }
12668
12669        for (name, rx) in [("a view", &mut view_rx), ("the transport's mirror", &mut mirror_rx)] {
12670            let mut drawn = Vec::new();
12671            while let Ok(frame) = rx.try_recv() {
12672                if let SessionUpdate::ChatAppended { msg, source, .. } = &*frame.update
12673                    && let forge_primitives::Message::User { uuid: Some(uuid), message, .. } = msg
12674                    && message
12675                        .content
12676                        .iter()
12677                        .any(|block| matches!(block, forge_primitives::ContentBlock::Image { .. }))
12678                {
12679                    drawn.push((uuid.clone(), *source));
12680                }
12681            }
12682            assert_eq!(
12683                drawn,
12684                vec![
12685                    ("fanout-1".to_owned(), Some(crate::protocol::PromptSource::You)),
12686                    ("fanout-2".to_owned(), Some(crate::protocol::PromptSource::You)),
12687                ],
12688                "{name} hears each send's row once, in order",
12689            );
12690        }
12691    }
12692
12693    /// **Only a person's prompt draws the started row.** A machinery prompt's
12694    /// words ride its own forged row, and a cron fire's ride the delivery
12695    /// echo; a second row here would draw every delivery and the handoff
12696    /// brief twice.
12697    #[tokio::test]
12698    async fn a_machinery_prompt_draws_no_started_row() {
12699        let dir = tempfile::tempdir().expect("tempdir");
12700        let (workspace, _rx) =
12701            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12702        let slot = SessionSlot::worker("TestOrg", "forge", "machinery-row");
12703        let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
12704        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("machinery-session"));
12705
12706        task.execute_command(Command::PromptUnder {
12707            key: slot.clone(),
12708            text: "machinery words".to_owned(),
12709            attachments: Vec::new(),
12710            uuid: "forge-1".to_owned(),
12711            source: crate::protocol::PromptSource::Forge,
12712            dispatch_id: None,
12713        });
12714        task.translate_event(AgentEvent::SdkMessage {
12715            session_id: "machinery-session".to_owned(),
12716            msg: forge_primitives::Message::CommandLifecycle {
12717                command_uuid: "forge-1".to_owned(),
12718                state: "started".to_owned(),
12719                uuid: "lifecycle-forge-1".to_owned(),
12720                session_id: "machinery-session".to_owned(),
12721                extras: forge_primitives::messages::Extras::new(),
12722            },
12723        });
12724
12725        let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
12726            .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12727            .collect();
12728        assert!(
12729            !announced.iter().any(|update| matches!(
12730                update,
12731                SessionUpdate::ChatAppended {
12732                    msg: forge_primitives::Message::User { uuid: Some(id), .. },
12733                    ..
12734                } if id.as_str() == "forge-1"
12735            )),
12736            "a machinery prompt draws no row from the task: {announced:?}",
12737        );
12738        assert!(
12739            announced.iter().any(|update| matches!(
12740                update,
12741                SessionUpdate::PromptLifecycle { uuid, state, .. }
12742                    if uuid == "forge-1" && state == "started"
12743            )),
12744            "the lifecycle still advances: {announced:?}",
12745        );
12746    }
12747
12748    /// **A refused or discarded prompt draws no row.** The CLI settles it
12749    /// without running: the queue entry leaves and no chat frame follows, so
12750    /// the pile's own ending line is the only place the words exist.
12751    #[tokio::test]
12752    async fn a_refused_or_discarded_prompt_draws_no_row() {
12753        let dir = tempfile::tempdir().expect("tempdir");
12754        let (workspace, _rx) =
12755            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12756        let slot = SessionSlot::worker("TestOrg", "forge", "dropped-row");
12757        let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
12758        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("dropped-session"));
12759
12760        for (uuid, state) in [("refused-1", "refused"), ("discarded-1", "discarded")] {
12761            task.execute_command(Command::PromptUnder {
12762                key: slot.clone(),
12763                text: format!("{state} words"),
12764                attachments: Vec::new(),
12765                uuid: uuid.to_owned(),
12766                source: crate::protocol::PromptSource::You,
12767                dispatch_id: None,
12768            });
12769            assert_eq!(task.domain.lock().prompt_queue.len(), 1, "premise: the prompt waits");
12770            task.translate_event(AgentEvent::SdkMessage {
12771                session_id: "dropped-session".to_owned(),
12772                msg: forge_primitives::Message::CommandLifecycle {
12773                    command_uuid: uuid.to_owned(),
12774                    state: state.to_owned(),
12775                    uuid: format!("lifecycle-{uuid}"),
12776                    session_id: "dropped-session".to_owned(),
12777                    extras: forge_primitives::messages::Extras::new(),
12778                },
12779            });
12780            assert!(
12781                task.domain.lock().prompt_queue.is_empty(),
12782                "the settled prompt leaves the pile",
12783            );
12784            let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
12785                .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12786                .collect();
12787            assert!(
12788                !announced.iter().any(|update| matches!(
12789                    update,
12790                    SessionUpdate::ChatAppended { msg: forge_primitives::Message::User { uuid: Some(id), .. }, .. } if id.as_str() == uuid
12791                )),
12792                "a {state} prompt draws no row: {announced:?}",
12793            );
12794        }
12795    }
12796
12797    /// **A dispatch that fails after recording draws the row and drains the
12798    /// card.** The prompt never reached the CLI, so no lifecycle frame is
12799    /// coming - the words draw over the failure, and the pile settles.
12800    #[tokio::test]
12801    async fn a_failed_dispatch_draws_the_row_and_drains_the_card() {
12802        let dir = tempfile::tempdir().expect("tempdir");
12803        let (workspace, _rx) =
12804            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12805        let slot = SessionSlot::worker("TestOrg", "forge", "failed-dispatch");
12806        let (mut task, mut update_rx, agent_rx) = nudge_task_for(&workspace, &slot);
12807        task.domain.lock().session_id = Some(forge_primitives::SessionId::new("failed-session"));
12808        // The bridge's receiver is gone, so the prompt cannot reach the CLI.
12809        drop(agent_rx);
12810
12811        task.execute_command(Command::PromptUnder {
12812            key: slot.clone(),
12813            text: "doomed words".to_owned(),
12814            attachments: Vec::new(),
12815            uuid: "failed-1".to_owned(),
12816            source: crate::protocol::PromptSource::You,
12817            dispatch_id: None,
12818        });
12819
12820        assert!(
12821            task.domain.lock().prompt_queue.is_empty(),
12822            "the entry a failed dispatch leaves behind is dropped",
12823        );
12824        let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
12825            .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12826            .collect();
12827        let row_at = announced
12828            .iter()
12829            .position(|update| {
12830                matches!(
12831                    update,
12832                    SessionUpdate::ChatAppended {
12833                        msg: forge_primitives::Message::User { uuid: Some(id), .. },
12834                        source: Some(crate::protocol::PromptSource::You),
12835                        ..
12836                    } if id == "failed-1"
12837                )
12838            })
12839            .expect("the failed prompt's words draw");
12840        let error_at = announced
12841            .iter()
12842            .position(|update| matches!(update, SessionUpdate::TurnError { .. }))
12843            .expect("the failure draws");
12844        assert!(row_at < error_at, "the words draw above the failure: {announced:?}");
12845        assert!(
12846            announced.iter().any(|update| matches!(
12847                update,
12848                SessionUpdate::PromptLifecycle { uuid, state, .. }
12849                    if uuid == "failed-1" && state == "started"
12850            )),
12851            "and the card settles: {announced:?}",
12852        );
12853    }
12854
12855    /// **A prompt queued into an occupant that dies or is replaced settles
12856    /// into the pile's ending, not a forged row.** A live row would have no
12857    /// read carrier: the transcript keeps the enqueue attachment and no start,
12858    /// so a reload draws nothing. The words ride the queue announcement again
12859    /// and the discarded state turns the card into the ending line - the same
12860    /// carrier a refused or discarded prompt uses.
12861    #[tokio::test]
12862    async fn a_lost_queue_settles_each_prompt_into_the_pile_ending() {
12863        for replaced in [false, true] {
12864            let dir = tempfile::tempdir().expect("tempdir");
12865            let (workspace, _rx) =
12866                crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12867            let label = if replaced { "lost-replaced" } else { "lost-failed" };
12868            let slot = SessionSlot::worker("TestOrg", "forge", label);
12869            let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
12870            task.domain.lock().session_id = Some(forge_primitives::SessionId::new("old-occupant"));
12871            task.connected_once = replaced;
12872            for (uuid, text) in [("lost-1", "first lost"), ("lost-2", "second lost")] {
12873                task.execute_command(Command::PromptUnder {
12874                    key: slot.clone(),
12875                    text: text.to_owned(),
12876                    attachments: Vec::new(),
12877                    uuid: uuid.to_owned(),
12878                    source: crate::protocol::PromptSource::You,
12879                    dispatch_id: None,
12880                });
12881            }
12882            while update_rx.try_recv().is_ok() {}
12883
12884            let event = if replaced {
12885                connected_event("new-occupant", "/proj")
12886            } else {
12887                AgentEvent::ConnectionFailed {
12888                    message: "the child exited".to_owned(),
12889                    kind: forge_agent::client::SpawnFailureKind::Unclassified,
12890                }
12891            };
12892            task.translate_event(event);
12893
12894            let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
12895                .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12896                .collect();
12897            let anchor = if replaced {
12898                announced
12899                    .iter()
12900                    .position(|update| matches!(update, SessionUpdate::SessionReplaced { .. }))
12901            } else {
12902                announced
12903                    .iter()
12904                    .position(|update| matches!(update, SessionUpdate::ConnectionFailed { .. }))
12905            }
12906            .unwrap_or_else(|| panic!("{label}: the occupant's own frame is missing"));
12907            for (uuid, text) in [("lost-1", "first lost"), ("lost-2", "second lost")] {
12908                let echoed = announced
12909                    .iter()
12910                    .position(|update| {
12911                        matches!(
12912                            update,
12913                            SessionUpdate::PromptQueued {
12914                                uuid: id,
12915                                source: crate::protocol::PromptSource::You,
12916                                text: said,
12917                                ..
12918                            } if id == uuid && said == text
12919                        )
12920                    })
12921                    .unwrap_or_else(|| {
12922                        panic!("{label}: {uuid}'s words reach the ending line: {announced:?}")
12923                    });
12924                let ended = announced
12925                    .iter()
12926                    .position(|update| {
12927                        matches!(
12928                            update,
12929                            SessionUpdate::PromptLifecycle { uuid: id, state, .. }
12930                                if id == uuid && state == "discarded"
12931                        )
12932                    })
12933                    .unwrap_or_else(|| panic!("{label}: {uuid}'s card settles into the ending"));
12934                assert!(
12935                    !announced.iter().any(|update| matches!(
12936                        update,
12937                        SessionUpdate::ChatAppended {
12938                            msg: forge_primitives::Message::User { uuid: Some(id), .. },
12939                            ..
12940                        } if id == uuid
12941                    )),
12942                    "{label}: {uuid} never draws a forged row",
12943                );
12944                // A replacement resets the page's conversation, so the ending
12945                // must follow it; a death is the other way round, with the
12946                // pile still standing as it settles.
12947                let ordered = if replaced {
12948                    echoed > anchor && ended > anchor
12949                } else {
12950                    echoed < anchor && ended < anchor
12951                };
12952                assert!(
12953                    ordered,
12954                    "{label}: the words and their settle sit with the occupant's frame"
12955                );
12956            }
12957        }
12958    }
12959
12960    /// **A prompt held for a successor that never comes settles its mark.**
12961    /// The press never reached a model, so no lifecycle frame is coming: the
12962    /// composer would say "sending" for the life of the seat.
12963    #[tokio::test]
12964    async fn a_swap_that_fails_settles_the_held_prompt() {
12965        let dir = tempfile::tempdir().expect("tempdir");
12966        let (workspace, _rx) =
12967            crate::Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
12968        let slot = SessionSlot::worker("TestOrg", "forge", "held-settles");
12969        let (mut task, mut update_rx, _agent_rx) = nudge_task_for(&workspace, &slot);
12970        workspace.begin_swap(&slot);
12971        task.execute_command(Command::PromptUnder {
12972            key: slot.clone(),
12973            text: "held words".to_owned(),
12974            attachments: Vec::new(),
12975            uuid: "held-1".to_owned(),
12976            source: crate::protocol::PromptSource::You,
12977            dispatch_id: None,
12978        });
12979
12980        task.translate_event(AgentEvent::ConnectionFailed {
12981            message: "the child never came up".to_owned(),
12982            kind: forge_agent::client::SpawnFailureKind::Unclassified,
12983        });
12984
12985        let announced: Vec<SessionUpdate> = std::iter::from_fn(|| update_rx.try_recv().ok())
12986            .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
12987            .collect();
12988        assert!(
12989            announced.iter().any(|update| matches!(
12990                update,
12991                SessionUpdate::PromptLifecycle { uuid, state, .. }
12992                    if uuid == "held-1" && state == "cancelled"
12993            )),
12994            "the held press is settled: {announced:?}",
12995        );
12996    }
12997
12998    /// The capability latch is per-occupant and re-reads.
12999    ///
13000    /// The flag and the pile go with the occupant that set them - the same
13001    /// drop the `Connected` arm runs - so a `/resume` onto a CLI that DOES
13002    /// advertise the frames gets its pile back, and one that does not cannot
13003    /// inherit a latched true beside rows nothing can settle. A latch that
13004    /// only ever wrote once would keep the pile silently off for the rest of
13005    /// the slot's life.
13006    #[tokio::test]
13007    async fn the_capability_latch_re_reads_on_a_new_occupant() {
13008        let (workspace, mut updates) = crate::Workspace::testing_stub();
13009        workspace.seed_test_project("latch", "/tmp/latch");
13010        let key = SessionSlot::from_str_for_test("latch-lead");
13011        let domain = Arc::new(parking_lot::Mutex::new(DomainSession::new(key.clone(), None)));
13012        domain.lock().session_id = Some(forge_primitives::SessionId::new("latch-session"));
13013        let (handle, _agent_rx) = Agent::testing_stub();
13014        let (_cmd_tx, command_rx) =
13015            tokio::sync::mpsc::unbounded_channel::<crate::protocol::Command>();
13016        let mut task = SessionTask {
13017            key: key.clone(),
13018            handle: Arc::new(handle),
13019            command_rx,
13020            domain: Arc::clone(&domain),
13021            update_tx: workspace.update_sender(),
13022            connected_once: false,
13023            workspace: Arc::downgrade(&workspace),
13024            compaction_count: 0,
13025            successor: None,
13026            teardown_done: None,
13027        };
13028
13029        let init = |capabilities: serde_json::Value| -> forge_primitives::Message {
13030            serde_json::from_value(serde_json::json!({
13031                "type": "system",
13032                "subtype": "init",
13033                "session_id": "latch-session",
13034                "capabilities": capabilities,
13035            }))
13036            .expect("parse an init frame")
13037        };
13038
13039        // The first occupant does not advertise: the pile is off for it.
13040        task.translate_event(forge_agent::AgentEvent::SdkMessage {
13041            session_id: "latch-session".to_owned(),
13042            msg: init(serde_json::json!(["interrupt_receipt_v1"])),
13043        });
13044        assert_eq!(domain.lock().lifecycle_frames, Some(false), "the first init is read");
13045
13046        // A new occupant: the per-occupant drop `Connected` runs.
13047        domain.lock().drop_background_tasks();
13048        assert_eq!(domain.lock().lifecycle_frames, None, "nothing has been advertised yet");
13049        assert!(domain.lock().prompt_queue.is_empty(), "nor is anything waiting");
13050
13051        // The new occupant advertises, so its init is the first sight again -
13052        // and the pile works for it.
13053        task.translate_event(forge_agent::AgentEvent::SdkMessage {
13054            session_id: "latch-session".to_owned(),
13055            msg: init(serde_json::json!(["msg_lifecycle_v1"])),
13056        });
13057        assert_eq!(
13058            domain.lock().lifecycle_frames,
13059            Some(true),
13060            "the re-read is what gives the new occupant its pile",
13061        );
13062
13063        task.execute_command(Command::Prompt {
13064            key: key.clone(),
13065            text: "recorded now".to_owned(),
13066            attachments: Vec::new(),
13067        });
13068        assert_eq!(
13069            domain.lock().prompt_queue.len(),
13070            1,
13071            "and a row is kept for a CLI that can settle it",
13072        );
13073        let announced: Vec<crate::protocol::SessionUpdate> =
13074            std::iter::from_fn(|| updates.try_recv().ok())
13075                .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
13076                .collect();
13077        assert!(
13078            announced.iter().any(|update| matches!(
13079                update,
13080                crate::protocol::SessionUpdate::PromptQueued { text, .. } if text == "recorded now"
13081            )),
13082            "and announced: {announced:?}",
13083        );
13084    }
13085
13086    /// `Command::Cancel` reaches the agent's command dispatcher.
13087    #[test]
13088    fn execute_cancel_forwards_to_handle() {
13089        let (handle, mut rx) = stub_handle_with_rx();
13090        let key = SessionSlot::from_str_for_test("sess");
13091        execute_command_via_handle(
13092            &handle,
13093            &key,
13094            Some("sess-1"),
13095            None,
13096            Command::Cancel { key: key.clone() },
13097        )
13098        .expect("dispatch succeeds");
13099        let cmd = rx.try_recv().expect("command queued");
13100        assert!(matches!(
13101            cmd,
13102            forge_primitives::AgentCommand::Cancel { session_id } if session_id == "sess-1"
13103        ));
13104    }
13105
13106    /// `Command::SetMode` translates the typed `PermissionMode` back
13107    /// to its wire form on the way out to the bridge.
13108    #[test]
13109    fn execute_set_mode_uses_wire_form() {
13110        use forge_primitives::permission::PermissionMode;
13111        let (handle, mut rx) = stub_handle_with_rx();
13112        let key = SessionSlot::from_str_for_test("sess");
13113        execute_command_via_handle(
13114            &handle,
13115            &key,
13116            Some("sess-1"),
13117            None,
13118            Command::SetMode { key: key.clone(), mode: PermissionMode::Plan },
13119        )
13120        .expect("dispatch succeeds");
13121        let cmd = rx.try_recv().expect("command queued");
13122        match cmd {
13123            forge_primitives::AgentCommand::SetMode { session_id, mode } => {
13124                assert_eq!(session_id.as_str(), "sess-1");
13125                assert_eq!(mode, PermissionMode::Plan);
13126            }
13127            other => panic!("expected SetMode, got {other:?}"),
13128        }
13129    }
13130
13131    /// `Command::Cancel` without a `session_id` (pre-Connect) logs
13132    /// and returns `Ok(())` rather than panicking.
13133    #[test]
13134    fn execute_command_without_session_id_is_dropped() {
13135        let (handle, mut rx) = stub_handle_with_rx();
13136        let key = SessionSlot::from_str_for_test("sess");
13137        let err = execute_command_via_handle(
13138            &handle,
13139            &key,
13140            None,
13141            None,
13142            Command::Cancel { key: key.clone() },
13143        )
13144        .expect_err("a no-session dispatch reports the drop, not Ok");
13145        assert!(err.to_string().contains("no active session"), "the error names the drop: {err}");
13146        // Nothing should have been queued.
13147        assert!(rx.try_recv().is_err());
13148    }
13149
13150    fn background_tasks(tasks: Vec<serde_json::Value>) -> forge_primitives::Message {
13151        forge_primitives::Message::BackgroundTasksChanged {
13152            tasks,
13153            uuid: "u1".to_owned(),
13154            session_id: "worker".to_owned(),
13155            extras: serde_json::Map::new(),
13156        }
13157    }
13158
13159    fn one_live_task() -> Vec<serde_json::Value> {
13160        vec![serde_json::json!({
13161            "task_id": "t1",
13162            "task_type": "local_bash",
13163            "description": "gh run watch",
13164        })]
13165    }
13166
13167    /// The registry a PROCESSES view leads its rows with, with the command
13168    /// each task's own tool call carried: the bool alone told a view that
13169    /// something was running and nothing about what.
13170    ///
13171    /// The order is the CLI's own, from the capture: the card that names the
13172    /// command, then the registry that names the task, then `task_started`
13173    /// that links the two.
13174    #[test]
13175    fn the_registry_carries_each_tasks_own_command() {
13176        let (workspace, _rx) = crate::Workspace::testing_stub();
13177        let key = SessionSlot::from_str_for_test("w-bg");
13178        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13179        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13180            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13181        };
13182
13183        send(&mut task, a_backgrounded_bash("toolu_1", "gh run watch 123 --exit-status"));
13184        send(&mut task, background_tasks(one_live_task()));
13185        send(&mut task, a_task_started("t1", "toolu_1"));
13186
13187        let held = task.domain.lock();
13188        let tasks = held.background_tasks.clone();
13189        assert_eq!(tasks.len(), 1, "the CLI's own entry is held: {tasks:?}");
13190        assert_eq!(tasks[0].task_id, "t1", "under the id the CLI gave it");
13191        assert_eq!(tasks[0].task_type, "local_bash", "with the kind a row routes on");
13192        assert_eq!(tasks[0].description, "gh run watch", "and the line the row leads with");
13193        assert_eq!(
13194            tasks[0].command.as_deref(),
13195            Some("gh run watch 123 --exit-status"),
13196            "and the command its own card carried, which the scan adopts on",
13197        );
13198        drop(held);
13199
13200        send(&mut task, background_tasks(Vec::new()));
13201        assert!(
13202            task.domain.lock().background_tasks.is_empty(),
13203            "the whole set arrives each change, so an empty snapshot clears",
13204        );
13205    }
13206
13207    /// The same link carries the CALL, the id a view jumps by: a registry row
13208    /// names the tool_use_id `task_started` held for its task, whichever order
13209    /// the two halves land in.
13210    #[test]
13211    fn the_registry_carries_the_tool_call_its_link_names() {
13212        let (workspace, _rx) = crate::Workspace::testing_stub();
13213        let key = SessionSlot::from_str_for_test("w-bg");
13214        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13215        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13216            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13217        };
13218
13219        // Registry first, then the link - the CLI's own order.
13220        send(&mut task, background_tasks(one_live_task()));
13221        assert_eq!(
13222            task.domain.lock().background_tasks[0].tool_use_id,
13223            None,
13224            "before the link lands the row carries no call rather than a guess",
13225        );
13226        send(&mut task, a_task_started("t1", "toolu_1"));
13227        assert_eq!(
13228            task.domain.lock().background_tasks[0].tool_use_id.as_deref(),
13229            Some("toolu_1"),
13230            "the link fills the call once the registry is held",
13231        );
13232
13233        // The reverse order on a fresh seat: the link first, then the snapshot.
13234        let key = SessionSlot::from_str_for_test("w-bg2");
13235        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13236        send(&mut task, a_task_started("t1", "toolu_1"));
13237        send(&mut task, background_tasks(one_live_task()));
13238        assert_eq!(
13239            task.domain.lock().background_tasks[0].tool_use_id.as_deref(),
13240            Some("toolu_1"),
13241            "and the snapshot fills from a link that arrived first",
13242        );
13243    }
13244
13245    /// The command is held for ANY card that carries one, not only a card
13246    /// whose input asked to run in the background: the CLI backgrounds a bash
13247    /// three ways and two of them cannot show on the card, so gating on the
13248    /// flag left those tasks with no command - and a view that needs the
13249    /// command draws no row for them at all.
13250    #[test]
13251    fn a_task_backgrounded_after_its_card_still_gets_its_command() {
13252        let (workspace, _rx) = crate::Workspace::testing_stub();
13253        let key = SessionSlot::from_str_for_test("w-bg");
13254        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13255        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13256            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13257        };
13258
13259        // The card as a foreground call leaves it: no `run_in_background`.
13260        send(&mut task, a_bash_card("toolu_fg", "sleep 30 && echo later", None));
13261        send(&mut task, background_tasks(one_live_task()));
13262        send(&mut task, a_task_started("t1", "toolu_fg"));
13263
13264        let held = task.domain.lock().background_tasks.clone();
13265        assert_eq!(
13266            held[0].command.as_deref(),
13267            Some("sleep 30 && echo later"),
13268            "the command the task's own card carried crosses even though the card did not ask \
13269             for the background: the registry and the link are what scope it",
13270        );
13271    }
13272
13273    /// The other half of that rule: the command a card carried is read only
13274    /// through a task that names it. A rostered task whose link points at a
13275    /// different card keeps no command, and a card no task names resolves
13276    /// nothing.
13277    #[test]
13278    fn a_rostered_task_reads_only_the_command_its_own_link_names() {
13279        let (workspace, _rx) = crate::Workspace::testing_stub();
13280        let key = SessionSlot::from_str_for_test("w-bg");
13281        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13282        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13283            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13284        };
13285
13286        send(&mut task, a_bash_card("toolu_other", "echo unrelated", None));
13287        send(&mut task, a_bash_card("toolu_ours", "gh run watch 9", None));
13288        send(&mut task, background_tasks(one_live_task()));
13289        send(&mut task, a_task_started("t1", "toolu_ours"));
13290
13291        let held = task.domain.lock().background_tasks.clone();
13292        assert_eq!(held.len(), 1, "one rostered task");
13293        assert_eq!(
13294            held[0].command.as_deref(),
13295            Some("gh run watch 9"),
13296            "the command the task's own link names, not the other card's",
13297        );
13298    }
13299
13300    /// A task whose card forge never saw keeps its row and has no command:
13301    /// a rostered task with no recorded card still draws from its roster
13302    /// entry.
13303    #[test]
13304    fn a_task_with_no_card_still_carries_its_registry_row() {
13305        let (workspace, _rx) = crate::Workspace::testing_stub();
13306        let key = SessionSlot::from_str_for_test("w-bg");
13307        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13308
13309        task.translate_event(AgentEvent::SdkMessage {
13310            session_id: "worker".to_owned(),
13311            msg: background_tasks(one_live_task()),
13312        });
13313
13314        let held = task.domain.lock();
13315        let tasks = held.background_tasks.clone();
13316        assert_eq!(tasks.len(), 1, "the roster entry is still drawn: {tasks:?}");
13317        assert_eq!(tasks[0].command, None, "and its command is absent rather than invented");
13318    }
13319
13320    /// **A long foreground call is a row too.** The CLI tracks it like a
13321    /// backgrounded one - `task_started` when it outlives a beat, its own
13322    /// `task_notification` at the end - but never lists it in the registry,
13323    /// which is the background set. The set a view draws has to carry it in
13324    /// between, or a seat running a gate draws no processes at all.
13325    #[test]
13326    fn a_foreground_call_is_a_row_while_it_runs() {
13327        let (workspace, _rx) = crate::Workspace::testing_stub();
13328        let key = SessionSlot::from_str_for_test("w-fg");
13329        let (mut task, mut updates) = review_task_for(&workspace, &key);
13330        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13331            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13332        };
13333
13334        send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13335        send(&mut task, a_task_started("t-fg", "toolu_fg"));
13336
13337        let held = task.domain.lock().task_rows();
13338        assert_eq!(held.len(), 1, "the announced foreground call is a row: {held:?}");
13339        assert_eq!(held[0].task_id, "t-fg", "under the id the CLI gave it");
13340        assert!(!held[0].is_backgrounded, "and the CLI ran it in the foreground");
13341        assert_eq!(held[0].task_type, "local_bash", "with the kind a view routes on");
13342        assert_eq!(held[0].description, "gh run watch", "and the line the row leads with");
13343        assert_eq!(
13344            held[0].command.as_deref(),
13345            Some("sleep 110"),
13346            "and the command its own card carried, which the walk matches the process on",
13347        );
13348        assert_eq!(held[0].tool_use_id.as_deref(), Some("toolu_fg"), "its own reveal link");
13349
13350        let announced = announced_tasks(&mut updates);
13351        assert_eq!(announced.len(), 1, "the update that pushes it to a view went out");
13352        assert_eq!(
13353            announced[0].iter().map(|task| task.task_id.as_str()).collect::<Vec<_>>(),
13354            held.iter().map(|task| task.task_id.as_str()).collect::<Vec<_>>(),
13355            "and it carries exactly the set the record reads, not the registry alone",
13356        );
13357    }
13358
13359    /// The call's own ending frame withdraws its row, and the registry's
13360    /// whole-set snapshot never takes a running foreground task with it.
13361    #[test]
13362    fn a_foreground_row_is_held_across_a_snapshot_and_ends_with_its_notification() {
13363        let (workspace, _rx) = crate::Workspace::testing_stub();
13364        let key = SessionSlot::from_str_for_test("w-fg");
13365        let (mut task, mut updates) = review_task_for(&workspace, &key);
13366        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13367            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13368        };
13369
13370        send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13371        send(&mut task, a_task_started("t-fg", "toolu_fg"));
13372        send(&mut task, background_tasks(Vec::new()));
13373
13374        assert_eq!(
13375            task.domain.lock().task_rows().len(),
13376            1,
13377            "the registry snapshot carries the background set, so it neither adds nor wipes a \
13378             foreground task",
13379        );
13380
13381        send(&mut task, a_task_notification("t-fg", "completed", None));
13382        assert!(
13383            task.domain.lock().task_rows().is_empty(),
13384            "the notification is that call's ending, so its row leaves",
13385        );
13386        let announced = announced_tasks(&mut updates);
13387        assert_eq!(
13388            announced.last().map(Vec::len),
13389            Some(0),
13390            "and the withdrawal is pushed: {announced:?}",
13391        );
13392    }
13393
13394    /// A task the registry already names is one row, not two: a backgrounded
13395    /// call reaches both the snapshot and `task_started`.
13396    #[test]
13397    fn a_background_task_announced_twice_is_one_row() {
13398        let (workspace, _rx) = crate::Workspace::testing_stub();
13399        let key = SessionSlot::from_str_for_test("w-bg");
13400        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13401        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13402            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13403        };
13404
13405        send(&mut task, a_backgrounded_bash("toolu_1", "gh run watch 123"));
13406        send(&mut task, background_tasks(one_live_task()));
13407        send(&mut task, a_task_started("t1", "toolu_1"));
13408
13409        let held = task.domain.lock().task_rows();
13410        assert_eq!(held.len(), 1, "one task, one row: {held:?}");
13411        assert!(
13412            held[0].is_backgrounded,
13413            "the registry's own entry is the one read, and it is backgrounded",
13414        );
13415        assert_eq!(
13416            held[0].command.as_deref(),
13417            Some("gh run watch 123"),
13418            "read from the registry's entry, which filled from the link",
13419        );
13420    }
13421
13422    /// Background and foreground calls live at once are two rows, each saying
13423    /// which it is. A seat running a gate beside a backgrounded watch is this
13424    /// shape.
13425    #[test]
13426    fn a_background_and_a_foreground_call_live_at_once_are_two_rows() {
13427        let (workspace, _rx) = crate::Workspace::testing_stub();
13428        let key = SessionSlot::from_str_for_test("w-both");
13429        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13430        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13431            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13432        };
13433
13434        send(&mut task, a_backgrounded_bash("toolu_bg", "gh run watch 123"));
13435        send(&mut task, background_tasks(one_live_task()));
13436        send(&mut task, a_task_started("t1", "toolu_bg"));
13437        send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13438        send(&mut task, a_task_started("t-fg", "toolu_fg"));
13439
13440        let held = task.domain.lock().task_rows();
13441        assert_eq!(held.len(), 2, "two live calls, two rows: {held:?}");
13442        let background = held.iter().find(|row| row.task_id == "t1").expect("the registry row");
13443        assert!(background.is_backgrounded, "the registry row says backgrounded");
13444        let foreground = held.iter().find(|row| row.task_id == "t-fg").expect("the held row");
13445        assert!(!foreground.is_backgrounded, "the announced call says foreground");
13446        assert_eq!(foreground.command.as_deref(), Some("sleep 110"), "its own command");
13447    }
13448
13449    /// One call, one row: a call the registry later names under another task
13450    /// id does not draw beside the hold that already had it.
13451    #[test]
13452    fn a_call_the_registry_names_under_another_task_id_is_one_row() {
13453        let (workspace, _rx) = crate::Workspace::testing_stub();
13454        let key = SessionSlot::from_str_for_test("w-call");
13455        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13456        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13457            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13458        };
13459
13460        send(&mut task, a_bash_card("toolu_shared", "sleep 60", None));
13461        send(&mut task, a_task_started("t-hold", "toolu_shared"));
13462        // The CLI restates the same call under a registry task id of its own.
13463        send(
13464            &mut task,
13465            background_tasks(vec![serde_json::json!({
13466                "task_id": "t-reg",
13467                "task_type": "local_bash",
13468                "description": "sleep 60",
13469            })]),
13470        );
13471        send(&mut task, a_task_started("t-reg", "toolu_shared"));
13472
13473        let held = task.domain.lock().task_rows();
13474        assert_eq!(held.len(), 1, "one call, one row: {held:?}");
13475        assert_eq!(held[0].task_id, "t-reg", "the CLI's own row is the one read");
13476        assert_eq!(held[0].tool_use_id.as_deref(), Some("toolu_shared"));
13477    }
13478
13479    /// A cancelled turn settles its foreground row even though no ending frame
13480    /// ever comes: a foreground call cannot outlive the turn that ran it.
13481    #[test]
13482    fn a_cancelled_turn_settles_its_foreground_row() {
13483        let (workspace, _rx) = crate::Workspace::testing_stub();
13484        let key = SessionSlot::from_str_for_test("w-cancel");
13485        let (mut task, mut updates) = review_task_for(&workspace, &key);
13486        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13487            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13488        };
13489
13490        send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13491        send(&mut task, a_task_started("t-fg", "toolu_fg"));
13492        task.domain.lock().pending_cancel = true;
13493        send(&mut task, result_message("error_during_execution", true));
13494
13495        assert!(
13496            task.domain.lock().task_rows().is_empty(),
13497            "the cancelled turn takes its own foreground hold with it",
13498        );
13499        let announced = announced_tasks(&mut updates);
13500        assert_eq!(
13501            announced.last().map(Vec::len),
13502            Some(0),
13503            "and the withdrawal is pushed: {announced:?}",
13504        );
13505    }
13506
13507    /// Only a `local_bash` task is this feed's. A sub-agent's task belongs to
13508    /// the dispatch rows, and a frame a sub-agent produced is not the
13509    /// session's own row at all.
13510    #[test]
13511    fn an_agents_task_is_not_a_process_row() {
13512        let (workspace, _rx) = crate::Workspace::testing_stub();
13513        let key = SessionSlot::from_str_for_test("w-fg");
13514        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13515        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13516            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13517        };
13518
13519        send(
13520            &mut task,
13521            serde_json::from_value(serde_json::json!({
13522                "type": "system",
13523                "subtype": "task_started",
13524                "task_id": "t-agent",
13525                "tool_use_id": "toolu_agent",
13526                "description": "a dispatched agent",
13527                "task_type": "local_agent",
13528                "uuid": "u4",
13529                "session_id": "worker",
13530            }))
13531            .expect("parse a local_agent task_started"),
13532        );
13533        send(
13534            &mut task,
13535            serde_json::from_value(serde_json::json!({
13536                "type": "system",
13537                "subtype": "task_started",
13538                "task_id": "t-sub",
13539                "tool_use_id": "toolu_sub",
13540                "description": "a sub-agent's own bash",
13541                "task_type": "local_bash",
13542                "parent_tool_use_id": "toolu_task",
13543                "uuid": "u5",
13544                "session_id": "worker",
13545            }))
13546            .expect("parse a dispatched task_started"),
13547        );
13548
13549        assert!(
13550            task.domain.lock().task_rows().is_empty(),
13551            "neither an agent's task nor a dispatched call's belongs in the processes feed",
13552        );
13553    }
13554
13555    /// A terminal `task_updated` settles the hold; a progress patch does not.
13556    #[test]
13557    fn a_terminal_task_update_settles_a_hold_and_a_progress_one_does_not() {
13558        let (workspace, _rx) = crate::Workspace::testing_stub();
13559        let key = SessionSlot::from_str_for_test("w-update");
13560        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13561        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13562            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13563        };
13564
13565        send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13566        send(&mut task, a_task_started("t-fg", "toolu_fg"));
13567
13568        send(&mut task, a_task_updated("t-fg", "running"));
13569        assert_eq!(
13570            task.domain.lock().task_rows().len(),
13571            1,
13572            "a progress patch states no ending, so the hold stands",
13573        );
13574
13575        send(&mut task, a_task_updated("t-fg", "completed"));
13576        assert!(task.domain.lock().task_rows().is_empty(), "the terminal patch ends the hold");
13577    }
13578
13579    /// A backgrounded hold is not the turn's to settle: the call outlives the
13580    /// turn that started it, and the registry is where its life is read.
13581    #[test]
13582    fn a_background_hold_survives_the_turn_end() {
13583        let (workspace, _rx) = crate::Workspace::testing_stub();
13584        let key = SessionSlot::from_str_for_test("w-bg-turn");
13585        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13586        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13587            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13588        };
13589
13590        send(&mut task, a_task_started_with("t-bg", Some("toolu_bg"), true));
13591        task.domain.lock().pending_cancel = true;
13592        send(&mut task, result_message("error_during_execution", true));
13593
13594        let held = task.domain.lock().task_rows();
13595        assert_eq!(held.len(), 1, "the backgrounded hold outlives the turn: {held:?}");
13596        assert!(held[0].is_backgrounded, "and it still says so");
13597    }
13598
13599    /// A new occupant's calls are its own: the previous CLI's holds go with
13600    /// it, exactly as its registry does.
13601    #[test]
13602    fn a_new_occupant_clears_the_announced_holds() {
13603        let (workspace, _rx) = crate::Workspace::testing_stub();
13604        let key = SessionSlot::from_str_for_test("w-occupant");
13605        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13606        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13607            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13608        };
13609
13610        send(&mut task, a_bash_card("toolu_fg", "sleep 110", None));
13611        send(&mut task, a_task_started("t-fg", "toolu_fg"));
13612        assert_eq!(task.domain.lock().task_rows().len(), 1, "the hold is up");
13613
13614        task.translate_event(a_connected("worker-2"));
13615        assert!(
13616            task.domain.lock().task_rows().is_empty(),
13617            "a new occupant starts with no holds of the last one's",
13618        );
13619    }
13620
13621    /// Two announcements of one call are one row even before the registry
13622    /// names either: the call is the identity.
13623    #[test]
13624    fn two_announcements_of_one_call_are_one_row() {
13625        let (workspace, _rx) = crate::Workspace::testing_stub();
13626        let key = SessionSlot::from_str_for_test("w-call-only");
13627        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13628        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13629            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13630        };
13631
13632        send(&mut task, a_bash_card("toolu_shared", "sleep 60", None));
13633        send(&mut task, a_task_started("t-one", "toolu_shared"));
13634        send(&mut task, a_task_started("t-two", "toolu_shared"));
13635
13636        let held = task.domain.lock().task_rows();
13637        assert_eq!(held.len(), 1, "one call, one row: {held:?}");
13638        assert_eq!(held[0].task_id, "t-one", "the first announcement holds the call");
13639    }
13640
13641    /// A task announced with no call still draws: the row is the task's.
13642    #[test]
13643    fn a_task_with_no_call_still_draws() {
13644        let (workspace, _rx) = crate::Workspace::testing_stub();
13645        let key = SessionSlot::from_str_for_test("w-bare");
13646        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13647        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13648            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13649        };
13650
13651        send(&mut task, a_task_started_with("t-bare", None, false));
13652
13653        let held = task.domain.lock().task_rows();
13654        assert_eq!(held.len(), 1, "a call-less task is still a row: {held:?}");
13655        assert_eq!(held[0].tool_use_id, None, "with no reveal link rather than a guess");
13656    }
13657
13658    /// The ending may name the other task id: one call can be announced
13659    /// twice, and the frame's own call is the join.
13660    #[test]
13661    fn an_ending_frame_settles_the_hold_by_its_call_too() {
13662        let (workspace, _rx) = crate::Workspace::testing_stub();
13663        let key = SessionSlot::from_str_for_test("w-end-call");
13664        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13665        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13666            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13667        };
13668
13669        send(&mut task, a_bash_card("toolu_shared", "sleep 60", None));
13670        send(&mut task, a_task_started("t-one", "toolu_shared"));
13671        send(&mut task, a_task_notification("t-other", "completed", Some("toolu_shared")));
13672
13673        assert!(
13674            task.domain.lock().task_rows().is_empty(),
13675            "the call's own ending settles it, whichever task id it names",
13676        );
13677    }
13678
13679    /// The `TaskStop` path's shape, in the wire's own order: the registry
13680    /// snapshot names the task BEFORE `task_started` links it (the CLI's
13681    /// captured order), the stop sends no ending and no registry change, and
13682    /// a later change to the set leaves the task out. The registry is the
13683    /// whole background set, so the row goes with it instead of resurfacing.
13684    #[test]
13685    fn a_background_hold_leaves_when_the_registry_stops_naming_its_task() {
13686        let (workspace, _rx) = crate::Workspace::testing_stub();
13687        let key = SessionSlot::from_str_for_test("w-stop");
13688        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13689        let send = |task: &mut SessionTask, msg: forge_primitives::Message| {
13690            task.translate_event(AgentEvent::SdkMessage { session_id: "worker".to_owned(), msg });
13691        };
13692
13693        send(&mut task, a_backgrounded_bash("toolu_stop", "gh run watch 9"));
13694        send(
13695            &mut task,
13696            background_tasks(vec![serde_json::json!({
13697                "task_id": "t-stop",
13698                "task_type": "local_bash",
13699                "description": "a stopped watch",
13700            })]),
13701        );
13702        send(&mut task, a_task_started_with("t-stop", Some("toolu_stop"), true));
13703        assert_eq!(task.domain.lock().task_rows().len(), 1, "named while it runs");
13704
13705        // The stop sends no ending and no registry change, so the only
13706        // evidence left is a set that stops naming the task.
13707        send(&mut task, background_tasks(Vec::new()));
13708        assert!(
13709            task.domain.lock().task_rows().is_empty(),
13710            "a backgrounded hold is the registry's to keep, and the set that leaves it out takes it",
13711        );
13712    }
13713
13714    fn a_backgrounded_bash(id: &str, command: &str) -> forge_primitives::Message {
13715        a_bash_card(id, command, Some(true))
13716    }
13717
13718    fn a_task_notification(
13719        task_id: &str,
13720        status: &str,
13721        tool_use_id: Option<&str>,
13722    ) -> forge_primitives::Message {
13723        serde_json::from_value(serde_json::json!({
13724            "type": "system",
13725            "subtype": "task_notification",
13726            "task_id": task_id,
13727            "tool_use_id": tool_use_id,
13728            "status": status,
13729            "output_file": "",
13730            "summary": "done",
13731            "uuid": "u3",
13732            "session_id": "worker",
13733        }))
13734        .expect("parse a task_notification message")
13735    }
13736
13737    /// A Bash card, with the `run_in_background` flag only when the caller
13738    /// says the card carried one.
13739    fn a_bash_card(
13740        id: &str,
13741        command: &str,
13742        run_in_background: Option<bool>,
13743    ) -> forge_primitives::Message {
13744        let mut input = serde_json::json!({ "command": command });
13745        if let Some(flag) = run_in_background {
13746            input["run_in_background"] = serde_json::Value::Bool(flag);
13747        }
13748        serde_json::from_value(serde_json::json!({
13749            "type": "assistant",
13750            "uuid": "a1",
13751            "session_id": "worker",
13752            "message": {
13753                "id": "m1",
13754                "role": "assistant",
13755                "model": "claude-opus-5",
13756                "content": [{
13757                    "type": "tool_use",
13758                    "id": id,
13759                    "name": "Bash",
13760                    "input": input,
13761                }],
13762            },
13763        }))
13764        .expect("parse an assistant message")
13765    }
13766
13767    fn a_task_started(task_id: &str, tool_use_id: &str) -> forge_primitives::Message {
13768        a_task_started_with(task_id, Some(tool_use_id), false)
13769    }
13770
13771    /// The same frame with the CLI's backgrounding flag as the wire carried
13772    /// it, and the call optional - a task can be announced with no call.
13773    fn a_task_started_with(
13774        task_id: &str,
13775        tool_use_id: Option<&str>,
13776        is_backgrounded: bool,
13777    ) -> forge_primitives::Message {
13778        serde_json::from_value(serde_json::json!({
13779            "type": "system",
13780            "subtype": "task_started",
13781            "task_id": task_id,
13782            "tool_use_id": tool_use_id,
13783            "description": "gh run watch",
13784            "task_type": "local_bash",
13785            "is_backgrounded": is_backgrounded,
13786            "uuid": "u2",
13787            "session_id": "worker",
13788        }))
13789        .expect("parse a task_started message")
13790    }
13791
13792    fn a_task_updated(task_id: &str, status: &str) -> forge_primitives::Message {
13793        serde_json::from_value(serde_json::json!({
13794            "type": "system",
13795            "subtype": "task_updated",
13796            "task_id": task_id,
13797            "patch": { "status": status },
13798            "uuid": "u6",
13799            "session_id": "worker",
13800        }))
13801        .expect("parse a task_updated message")
13802    }
13803
13804    /// A fresh occupant in the same slot, which resets everything a previous
13805    /// CLI process owned.
13806    fn a_connected(session_id: &str) -> AgentEvent {
13807        AgentEvent::Connected {
13808            session_id: session_id.to_owned(),
13809            cwd: "/tmp/connected".to_owned(),
13810            current_model: forge_primitives::CurrentModel {
13811                resolved_id: "claude".to_owned(),
13812                display_name_short: "claude".to_owned(),
13813                display_name_long: "claude".to_owned(),
13814                requested_id: None,
13815                catalog_id: None,
13816                supports_effort: false,
13817                supported_effort_levels: Vec::new(),
13818                supports_auto_mode: None,
13819                supports_adaptive_thinking: None,
13820                is_authoritative: true,
13821            },
13822            available_models: Vec::new(),
13823            mode: None,
13824            history_updates: None,
13825            compaction_count: 0,
13826        }
13827    }
13828
13829    /// The session's own registry follows the CLI's snapshot, which
13830    /// carries the whole set every change - so an empty one clears rather
13831    /// than adding to what was there.
13832    #[test]
13833    fn background_work_follows_the_cli_snapshot() {
13834        let (workspace, _rx) = crate::Workspace::testing_stub();
13835        let key = SessionSlot::from_str_for_test("w-bg");
13836        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13837
13838        task.translate_event(AgentEvent::SdkMessage {
13839            session_id: "worker".to_owned(),
13840            msg: background_tasks(one_live_task()),
13841        });
13842        assert!(
13843            task.domain.lock().background_work,
13844            "a snapshot naming a live task is background work",
13845        );
13846
13847        task.translate_event(AgentEvent::SdkMessage {
13848            session_id: "worker".to_owned(),
13849            msg: background_tasks(Vec::new()),
13850        });
13851        assert!(
13852            !task.domain.lock().background_work,
13853            "the whole set arrives each change, so an empty snapshot clears it",
13854        );
13855    }
13856
13857    /// The CLI never sends a terminal `background_tasks_changed` for a
13858    /// session that died, so without this the registry stays true behind a
13859    /// subprocess that is gone and the row spins forever.
13860    #[test]
13861    fn a_connection_failure_drops_background_work() {
13862        let (workspace, _rx) = crate::Workspace::testing_stub();
13863        let key = SessionSlot::from_str_for_test("w-bg-fail");
13864        let (mut task, _update_rx) = review_task_for(&workspace, &key);
13865        task.translate_event(AgentEvent::SdkMessage {
13866            session_id: "worker".to_owned(),
13867            msg: background_tasks(one_live_task()),
13868        });
13869        assert!(task.domain.lock().background_work, "precondition: the snapshot armed it");
13870
13871        task.translate_event(AgentEvent::ConnectionFailed {
13872            message: "the subprocess exited".to_owned(),
13873            kind: SpawnFailureKind::Unclassified,
13874        });
13875
13876        assert!(
13877            !task.domain.lock().background_work,
13878            "a dead subprocess has no live background work, whatever the last snapshot said",
13879        );
13880    }
13881}
13882
13883#[cfg(test)]
13884#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
13885mod connected_hook_tests {
13886    use super::*;
13887    use crate::Workspace;
13888    use crate::protocol::Command;
13889    use crate::target::ProjectKey;
13890
13891    /// A project's lead slot: the label is what marks the role, so a
13892    /// project whose name looks worker-shaped is still a lead.
13893    fn lead_slot(project_name: &str) -> crate::SessionSlot {
13894        crate::SessionSlot::lead("TestOrg", project_name)
13895    }
13896
13897    /// Seed `proj-x` with one persisted worker row and return the
13898    /// tempdir backing the store, whose lifetime must outlive the test.
13899    ///
13900    /// The project path is a real directory: a non-git worker runs in the
13901    /// project root, so the wave checks that root is there before
13902    /// re-spawning it.
13903    fn seed_project_with_one_worker_row(
13904        workspace: &Arc<Workspace>,
13905        label: &str,
13906    ) -> tempfile::TempDir {
13907        let dir = tempfile::tempdir().expect("tempdir");
13908        workspace.install_db_for_test(
13909            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
13910        );
13911        let project_path = dir.path().join("proj-x");
13912        std::fs::create_dir_all(&project_path).expect("create the project dir");
13913        let project_path = project_path.to_string_lossy().into_owned();
13914        workspace.seed_test_project("proj-x", &project_path);
13915        let project_key = ProjectKey::new(
13916            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(&project_path)),
13917        );
13918        workspace
13919            .record_worker_row(
13920                &project_key,
13921                label,
13922                &format!("{label}-test-id"),
13923                &format!("charter for {label}"),
13924                None,
13925                None,
13926                false,
13927                crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
13928                None,
13929            )
13930            .expect("seed the worker row");
13931        dir
13932    }
13933
13934    /// A lead Connected for a project with a persisted worker triggers
13935    /// one `Command::SpawnWorker` per row.
13936    #[test]
13937    fn lead_connected_with_a_worker_row_triggers_respawn() {
13938        let (workspace, _update_rx) = Workspace::testing_stub();
13939        let _db = seed_project_with_one_worker_row(&workspace, "implementer");
13940        workspace.enable_test_dispatch_intercept();
13941
13942        on_connected_for_test(&workspace, &lead_slot("proj-x"), "lead-uuid");
13943
13944        let dispatched = workspace.drain_test_dispatch_buffer();
13945        let spawns: Vec<&Command> =
13946            dispatched.iter().filter(|c| matches!(c, Command::SpawnWorker { .. })).collect();
13947        assert_eq!(spawns.len(), 1, "one SpawnWorker per stored worker");
13948    }
13949
13950    /// **A closed lead does not bring its workers back.** The boot wave
13951    /// never spawns a closed lead, so its Connected never runs; this pins
13952    /// the same guarantee at the hook, so it does not rest on that alone.
13953    #[test]
13954    fn a_closed_lead_does_not_respawn_its_workers() {
13955        let (workspace, _update_rx) = Workspace::testing_stub();
13956        let _db = seed_project_with_one_worker_row(&workspace, "implementer");
13957        workspace.enable_test_dispatch_intercept();
13958        let lead = lead_slot("proj-x");
13959        workspace.record_session_id(lead.org(), lead.project(), lead.label(), "lead-id");
13960        workspace.record_seat_state(&lead, crate::store::sessions::SeatState::Closed);
13961
13962        on_connected_for_test(&workspace, &lead, "lead-uuid");
13963
13964        let dispatched = workspace.drain_test_dispatch_buffer();
13965        assert!(
13966            !dispatched.iter().any(|command| matches!(command, Command::SpawnWorker { .. })),
13967            "the stop stands: no worker comes back for a closed lead",
13968        );
13969    }
13970
13971    /// A lead Connected for a project with no persisted workers is
13972    /// a no-op - nothing dispatched.
13973    ///
13974    /// UNTESTED: this cannot tell the empty-set early return from
13975    /// falling through and iterating an empty slice, and deleting the
13976    /// store setup below passes too. What is hard is not the harness -
13977    /// `workspace::worker_respawn_tests` already has one - but that
13978    /// observing the skipped scan means asserting on private state or
13979    /// racing the spawned task. Recorded so the gap stays findable.
13980    #[test]
13981    fn lead_connected_without_a_worker_row_does_nothing() {
13982        let (workspace, _update_rx) = Workspace::testing_stub();
13983        let dir = tempfile::tempdir().expect("tempdir");
13984        workspace.install_db_for_test(
13985            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
13986        );
13987        workspace.enable_test_dispatch_intercept();
13988        workspace.seed_test_project("proj-y", "/tmp/proj-y");
13989
13990        on_connected_for_test(&workspace, &lead_slot("proj-y"), "lead-uuid");
13991
13992        assert!(workspace.drain_test_dispatch_buffer().is_empty());
13993    }
13994
13995    /// A worker's Connected does NOT trigger the respawn hook - the
13996    /// slot's label is what stops it.
13997    ///
13998    /// The project's name is the one a key-shaped parse would have read
13999    /// as a worker, so a reintroduced parse would classify the project
14000    /// itself and the hook would have to be stopped by the label alone.
14001    /// A persisted row is necessary for this to have teeth and is not
14002    /// sufficient.
14003    #[test]
14004    fn worker_connected_does_not_trigger_respawn() {
14005        let (workspace, _update_rx) = Workspace::testing_stub();
14006        let dir = tempfile::tempdir().expect("tempdir");
14007        workspace.install_db_for_test(
14008            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
14009        );
14010        // A project named like the worker key shape the old parse read.
14011        let lookalike = "worker_wp_planner_abc";
14012        workspace.seed_test_project(lookalike, "/tmp/wp");
14013        let project_key = ProjectKey::new(
14014            forge_agent::userdata::catalog::scan::project_key_for_directory(Some("/tmp/wp")),
14015        );
14016        workspace
14017            .record_worker_row(
14018                &project_key,
14019                "planner",
14020                "planner-test-id",
14021                "charter for planner",
14022                None,
14023                None,
14024                false,
14025                crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
14026                None,
14027            )
14028            .expect("seed the worker row");
14029        workspace.enable_test_dispatch_intercept();
14030
14031        let worker = crate::SessionSlot::worker("TestOrg", lookalike, "planner");
14032        on_connected_for_test(&workspace, &worker, "worker-uuid");
14033
14034        let dispatched = workspace.drain_test_dispatch_buffer();
14035        assert!(
14036            dispatched.iter().all(|c| !matches!(c, Command::SpawnWorker { .. })),
14037            "a worker's own Connected does not respawn the team",
14038        );
14039    }
14040
14041    /// Idempotency: a second Connected event for the same lead must
14042    /// not double-spawn. The first call inserts WorkerEntries into
14043    /// `live_workers`; the second call's `list_live_workers(...).is_empty()`
14044    /// gate trips and the trigger no-ops.
14045    #[test]
14046    fn second_lead_connected_does_not_double_spawn() {
14047        let (workspace, _update_rx) = Workspace::testing_stub();
14048        let _db = seed_project_with_one_worker_row(&workspace, "implementer");
14049        workspace.enable_test_dispatch_intercept();
14050
14051        let lead = lead_slot("proj-x");
14052
14053        // First Connected: triggers the respawn (1 SpawnWorker).
14054        on_connected_for_test(&workspace, &lead, "lead-uuid");
14055        let after_first = workspace.drain_test_dispatch_buffer();
14056        let first_spawns: usize =
14057            after_first.iter().filter(|c| matches!(c, Command::SpawnWorker { .. })).count();
14058        assert_eq!(first_spawns, 1, "first Connected respawns the workers");
14059
14060        // Simulate the workers having become live (the production
14061        // flow does this via `handle_spawn_worker`'s
14062        // `insert_live_worker`; the test intercept skipped that
14063        // path so we seed it manually for the idempotency gate).
14064        let project_key = workspace.project_key_for_name("proj-x").expect("seeded project");
14065        workspace.insert_live_worker(
14066            &project_key,
14067            crate::mcp::workers::types::WorkerEntry {
14068                label: "implementer".into(),
14069                charter: "test".into(),
14070                slot: SessionSlot::from_str_for_test("worker-uuid"),
14071                session_id: None,
14072                status: forge_primitives::WorkerLiveness::Running,
14073                spawned_at: std::time::SystemTime::UNIX_EPOCH,
14074                spawned_by: SessionSlot::from_str_for_test("lead-uuid"),
14075                needs_tag: false,
14076                is_git_repo_at_spawn: false,
14077                cwd: None,
14078                diagnostic: None,
14079                kick: None,
14080            },
14081        );
14082
14083        // Second Connected: gate trips, no new SpawnWorker.
14084        on_connected_for_test(&workspace, &lead, "lead-uuid");
14085        let after_second = workspace.drain_test_dispatch_buffer();
14086        let second_spawns: usize =
14087            after_second.iter().filter(|c| matches!(c, Command::SpawnWorker { .. })).count();
14088        assert_eq!(second_spawns, 0, "second Connected must not double-spawn");
14089    }
14090
14091    /// Helper: insert a live ad-hoc worker carrying `kick` under the
14092    /// seeded project, returning its slot for `on_connected_for_test`.
14093    #[cfg(test)]
14094    fn seed_adhoc_worker_with_kick(
14095        workspace: &Arc<Workspace>,
14096        label: &str,
14097        kick: Option<String>,
14098    ) -> crate::SessionSlot {
14099        workspace.seed_test_project("forge", "/tmp/forge");
14100        let project_key = workspace
14101            .list_projects()
14102            .into_iter()
14103            .find(|v| v.name == "forge")
14104            .expect("seeded project present")
14105            .key;
14106        let slot = crate::SessionSlot::worker("TestOrg", "forge", label);
14107        workspace.insert_live_worker(
14108            &project_key,
14109            crate::mcp::workers::types::WorkerEntry {
14110                label: label.to_owned(),
14111                charter: "ad-hoc".into(),
14112                slot: slot.clone(),
14113                session_id: None,
14114                status: forge_primitives::WorkerLiveness::Running,
14115                spawned_at: std::time::SystemTime::UNIX_EPOCH,
14116                spawned_by: crate::SessionSlot::lead("TestOrg", "forge"),
14117                needs_tag: false,
14118                is_git_repo_at_spawn: false,
14119                cwd: None,
14120                diagnostic: None,
14121                kick,
14122            },
14123        );
14124        slot
14125    }
14126
14127    /// A worker spawned with `agents__spawn(kick=...)` gets that kick
14128    /// delivered as its first turn, verbatim, through the rate-limited
14129    /// dispatcher.
14130    #[tokio::test(start_paused = true)]
14131    async fn worker_with_inline_kick_dispatches_it_as_first_turn() {
14132        let (workspace, _update_rx) = Workspace::testing_stub();
14133        workspace.enable_test_dispatch_intercept();
14134        workspace.start_kick_dispatcher();
14135        let synth = seed_adhoc_worker_with_kick(
14136            &workspace,
14137            "scratch",
14138            Some("Begin: triage the failing test now.".into()),
14139        );
14140
14141        on_connected_for_test(&workspace, &synth, "worker-uuid");
14142        tokio::task::yield_now().await;
14143        tokio::task::yield_now().await;
14144
14145        let dispatched = workspace.drain_test_dispatch_buffer();
14146        let prompts: Vec<&Command> = dispatched
14147            .iter()
14148            .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
14149            .collect();
14150        assert_eq!(prompts.len(), 1, "inline-kick worker gets exactly one kick");
14151        if let Command::Prompt { key, text, .. } | Command::PromptUnder { key, text, .. } =
14152            prompts[0]
14153        {
14154            assert_eq!(key, &synth, "kick targets the worker's own slot");
14155            assert_eq!(
14156                text, "Begin: triage the failing test now.",
14157                "inline kick delivered verbatim",
14158            );
14159        }
14160    }
14161
14162    /// A boot spawn that resumed a lead hands the restart note to the
14163    /// Connected hook, which kicks it once as the lead's first turn. The
14164    /// hook takes the note, so a reconnect repeats nothing; a worker slot
14165    /// never takes a lead's note.
14166    #[tokio::test(start_paused = true)]
14167    async fn a_lead_restart_note_is_kicked_once_and_only_for_a_lead() {
14168        let (workspace, _update_rx) = Workspace::testing_stub();
14169        workspace.enable_test_dispatch_intercept();
14170        workspace.start_kick_dispatcher();
14171        let lead = lead_slot("proj-x");
14172        let mut domain = DomainSession::new(lead.clone(), None);
14173        domain.pending_lead_resume_note = Some("continue where you left off".to_owned());
14174        let domain = Mutex::new(domain);
14175
14176        maybe_kick_lead_on_connected(&workspace, &lead, &domain);
14177        tokio::task::yield_now().await;
14178        tokio::task::yield_now().await;
14179
14180        let dispatched = workspace.drain_test_dispatch_buffer();
14181        let prompts: Vec<&Command> = dispatched
14182            .iter()
14183            .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
14184            .collect();
14185        assert_eq!(prompts.len(), 1, "a resumed lead is kicked exactly once");
14186        if let Command::Prompt { key, text, .. } | Command::PromptUnder { key, text, .. } =
14187            prompts[0]
14188        {
14189            assert_eq!(key, &lead, "the note targets the lead's own slot");
14190            assert_eq!(text, "continue where you left off", "the note arrives verbatim");
14191        }
14192
14193        maybe_kick_lead_on_connected(&workspace, &lead, &domain);
14194        let worker = crate::SessionSlot::from_str_for_test("w");
14195        let mut worker_domain = DomainSession::new(worker.clone(), None);
14196        worker_domain.pending_lead_resume_note = Some("never delivered".to_owned());
14197        maybe_kick_lead_on_connected(&workspace, &worker, &Mutex::new(worker_domain));
14198        // The dispatcher fires one kick per interval, so the clock has to
14199        // move before a second enqueue can reach the buffer: without the
14200        // advance, a hook that left the note in place would read as clean.
14201        tokio::time::advance(std::time::Duration::from_secs(2)).await;
14202        tokio::task::yield_now().await;
14203        tokio::task::yield_now().await;
14204        assert!(
14205            workspace.drain_test_dispatch_buffer().is_empty(),
14206            "a reconnect after the note was taken and a worker slot deliver nothing",
14207        );
14208    }
14209
14210    /// #695: the same inline kick with an underscore in the label as the
14211    /// only variable. `worker_with_inline_kick_dispatches_it_as_first_turn`
14212    /// above is the control - same fixture, same seed, plain label - which
14213    /// is what makes a failure here mean the label rather than the harness.
14214    #[tokio::test(start_paused = true)]
14215    async fn worker_with_an_underscore_label_dispatches_its_kick() {
14216        let (workspace, _update_rx) = Workspace::testing_stub();
14217        workspace.enable_test_dispatch_intercept();
14218        workspace.start_kick_dispatcher();
14219        let synth = seed_adhoc_worker_with_kick(
14220            &workspace,
14221            "code_review",
14222            Some("Begin: review the open diff.".into()),
14223        );
14224
14225        on_connected_for_test(&workspace, &synth, "worker-uuid");
14226        tokio::task::yield_now().await;
14227        tokio::task::yield_now().await;
14228
14229        let dispatched = workspace.drain_test_dispatch_buffer();
14230        let prompts: Vec<&Command> = dispatched
14231            .iter()
14232            .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
14233            .collect();
14234        assert_eq!(prompts.len(), 1, "an underscore-labelled worker gets its kick");
14235        if let Command::Prompt { text, .. } | Command::PromptUnder { text, .. } = prompts[0] {
14236            assert_eq!(text, "Begin: review the open diff.", "the kick arrives verbatim");
14237        }
14238    }
14239
14240    /// A live worker whose entry carries no kick gets none - it idles
14241    /// until the lead sends an agents__send_message.
14242    #[tokio::test(start_paused = true)]
14243    async fn worker_without_inline_kick_for_adhoc_label_does_not_kick() {
14244        let (workspace, _update_rx) = Workspace::testing_stub();
14245        workspace.enable_test_dispatch_intercept();
14246        workspace.start_kick_dispatcher();
14247        let synth = seed_adhoc_worker_with_kick(&workspace, "scratch", None);
14248
14249        on_connected_for_test(&workspace, &synth, "worker-uuid");
14250        tokio::task::yield_now().await;
14251        tokio::task::yield_now().await;
14252
14253        let dispatched = workspace.drain_test_dispatch_buffer();
14254        let prompts: Vec<&Command> = dispatched
14255            .iter()
14256            .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
14257            .collect();
14258        assert!(prompts.is_empty(), "a live entry carrying no kick gets none");
14259    }
14260
14261    /// A label with no live `WorkerEntry` of its own gets no kick, even
14262    /// when the project has a live worker holding one. The other entry
14263    /// is what gives this teeth: with an empty project the lookup has
14264    /// nothing to wrongly return. The drainer has to be running too -
14265    /// without it a mis-delivered kick only reaches the queue, and the
14266    /// dispatch buffer stays empty for the wrong reason.
14267    #[tokio::test(start_paused = true)]
14268    async fn worker_connected_for_an_unmatched_label_does_not_take_another_workers_kick() {
14269        let (workspace, _update_rx) = Workspace::testing_stub();
14270        workspace.enable_test_dispatch_intercept();
14271        workspace.start_kick_dispatcher();
14272        // One live worker, carrying a kick, under a DIFFERENT label.
14273        seed_adhoc_worker_with_kick(&workspace, "other", Some("not yours".into()));
14274        // A label with no entry of its own, in the same project.
14275        let other_label = crate::SessionSlot::worker("TestOrg", "forge", "scratchpad");
14276
14277        on_connected_for_test(&workspace, &other_label, "worker-uuid");
14278        tokio::task::yield_now().await;
14279        tokio::task::yield_now().await;
14280
14281        let dispatched = workspace.drain_test_dispatch_buffer();
14282        assert!(
14283            dispatched
14284                .iter()
14285                .all(|c| !matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. })),
14286            "a label with no entry of its own must not be handed another worker's kick",
14287        );
14288    }
14289
14290    /// The guard directly, with no preconditions to decay: a lead key
14291    /// A label may contain underscores, and the kick hook matches on the
14292    /// label as a whole string rather than on any parse of it.
14293    #[tokio::test(start_paused = true)]
14294    async fn a_worker_label_with_an_underscore_keeps_its_kick() {
14295        let (workspace, _update_rx) = Workspace::testing_stub();
14296        workspace.enable_test_dispatch_intercept();
14297        workspace.start_kick_dispatcher();
14298        let slot = seed_adhoc_worker_with_kick(&workspace, "code_review", Some("go".into()));
14299
14300        on_connected_for_test(&workspace, &slot, "worker-uuid");
14301        tokio::task::yield_now().await;
14302        tokio::task::yield_now().await;
14303
14304        let dispatched = workspace.drain_test_dispatch_buffer();
14305        assert!(
14306            dispatched.iter().any(|c| matches!(
14307                c, Command::Prompt { text, .. } | Command::PromptUnder { text, .. }
14308                    if text == "go"
14309            )),
14310            "an underscore in the label must not cost the kick: {dispatched:?}",
14311        );
14312    }
14313}