Skip to repository content931 lines · 32.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:35:32.871Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
interaction.rs
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