Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 150 additions & 3 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,10 @@ struct SessionHydrationRequest {
session_id: String,
needs_history: bool,
terminal_pane_size: (u16, u16),
/// Whether the session is headless (no PTY). Headless sessions carry
/// their conversation as structured Message/Reasoning events, which
/// replay folds into the items history; PTY-backed sessions don't.
is_headless: bool,
}

struct PtyInputJob {
Expand Down Expand Up @@ -646,6 +650,7 @@ async fn load_session_hydration(req: SessionHydrationRequest) -> Result<SessionH
&mut h,
&mut editor_state,
&mut agent_status,
req.is_headless,
);
let (cols, rows) = req.terminal_pane_size;
let _ = h.replay(cols.max(1), rows.max(1), 0);
Expand Down Expand Up @@ -1741,11 +1746,18 @@ impl App {
// ends up true here (the fetch always inserts an entry, so the
// re-trigger can't spin).
let needs_history = self.view == ViewMode::Terminal && !self.histories.contains_key(&id);
let is_headless = self
.sessions
.iter()
.find(|s| s.id == id)
.map(crate::ui::is_headless)
.unwrap_or(false);
Some(SessionHydrationRequest {
socket: self.client.socket_path().to_path_buf(),
session_id: id,
needs_history,
terminal_pane_size: self.terminal_pane_size,
is_headless,
})
}

Expand Down Expand Up @@ -1984,6 +1996,12 @@ impl App {
// call_id and just fills `output` on the existing block.
let mut replayed_editor_state: Option<EditorState> = None;
let mut replayed_agent_status: Option<agentd_protocol::AgentStatus> = None;
let is_headless = self
.sessions
.iter()
.find(|s| s.id == id)
.map(crate::ui::is_headless)
.unwrap_or(false);
match self.client.transcript(id, 0, None).await {
Ok(t) => {
if t.events
Expand All @@ -2002,6 +2020,7 @@ impl App {
&mut history,
&mut replayed_editor_state,
&mut replayed_agent_status,
is_headless,
);
}
Err(e) => {
Expand Down Expand Up @@ -2311,6 +2330,43 @@ impl App {
.or_default();
history.feed_tool_result(tool, *ok, output.clone());
}
// Headless sessions (any harness) emit their
// conversation as structured Message/Reasoning
// events with no PTY, so fold the prose into the
// items history. PTY-backed sessions already carry
// it in the PTY stream, so skip them to avoid
// double-rendering. (Streaming arrives as many
// same-kind deltas; `feed_message` coalesces.)
let msg = match &payload.event {
SessionEvent::Message { role, text } => Some((
match role {
agentd_protocol::MessageRole::User => {
crate::pty_render::MessageKind::User
}
_ => crate::pty_render::MessageKind::Assistant,
},
text.clone(),
)),
SessionEvent::Reasoning { text } => {
Some((crate::pty_render::MessageKind::Reasoning, text.clone()))
}
_ => None,
};
if let Some((kind, text)) = msg {
let headless = self
.sessions
.iter()
.find(|s| s.id == payload.session_id)
.map(crate::ui::is_headless)
.unwrap_or(false);
if headless {
let history = self
.histories
.entry(payload.session_id.clone())
.or_default();
history.feed_message(kind, &text);
}
}
// Adapter editor state — drives the fixed
// bottom input pane.
if let SessionEvent::EditorState {
Expand Down Expand Up @@ -4881,6 +4937,7 @@ pub fn apply_transcript_to_local_state(
history: &mut crate::pty_render::ItemHistory,
editor_state: &mut Option<EditorState>,
agent_status: &mut Option<agentd_protocol::AgentStatus>,
is_headless: bool,
) {
for ev in events {
match &ev.event {
Expand Down Expand Up @@ -4963,6 +5020,20 @@ pub fn apply_transcript_to_local_state(
}
}
}
// Headless sessions carry their conversation as structured
// Message / Reasoning events (no PTY), so fold the prose into
// history to render it on reconnect. PTY-backed sessions keep
// their prose in the PTY stream — skip to avoid double-render.
SessionEvent::Message { role, text } if is_headless => {
let kind = match role {
agentd_protocol::MessageRole::User => crate::pty_render::MessageKind::User,
_ => crate::pty_render::MessageKind::Assistant,
};
history.feed_message(kind, text);
}
SessionEvent::Reasoning { text } if is_headless => {
history.feed_message(crate::pty_render::MessageKind::Reasoning, text);
}
_ => {}
}
}
Expand Down Expand Up @@ -6342,6 +6413,7 @@ mod tests {
session_id: "s-big".to_string(),
needs_history: true,
terminal_pane_size: (80, 24),
is_headless: false,
}));

tokio::time::timeout(std::time::Duration::from_secs(1), transcript_seen_rx.recv())
Expand Down Expand Up @@ -6410,7 +6482,7 @@ mod tests {
let mut history = crate::pty_render::ItemHistory::new();
let mut editor: Option<EditorState> = None;
let mut status: Option<AgentStatus> = None;
apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status);
apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status, false);

