use std::collections::HashSet;
use std::collections::VecDeque;
use std::time::Instant;
use serde_json::Value;
use supercode::FrontendApprovalDecision;
use supercode::FrontendElicitationAction;
use supercode::FrontendEvent;
use supercode::FrontendOperationInvocation;
use supercode::FrontendRequest;
use supercode::FrontendRequestKind;
use supercode::FrontendResponse;
use supercode::FrontendRuntimeDescriptor;
use supercode::FrontendTurnState;
use unicode_segmentation::UnicodeSegmentation;
use crate::paste_burst::PasteBurst;
use crate::semantic_value_summary;
use crate::ComposerCapabilities;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ComposerKeyCode {
Char(char),
Enter,
Backspace,
Delete,
Left,
Right,
Home,
End,
Up,
Down,
Escape,
Other,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ComposerKey {
pub code: ComposerKeyCode,
pub control: bool,
pub shift: bool,
pub alt: bool,
pub release: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ComposerInputEvent {
Key(ComposerKey),
Paste(String),
Resize { width: u16, height: u16 },
FocusChanged(bool),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ComposerTurnState {
Idle,
Working { since: Instant },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ComposerOverlayKind {
Approval,
ChildApproval,
Elicitation,
Other,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ComposerOverlay {
pub request: FrontendRequest,
pub kind: ComposerOverlayKind,
pub input: String,
cursor: usize,
}
impl ComposerOverlay {
pub fn summary(&self) -> String {
let label = match self.kind {
ComposerOverlayKind::Approval | ComposerOverlayKind::ChildApproval => {
"Approval request"
}
ComposerOverlayKind::Elicitation => "MCP input request",
ComposerOverlayKind::Other => "Runtime input request",
};
semantic_value_summary(&self.request.payload, label, 512)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ComposerAction {
Submit(String),
Invoke(FrontendOperationInvocation),
Steer(String),
Interrupt,
Respond {
response: FrontendResponse,
request: FrontendRequest,
resolution: String,
},
}
#[derive(Clone, Debug)]
pub struct ComposerModel {
capabilities: ComposerCapabilities,
input: String,
cursor: usize,
history: Vec<String>,
history_index: Option<usize>,
history_draft: Option<String>,
queued_steering: VecDeque<String>,
turn_state: ComposerTurnState,
overlays: VecDeque<ComposerOverlay>,
paste_burst: PasteBurst,
resolved_requests: HashSet<u64>,
canonical_resolved_requests: HashSet<u64>,
last_sequence: u64,
last_failure: Option<String>,
}
impl ComposerModel {
pub fn new(descriptor: &FrontendRuntimeDescriptor) -> Self {
Self {
capabilities: ComposerCapabilities::from_descriptor(descriptor),
input: String::new(),
cursor: 0,
history: Vec::new(),
history_index: None,
history_draft: None,
queued_steering: VecDeque::new(),
turn_state: match descriptor.turn_state {
FrontendTurnState::Idle => ComposerTurnState::Idle,
FrontendTurnState::Busy => ComposerTurnState::Working {
since: Instant::now(),
},
},
overlays: VecDeque::new(),
paste_burst: PasteBurst::default(),
resolved_requests: HashSet::new(),
canonical_resolved_requests: HashSet::new(),
last_sequence: 0,
last_failure: None,
}
}
pub fn capabilities(&self) -> &ComposerCapabilities {
&self.capabilities
}
pub fn input(&self) -> &str {
&self.input
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn history(&self) -> &[String] {
&self.history
}
pub fn queued_steering(&self) -> &VecDeque<String> {
&self.queued_steering
}
pub fn turn_state(&self) -> ComposerTurnState {
self.turn_state
}
pub fn overlay(&self) -> Option<&ComposerOverlay> {
self.overlays.front()
}
pub fn pending_overlay_count(&self) -> usize {
self.overlays.len()
}
pub fn last_failure(&self) -> Option<&str> {
self.last_failure.as_deref()
}
pub fn record_dispatch_failure(&mut self, action: &str) {
self.turn_state = ComposerTurnState::Idle;
self.last_failure = Some(format!("{action}_failure"));
}
pub fn restore_request(&mut self, request: FrontendRequest) -> bool {
if self.canonical_resolved_requests.contains(&request.id) {
return false;
}
self.resolved_requests.remove(&request.id);
if self
.overlays
.iter()
.any(|overlay| overlay.request.id == request.id)
{
return false;
}
let kind = overlay_kind(&request);
self.overlays.push_front(ComposerOverlay {
request,
kind,
input: String::new(),
cursor: 0,
});
true
}
pub fn refresh_descriptor(&mut self, descriptor: &FrontendRuntimeDescriptor) {
self.capabilities = ComposerCapabilities::from_descriptor(descriptor);
if descriptor.turn_state == FrontendTurnState::Busy
&& self.turn_state == ComposerTurnState::Idle
{
self.turn_state = ComposerTurnState::Working {
since: Instant::now(),
};
}
}
pub fn apply_event(&mut self, event: &FrontendEvent) -> bool {
if event.sequence <= self.last_sequence {
return false;
}
self.last_sequence = event.sequence;
match event.kind.as_str() {
"turn_started" | "user_message" if self.turn_state == ComposerTurnState::Idle => {
self.turn_state = ComposerTurnState::Working {
since: Instant::now(),
};
true
}
"turn_succeeded" | "turn_interrupted" | "turn_failed" => {
self.turn_state = ComposerTurnState::Idle;
self.queued_steering.clear();
if event.kind == "turn_failed" {
self.last_failure = Some(event.kind.clone());
}
true
}
"stream_error" | "transport_error" | "watch_error" | "runtime_disconnected" => {
self.turn_state = ComposerTurnState::Idle;
self.last_failure = Some(event.kind.clone());
true
}
"request" => self.open_request(event),
"request_resolved" => self.apply_request_resolution(event),
_ => false,
}
}
fn apply_request_resolution(&mut self, event: &FrontendEvent) -> bool {
let Some(request_id) = event.payload.get("request_id").and_then(Value::as_u64) else {
return false;
};
let previous = self.overlays.len();
self.overlays
.retain(|overlay| overlay.request.id != request_id);
self.resolved_requests.insert(request_id);
self.canonical_resolved_requests.insert(request_id);
self.overlays.len() != previous
}
pub fn handle_input(&mut self, event: ComposerInputEvent) -> Option<ComposerAction> {
match event {
ComposerInputEvent::Key(key) => self.handle_key(key),
ComposerInputEvent::Paste(text) => {
self.paste_burst.clear_after_explicit_paste();
self.insert_text(&text);
None
}
ComposerInputEvent::Resize { .. } | ComposerInputEvent::FocusChanged(_) => None,
}
}
pub fn handle_key(&mut self, key: ComposerKey) -> Option<ComposerAction> {
self.handle_key_at(key, Instant::now())
}
fn handle_key_at(&mut self, key: ComposerKey, now: Instant) -> Option<ComposerAction> {
if key.release {
return None;
}
if !self.overlays.is_empty() {
self.paste_burst.clear_window_after_non_char();
return self.handle_overlay_key(key);
}
let control = key.control;
let shift = key.shift;
if let ComposerKeyCode::Char(character) = key.code {
if !control && !key.alt {
if self.paste_burst.on_plain_char_no_hold(now).is_some() {
self.paste_burst.extend_window(now);
}
self.insert_char(character);
return None;
}
}
if key.code == ComposerKeyCode::Enter
&& !shift
&& !control
&& self.paste_burst.direct_insert_newline_should_insert(now)
{
self.insert_text("\n");
self.paste_burst.extend_window(now);
return None;
}
self.paste_burst.clear_window_after_non_char();
match key.code {
ComposerKeyCode::Enter if shift || control => {
self.insert_text("\n");
None
}
ComposerKeyCode::Char('j') if control => {
self.insert_text("\n");
None
}
ComposerKeyCode::Enter => self.submit_or_steer(),
ComposerKeyCode::Char('c') if control => {
if matches!(self.turn_state, ComposerTurnState::Working { .. })
&& self.capabilities.can_interrupt
{
Some(ComposerAction::Interrupt)
} else {
self.clear_input();
None
}
}
ComposerKeyCode::Backspace => {
self.backspace();
None
}
ComposerKeyCode::Delete => {
self.delete();
None
}
ComposerKeyCode::Left => {
self.cursor = previous_grapheme_boundary(&self.input, self.cursor);
None
}
ComposerKeyCode::Right => {
self.cursor = next_grapheme_boundary(&self.input, self.cursor);
None
}
ComposerKeyCode::Home => {
self.cursor = line_start(&self.input, self.cursor);
None
}
ComposerKeyCode::End => {
self.cursor = line_end(&self.input, self.cursor);
None
}
ComposerKeyCode::Up => {
self.history_previous();
None
}
ComposerKeyCode::Down => {
self.history_next();
None
}
_ => None,
}
}
pub fn set_input(&mut self, input: impl Into<String>) {
self.input = input.into();
self.cursor = self.input.len();
self.history_index = None;
self.history_draft = None;
}
fn submit_or_steer(&mut self) -> Option<ComposerAction> {
if self.input.trim().is_empty() {
return None;
}
let prompt = std::mem::take(&mut self.input);
self.cursor = 0;
self.history_index = None;
self.history_draft = None;
if self.history.last() != Some(&prompt) {
self.history.push(prompt.clone());
}
if self.turn_state == ComposerTurnState::Idle {
if let Some(operation) = self.capabilities.invocation_for_command(&prompt) {
self.turn_state = ComposerTurnState::Working {
since: Instant::now(),
};
self.last_failure = None;
return Some(ComposerAction::Invoke(operation));
}
}
match self.turn_state {
ComposerTurnState::Idle if self.capabilities.can_submit => {
self.turn_state = ComposerTurnState::Working {
since: Instant::now(),
};
self.last_failure = None;
Some(ComposerAction::Submit(prompt))
}
ComposerTurnState::Working { .. } if self.capabilities.can_steer => {
self.queued_steering.push_back(prompt.clone());
Some(ComposerAction::Steer(prompt))
}
_ => {
self.input = prompt;
self.cursor = self.input.len();
None
}
}
}
fn open_request(&mut self, event: &FrontendEvent) -> bool {
if !self.capabilities.can_respond {
return false;
}
let value = event
.payload
.get("request")
.unwrap_or(&event.payload)
.clone();
let Ok(request) = serde_json::from_value::<FrontendRequest>(value) else {
return false;
};
if self.resolved_requests.contains(&request.id)
|| self
.overlays
.iter()
.any(|overlay| overlay.request.id == request.id)
{
return false;
}
let kind = overlay_kind(&request);
self.overlays.push_back(ComposerOverlay {
request,
kind,
input: String::new(),
cursor: 0,
});
true
}
fn handle_overlay_key(&mut self, key: ComposerKey) -> Option<ComposerAction> {
let kind = self.overlays.front()?.kind;
let control = key.control;
match kind {
ComposerOverlayKind::Approval | ComposerOverlayKind::ChildApproval => match key.code {
ComposerKeyCode::Char('y') => {
self.resolve_approval(FrontendApprovalDecision::Allow)
}
ComposerKeyCode::Char('a') => {
self.resolve_approval(FrontendApprovalDecision::AllowForSession)
}
ComposerKeyCode::Char('n') | ComposerKeyCode::Escape => {
self.resolve_approval(FrontendApprovalDecision::Deny)
}
_ => None,
},
ComposerOverlayKind::Elicitation | ComposerOverlayKind::Other => match key.code {
ComposerKeyCode::Enter if !key.shift => {
let content = self.overlays.front().map(|overlay| {
if overlay.input.trim().is_empty() {
None
} else {
serde_json::from_str(&overlay.input)
.ok()
.or_else(|| Some(Value::String(overlay.input.clone())))
}
})?;
self.resolve_structured_request(FrontendElicitationAction::Accept, content)
}
ComposerKeyCode::Escape => {
self.resolve_structured_request(FrontendElicitationAction::Cancel, None)
}
ComposerKeyCode::Char('c') if control => {
self.resolve_structured_request(FrontendElicitationAction::Decline, None)
}
ComposerKeyCode::Char(character) if !control => {
self.overlay_insert_char(character);
None
}
ComposerKeyCode::Backspace => {
self.overlay_backspace();
None
}
ComposerKeyCode::Left => {
let overlay = self.overlays.front_mut()?;
overlay.cursor = previous_grapheme_boundary(&overlay.input, overlay.cursor);
None
}
ComposerKeyCode::Right => {
let overlay = self.overlays.front_mut()?;
overlay.cursor = next_grapheme_boundary(&overlay.input, overlay.cursor);
None
}
_ => None,
},
}
}
fn resolve_approval(&mut self, decision: FrontendApprovalDecision) -> Option<ComposerAction> {
let overlay = self.overlays.pop_front()?;
if !matches!(
overlay.kind,
ComposerOverlayKind::Approval | ComposerOverlayKind::ChildApproval
) {
self.overlays.push_front(overlay);
return None;
}
self.resolved_requests.insert(overlay.request.id);
Some(ComposerAction::Respond {
response: FrontendResponse::Approval {
request_id: overlay.request.id,
decision,
},
request: overlay.request,
resolution: match decision {
FrontendApprovalDecision::Deny => "denied",
FrontendApprovalDecision::Allow => "allowed once",
FrontendApprovalDecision::AllowForSession => "allowed for session",
}
.into(),
})
}
fn resolve_structured_request(
&mut self,
action: FrontendElicitationAction,
content: Option<Value>,
) -> Option<ComposerAction> {
let overlay = self.overlays.pop_front()?;
if !matches!(
overlay.kind,
ComposerOverlayKind::Elicitation | ComposerOverlayKind::Other
) {
self.overlays.push_front(overlay);
return None;
}
self.resolved_requests.insert(overlay.request.id);
let response = if overlay.kind == ComposerOverlayKind::Elicitation {
FrontendResponse::Elicitation {
request_id: overlay.request.id,
action,
content,
}
} else {
FrontendResponse::Other {
request_id: overlay.request.id,
action,
content,
}
};
Some(ComposerAction::Respond {
response,
request: overlay.request,
resolution: match action {
FrontendElicitationAction::Accept => "accepted",
FrontendElicitationAction::Decline => "declined",
FrontendElicitationAction::Cancel => "cancelled",
}
.into(),
})
}
fn insert_char(&mut self, character: char) {
self.input.insert(self.cursor, character);
self.cursor += character.len_utf8();
}
fn insert_text(&mut self, text: &str) {
if let Some(overlay) = self.overlays.front_mut() {
if matches!(
overlay.kind,
ComposerOverlayKind::Elicitation | ComposerOverlayKind::Other
) {
overlay.input.insert_str(overlay.cursor, text);
overlay.cursor += text.len();
return;
}
}
self.input.insert_str(self.cursor, text);
self.cursor += text.len();
}
fn backspace(&mut self) {
let start = previous_grapheme_boundary(&self.input, self.cursor);
if start < self.cursor {
self.input.replace_range(start..self.cursor, "");
self.cursor = start;
}
}
fn delete(&mut self) {
let end = next_grapheme_boundary(&self.input, self.cursor);
if end > self.cursor {
self.input.replace_range(self.cursor..end, "");
}
}
fn clear_input(&mut self) {
self.input.clear();
self.cursor = 0;
self.history_index = None;
self.history_draft = None;
}
fn history_previous(&mut self) {
if self.history.is_empty() || self.input.contains('\n') {
return;
}
let index = match self.history_index {
Some(0) => 0,
Some(index) => index - 1,
None => {
self.history_draft = Some(self.input.clone());
self.history.len() - 1
}
};
self.history_index = Some(index);
self.input.clone_from(&self.history[index]);
self.cursor = self.input.len();
}
fn history_next(&mut self) {
let Some(index) = self.history_index else {
return;
};
if index + 1 < self.history.len() {
self.history_index = Some(index + 1);
self.input.clone_from(&self.history[index + 1]);
} else {
self.history_index = None;
self.input = self.history_draft.take().unwrap_or_default();
}
self.cursor = self.input.len();
}
fn overlay_insert_char(&mut self, character: char) {
if let Some(overlay) = self.overlays.front_mut() {
overlay.input.insert(overlay.cursor, character);
overlay.cursor += character.len_utf8();
}
}
fn overlay_backspace(&mut self) {
if let Some(overlay) = self.overlays.front_mut() {
let start = previous_grapheme_boundary(&overlay.input, overlay.cursor);
if start < overlay.cursor {
overlay.input.replace_range(start..overlay.cursor, "");
overlay.cursor = start;
}
}
}
}
fn overlay_kind(request: &FrontendRequest) -> ComposerOverlayKind {
match request.kind {
FrontendRequestKind::Approval
if request.payload.get("child_agent_id").is_some()
|| request.payload.get("child_id").is_some()
|| request.payload.get("agent_id").is_some() =>
{
ComposerOverlayKind::ChildApproval
}
FrontendRequestKind::Approval => ComposerOverlayKind::Approval,
FrontendRequestKind::Elicitation => ComposerOverlayKind::Elicitation,
FrontendRequestKind::Other => ComposerOverlayKind::Other,
}
}
fn previous_grapheme_boundary(text: &str, cursor: usize) -> usize {
text[..cursor]
.grapheme_indices(true)
.map(|(index, _)| index)
.next_back()
.unwrap_or(0)
}
fn next_grapheme_boundary(text: &str, cursor: usize) -> usize {
text[cursor..]
.grapheme_indices(true)
.nth(1)
.map(|(index, _)| cursor + index)
.unwrap_or(text.len())
}
fn line_start(text: &str, cursor: usize) -> usize {
text[..cursor].rfind('\n').map_or(0, |index| index + 1)
}
fn line_end(text: &str, cursor: usize) -> usize {
text[cursor..]
.find('\n')
.map_or(text.len(), |index| cursor + index)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use supercode::FrontendActions;
use supercode::FrontendCommandDescriptor;
use supercode::FrontendConnectionState;
use supercode::FrontendDisplayCapabilities;
use supercode::FrontendOperationDescriptor;
use supercode::FrontendOperationKind;
use supercode::FRONTEND_RUNTIME_SCHEMA_VERSION;
use super::*;
#[derive(Clone, Copy)]
struct TestModifiers {
control: bool,
shift: bool,
alt: bool,
}
impl TestModifiers {
const NONE: Self = Self {
control: false,
shift: false,
alt: false,
};
const CONTROL: Self = Self {
control: true,
..Self::NONE
};
const SHIFT: Self = Self {
shift: true,
..Self::NONE
};
}
fn descriptor(turn_state: FrontendTurnState) -> FrontendRuntimeDescriptor {
FrontendRuntimeDescriptor {
schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
session_id: "test".into(),
source_harness: Some("claude-code".into()),
emulation_profile: Some("cc-parity".into()),
active_modules: vec![
"tui".into(),
"permissions".into(),
"mcp".into(),
"subagents".into(),
],
commands: vec![FrontendCommandDescriptor {
name: "agents".into(),
description: None,
argument_hint: None,
}],
operations: vec![FrontendOperationDescriptor {
id: "prompt:agents".into(),
kind: FrontendOperationKind::Prompt,
command: Some(FrontendCommandDescriptor {
name: "agents".into(),
description: None,
argument_hint: Some("[arguments]".into()),
}),
}],
actions: FrontendActions {
submit: true,
interrupt: true,
steer: true,
respond: true,
detach: true,
close: true,
},
display: FrontendDisplayCapabilities {
event_kinds: vec![],
opaque_fallback: true,
},
model: "test".into(),
turn_state,
connection_state: FrontendConnectionState::Connected,
extensions: Default::default(),
}
}
fn key(code: ComposerKeyCode, modifiers: TestModifiers) -> ComposerKey {
ComposerKey {
code,
control: modifiers.control,
shift: modifiers.shift,
alt: modifiers.alt,
release: false,
}
}
fn event(sequence: u64, kind: &str, payload: Value) -> FrontendEvent {
FrontendEvent {
sequence,
kind: kind.into(),
payload,
}
}
#[test]
fn editing_submit_and_paste_are_immediate_and_deterministic() {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
model.handle_key(key(ComposerKeyCode::Char('h'), TestModifiers::NONE));
model.handle_key(key(ComposerKeyCode::Char('i'), TestModifiers::NONE));
model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::SHIFT));
model.handle_input(ComposerInputEvent::Paste("one\ntwo\n/never-submit".into()));
assert_eq!(model.input(), "hi\none\ntwo\n/never-submit");
assert_eq!(model.history().len(), 0);
let before = Instant::now();
let action = model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE));
assert_eq!(
action,
Some(ComposerAction::Submit("hi\none\ntwo\n/never-submit".into()))
);
let ComposerTurnState::Working { since } = model.turn_state() else {
panic!("submit must synchronously enter working state");
};
assert!(since >= before);
assert!(before.elapsed().as_millis() < 100);
assert!(model.input().is_empty());
assert_eq!(model.history().len(), 1);
assert_eq!(
model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
None
);
}
#[test]
fn every_catalog_command_has_an_exact_typed_trigger() {
let descriptor = descriptor(FrontendTurnState::Idle);
for operation in &descriptor.operations {
let command = operation.command.as_ref().unwrap();
let mut model = ComposerModel::new(&descriptor);
model.set_input(format!("/{} inspect this", command.name));
assert_eq!(
model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
Some(ComposerAction::Invoke(
FrontendOperationInvocation::Prompt {
operation_id: operation.id.clone(),
arguments: "inspect this".into(),
}
)),
"rendered command /{} lacked its typed operation route",
command.name
);
}
}
#[test]
fn legacy_commands_and_modules_cannot_create_an_invoke_route() {
let mut descriptor = descriptor(FrontendTurnState::Idle);
descriptor.operations.clear();
descriptor.active_modules = vec![
"model_catalog".into(),
"session_tree".into(),
"subagents".into(),
"reduction".into(),
];
let mut model = ComposerModel::new(&descriptor);
model.set_input("/agents inspect this");
assert_eq!(
model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
Some(ComposerAction::Submit("/agents inspect this".into()))
);
}
#[test]
fn unbracketed_rapid_key_stream_treats_enter_as_pasted_newline() {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
let start = Instant::now();
for (offset, character) in [(0, 'a'), (1, 'b'), (2, 'c')] {
assert_eq!(
model.handle_key_at(
key(ComposerKeyCode::Char(character), TestModifiers::NONE),
start + std::time::Duration::from_millis(offset),
),
None
);
}
assert_eq!(
model.handle_key_at(
key(ComposerKeyCode::Enter, TestModifiers::NONE),
start + std::time::Duration::from_millis(3),
),
None
);
assert_eq!(model.input(), "abc\n");
assert!(model.history().is_empty());
assert_eq!(
model.handle_key_at(
key(ComposerKeyCode::Enter, TestModifiers::NONE),
start + std::time::Duration::from_millis(200),
),
Some(ComposerAction::Submit("abc\n".into()))
);
}
#[test]
fn one_fast_typed_character_then_enter_still_submits() {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
let start = Instant::now();
model.handle_key_at(key(ComposerKeyCode::Char('x'), TestModifiers::NONE), start);
assert_eq!(
model.handle_key_at(
key(ComposerKeyCode::Enter, TestModifiers::NONE),
start + std::time::Duration::from_millis(1),
),
Some(ComposerAction::Submit("x".into()))
);
}
#[test]
fn only_terminal_events_return_working_state_to_idle() {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
model.set_input("go");
assert!(matches!(
model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
Some(ComposerAction::Submit(_))
));
assert!(!model.apply_event(&event(
1,
"text_delta",
json!({"type":"text_delta", "text":"hello"}),
)));
assert!(matches!(
model.turn_state(),
ComposerTurnState::Working { .. }
));
assert!(!model.apply_event(&event(
2,
"turn_completed",
json!({"type":"turn_completed"}),
)));
assert!(matches!(
model.turn_state(),
ComposerTurnState::Working { .. }
));
assert!(model.apply_event(&event(
3,
"turn_succeeded",
json!({"type":"turn_succeeded", "schema_version":1}),
)));
assert_eq!(model.turn_state(), ComposerTurnState::Idle);
}
#[test]
fn versioned_turn_start_is_accepted_without_shape_coupling() {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
assert!(model.apply_event(&event(
1,
"turn_started",
json!({"type":"turn_started","schema_version":1,"turn_id":"turn-1"}),
)));
assert!(matches!(
model.turn_state(),
ComposerTurnState::Working { .. }
));
}
#[test]
fn steering_and_interrupt_are_distinct_and_preserve_history() {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
model.set_input("look at the failing test");
assert_eq!(
model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
Some(ComposerAction::Steer("look at the failing test".into()))
);
assert_eq!(model.queued_steering().len(), 1);
assert_eq!(model.history(), ["look at the failing test"]);
assert_eq!(
model.handle_key(key(ComposerKeyCode::Char('c'), TestModifiers::CONTROL)),
Some(ComposerAction::Interrupt)
);
assert_eq!(model.queued_steering().len(), 1);
assert_eq!(model.history(), ["look at the failing test"]);
}
#[test]
fn approval_and_child_approval_emit_exactly_one_typed_response() {
for (id, payload, expected_kind) in [
(7, json!({"tool":"bash"}), ComposerOverlayKind::Approval),
(
8,
json!({"tool":"bash", "child_agent_id":"agent-2"}),
ComposerOverlayKind::ChildApproval,
),
] {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
let request = FrontendRequest {
id,
kind: FrontendRequestKind::Approval,
payload,
};
assert!(model.apply_event(&event(
id,
"request",
json!({"type":"request", "request":request}),
)));
assert_eq!(model.overlay().unwrap().kind, expected_kind);
assert_eq!(
model.handle_key(key(ComposerKeyCode::Char('y'), TestModifiers::NONE)),
Some(ComposerAction::Respond {
response: FrontendResponse::Approval {
request_id: id,
decision: FrontendApprovalDecision::Allow,
},
request,
resolution: "allowed once".into(),
})
);
assert_eq!(
model.handle_key(key(ComposerKeyCode::Char('y'), TestModifiers::NONE)),
None
);
}
}
#[test]
fn request_overlays_follow_runtime_events_and_respond_capability() {
let mut enabled_descriptor = descriptor(FrontendTurnState::Busy);
enabled_descriptor.active_modules.clear();
let mut enabled = ComposerModel::new(&enabled_descriptor);
assert!(enabled.apply_event(&event(
1,
"request",
json!({"request":{"id":40,"kind":"approval","payload":{"tool":"bash"}}}),
)));
assert_eq!(
enabled.overlay().unwrap().kind,
ComposerOverlayKind::Approval
);
let mut disabled_descriptor = descriptor(FrontendTurnState::Busy);
disabled_descriptor.active_modules = vec!["permissions".into(), "mcp".into()];
disabled_descriptor.actions.respond = false;
let mut disabled = ComposerModel::new(&disabled_descriptor);
assert!(!disabled.apply_event(&event(
1,
"request",
json!({"request":{"id":41,"kind":"elicitation","payload":{"prompt":"Choose"}}}),
)));
assert!(disabled.overlay().is_none());
}
#[test]
fn elicitation_accepts_json_and_emits_once() {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
let request = FrontendRequest {
id: 9,
kind: FrontendRequestKind::Elicitation,
payload: json!({"prompt":"Choose"}),
};
assert!(model.apply_event(&event(
9,
"request",
json!({"type":"request", "request":request}),
)));
model.handle_input(ComposerInputEvent::Paste("{\"choice\":2}".into()));
assert_eq!(
model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
Some(ComposerAction::Respond {
response: FrontendResponse::Elicitation {
request_id: 9,
action: FrontendElicitationAction::Accept,
content: Some(json!({"choice":2})),
},
request,
resolution: "accepted".into(),
})
);
assert_eq!(
model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
None
);
}
#[test]
fn concurrent_and_unknown_requests_queue_and_failed_response_can_be_restored() {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
let approval = FrontendRequest {
id: 20,
kind: FrontendRequestKind::Approval,
payload: json!({"tool":"bash"}),
};
let other = json!({"id":21, "kind":"oauth_code", "payload":{"prompt":"Paste code"}});
assert!(model.apply_event(&event(
20,
"request",
json!({"type":"request", "request":approval}),
)));
assert!(model.apply_event(&event(
21,
"request",
json!({"type":"request", "request":other}),
)));
assert_eq!(model.pending_overlay_count(), 2);
assert_eq!(model.overlay().unwrap().request.id, 20);
let Some(ComposerAction::Respond { request, .. }) =
model.handle_key(key(ComposerKeyCode::Char('n'), TestModifiers::NONE))
else {
panic!("approval should produce a response");
};
assert_eq!(request, approval);
assert_eq!(model.overlay().unwrap().request.id, 21);
assert_eq!(model.overlay().unwrap().kind, ComposerOverlayKind::Other);
model.restore_request(request);
assert_eq!(model.pending_overlay_count(), 2);
assert_eq!(model.overlay().unwrap().request.id, 20);
assert_eq!(
model.handle_key(key(ComposerKeyCode::Char('y'), TestModifiers::NONE)),
Some(ComposerAction::Respond {
response: FrontendResponse::Approval {
request_id: 20,
decision: FrontendApprovalDecision::Allow,
},
request: approval,
resolution: "allowed once".into(),
})
);
assert_eq!(model.overlay().unwrap().request.id, 21);
assert!(matches!(
model.handle_key(key(ComposerKeyCode::Enter, TestModifiers::NONE)),
Some(ComposerAction::Respond {
response: FrontendResponse::Other {
request_id: 21,
action: FrontendElicitationAction::Accept,
..
},
..
})
));
assert_eq!(model.pending_overlay_count(), 0);
}
#[test]
fn canonical_request_resolution_dismisses_matching_overlay_for_sibling_frontend() {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
for id in [30, 31] {
model.apply_event(&event(
id,
"request",
json!({"request":{"id":id,"kind":"approval","payload":{"tool":"bash"}}}),
));
}
assert_eq!(model.pending_overlay_count(), 2);
assert!(model.apply_event(&event(
32,
"request_resolved",
json!({"request_id":30,"response":{"decision":"allow"}}),
)));
assert_eq!(model.pending_overlay_count(), 1);
assert_eq!(model.overlay().unwrap().request.id, 31);
assert!(!model.apply_event(&event(
33,
"request_resolved",
json!({"request_id":30,"response":{"decision":"allow"}}),
)));
}
#[test]
fn loser_response_completion_cannot_resurrect_a_canonically_resolved_overlay() {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Busy));
model.apply_event(&event(
1,
"request",
json!({"request":{"id":30,"kind":"approval","payload":{"tool":"bash"}}}),
));
let Some(ComposerAction::Respond { request, .. }) =
model.handle_key(key(ComposerKeyCode::Char('y'), TestModifiers::NONE))
else {
panic!("approval should produce a response");
};
assert!(!model.apply_event(&event(
2,
"request_resolved",
json!({"request_id":30,"response":{"kind":"approval","request_id":30,"decision":"deny"}}),
)));
assert!(!model.restore_request(request));
assert_eq!(model.pending_overlay_count(), 0);
}
#[test]
fn unicode_editing_tracks_grapheme_boundaries() {
let mut model = ComposerModel::new(&descriptor(FrontendTurnState::Idle));
model.set_input("a👩💻b");
model.handle_key(key(ComposerKeyCode::Left, TestModifiers::NONE));
model.handle_key(key(ComposerKeyCode::Backspace, TestModifiers::NONE));
assert_eq!(model.input(), "ab");
assert_eq!(model.cursor(), 1);
}
}