Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:35:05.054Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

interaction.rs

931 lines · 32.6 KB · rust
1//! Pane interaction states for send / stream / interrupt (`OMEGA-SW-04`).
2//!
3//! Record-agnostic pure transitions. Transport lives in `SARAH-NR-06`
4//! (`omega_effectd` Sarah conversation client). GPUI only projects these
5//! states; it holds no durable thread or turn store.
6//!
7//! Liveness honesty: the provider call is not a token stream
8//! (`runSarahAgentTurn` sets `stream: false`). The ordered tool ladder is
9//! the honest liveness signal. This module never invents partial tokens.
10
11use crate::projections::{
12    ActivityRow, Freshness, GapState, InterruptIntentState, MessageAck, ProjectionMeta, RunPhase,
13    RunStateProjection, TranscriptRow, sources,
14};
15
16/// Kinds that form the ordered tool ladder (honest liveness signal).
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum ToolLadderKind {
19    Call,
20    Result,
21    Error,
22}
23
24impl ToolLadderKind {
25    pub fn as_event_kind(self) -> &'static str {
26        match self {
27            Self::Call => "tool.call",
28            Self::Result => "tool.result",
29            Self::Error => "tool.error",
30        }
31    }
32
33    pub fn from_event_kind(kind: &str) -> Option<Self> {
34        match kind {
35            "tool.call" => Some(Self::Call),
36            "tool.result" => Some(Self::Result),
37            "tool.error" => Some(Self::Error),
38            _ => None,
39        }
40    }
41}
42
43/// One ordered tool-ladder step for the active or recent turn.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct ToolLadderEntry {
46    pub event_ref: String,
47    pub kind: ToolLadderKind,
48    pub tool_ref: Option<String>,
49    pub summary: String,
50    pub turn_ref: Option<String>,
51}
52
53impl ToolLadderEntry {
54    pub fn to_activity_row(&self) -> ActivityRow {
55        ActivityRow {
56            event_ref: self.event_ref.clone(),
57            kind: self.kind.as_event_kind().to_string(),
58            summary: self.summary.clone(),
59            turn_ref: self.turn_ref.clone(),
60        }
61    }
62}
63
64/// Answer projection. Full text arrives as one block; never token-streamed.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub enum AnswerState {
67    /// No answer for the active turn yet.
68    None,
69    /// `text.delta` landed as one complete block (`stream: false`).
70    Text { text: String },
71    /// `text.completed` landed; answer is final for this turn.
72    Completed { text: String },
73}
74
75impl AnswerState {
76    pub fn text(&self) -> Option<&str> {
77        match self {
78            Self::None => None,
79            Self::Text { text } | Self::Completed { text } => Some(text.as_str()),
80        }
81    }
82
83    pub fn is_completed(&self) -> bool {
84        matches!(self, Self::Completed { .. })
85    }
86
87    pub fn label(&self) -> &'static str {
88        match self {
89            Self::None => "none",
90            Self::Text { .. } => "text",
91            Self::Completed { .. } => "completed",
92        }
93    }
94}
95
96/// Terminal turn outcome with the **exact** reason from the record.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub enum TerminalOutcome {
99    None,
100    Finished { reason: String },
101    Interrupted { reason: String },
102    Error { reason: String },
103}
104
105impl TerminalOutcome {
106    pub fn reason(&self) -> Option<&str> {
107        match self {
108            Self::None => None,
109            Self::Finished { reason }
110            | Self::Interrupted { reason }
111            | Self::Error { reason } => Some(reason.as_str()),
112        }
113    }
114
115    pub fn label(&self) -> &'static str {
116        match self {
117            Self::None => "none",
118            Self::Finished { .. } => "finished",
119            Self::Interrupted { .. } => "interrupted",
120            Self::Error { .. } => "error",
121        }
122    }
123
124    pub fn is_terminal(&self) -> bool {
125        !matches!(self, Self::None)
126    }
127}
128
129/// Record-agnostic events the pane applies in arrival order.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub enum InteractionEvent {
132    /// Durable claim: turn is running.
133    TurnQueued { turn_ref: String },
134    TurnStarted { turn_ref: String },
135    TurnRunning { turn_ref: String },
136    ToolCall {
137        event_ref: String,
138        turn_ref: Option<String>,
139        tool_ref: Option<String>,
140        summary: String,
141    },
142    ToolResult {
143        event_ref: String,
144        turn_ref: Option<String>,
145        tool_ref: Option<String>,
146        summary: String,
147    },
148    ToolError {
149        event_ref: String,
150        turn_ref: Option<String>,
151        tool_ref: Option<String>,
152        summary: String,
153    },
154    /// Full answer block (`stream: false`). Not a token fragment.
155    TextDelta {
156        event_ref: String,
157        turn_ref: Option<String>,
158        text: String,
159    },
160    TextCompleted {
161        event_ref: String,
162        turn_ref: Option<String>,
163    },
164    TurnFinished {
165        turn_ref: Option<String>,
166        reason: String,
167    },
168    TurnInterrupted {
169        turn_ref: Option<String>,
170        reason: String,
171    },
172    /// Message confirmed on the durable record (may match a local pending row).
173    MessageConfirmed {
174        local_ref: Option<String>,
175        message_ref: String,
176        text: String,
177        role: String,
178    },
179}
180
181impl InteractionEvent {
182    /// Parse a runtime event kind string into an interaction event.
183    ///
184    /// Unknown kinds return `None` (ignored, not invented).
185    pub fn from_runtime_kind(
186        kind: &str,
187        event_ref: impl Into<String>,
188        turn_ref: Option<String>,
189        summary_or_text: impl Into<String>,
190        tool_ref: Option<String>,
191        reason: Option<String>,
192    ) -> Option<Self> {
193        let event_ref = event_ref.into();
194        let summary_or_text = summary_or_text.into();
195        match kind {
196            "turn.queued" => Some(Self::TurnQueued {
197                turn_ref: turn_ref.unwrap_or_else(|| "turn.unknown".into()),
198            }),
199            "turn.started" => Some(Self::TurnStarted {
200                turn_ref: turn_ref.unwrap_or_else(|| "turn.unknown".into()),
201            }),
202            "turn.running" => Some(Self::TurnRunning {
203                turn_ref: turn_ref.unwrap_or_else(|| "turn.unknown".into()),
204            }),
205            "tool.call" => Some(Self::ToolCall {
206                event_ref,
207                turn_ref,
208                tool_ref,
209                summary: summary_or_text,
210            }),
211            "tool.result" => Some(Self::ToolResult {
212                event_ref,
213                turn_ref,
214                tool_ref,
215                summary: summary_or_text,
216            }),
217            "tool.error" => Some(Self::ToolError {
218                event_ref,
219                turn_ref,
220                tool_ref,
221                summary: summary_or_text,
222            }),
223            "text.delta" => Some(Self::TextDelta {
224                event_ref,
225                turn_ref,
226                text: summary_or_text,
227            }),
228            "text.completed" => Some(Self::TextCompleted {
229                event_ref,
230                turn_ref,
231            }),
232            "turn.finished" | "turn.completed" => Some(Self::TurnFinished {
233                turn_ref,
234                reason: reason.unwrap_or_else(|| summary_or_text),
235            }),
236            "turn.interrupted" => Some(Self::TurnInterrupted {
237                turn_ref,
238                reason: reason.unwrap_or_else(|| summary_or_text),
239            }),
240            _ => None,
241        }
242    }
243}
244
245/// Local optimistic send until the durable record confirms the message.
246#[derive(Clone, Debug, PartialEq, Eq)]
247pub struct LocalPendingSend {
248    pub local_ref: String,
249    pub text: String,
250    pub turn_ref: Option<String>,
251}
252
253/// Pure interaction projection for one workroom pane session.
254///
255/// Rebuilt from effectd snapshots and ordered events. Never durable.
256#[derive(Clone, Debug, PartialEq, Eq)]
257pub struct InteractionState {
258    next_local_seq: u64,
259    /// Pending local sends not yet confirmed on the record.
260    pub pending_sends: Vec<LocalPendingSend>,
261    /// Ordered tool ladder for the active turn (honest liveness).
262    pub tool_ladder: Vec<ToolLadderEntry>,
263    pub answer: AnswerState,
264    pub terminal: TerminalOutcome,
265    pub run: RunStateProjection,
266    /// When true, the pane must not render fake token motion.
267    pub honest_liveness_only: bool,
268    /// Sarah answer rows already projected into the transcript for this turn.
269    answer_message_ref: Option<String>,
270}
271
272impl Default for InteractionState {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278impl InteractionState {
279    pub fn new() -> Self {
280        Self {
281            next_local_seq: 1,
282            pending_sends: Vec::new(),
283            tool_ladder: Vec::new(),
284            answer: AnswerState::None,
285            terminal: TerminalOutcome::None,
286            run: RunStateProjection {
287                meta: ProjectionMeta::fresh(sources::RUN_STATE),
288                phase: RunPhase::Idle,
289                reason: None,
290                turn_ref: None,
291                interrupt_intent: InterruptIntentState::None,
292            },
293            honest_liveness_only: true,
294            answer_message_ref: None,
295        }
296    }
297
298    /// Start a local pending owner message. Returns the local ref and row.
299    ///
300    /// The row stays `MessageAck::Pending` until [`confirm_send`] or a matching
301    /// `MessageConfirmed` event lands. Pending never renders as applied.
302    pub fn begin_send(&mut self, text: impl Into<String>) -> (String, TranscriptRow) {
303        let text = text.into();
304        let local_ref = format!("local:{}", self.next_local_seq);
305        self.next_local_seq = self.next_local_seq.saturating_add(1);
306        self.pending_sends.push(LocalPendingSend {
307            local_ref: local_ref.clone(),
308            text: text.clone(),
309            turn_ref: None,
310        });
311        let row = TranscriptRow {
312            message_ref: local_ref.clone(),
313            role: "owner".into(),
314            text,
315            ack: MessageAck::Pending,
316        };
317        (local_ref, row)
318    }
319
320    /// Record accepted the send. Upgrade pending → confirmed.
321    ///
322    /// Does **not** mark the turn running; that waits for the claim
323    /// (`turn.started` / `turn.running`).
324    pub fn confirm_send(
325        &mut self,
326        local_ref: &str,
327        message_ref: impl Into<String>,
328        turn_ref: Option<String>,
329    ) -> Option<TranscriptRow> {
330        let message_ref = message_ref.into();
331        let idx = self
332            .pending_sends
333            .iter()
334            .position(|p| p.local_ref == local_ref)?;
335        let pending = self.pending_sends.remove(idx);
336        if let Some(turn_ref) = turn_ref {
337            self.run.turn_ref = Some(turn_ref);
338        }
339        // Accepted message only — claim may still be outstanding.
340        if self.run.phase == RunPhase::Idle || self.run.phase == RunPhase::Unknown {
341            self.run.phase = RunPhase::Queued;
342            self.run.meta = ProjectionMeta::fresh(sources::RUN_STATE);
343        }
344        Some(TranscriptRow {
345            message_ref,
346            role: "owner".into(),
347            text: pending.text,
348            ack: MessageAck::Confirmed,
349        })
350    }
351
352    /// Send failed before record confirmation. Drop the pending local row.
353    pub fn fail_send(&mut self, local_ref: &str) -> bool {
354        let before = self.pending_sends.len();
355        self.pending_sends.retain(|p| p.local_ref != local_ref);
356        before != self.pending_sends.len()
357    }
358
359    pub fn pending_send_count(&self) -> usize {
360        self.pending_sends.len()
361    }
362
363    /// Typed interrupt intent. Pending until a terminal interrupted event.
364    pub fn begin_interrupt(&mut self) {
365        self.run.mark_interrupt_pending();
366        // Law: pending never upgrades phase to Interrupted without terminal.
367    }
368
369    /// Apply one ordered record event. Returns transcript rows to merge (if any).
370    pub fn apply_event(&mut self, event: InteractionEvent) -> Vec<TranscriptRow> {
371        let mut transcript_rows = Vec::new();
372        match event {
373            InteractionEvent::TurnQueued { turn_ref } => {
374                self.run.phase = RunPhase::Queued;
375                self.run.turn_ref = Some(turn_ref);
376                self.run.reason = None;
377                self.run.meta = ProjectionMeta::fresh(sources::RUN_STATE);
378                self.clear_turn_progress_if_new_turn();
379            }
380            InteractionEvent::TurnStarted { turn_ref } | InteractionEvent::TurnRunning { turn_ref } => {
381                self.run.phase = RunPhase::Running;
382                self.run.turn_ref = Some(turn_ref);
383                self.run.reason = None;
384                self.run.meta = ProjectionMeta::fresh(sources::RUN_STATE);
385                // New claim: clear prior ladder/answer unless already terminal
386                // for this same turn (idempotent replay).
387                if self.terminal.is_terminal() {
388                    // Restart / rehydrate mid-history: keep terminal if already set
389                    // for a finished turn; a new start after terminal resets.
390                    self.reset_open_turn_progress();
391                }
392            }
393            InteractionEvent::ToolCall {
394                event_ref,
395                turn_ref,
396                tool_ref,
397                summary,
398            } => {
399                self.push_tool(ToolLadderKind::Call, event_ref, turn_ref, tool_ref, summary);
400                self.ensure_running_from_ladder();
401            }
402            InteractionEvent::ToolResult {
403                event_ref,
404                turn_ref,
405                tool_ref,
406                summary,
407            } => {
408                self.push_tool(ToolLadderKind::Result, event_ref, turn_ref, tool_ref, summary);
409                self.ensure_running_from_ladder();
410            }
411            InteractionEvent::ToolError {
412                event_ref,
413                turn_ref,
414                tool_ref,
415                summary,
416            } => {
417                self.push_tool(ToolLadderKind::Error, event_ref, turn_ref, tool_ref, summary);
418                self.ensure_running_from_ladder();
419            }
420            InteractionEvent::TextDelta {
421                event_ref,
422                turn_ref,
423                text,
424            } => {
425                // One block only — never append token fragments.
426                self.answer = AnswerState::Text { text: text.clone() };
427                if let Some(tr) = turn_ref {
428                    self.run.turn_ref = Some(tr);
429                }
430                let msg_ref = self
431                    .answer_message_ref
432                    .clone()
433                    .unwrap_or_else(|| format!("answer:{}", event_ref));
434                self.answer_message_ref = Some(msg_ref.clone());
435                transcript_rows.push(TranscriptRow {
436                    message_ref: msg_ref,
437                    role: "sarah".into(),
438                    text,
439                    ack: MessageAck::Confirmed,
440                });
441            }
442            InteractionEvent::TextCompleted { event_ref, turn_ref } => {
443                let text = self
444                    .answer
445                    .text()
446                    .map(str::to_string)
447                    .unwrap_or_default();
448                self.answer = AnswerState::Completed { text: text.clone() };
449                if let Some(tr) = turn_ref {
450                    self.run.turn_ref = Some(tr);
451                }
452                if !text.is_empty() {
453                    let msg_ref = self
454                        .answer_message_ref
455                        .clone()
456                        .unwrap_or_else(|| format!("answer:{}", event_ref));
457                    self.answer_message_ref = Some(msg_ref.clone());
458                    transcript_rows.push(TranscriptRow {
459                        message_ref: msg_ref,
460                        role: "sarah".into(),
461                        text,
462                        ack: MessageAck::Confirmed,
463                    });
464                }
465            }
466            InteractionEvent::TurnFinished { turn_ref, reason } => {
467                self.run.phase = RunPhase::Finished;
468                if let Some(tr) = turn_ref {
469                    self.run.turn_ref = Some(tr);
470                }
471                self.run.reason = Some(reason.clone());
472                self.run.meta.freshness = Freshness::Fresh;
473                self.run.meta.gap = GapState::None;
474                self.terminal = TerminalOutcome::Finished { reason };
475                // Finished is not interrupt-applied.
476            }
477            InteractionEvent::TurnInterrupted { turn_ref, reason } => {
478                self.run.apply_terminal_interrupted(turn_ref, Some(reason.clone()));
479                self.terminal = TerminalOutcome::Interrupted { reason };
480            }
481            InteractionEvent::MessageConfirmed {
482                local_ref,
483                message_ref,
484                text,
485                role,
486            } => {
487                if let Some(local_ref) = local_ref.as_deref() {
488                    if let Some(row) = self.confirm_send(local_ref, message_ref.clone(), None) {
489                        transcript_rows.push(row);
490                        return transcript_rows;
491                    }
492                }
493                // Record-side confirm without local pending (other reader view).
494                self.pending_sends
495                    .retain(|p| p.local_ref != message_ref && p.text != text);
496                transcript_rows.push(TranscriptRow {
497                    message_ref,
498                    role,
499                    text,
500                    ack: MessageAck::Confirmed,
501                });
502            }
503        }
504        transcript_rows
505    }
506
507    fn push_tool(
508        &mut self,
509        kind: ToolLadderKind,
510        event_ref: String,
511        turn_ref: Option<String>,
512        tool_ref: Option<String>,
513        summary: String,
514    ) {
515        // De-dupe by event_ref for idempotent rehydrate.
516        if self.tool_ladder.iter().any(|e| e.event_ref == event_ref) {
517            return;
518        }
519        self.tool_ladder.push(ToolLadderEntry {
520            event_ref,
521            kind,
522            tool_ref,
523            summary,
524            turn_ref,
525        });
526    }
527
528    fn ensure_running_from_ladder(&mut self) {
529        if matches!(
530            self.run.phase,
531            RunPhase::Idle | RunPhase::Queued | RunPhase::Unknown
532        ) && !self.terminal.is_terminal()
533        {
534            self.run.phase = RunPhase::Running;
535            self.run.meta = ProjectionMeta::fresh(sources::RUN_STATE);
536        }
537    }
538
539    fn clear_turn_progress_if_new_turn(&mut self) {
540        if self.terminal.is_terminal() {
541            self.reset_open_turn_progress();
542        }
543    }
544
545    fn reset_open_turn_progress(&mut self) {
546        self.tool_ladder.clear();
547        self.answer = AnswerState::None;
548        self.terminal = TerminalOutcome::None;
549        self.answer_message_ref = None;
550        if self.run.interrupt_intent == InterruptIntentState::Applied {
551            self.run.interrupt_intent = InterruptIntentState::None;
552        }
553    }
554
555    /// Activity rows for the pane, in arrival order (tool ladder).
556    pub fn activity_rows(&self) -> Vec<ActivityRow> {
557        self.tool_ladder
558            .iter()
559            .map(ToolLadderEntry::to_activity_row)
560            .collect()
561    }
562
563    /// True when the only liveness signal is the tool ladder (no fake tokens).
564    pub fn uses_honest_liveness(&self) -> bool {
565        self.honest_liveness_only
566    }
567
568    /// Rehydrate run phase from a snapshot field without inventing progress.
569    pub fn apply_snapshot_run(
570        &mut self,
571        phase: RunPhase,
572        turn_ref: Option<String>,
573        reason: Option<String>,
574    ) {
575        // Preserve pending interrupt intent across snapshot refresh.
576        let interrupt = self.run.interrupt_intent;
577        self.run.phase = phase;
578        self.run.turn_ref = turn_ref;
579        self.run.reason = reason.clone();
580        self.run.meta = ProjectionMeta::fresh(sources::RUN_STATE);
581        self.run.interrupt_intent = interrupt;
582
583        if phase == RunPhase::Interrupted {
584            if interrupt == InterruptIntentState::Pending
585                || interrupt == InterruptIntentState::Applied
586            {
587                self.run.interrupt_intent = InterruptIntentState::Applied;
588            }
589            if let Some(reason) = reason {
590                self.terminal = TerminalOutcome::Interrupted { reason };
591            }
592        } else if phase == RunPhase::Finished {
593            if let Some(reason) = reason {
594                self.terminal = TerminalOutcome::Finished { reason };
595            }
596        }
597    }
598
599    /// Pending interrupt must not look applied.
600    pub fn interrupt_not_falsely_applied(&self) -> bool {
601        self.run.interrupt_not_falsely_applied()
602            && !(self.run.interrupt_intent == InterruptIntentState::Pending
603                && matches!(self.terminal, TerminalOutcome::Interrupted { .. }))
604    }
605
606    /// Status line for the pane chrome.
607    pub fn status_line(&self) -> String {
608        let phase = self.run.phase.label();
609        let interrupt = self.run.interrupt_intent.label();
610        let answer = self.answer.label();
611        let terminal = self.terminal.label();
612        let tools = self.tool_ladder.len();
613        let pending = self.pending_sends.len();
614        let mut line = format!(
615            "phase={phase} · interrupt={interrupt} · answer={answer} · terminal={terminal} · tools={tools} · pending_sends={pending}"
616        );
617        if let Some(reason) = self.terminal.reason().or(self.run.reason.as_deref()) {
618            line.push_str(" · reason=");
619            line.push_str(reason);
620        }
621        line
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    #[test]
630    fn pending_local_message_until_record_confirms() {
631        let mut s = InteractionState::new();
632        let (local_ref, row) = s.begin_send("hello Sarah");
633        assert_eq!(row.ack, MessageAck::Pending);
634        assert!(!row.ack.renders_as_applied());
635        assert_eq!(s.pending_send_count(), 1);
636        assert!(local_ref.starts_with("local:"));
637
638        let confirmed = s
639            .confirm_send(&local_ref, "msg.1", Some("turn.1".into()))
640            .expect("confirm");
641        assert_eq!(confirmed.ack, MessageAck::Confirmed);
642        assert_eq!(confirmed.message_ref, "msg.1");
643        assert_eq!(confirmed.text, "hello Sarah");
644        assert_eq!(s.pending_send_count(), 0);
645        // Claim not yet landed — queued, not running.
646        assert_eq!(s.run.phase, RunPhase::Queued);
647        assert_eq!(s.run.turn_ref.as_deref(), Some("turn.1"));
648    }
649
650    #[test]
651    fn turn_running_after_claim() {
652        let mut s = InteractionState::new();
653        let (local_ref, _) = s.begin_send("plan next packet");
654        s.confirm_send(&local_ref, "msg.1", Some("turn.1".into()));
655        assert_ne!(s.run.phase, RunPhase::Running);
656
657        s.apply_event(InteractionEvent::TurnStarted {
658            turn_ref: "turn.1".into(),
659        });
660        assert_eq!(s.run.phase, RunPhase::Running);
661        assert_eq!(s.run.turn_ref.as_deref(), Some("turn.1"));
662        assert!(!s.terminal.is_terminal());
663    }
664
665    #[test]
666    fn ordered_tool_ladder_is_liveness_signal() {
667        let mut s = InteractionState::new();
668        s.apply_event(InteractionEvent::TurnStarted {
669            turn_ref: "turn.1".into(),
670        });
671        s.apply_event(InteractionEvent::ToolCall {
672            event_ref: "e1".into(),
673            turn_ref: Some("turn.1".into()),
674            tool_ref: Some("codex_workers_capacity".into()),
675            summary: "capacity".into(),
676        });
677        s.apply_event(InteractionEvent::ToolResult {
678            event_ref: "e2".into(),
679            turn_ref: Some("turn.1".into()),
680            tool_ref: Some("codex_workers_capacity".into()),
681            summary: "ready=2".into(),
682        });
683        s.apply_event(InteractionEvent::ToolError {
684            event_ref: "e3".into(),
685            turn_ref: Some("turn.1".into()),
686            tool_ref: Some("full_auto_control".into()),
687            summary: "refused: authority".into(),
688        });
689
690        let kinds: Vec<_> = s
691            .tool_ladder
692            .iter()
693            .map(|e| e.kind.as_event_kind())
694            .collect();
695        assert_eq!(kinds, ["tool.call", "tool.result", "tool.error"]);
696        assert!(s.uses_honest_liveness());
697        // No answer yet — liveness is the ladder, not token motion.
698        assert_eq!(s.answer, AnswerState::None);
699        let activity = s.activity_rows();
700        assert_eq!(activity.len(), 3);
701        assert_eq!(activity[0].kind, "tool.call");
702        assert_eq!(activity[2].kind, "tool.error");
703    }
704
705    #[test]
706    fn answer_arrives_as_one_block_then_completes() {
707        let mut s = InteractionState::new();
708        s.apply_event(InteractionEvent::TurnStarted {
709            turn_ref: "turn.1".into(),
710        });
711        // Full block — not per-token deltas.
712        let rows = s.apply_event(InteractionEvent::TextDelta {
713            event_ref: "td1".into(),
714            turn_ref: Some("turn.1".into()),
715            text: "Here is the plan.".into(),
716        });
717        assert_eq!(s.answer.label(), "text");
718        assert_eq!(s.answer.text(), Some("Here is the plan."));
719        assert_eq!(rows.len(), 1);
720        assert_eq!(rows[0].role, "sarah");
721        assert_eq!(rows[0].ack, MessageAck::Confirmed);
722
723        s.apply_event(InteractionEvent::TextCompleted {
724            event_ref: "tc1".into(),
725            turn_ref: Some("turn.1".into()),
726        });
727        assert!(s.answer.is_completed());
728        assert_eq!(s.answer.text(), Some("Here is the plan."));
729    }
730
731    #[test]
732    fn terminal_outcome_carries_exact_reason() {
733        let mut s = InteractionState::new();
734        s.apply_event(InteractionEvent::TurnStarted {
735            turn_ref: "turn.1".into(),
736        });
737        s.apply_event(InteractionEvent::TurnFinished {
738            turn_ref: Some("turn.1".into()),
739            reason: "stop".into(),
740        });
741        assert_eq!(s.run.phase, RunPhase::Finished);
742        assert_eq!(s.terminal.reason(), Some("stop"));
743        assert_eq!(s.run.reason.as_deref(), Some("stop"));
744        assert_eq!(s.terminal.label(), "finished");
745    }
746
747    #[test]
748    fn interrupt_pending_until_terminal_applied() {
749        let mut s = InteractionState::new();
750        s.apply_event(InteractionEvent::TurnStarted {
751            turn_ref: "turn.1".into(),
752        });
753        s.begin_interrupt();
754        assert_eq!(s.run.interrupt_intent, InterruptIntentState::Pending);
755        assert_eq!(s.run.phase, RunPhase::Running);
756        assert!(s.interrupt_not_falsely_applied());
757        assert_ne!(s.run.interrupt_intent, InterruptIntentState::Applied);
758
759        s.apply_event(InteractionEvent::TurnInterrupted {
760            turn_ref: Some("turn.1".into()),
761            reason: "owner_interrupt".into(),
762        });
763        assert_eq!(s.run.interrupt_intent, InterruptIntentState::Applied);
764        assert_eq!(s.run.phase, RunPhase::Interrupted);
765        assert_eq!(s.terminal.reason(), Some("owner_interrupt"));
766        assert!(s.interrupt_not_falsely_applied());
767    }
768
769    #[test]
770    fn full_send_to_finish_state_machine() {
771        let mut s = InteractionState::new();
772        let (local_ref, pending_row) = s.begin_send("What is capacity?");
773        assert_eq!(pending_row.ack, MessageAck::Pending);
774
775        let confirmed = s
776            .confirm_send(&local_ref, "msg.9", Some("turn.9".into()))
777            .unwrap();
778        assert_eq!(confirmed.ack, MessageAck::Confirmed);
779        assert_eq!(s.run.phase, RunPhase::Queued);
780
781        s.apply_event(InteractionEvent::TurnStarted {
782            turn_ref: "turn.9".into(),
783        });
784        assert_eq!(s.run.phase, RunPhase::Running);
785
786        s.apply_event(InteractionEvent::ToolCall {
787            event_ref: "t1".into(),
788            turn_ref: Some("turn.9".into()),
789            tool_ref: Some("codex_workers_capacity".into()),
790            summary: "check".into(),
791        });
792        s.apply_event(InteractionEvent::ToolResult {
793            event_ref: "t2".into(),
794            turn_ref: Some("turn.9".into()),
795            tool_ref: Some("codex_workers_capacity".into()),
796            summary: "ok".into(),
797        });
798        s.apply_event(InteractionEvent::TextDelta {
799            event_ref: "a1".into(),
800            turn_ref: Some("turn.9".into()),
801            text: "Two workers ready.".into(),
802        });
803        s.apply_event(InteractionEvent::TextCompleted {
804            event_ref: "a2".into(),
805            turn_ref: Some("turn.9".into()),
806        });
807        s.apply_event(InteractionEvent::TurnFinished {
808            turn_ref: Some("turn.9".into()),
809            reason: "stop".into(),
810        });
811
812        assert_eq!(s.tool_ladder.len(), 2);
813        assert!(s.answer.is_completed());
814        assert_eq!(s.terminal.reason(), Some("stop"));
815        assert_eq!(s.run.phase, RunPhase::Finished);
816        assert!(s.uses_honest_liveness());
817        assert!(s.status_line().contains("reason=stop"));
818    }
819
820    #[test]
821    fn fail_send_drops_pending() {
822        let mut s = InteractionState::new();
823        let (local_ref, _) = s.begin_send("will fail");
824        assert!(s.fail_send(&local_ref));
825        assert_eq!(s.pending_send_count(), 0);
826        assert!(s.confirm_send(&local_ref, "msg.x", None).is_none());
827    }
828
829    #[test]
830    fn from_runtime_kind_parses_ladder_and_terminals() {
831        let call = InteractionEvent::from_runtime_kind(
832            "tool.call",
833            "e1",
834            Some("turn.1".into()),
835            "cap",
836            Some("tool.a".into()),
837            None,
838        );
839        assert!(matches!(call, Some(InteractionEvent::ToolCall { .. })));
840
841        let fin = InteractionEvent::from_runtime_kind(
842            "turn.finished",
843            "e2",
844            Some("turn.1".into()),
845            "",
846            None,
847            Some("error".into()),
848        );
849        match fin {
850            Some(InteractionEvent::TurnFinished { reason, .. }) => {
851                assert_eq!(reason, "error");
852            }
853            other => panic!("unexpected {other:?}"),
854        }
855
856        assert!(InteractionEvent::from_runtime_kind(
857            "usage.recorded",
858            "e3",
859            None,
860            "",
861            None,
862            None
863        )
864        .is_none());
865    }
866
867    #[test]
868    fn tool_event_dedupes_by_event_ref() {
869        let mut s = InteractionState::new();
870        s.apply_event(InteractionEvent::ToolCall {
871            event_ref: "same".into(),
872            turn_ref: None,
873            tool_ref: None,
874            summary: "first".into(),
875        });
876        s.apply_event(InteractionEvent::ToolCall {
877            event_ref: "same".into(),
878            turn_ref: None,
879            tool_ref: None,
880            summary: "replay".into(),
881        });
882        assert_eq!(s.tool_ladder.len(), 1);
883        assert_eq!(s.tool_ladder[0].summary, "first");
884    }
885
886    #[test]
887    fn snapshot_preserves_pending_interrupt() {
888        let mut s = InteractionState::new();
889        s.apply_event(InteractionEvent::TurnStarted {
890            turn_ref: "turn.1".into(),
891        });
892        s.begin_interrupt();
893        s.apply_snapshot_run(RunPhase::Running, Some("turn.1".into()), None);
894        assert_eq!(s.run.interrupt_intent, InterruptIntentState::Pending);
895
896        s.apply_snapshot_run(
897            RunPhase::Interrupted,
898            Some("turn.1".into()),
899            Some("owner_interrupt".into()),
900        );
901        assert_eq!(s.run.interrupt_intent, InterruptIntentState::Applied);
902        assert_eq!(s.terminal.reason(), Some("owner_interrupt"));
903    }
904
905    #[test]
906    fn restart_mid_turn_shows_one_terminal_never_two_answers() {
907        // Rehydrate path: apply claim + one answer + terminal once.
908        let mut s = InteractionState::new();
909        s.apply_event(InteractionEvent::TurnStarted {
910            turn_ref: "turn.1".into(),
911        });
912        s.apply_event(InteractionEvent::TextDelta {
913            event_ref: "a1".into(),
914            turn_ref: Some("turn.1".into()),
915            text: "single answer".into(),
916        });
917        s.apply_event(InteractionEvent::TextCompleted {
918            event_ref: "a2".into(),
919            turn_ref: Some("turn.1".into()),
920        });
921        s.apply_event(InteractionEvent::TurnFinished {
922            turn_ref: Some("turn.1".into()),
923            reason: "stop".into(),
924        });
925        // Idempotent text.completed does not invent a second answer body.
926        assert_eq!(s.answer.text(), Some("single answer"));
927        assert!(s.answer.is_completed());
928        assert_eq!(s.terminal.label(), "finished");
929    }
930}
931
Served at tenant.openagents/omega Member data and write actions are omitted.