// The render must include the synthesized header for the
// block. Before the fix, no `ToolBlock` items existed and
Expand Down Expand Up @@ -6438,6 +6510,80 @@ mod tests {
);
}

/// Headless sessions carry their conversation as structured
/// Message/Reasoning events (no PTY). Replay must fold them into the
/// items history when the session is headless, and ignore them when
/// it's PTY-backed (the prose is already in the PTY stream there).
#[test]
fn transcript_replay_renders_messages_only_when_headless() {
use agentd_protocol::{MessageRole, SessionEvent, TimestampedEvent};
use chrono::Utc;
fn ev(seq: u64, event: SessionEvent) -> TimestampedEvent {
TimestampedEvent {
seq,
at: Utc::now(),
event,
}
}
let events = vec![
ev(
1,
SessionEvent::Message {
role: MessageRole::Assistant,
text: "answer from a headless run".into(),
},
),
ev(
2,
SessionEvent::Reasoning {
text: "some reasoning".into(),
},
),
];

let render = |is_headless: bool| {
let mut history = crate::pty_render::ItemHistory::new();
let mut editor: Option<EditorState> = None;
let mut status: Option<agentd_protocol::AgentStatus> = None;
apply_transcript_to_local_state(
&events,
&mut history,
&mut editor,
&mut status,
is_headless,
);
let out = history.replay(80, 24, 0);
(0..24u16)
.flat_map(|r| {
let mut row = String::new();
for c in 0..80u16 {
if let Some(cell) = out.screen.cell(r, c) {
row.push_str(&cell.contents());
}
}
row.push('\n');
row.chars().collect::<Vec<_>>()
})
.collect::<String>()
};

let headless = render(true);
assert!(
headless.contains("answer from a headless run"),
"headless replay must render assistant prose:\n{headless}"
);
assert!(
headless.contains("some reasoning"),
"headless replay must render reasoning:\n{headless}"
);

let interactive = render(false);
assert!(
!interactive.contains("answer from a headless run"),
"PTY-backed replay must NOT re-render Message prose (it's in the PTY):\n{interactive}"
);
}

#[test]
fn transcript_replay_preserves_answer_after_tool_order() {
use agentd_protocol::{SessionEvent, TimestampedEvent};
Expand Down Expand Up @@ -6475,7 +6621,7 @@ mod tests {
let mut history = crate::pty_render::ItemHistory::new();
let mut editor: Option<EditorState> = None;
let mut status = None;
apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status);
apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status, false);

let screen_rows = 24u16;
let screen_cols = 80u16;
Expand Down Expand Up @@ -6532,7 +6678,7 @@ mod tests {
started_at_ms,
status: "Working".into(),
});
apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status);
apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status, false);

assert!(
status.is_none(),
Expand Down Expand Up @@ -6669,6 +6815,7 @@ mod tests {
&mut history,
&mut editor_state,
&mut agent_status,
false,
);

let state = editor_state
Expand Down
Loading
Loading