From f70ff8e7fcfcd017639e3f37c206378425d8e256 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 29 Aug 2026 14:16:48 +0800 Subject: [PATCH 1/8] Unify live and resume transcript rendering, and restore context on resume. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share one history-commit path for tools, restore session occupancy for the context bar, polish agent/task tool cells and ▌ markers, and fix related projector/seatbelt golden regressions. Co-authored-by: Cursor --- crates/cli/src/prompt_command.rs | 15 + crates/core/src/conversation/history.rs | 21 +- .../core/src/conversation/legacy_projector.rs | 49 +- crates/core/src/query/event.rs | 7 + crates/core/src/query/stream_consumer.rs | 50 +- crates/core/src/query/tests.rs | 7 +- crates/core/tests/context_limit_compaction.rs | 1 + crates/core/tests/v2_roundtrip.rs | 7 +- crates/protocol/src/event.rs | 2 + crates/protocol/src/native/event.rs | 9 + .../protocol/src/native/legacy_projector.rs | 591 +++++ crates/protocol/src/native/mod.rs | 3 + crates/protocol/src/native/rpc_session.rs | 6 + crates/protocol/src/native/wire_projector.rs | 114 +- crates/sandbox/src/seatbelt.rs | 6 + crates/server/src/runtime/approval.rs | 273 +- .../server/src/runtime/handlers/compaction.rs | 35 +- crates/server/src/runtime/handlers/session.rs | 59 +- crates/server/src/runtime/items.rs | 88 + .../runtime/turn_exec/context_compaction.rs | 122 +- .../src/runtime/turn_exec/event_stream.rs | 63 + .../src/runtime/turn_exec/item_stream.rs | 53 +- crates/server/src/runtime/turn_exec/mod.rs | 3 + crates/server/src/runtime/turn_exec/tests.rs | 82 +- .../src/runtime/turn_exec/tool_results.rs | 128 +- crates/server/src/runtime/turn_exec/trace.rs | 4 + crates/tui/AGENTS.md | 24 + crates/tui/src/agent_tool_cell.rs | 491 ++++ crates/tui/src/chatwidget.rs | 15 +- crates/tui/src/chatwidget/history_commit.rs | 548 ++++ crates/tui/src/chatwidget/restored_session.rs | 259 +- crates/tui/src/chatwidget/session_header.rs | 94 +- crates/tui/src/chatwidget/session_history.rs | 18 +- crates/tui/src/chatwidget/text_stream.rs | 404 +-- crates/tui/src/chatwidget/transcript_sync.rs | 190 ++ crates/tui/src/chatwidget/transcript_view.rs | 225 +- crates/tui/src/chatwidget/worker_events.rs | 800 ++---- .../tui/src/chatwidget_tail_follow_tests.rs | 43 +- crates/tui/src/chatwidget_tests.rs | 2187 +++++++++------- crates/tui/src/events.rs | 107 +- crates/tui/src/exec_cell/model.rs | 10 + crates/tui/src/exec_cell/render.rs | 167 +- crates/tui/src/history_cell.rs | 4 +- crates/tui/src/interactive.rs | 24 +- crates/tui/src/lib.rs | 4 + crates/tui/src/streaming/controller.rs | 10 +- crates/tui/src/tool_io_cell.rs | 31 + crates/tui/src/tool_rendering_e2e_tests.rs | 75 +- crates/tui/src/transcript/file_change.rs | 18 + crates/tui/src/transcript/lifecycle.rs | 80 + crates/tui/src/transcript/mod.rs | 14 + crates/tui/src/transcript/model.rs | 202 ++ crates/tui/src/transcript/presentation.rs | 544 ++++ crates/tui/src/transcript/projector.rs | 452 ++++ crates/tui/src/transcript/render.rs | 153 ++ crates/tui/src/transcript/restore.rs | 13 + crates/tui/src/transcript/restore_session.rs | 302 +++ crates/tui/src/transcript/stream_text.rs | 84 + crates/tui/src/transcript/tool_state.rs | 60 + crates/tui/src/ui_consts.rs | 3 + crates/tui/src/worker.rs | 2215 ++++------------- crates/tui/src/worker/approval_items.rs | 99 + crates/tui/src/worker/compaction_items.rs | 44 + crates/tui/src/worker/goals.rs | 106 + crates/tui/src/worker/history.rs | 217 ++ crates/tui/src/worker/item_dispatch.rs | 57 + crates/tui/src/worker/native_items.rs | 151 ++ crates/tui/src/worker/plan_items.rs | 49 + crates/tui/src/worker/session_preview.rs | 119 + crates/tui/src/worker/session_restore.rs | 183 ++ crates/tui/src/worker/skills.rs | 104 + crates/tui/src/worker/tool_lifecycle.rs | 221 ++ crates/tui/src/worker/tool_summaries.rs | 697 ++++++ crates/tui/src/worker/typed_events.rs | 400 +-- crates/tui/src/worker_event_test_helpers.rs | 344 +++ .../tui/src/worker_queue_compaction_tests.rs | 3 +- 76 files changed, 9665 insertions(+), 4797 deletions(-) create mode 100644 crates/protocol/src/native/legacy_projector.rs create mode 100644 crates/tui/src/agent_tool_cell.rs create mode 100644 crates/tui/src/chatwidget/history_commit.rs create mode 100644 crates/tui/src/chatwidget/transcript_sync.rs create mode 100644 crates/tui/src/transcript/file_change.rs create mode 100644 crates/tui/src/transcript/lifecycle.rs create mode 100644 crates/tui/src/transcript/mod.rs create mode 100644 crates/tui/src/transcript/model.rs create mode 100644 crates/tui/src/transcript/presentation.rs create mode 100644 crates/tui/src/transcript/projector.rs create mode 100644 crates/tui/src/transcript/render.rs create mode 100644 crates/tui/src/transcript/restore.rs create mode 100644 crates/tui/src/transcript/restore_session.rs create mode 100644 crates/tui/src/transcript/stream_text.rs create mode 100644 crates/tui/src/transcript/tool_state.rs create mode 100644 crates/tui/src/worker/approval_items.rs create mode 100644 crates/tui/src/worker/compaction_items.rs create mode 100644 crates/tui/src/worker/goals.rs create mode 100644 crates/tui/src/worker/history.rs create mode 100644 crates/tui/src/worker/item_dispatch.rs create mode 100644 crates/tui/src/worker/native_items.rs create mode 100644 crates/tui/src/worker/plan_items.rs create mode 100644 crates/tui/src/worker/session_preview.rs create mode 100644 crates/tui/src/worker/session_restore.rs create mode 100644 crates/tui/src/worker/skills.rs create mode 100644 crates/tui/src/worker/tool_lifecycle.rs create mode 100644 crates/tui/src/worker/tool_summaries.rs create mode 100644 crates/tui/src/worker_event_test_helpers.rs diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs index 982c19f0..3bc03dd3 100644 --- a/crates/cli/src/prompt_command.rs +++ b/crates/cli/src/prompt_command.rs @@ -357,6 +357,13 @@ enum PromptJsonlEvent<'a> { tool_call_id: &'a str, delta: &'a str, }, + #[serde(rename = "item.updated")] + ToolCallInputDelta { + session_id: &'a str, + item_type: &'static str, + tool_call_id: &'a str, + delta: &'a str, + }, #[serde(rename = "item.completed")] ToolResult { session_id: &'a str, @@ -522,6 +529,14 @@ fn write_query_event_jsonl(session_id: &str, event: &QueryEvent) -> Result<()> { input, }) } + QueryEvent::ToolUseInputDelta { id, partial_json } => { + write_jsonl(&PromptJsonlEvent::ToolCallInputDelta { + session_id, + item_type: "tool_call", + tool_call_id: id, + delta: partial_json, + }) + } QueryEvent::ToolExecutionStart { .. } => Ok(()), QueryEvent::ToolProgress { tool_use_id, diff --git a/crates/core/src/conversation/history.rs b/crates/core/src/conversation/history.rs index e6ff598e..eb2ad565 100644 --- a/crates/core/src/conversation/history.rs +++ b/crates/core/src/conversation/history.rs @@ -31,6 +31,9 @@ pub struct CanonicalHistory { pub turns: Vec, /// Item envelopes in ascending `seq` order, approval folds applied. pub items: Vec, + /// Latest context-window occupancy observed while reading the rollout + /// (turn extras or compaction snapshots), when present. + pub latest_context_occupancy: Option, } /// Errors from reading a rollout file as canonical history. @@ -98,7 +101,15 @@ pub fn read_canonical_history(path: &Path) -> Result history.session = Some(session), - RolloutLineV2::Turn { turn, .. } => history.turns.push(turn), + RolloutLineV2::Turn { turn, extras, .. } => { + history.turns.push(turn); + if let Some(extras) = extras + .as_ref() + .and_then(|extras| extras.context_occupancy.clone()) + { + history.latest_context_occupancy = Some(extras); + } + } RolloutLineV2::Item { item, .. } => history.items.push(item), RolloutLineV2::Internal { entry: @@ -144,11 +155,17 @@ fn apply_v2_line(history: &mut CanonicalHistory, line: RolloutLineV2) { // the prompt, not the displayed history; workspace lines are not part // of the conversational timeline. RolloutLineV2::Internal { .. } - | RolloutLineV2::CompactionSnapshot { .. } | RolloutLineV2::WorkspaceCheckpoint { .. } | RolloutLineV2::WorkspaceChange { .. } | RolloutLineV2::WorkspaceRestoreStarted { .. } | RolloutLineV2::WorkspaceRestoreCompleted { .. } => {} + RolloutLineV2::CompactionSnapshot { + context_occupancy, .. + } => { + if let Some(occupancy) = context_occupancy { + history.latest_context_occupancy = Some(occupancy); + } + } } } diff --git a/crates/core/src/conversation/legacy_projector.rs b/crates/core/src/conversation/legacy_projector.rs index f474e2bc..539b5f0c 100644 --- a/crates/core/src/conversation/legacy_projector.rs +++ b/crates/core/src/conversation/legacy_projector.rs @@ -813,26 +813,35 @@ pub fn canonical_turn_from_record(record: &TurnRecord) -> Result, hosted_tool_inputs: HashMap, emitted_tool_use_starts: HashSet, + emitted_early_tool_use_starts: HashSet, emitted_hosted_tool_starts: HashSet, emitted_hosted_tool_results: HashSet, final_response: Option, @@ -112,6 +113,7 @@ async fn consume_provider_stream( tool_uses: Vec::new(), hosted_tool_inputs: HashMap::new(), emitted_tool_use_starts: HashSet::new(), + emitted_early_tool_use_starts: HashSet::new(), emitted_hosted_tool_starts: HashSet::new(), emitted_hosted_tool_results: HashSet::new(), final_response: None, @@ -152,7 +154,29 @@ async fn consume_provider_stream( name, input, }) => { - acc.tool_uses.push((index, id, name, input, String::new(), false)); + acc.tool_uses.push(( + index, + id.clone(), + name.clone(), + input.clone(), + String::new(), + false, + )); + if acc.emitted_early_tool_use_starts.insert(id.clone()) { + let should_emit_early = input.is_null() + || matches!(&input, serde_json::Value::Object(map) if map.is_empty()); + if should_emit_early { + emit_query_event( + on_event, + QueryEvent::ToolUseStart { + id, + name, + input: serde_json::json!({}), + }, + ) + .await; + } + } } Ok(StreamEvent::HostedToolCallStart { index, @@ -213,13 +237,22 @@ async fn consume_provider_stream( index, partial_json, }) => { - if let Some(tool_use) = acc.tool_uses + if let Some(tool_use) = acc + .tool_uses .iter_mut() .rev() .find(|(tool_index, ..)| *tool_index == index) { tool_use.4.push_str(&partial_json); tool_use.5 = true; + emit_query_event( + on_event, + QueryEvent::ToolUseInputDelta { + id: tool_use.1.clone(), + partial_json, + }, + ) + .await; } } Ok(StreamEvent::MessageDone { response }) => { @@ -284,6 +317,7 @@ async fn assemble_model_turn( mut tool_uses, mut hosted_tool_inputs, mut emitted_tool_use_starts, + emitted_early_tool_use_starts, mut emitted_hosted_tool_starts, mut emitted_hosted_tool_results, final_response, @@ -518,7 +552,17 @@ async fn assemble_model_turn( } else { final_tool_inputs.get(&id).cloned().unwrap_or(initial_input) }; - if emitted_tool_use_starts.insert(id.clone()) { + if emitted_early_tool_use_starts.contains(&id) { + emit_query_event( + on_event, + QueryEvent::ToolUseStart { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }, + ) + .await; + } else if emitted_tool_use_starts.insert(id.clone()) { emit_query_event( on_event, QueryEvent::ToolUseStart { diff --git a/crates/core/src/query/tests.rs b/crates/core/src/query/tests.rs index e28784d8..83b3a44f 100644 --- a/crates/core/src/query/tests.rs +++ b/crates/core/src/query/tests.rs @@ -1277,6 +1277,7 @@ fn recorded_compaction_events(events: &[QueryEvent]) -> Vec SessionRecord { } /// TurnRecord fields the canonical model does not carry. +/// +/// Aggregate `usage` is replaced by `latest_query_usage` when that field is +/// present: the forward projector prefers the latest query for canonical +/// `Turn.usage`, so the inverse cannot recover a distinct aggregate. fn normalize_turn(turn: &TurnRecord) -> TurnRecord { + let usage_source = turn.latest_query_usage.as_ref().or(turn.usage.as_ref()); TurnRecord { status: match turn.status.clone() { TurnStatus::Pending | TurnStatus::Running | TurnStatus::WaitingApproval => { @@ -312,7 +317,7 @@ fn normalize_turn(turn: &TurnRecord) -> TurnRecord { } else { turn.request_model.clone() }, - usage: turn.usage.as_ref().map(|usage| devo_core::TurnUsage { + usage: usage_source.map(|usage| devo_core::TurnUsage { cache_creation_input_tokens: usage.cache_creation_input_tokens.filter(|v| *v > 0), cache_read_input_tokens: usage.cache_read_input_tokens.filter(|v| *v > 0), reasoning_output_tokens: usage.reasoning_output_tokens.filter(|v| *v > 0), diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 88294b33..9cb7cdb6 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -349,6 +349,7 @@ pub enum ItemDeltaKind { CommandExecutionOutputDelta, FileChangeOutputDelta, PlanDelta, + ToolCallInputDelta, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -536,6 +537,7 @@ impl ServerEvent { ItemDeltaKind::CommandExecutionOutputDelta => "item/commandExecution/outputDelta", ItemDeltaKind::FileChangeOutputDelta => "item/fileChange/outputDelta", ItemDeltaKind::PlanDelta => "item/plan/delta", + ItemDeltaKind::ToolCallInputDelta => "item/toolCall/inputDelta", }, Self::ServerRequestResolved(_) => "serverRequest/resolved", Self::ReferenceSearchUpdated(_) => "search/updated", diff --git a/crates/protocol/src/native/event.rs b/crates/protocol/src/native/event.rs index b2da3af4..631b2e31 100644 --- a/crates/protocol/src/native/event.rs +++ b/crates/protocol/src/native/event.rs @@ -205,6 +205,10 @@ pub enum ServerNotification { ItemReasoningDelta(ItemDelta), #[serde(rename = "item/commandExecution/outputDelta")] ItemCommandExecutionOutputDelta(ItemDelta), + #[serde(rename = "item/toolCall/inputDelta")] + ItemToolCallInputDelta(ItemDelta), + #[serde(rename = "item/plan/delta")] + ItemPlanDelta(ItemDelta), /// All terminal states (Completed/Failed/Interrupted/Lost) go through /// this one notification with the terminal full snapshot; no separate /// `item/failed` exists. @@ -309,6 +313,11 @@ pub enum ServerNotification { turn_id: TurnId, item_id: ItemId, }, + #[serde(rename = "context/compactionFailed")] + ContextCompactionFailed { + session_id: SessionId, + message: String, + }, #[serde(rename = "session/usage/updated")] SessionUsageUpdated { session_id: SessionId, diff --git a/crates/protocol/src/native/legacy_projector.rs b/crates/protocol/src/native/legacy_projector.rs new file mode 100644 index 00000000..5746893f --- /dev/null +++ b/crates/protocol/src/native/legacy_projector.rs @@ -0,0 +1,591 @@ +//! Inverse wire projector: native typed `Item` → legacy `(ItemKind, +//! serde_json::Value)` envelope for ACP and other legacy consumers. +//! +//! Companion of [`super::wire_projector::project_wire_item`]. Used by the +//! server emit path to construct native items first while still broadcasting +//! legacy `ItemStarted` / `ItemCompleted` events. + +use std::path::PathBuf; + +use super::item::{ + ApprovalDecisionKind, ApprovalScope, ApprovalTarget, CompactionTrigger, ExecOrigin, + FileChangeEntry, FileChangeKind, Item, PlanEntry, ToolSource, UserInput, +}; +use crate::protocol::ExecCommandSource; +use crate::protocol::FileChange; +use crate::{ + ApprovalDecisionPayload, ApprovalRequestPayload, CommandExecutionPayload, FileChangePayload, + ItemKind, PendingServerRequestContext, ServerRequestKind, ToolCallPayload, ToolResultPayload, +}; + +/// Converts one native item into the legacy wire `(ItemKind, payload)` pair. +/// +/// Returns `None` for item variants that have no legacy wire counterpart +/// (sub-agents, warnings, user-input requests, …). +pub fn legacy_wire_from_native_item(item: &Item) -> Option<(ItemKind, serde_json::Value)> { + match item { + Item::UserMessage { content, .. } => { + let text = user_message_text(content); + Some((ItemKind::UserMessage, text_display_payload("You", &text))) + } + Item::AssistantMessage { text, .. } => Some(( + ItemKind::AgentMessage, + text_display_payload("Assistant", text), + )), + Item::Reasoning { text, .. } => { + Some((ItemKind::Reasoning, text_display_payload("Reasoning", text))) + } + Item::Plan { entries } => Some(( + ItemKind::Plan, + text_display_payload("Plan", &plan_entries_text(entries)), + )), + Item::ToolCall { + call_id, + tool_name, + source, + input, + .. + } => { + let kind = match source { + ToolSource::Mcp => ItemKind::McpToolCall, + ToolSource::Builtin | ToolSource::Plugin => ItemKind::ToolCall, + }; + let payload = serde_json::to_value(ToolCallPayload { + tool_call_id: call_id.clone(), + tool_name: tool_name.clone(), + parameters: input.clone().unwrap_or(serde_json::Value::Null), + command_actions: Vec::new(), + }) + .expect("serialize tool call payload"); + Some((kind, payload)) + } + Item::ToolResult { + call_id, + output, + display_content, + is_error, + truncated: _, + } => { + let payload = serde_json::to_value(ToolResultPayload { + tool_call_id: call_id.clone(), + tool_name: None, + input: None, + content: output.clone(), + display_content: display_content.clone(), + is_error: *is_error, + summary: String::new(), + }) + .expect("serialize tool result payload"); + Some((ItemKind::ToolResult, payload)) + } + Item::CommandExecution { + call_id, + command, + input, + output, + is_error, + origin, + .. + } => { + let source = match origin { + ExecOrigin::UserShell => ExecCommandSource::UserShell, + ExecOrigin::AgentTool => ExecCommandSource::Agent, + }; + let payload = serde_json::to_value(CommandExecutionPayload { + tool_call_id: call_id.clone(), + tool_name: "exec_command".to_string(), + command: command.clone(), + input: input.clone(), + source, + command_actions: Vec::new(), + output: output.clone(), + is_error: *is_error, + }) + .expect("serialize command execution payload"); + Some((ItemKind::CommandExecution, payload)) + } + Item::FileChange { + call_id, + changes, + sandbox: _, + } => { + let payload = serde_json::to_value(FileChangePayload { + tool_call_id: call_id.clone(), + tool_name: None, + input: None, + changes: changes.iter().map(file_change_entry_to_legacy).collect(), + is_error: false, + }) + .expect("serialize file change payload"); + Some((ItemKind::FileChange, payload)) + } + Item::HostedToolCall { + tool_name, output, .. + } => match tool_name.as_str() { + "web_search" => Some(( + ItemKind::WebSearch, + hosted_tool_display_payload("Web Search", output), + )), + "image_view" => Some(( + ItemKind::ImageView, + hosted_tool_display_payload("Image", output), + )), + _ => None, + }, + Item::ContextCompaction { + trigger, summary, .. + } => Some(( + ItemKind::ContextCompaction, + context_compaction_payload(*trigger, summary.as_deref()), + )), + Item::Approval { + approval_id, + action_summary, + justification, + resource, + available_scopes, + command_pattern, + command_prefix, + target, + decision, + .. + } => { + let (path, host, target_command) = approval_target_fields(target.as_ref()); + if decision.is_none() { + let payload = serde_json::to_value(ApprovalRequestPayload { + request: PendingServerRequestContext { + request_id: approval_id.clone().into(), + request_kind: approval_request_kind(resource.as_deref()), + session_id: crate::SessionId::new(), + turn_id: None, + item_id: None, + }, + approval_id: approval_id.clone().into(), + action_summary: action_summary.clone(), + justification: justification.clone(), + resource: resource.clone(), + available_scopes: available_scopes.clone(), + path, + host, + target: target_command, + command_pattern: command_pattern.clone(), + command_prefix: command_prefix.clone(), + }) + .expect("serialize approval request payload"); + Some((ItemKind::ApprovalRequest, payload)) + } else { + let decision = decision.as_ref().expect("checked above"); + let mut payload = serde_json::to_value(ApprovalDecisionPayload { + approval_id: approval_id.clone().into(), + decision: legacy_decision_label(decision.decision).to_string(), + scope: legacy_scope_label(decision.scope).to_string(), + decision_source: Some(decision.decision_source), + }) + .expect("serialize approval decision payload"); + if let Some(payload) = payload.as_object_mut() { + payload.insert("revision".into(), serde_json::json!(2)); + payload.insert( + "action_summary".into(), + serde_json::json!(action_summary.clone()), + ); + payload.insert( + "justification".into(), + serde_json::json!(justification.clone()), + ); + payload.insert("resource".into(), serde_json::json!(resource.clone())); + payload.insert( + "available_scopes".into(), + serde_json::json!(available_scopes.clone()), + ); + payload.insert("path".into(), serde_json::json!(path.clone())); + payload.insert("host".into(), serde_json::json!(host.clone())); + payload.insert("target".into(), serde_json::json!(target_command.clone())); + payload.insert( + "command_pattern".into(), + serde_json::json!(command_pattern.clone()), + ); + payload.insert( + "command_prefix".into(), + serde_json::json!(command_prefix.clone()), + ); + payload.insert("decided_at".into(), serde_json::json!(decision.decided_at)); + } + Some((ItemKind::ApprovalDecision, payload)) + } + } + Item::UserInputRequest { .. } + | Item::SubAgent { .. } + | Item::BackgroundTask { .. } + | Item::GoalProgress { .. } + | Item::Warning { .. } => None, + } +} + +fn text_display_payload(title: &str, text: &str) -> serde_json::Value { + serde_json::json!({ "title": title, "text": text }) +} + +fn hosted_tool_display_payload( + title: &str, + output: &Option, +) -> serde_json::Value { + let text = output + .as_ref() + .and_then(|value| match value { + serde_json::Value::String(text) => Some(text.as_str()), + _ => None, + }) + .unwrap_or_default(); + text_display_payload(title, text) +} + +fn user_message_text(content: &[UserInput]) -> String { + content + .iter() + .filter_map(|input| match input { + UserInput::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") +} + +fn plan_entries_text(entries: &[PlanEntry]) -> String { + if entries.len() == 1 { + entries[0].step.clone() + } else { + entries + .iter() + .map(|entry| entry.step.as_str()) + .collect::>() + .join("\n") + } +} + +fn file_change_entry_to_legacy(entry: &FileChangeEntry) -> (PathBuf, FileChange) { + let change = match &entry.change { + FileChangeKind::Add { content } => FileChange::Add { + content: content.clone(), + }, + FileChangeKind::Delete { content } => FileChange::Delete { + content: content.clone(), + }, + FileChangeKind::Update { + unified_diff, + move_path, + } => FileChange::Update { + unified_diff: unified_diff.clone(), + old_text: None, + new_text: None, + move_path: move_path.clone(), + }, + }; + (entry.path.clone(), change) +} + +fn context_compaction_payload( + trigger: CompactionTrigger, + summary: Option<&str>, +) -> serde_json::Value { + let trigger = match trigger { + CompactionTrigger::Manual => "manual", + CompactionTrigger::ProviderRetry => "providerRetry", + CompactionTrigger::AutoThreshold => "autoThreshold", + }; + let summary = summary.unwrap_or_default(); + let failed = summary.starts_with("Compaction failed"); + if failed { + let message = summary + .strip_prefix("Compaction failed: ") + .or_else(|| summary.strip_prefix("Compaction failed")) + .unwrap_or(summary) + .trim(); + serde_json::json!({ + "title": "Compaction failed", + "text": summary, + "status": "failed", + "message": message, + "trigger": trigger, + }) + } else { + serde_json::json!({ + "title": summary, + "text": summary, + "trigger": trigger, + }) + } +} + +fn approval_target_fields( + target: Option<&ApprovalTarget>, +) -> (Option, Option, Option) { + match target { + Some(ApprovalTarget::Path { path }) => (Some(path.display().to_string()), None, None), + Some(ApprovalTarget::Host { host }) => (None, Some(host.clone()), None), + Some(ApprovalTarget::Command { command }) => (None, None, Some(command.clone())), + None => (None, None, None), + } +} + +fn approval_request_kind(resource: Option<&str>) -> ServerRequestKind { + match resource { + Some(resource) if resource.contains("ShellExec") => { + ServerRequestKind::ItemCommandExecutionRequestApproval + } + Some(resource) if resource.contains("FileWrite") => { + ServerRequestKind::ItemFileChangeRequestApproval + } + _ => ServerRequestKind::ItemPermissionsRequestApproval, + } +} + +fn legacy_decision_label(decision: ApprovalDecisionKind) -> &'static str { + match decision { + ApprovalDecisionKind::Approved => "approve", + ApprovalDecisionKind::Denied => "deny", + ApprovalDecisionKind::Cancelled => "cancel", + } +} + +fn legacy_scope_label(scope: ApprovalScope) -> &'static str { + match scope { + ApprovalScope::Once => "once", + ApprovalScope::Turn => "turn", + ApprovalScope::Session => "session", + ApprovalScope::PathPrefix => "path_prefix", + ApprovalScope::Host => "host", + ApprovalScope::Tool => "tool", + ApprovalScope::CommandPrefix => "command_prefix", + ApprovalScope::CommandPrefixPersist => "command_prefix_persist", + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use chrono::DateTime; + use chrono::TimeZone; + use chrono::Utc; + use pretty_assertions::assert_eq; + use smol_str::SmolStr; + + use super::*; + use crate::native::item::{ApprovalDecisionSource, UserMessageEntry}; + use crate::native::wire_projector::project_wire_item; + use crate::parse_command::ParsedCommand; + use crate::{ApprovalRequestPayload, PendingServerRequestContext, ServerRequestKind}; + + fn decided_at() -> DateTime { + Utc.with_ymd_and_hms(2026, 8, 1, 12, 0, 0).unwrap() + } + + fn round_trip(kind: ItemKind, payload: serde_json::Value) { + let native = project_wire_item(&kind, &payload, decided_at()).expect("forward project"); + let (reverse_kind, reverse_payload) = + legacy_wire_from_native_item(&native).expect("reverse project"); + assert_eq!(reverse_kind, kind); + let re_native = + project_wire_item(&reverse_kind, &reverse_payload, decided_at()).expect("re-forward"); + assert_eq!(re_native, native); + } + + #[test] + fn round_trips_user_message() { + round_trip( + ItemKind::UserMessage, + serde_json::json!({ "title": "You", "text": "hello" }), + ); + } + + #[test] + fn round_trips_agent_message() { + round_trip( + ItemKind::AgentMessage, + serde_json::json!({ "title": "Assistant", "text": "done" }), + ); + } + + #[test] + fn round_trips_reasoning() { + round_trip( + ItemKind::Reasoning, + serde_json::json!({ "title": "Reasoning", "text": "thinking" }), + ); + } + + #[test] + fn round_trips_plan() { + round_trip( + ItemKind::Plan, + serde_json::json!({ "title": "Plan", "text": "1. do\n2. done" }), + ); + } + + #[test] + fn round_trips_tool_call() { + round_trip( + ItemKind::ToolCall, + serde_json::to_value(ToolCallPayload { + tool_call_id: "call-1".into(), + tool_name: "read_file".into(), + parameters: serde_json::json!({ "path": "src/lib.rs" }), + command_actions: vec![ParsedCommand::Unknown { cmd: "ls".into() }], + }) + .expect("serialize payload"), + ); + } + + #[test] + fn round_trips_tool_result() { + round_trip( + ItemKind::ToolResult, + serde_json::to_value(ToolResultPayload { + tool_call_id: "call-1".into(), + tool_name: Some("read_file".into()), + input: None, + content: serde_json::json!({ "content": "fn main() {}" }), + display_content: Some("fn main() {}".into()), + is_error: false, + summary: String::new(), + }) + .expect("serialize payload"), + ); + } + + #[test] + fn round_trips_command_execution() { + round_trip( + ItemKind::CommandExecution, + serde_json::to_value(CommandExecutionPayload { + tool_call_id: "call-3".into(), + tool_name: "exec_command".into(), + command: "cargo test".into(), + input: Some(serde_json::json!({ "command": "cargo test" })), + source: ExecCommandSource::Agent, + command_actions: Vec::new(), + output: Some(serde_json::json!({ "stdout": "ok" })), + is_error: false, + }) + .expect("serialize payload"), + ); + } + + #[test] + fn round_trips_file_change() { + round_trip( + ItemKind::FileChange, + serde_json::to_value(FileChangePayload { + tool_call_id: "call-5".into(), + tool_name: Some("apply_patch".into()), + input: None, + changes: vec![( + PathBuf::from("a.rs"), + FileChange::Add { + content: "new".into(), + }, + )], + is_error: false, + }) + .expect("serialize payload"), + ); + } + + #[test] + fn round_trips_context_compaction_success() { + round_trip( + ItemKind::ContextCompaction, + serde_json::json!({ "title": "Context compacted", "trigger": "autoThreshold" }), + ); + } + + #[test] + fn round_trips_context_compaction_failure() { + round_trip( + ItemKind::ContextCompaction, + serde_json::json!({ + "title": "Compaction failed", + "status": "failed", + "message": "boom", + }), + ); + } + + #[test] + fn round_trips_approval_request() { + let payload = serde_json::to_value(ApprovalRequestPayload { + request: PendingServerRequestContext { + request_id: SmolStr::new("req-1"), + request_kind: ServerRequestKind::ItemCommandExecutionRequestApproval, + session_id: crate::SessionId::new(), + turn_id: None, + item_id: None, + }, + approval_id: SmolStr::new("appr-1"), + action_summary: "Run cargo test".into(), + justification: "Need to verify".into(), + resource: Some("ShellExec".into()), + available_scopes: vec!["Once".into()], + path: None, + host: None, + target: Some("cargo test".into()), + command_pattern: None, + command_prefix: None, + }) + .expect("serialize payload"); + let native = project_wire_item(&ItemKind::ApprovalRequest, &payload, decided_at()) + .expect("forward project"); + let (reverse_kind, reverse_payload) = + legacy_wire_from_native_item(&native).expect("reverse project"); + assert_eq!(reverse_kind, ItemKind::ApprovalRequest); + let restored = serde_json::from_value::(reverse_payload) + .expect("approval request payload"); + assert_eq!(restored.approval_id, SmolStr::new("appr-1")); + assert_eq!(restored.action_summary, "Run cargo test"); + assert_eq!(restored.justification, "Need to verify"); + assert_eq!(restored.resource.as_deref(), Some("ShellExec")); + assert_eq!(restored.target.as_deref(), Some("cargo test")); + } + + #[test] + fn round_trips_approval_decision() { + round_trip( + ItemKind::ApprovalDecision, + serde_json::to_value(ApprovalDecisionPayload { + approval_id: SmolStr::new("appr-1"), + decision: "Allow".into(), + scope: "Session".into(), + decision_source: Some(ApprovalDecisionSource::User), + }) + .expect("serialize payload"), + ); + } + + #[test] + fn assistant_message_projects_from_native_item() { + let item = Item::AssistantMessage { + text: "hi".into(), + phase: None, + }; + let (kind, payload) = legacy_wire_from_native_item(&item).expect("reverse"); + assert_eq!(kind, ItemKind::AgentMessage); + assert_eq!( + payload, + serde_json::json!({ "title": "Assistant", "text": "hi" }) + ); + } + + #[test] + fn user_message_projects_from_native_item() { + let item = Item::UserMessage { + client_user_message_id: None, + content: vec![UserInput::Text { + text: "hello".into(), + }], + entry: UserMessageEntry::TurnStart, + }; + let (kind, payload) = legacy_wire_from_native_item(&item).expect("reverse"); + assert_eq!(kind, ItemKind::UserMessage); + assert_eq!(payload["text"].as_str(), Some("hello")); + } +} diff --git a/crates/protocol/src/native/mod.rs b/crates/protocol/src/native/mod.rs index 3490f0d8..8fc34407 100644 --- a/crates/protocol/src/native/mod.rs +++ b/crates/protocol/src/native/mod.rs @@ -12,6 +12,7 @@ pub mod event; pub mod goal; pub mod ids; pub mod item; +pub mod legacy_projector; pub mod methods; pub mod model; pub mod page; @@ -26,3 +27,5 @@ pub mod session; pub mod turn; pub mod usage; pub mod wire_projector; + +pub use legacy_projector::legacy_wire_from_native_item; diff --git a/crates/protocol/src/native/rpc_session.rs b/crates/protocol/src/native/rpc_session.rs index 5999e082..0dd42aa6 100644 --- a/crates/protocol/src/native/rpc_session.rs +++ b/crates/protocol/src/native/rpc_session.rs @@ -134,6 +134,12 @@ pub struct SessionResumeParams { #[serde(rename_all = "camelCase")] pub struct SessionResumeResult { pub session: Session, + /// Latest context-window occupancy from rollout replay or session stats. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_context_occupancy: Option, + /// Latest completed model-query display total, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_query_total_tokens: Option, } // ── session/fork ── diff --git a/crates/protocol/src/native/wire_projector.rs b/crates/protocol/src/native/wire_projector.rs index 39c35d8e..1d1fa5c4 100644 --- a/crates/protocol/src/native/wire_projector.rs +++ b/crates/protocol/src/native/wire_projector.rs @@ -186,20 +186,53 @@ pub fn project_wire_item( output: Some(hosted_tool_output(payload)), }) } - ItemKind::ContextCompaction => Some(Item::ContextCompaction { - // The wire payload carries only a display title, never the - // trigger or the summary text. - trigger: CompactionTrigger::AutoThreshold, - before: ContextUsage { - measured: false, - ..ContextUsage::default() - }, - after: None, - summary: payload - .get("text") + ItemKind::ContextCompaction => { + let failed = payload + .get("status") + .and_then(serde_json::Value::as_str) + .is_some_and(|status| { + status.eq_ignore_ascii_case("failed") || status.eq_ignore_ascii_case("error") + }) + || payload + .get("title") + .and_then(serde_json::Value::as_str) + .is_some_and(|title| title.eq_ignore_ascii_case("Compaction failed")); + let message = payload + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()); + let summary = if failed { + Some(match message { + Some(message) => format!("Compaction failed: {message}"), + None => "Compaction failed".to_string(), + }) + } else { + payload + .get("text") + .or_else(|| payload.get("title")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }; + let trigger = match payload + .get("trigger") .and_then(serde_json::Value::as_str) - .map(str::to_owned), - }), + .unwrap_or_default() + { + "manual" => CompactionTrigger::Manual, + "providerRetry" | "provider_retry" => CompactionTrigger::ProviderRetry, + _ => CompactionTrigger::AutoThreshold, + }; + Some(Item::ContextCompaction { + trigger, + before: ContextUsage { + measured: false, + ..ContextUsage::default() + }, + after: None, + summary, + }) + } ItemKind::ApprovalRequest => { let request = serde_json::from_value::(payload.clone()).ok()?; Some(Item::Approval { @@ -485,8 +518,7 @@ pub fn typed_item_notification_from_server_event( ) -> Option<(String, serde_json::Value)> { // Delta family (L2-DES-APP-009 DD-3): mapped onto the native delta // notifications; the per-item `chunk_index` is assigned at the emit site. - // `PlanDelta` and `FileChangeOutputDelta` have no native kind yet and - // stay on the legacy path. + // `FileChangeOutputDelta` has no native kind yet and stays on the legacy path. if let ServerEvent::ItemDelta { delta_kind, payload, @@ -499,7 +531,9 @@ pub fn typed_item_notification_from_server_event( crate::ItemDeltaKind::CommandExecutionOutputDelta => { "item/commandExecution/outputDelta" } - crate::ItemDeltaKind::FileChangeOutputDelta | crate::ItemDeltaKind::PlanDelta => { + crate::ItemDeltaKind::ToolCallInputDelta => "item/toolCall/inputDelta", + crate::ItemDeltaKind::PlanDelta => "item/plan/delta", + crate::ItemDeltaKind::FileChangeOutputDelta => { return None; } }; @@ -830,6 +864,15 @@ pub fn typed_item_notification_from_server_event( }); return Some(("context/compactionCompleted".to_string(), value)); } + ServerEvent::SessionCompactionFailed(payload) => { + let value = serde_json::json!({ + "sessionId": crate::native::ids::SessionId::from_legacy_uuid( + Uuid::from(payload.session_id), + ), + "message": payload.message, + }); + return Some(("context/compactionFailed".to_string(), value)); + } // `model/queryRetrying` is deferred: the native shape lacks the // provider/model/phase fields the TUI renders, so projecting now // would silently degrade the retry display (straggler, @@ -1216,7 +1259,7 @@ mod tests { } #[test] - fn context_compaction_projects_without_summary_on_wire() { + fn context_compaction_projects_title_as_summary_on_wire() { let item = project( ItemKind::ContextCompaction, serde_json::json!({ "title": "Context compacted" }), @@ -1230,7 +1273,31 @@ mod tests { ..ContextUsage::default() }, after: None, - summary: None, + summary: Some("Context compacted".to_string()), + }) + ); + } + + #[test] + fn context_compaction_failed_projects_message_into_summary() { + let item = project( + ItemKind::ContextCompaction, + serde_json::json!({ + "title": "Compaction failed", + "status": "failed", + "message": "boom", + }), + ); + assert_eq!( + item, + Some(Item::ContextCompaction { + trigger: CompactionTrigger::AutoThreshold, + before: ContextUsage { + measured: false, + ..ContextUsage::default() + }, + after: None, + summary: Some("Compaction failed: boom".to_string()), }) ); } @@ -1426,13 +1493,20 @@ mod tests { .expect("reasoning delta projects"); assert_eq!(method, "item/reasoning/delta"); + let (method, _) = typed_item_notification_from_server_event(&delta_event( + crate::ItemDeltaKind::PlanDelta, + Some(0), + )) + .expect("plan delta projects"); + assert_eq!(method, "item/plan/delta"); + assert!( typed_item_notification_from_server_event(&delta_event( - crate::ItemDeltaKind::PlanDelta, + crate::ItemDeltaKind::FileChangeOutputDelta, Some(0), )) .is_none(), - "plan deltas stay on the legacy path until a native kind exists" + "file-change output deltas stay on the legacy path until a native kind exists" ); } diff --git a/crates/sandbox/src/seatbelt.rs b/crates/sandbox/src/seatbelt.rs index 4b55392c..93a1d100 100644 --- a/crates/sandbox/src/seatbelt.rs +++ b/crates/sandbox/src/seatbelt.rs @@ -497,6 +497,9 @@ mod tests { "literal \"/dev/urandom\"".to_string(), "literal \"/dev/tty\"".to_string(), "literal \"/dev/ptmx\"".to_string(), + // `/dev/fd` is a directory (symlink to `/proc/self/fd` on Linux; + // directory-like on macOS), so Seatbelt emits `subpath`. + "subpath \"/dev/fd\"".to_string(), ] { expected.push_str(&format!("(allow file-map-executable ({filter}))\n")); } @@ -516,6 +519,7 @@ mod tests { "literal \"/dev/urandom\"".to_string(), "literal \"/dev/tty\"".to_string(), "literal \"/dev/ptmx\"".to_string(), + "subpath \"/dev/fd\"".to_string(), ] { expected.push_str(&format!("(allow file-ioctl ({filter}))\n")); } @@ -530,6 +534,7 @@ mod tests { "literal \"/dev/urandom\"".to_string(), "literal \"/dev/tty\"".to_string(), "literal \"/dev/ptmx\"".to_string(), + "subpath \"/dev/fd\"".to_string(), ] { expected.push_str(&format!("(allow file-read* ({filter}))\n")); } @@ -550,6 +555,7 @@ mod tests { "literal \"/dev/urandom\"".to_string(), "literal \"/dev/tty\"".to_string(), "literal \"/dev/ptmx\"".to_string(), + "subpath \"/dev/fd\"".to_string(), ] { expected.push_str(&format!("(allow file-write* ({filter}))\n")); } diff --git a/crates/server/src/runtime/approval.rs b/crates/server/src/runtime/approval.rs index 836203b9..7290e571 100644 --- a/crates/server/src/runtime/approval.rs +++ b/crates/server/src/runtime/approval.rs @@ -6,10 +6,8 @@ use crate::runtime::session_actor::approval_scope::{ apply_approval_scope_to_state, apply_path_scope_to_permission_profile, }; use crate::runtime::session_interactive::complete_approval_wait; -use devo_protocol::ApprovalDecisionPayload; -use devo_protocol::ApprovalRequestPayload; -use devo_protocol::PendingServerRequestContext; -use devo_protocol::ServerRequestKind; +use chrono::Utc; +use devo_protocol::native::item::{ApprovalDecision, Item}; use std::path::Component; use std::path::Path; @@ -687,37 +685,6 @@ impl ServerRuntime { let approval_id = request.tool_call_id.clone(); let approval_item_id = ItemId::new(); let approval_item_seq = self.allocate_item_sequence(session_id).await; - let request_payload = ApprovalRequestPayload { - request: PendingServerRequestContext { - request_id: approval_id.clone().into(), - request_kind: match request.resource { - devo_safety::ResourceKind::ShellExec => { - ServerRequestKind::ItemCommandExecutionRequestApproval - } - devo_safety::ResourceKind::FileWrite => { - ServerRequestKind::ItemFileChangeRequestApproval - } - devo_safety::ResourceKind::FileRead - | devo_safety::ResourceKind::Network - | devo_safety::ResourceKind::Custom(_) => { - ServerRequestKind::ItemPermissionsRequestApproval - } - }, - session_id, - turn_id: Some(turn_id), - item_id: Some(approval_item_id), - }, - approval_id: approval_id.clone().into(), - action_summary: request.action_summary.clone(), - justification: request.justification.clone().unwrap_or_default(), - resource: Some(format!("{:?}", request.resource)), - available_scopes: available_scopes.clone(), - path: request.path.as_ref().map(|path| path.display().to_string()), - host: request.host.clone(), - target: request.target.clone(), - command_pattern: request.command_pattern.clone(), - command_prefix: request.command_prefix.clone(), - }; let persisted_approval = self .persist_waiting_approval_item( session_id, @@ -770,33 +737,13 @@ impl ServerRuntime { | devo_safety::ResourceKind::Network | devo_safety::ResourceKind::Custom(_) => "approval/permission/request", }; - let native_target = if let Some(path) = &request.path { - Some(devo_protocol::native::item::ApprovalTarget::Path { path: path.clone() }) - } else if let Some(host) = &request.host { - Some(devo_protocol::native::item::ApprovalTarget::Host { host: host.clone() }) - } else { - devo_core::tools::command_str_for_permission_request(&request) - .map(|command| devo_protocol::native::item::ApprovalTarget::Command { command }) - }; - let native_params = serde_json::to_value(devo_protocol::native::item::Item::Approval { - approval_id: approval_id.clone(), - target_item_id: None, - action_summary: request.action_summary.clone(), - justification: request.justification.clone().unwrap_or_default(), - resource: Some(format!("{:?}", request.resource)), - available_scopes: available_scopes - .iter() - .filter_map(|scope| { - serde_json::to_value(scope) - .ok() - .and_then(|value| value.as_str().map(str::to_string)) - }) - .collect(), - command_pattern: request.command_pattern.clone(), - command_prefix: request.command_prefix.clone(), - target: native_target, - decision: None, - }) + let native_target = native_approval_target(&request); + let native_params = serde_json::to_value(native_waiting_approval_item( + &approval_id, + &request, + &available_scopes, + native_target.clone(), + )) .expect("serialize native approval request params"); let cancel_token = self .active_turns @@ -824,14 +771,17 @@ impl ServerRuntime { let publish_waiting_item = async { match request_ready_rx.await { Ok(Ok(())) => { - self.emit_item_started( + self.emit_native_item_started( session_id, turn_id, approval_item_id, Some(approval_item_seq), - ItemKind::ApprovalRequest, - serde_json::to_value(&request_payload) - .expect("serialize approval request payload"), + native_waiting_approval_item( + &approval_id, + &request, + &available_scopes, + native_target.clone(), + ), ) .await; Ok(()) @@ -859,52 +809,25 @@ impl ServerRuntime { .await; } if publish_result.is_ok() { - let mut decision_payload = serde_json::to_value(ApprovalDecisionPayload { - approval_id: approval_id.clone().into(), - decision: "cancel".to_string(), - scope: "once".to_string(), - decision_source: Some( - devo_protocol::native::item::ApprovalDecisionSource::ExternalPolicy, - ), - }) - .expect("serialize cancelled approval decision payload"); - if let Some(payload) = decision_payload.as_object_mut() { - payload.insert("revision".into(), serde_json::json!(2)); - payload.insert( - "action_summary".into(), - serde_json::json!(request.action_summary.clone()), - ); - payload.insert( - "justification".into(), - serde_json::json!(request.justification.clone().unwrap_or_default()), - ); - payload.insert( - "resource".into(), - serde_json::json!(format!("{:?}", request.resource)), - ); - payload.insert( - "available_scopes".into(), - serde_json::json!(available_scopes), - ); - payload.insert("path".into(), serde_json::json!(request.path.clone())); - payload.insert("host".into(), serde_json::json!(request.host.clone())); - payload.insert("target".into(), serde_json::json!(request.target.clone())); - payload.insert( - "command_pattern".into(), - serde_json::json!(request.command_pattern.clone()), - ); - payload.insert( - "command_prefix".into(), - serde_json::json!(request.command_prefix.clone()), - ); - } - self.emit_item_completed( + self.emit_native_item_completed( session_id, turn_id, approval_item_id, Some(approval_item_seq), - ItemKind::ApprovalDecision, - decision_payload, + native_decided_approval_item( + &approval_id, + &request, + &available_scopes, + native_target.clone(), + ApprovalDecision { + decision: + devo_protocol::native::item::ApprovalDecisionKind::Cancelled, + scope: devo_protocol::native::item::ApprovalScope::Once, + decision_source: + devo_protocol::native::item::ApprovalDecisionSource::ExternalPolicy, + decided_at: Utc::now(), + }, + ), ) .await; } @@ -927,12 +850,6 @@ impl ServerRuntime { outcome, reason, ); - let decision_label = match &decision { - ApprovalDecisionValue::Approve => "approve", - ApprovalDecisionValue::Deny => "deny", - ApprovalDecisionValue::Cancel => "cancel", - }; - let scope_label = approval_scope_label(&scope); let native_decision = match &decision { ApprovalDecisionValue::Approve => { devo_protocol::native::item::ApprovalDecisionKind::Approved @@ -957,50 +874,23 @@ impl ServerRuntime { ) .await; } - let mut decision_payload = serde_json::to_value(ApprovalDecisionPayload { - approval_id: approval_id.clone().into(), - decision: decision_label.to_string(), - scope: scope_label.to_string(), - decision_source: Some(devo_protocol::native::item::ApprovalDecisionSource::User), - }) - .expect("serialize approval decision payload"); - if let Some(payload) = decision_payload.as_object_mut() { - payload.insert("revision".into(), serde_json::json!(2)); - payload.insert( - "action_summary".into(), - serde_json::json!(request.action_summary.clone()), - ); - payload.insert( - "justification".into(), - serde_json::json!(request.justification.clone().unwrap_or_default()), - ); - payload.insert( - "resource".into(), - serde_json::json!(format!("{:?}", request.resource)), - ); - payload.insert( - "available_scopes".into(), - serde_json::json!(available_scopes), - ); - payload.insert("path".into(), serde_json::json!(request.path.clone())); - payload.insert("host".into(), serde_json::json!(request.host.clone())); - payload.insert("target".into(), serde_json::json!(request.target.clone())); - payload.insert( - "command_pattern".into(), - serde_json::json!(request.command_pattern.clone()), - ); - payload.insert( - "command_prefix".into(), - serde_json::json!(request.command_prefix.clone()), - ); - } - self.emit_item_completed( + self.emit_native_item_completed( session_id, turn_id, approval_item_id, Some(approval_item_seq), - ItemKind::ApprovalDecision, - decision_payload, + native_decided_approval_item( + &approval_id, + &request, + &available_scopes, + native_target, + ApprovalDecision { + decision: native_decision, + scope: native_approval_scope(&scope), + decision_source: devo_protocol::native::item::ApprovalDecisionSource::User, + decided_at: Utc::now(), + }, + ), ) .await; @@ -1338,16 +1228,71 @@ fn approval_scopes_for_request(request: &ToolPermissionRequest) -> Vec { scopes } -fn approval_scope_label(scope: &ApprovalScopeValue) -> &'static str { - match scope { - ApprovalScopeValue::Once => "once", - ApprovalScopeValue::Turn => "turn", - ApprovalScopeValue::Session => "session", - ApprovalScopeValue::PathPrefix => "path_prefix", - ApprovalScopeValue::Host => "host", - ApprovalScopeValue::Tool => "tool", - ApprovalScopeValue::CommandPrefix => "command_prefix", - ApprovalScopeValue::CommandPrefixPersist => "command_prefix_persist", +fn native_approval_target( + request: &ToolPermissionRequest, +) -> Option { + if let Some(path) = &request.path { + Some(devo_protocol::native::item::ApprovalTarget::Path { path: path.clone() }) + } else if let Some(host) = &request.host { + Some(devo_protocol::native::item::ApprovalTarget::Host { host: host.clone() }) + } else { + devo_core::tools::command_str_for_permission_request(request) + .map(|command| devo_protocol::native::item::ApprovalTarget::Command { command }) + } +} + +fn native_waiting_approval_item( + approval_id: &str, + request: &ToolPermissionRequest, + available_scopes: &[String], + target: Option, +) -> Item { + Item::Approval { + approval_id: approval_id.to_string(), + target_item_id: None, + action_summary: request.action_summary.clone(), + justification: request.justification.clone().unwrap_or_default(), + resource: Some(format!("{:?}", request.resource)), + available_scopes: available_scopes + .iter() + .filter_map(|scope| { + serde_json::to_value(scope) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + }) + .collect(), + command_pattern: request.command_pattern.clone(), + command_prefix: request.command_prefix.clone(), + target, + decision: None, + } +} + +fn native_decided_approval_item( + approval_id: &str, + request: &ToolPermissionRequest, + available_scopes: &[String], + target: Option, + decision: ApprovalDecision, +) -> Item { + Item::Approval { + approval_id: approval_id.to_string(), + target_item_id: None, + action_summary: request.action_summary.clone(), + justification: request.justification.clone().unwrap_or_default(), + resource: Some(format!("{:?}", request.resource)), + available_scopes: available_scopes + .iter() + .filter_map(|scope| { + serde_json::to_value(scope) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + }) + .collect(), + command_pattern: request.command_pattern.clone(), + command_prefix: request.command_prefix.clone(), + target, + decision: Some(decision), } } diff --git a/crates/server/src/runtime/handlers/compaction.rs b/crates/server/src/runtime/handlers/compaction.rs index f1908c70..5f3fa7da 100644 --- a/crates/server/src/runtime/handlers/compaction.rs +++ b/crates/server/src/runtime/handlers/compaction.rs @@ -670,37 +670,14 @@ impl ServerRuntime { runtime_session.loaded_item_count += 1; runtime_session.next_item_seq += 1; - let payload = serde_json::json!({ "title": "Context Compaction" }); - self.broadcast_event(ServerEvent::ItemStarted(ItemEventPayload { - context: EventContext { - session_id, - turn_id: Some(turn_id), - item_id: Some(item_id), - seq: item_seq, - item_seq: Some(item_seq), - }, - item: ItemEnvelope { - item_id, - item_kind: ItemKind::ContextCompaction, - payload: payload.clone(), - }, - })) + self.broadcast_event(super::super::turn_exec::manual_compaction_started_event( + session_id, turn_id, item_id, item_seq, + )) .await; - self.broadcast_event(ServerEvent::ItemCompleted(ItemEventPayload { - context: EventContext { - session_id, - turn_id: Some(turn_id), - item_id: Some(item_id), - seq: item_seq, - item_seq: Some(item_seq), - }, - item: ItemEnvelope { - item_id, - item_kind: ItemKind::ContextCompaction, - payload, - }, - })) + self.broadcast_event(super::super::turn_exec::manual_compaction_completed_event( + session_id, turn_id, item_id, item_seq, + )) .await; let summary_turn_item = summary_turn_item_from_compacted(&compacted_items); diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index a04227a7..6c3b9696 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -1464,11 +1464,68 @@ impl ServerRuntime { if response.get("error").is_some() { return response; } - self.native_session_snapshot_response(request_id, legacy_session_id) + self.native_session_resume_response(request_id, legacy_session_id) .await .unwrap_or(response) } + async fn native_session_resume_response( + &self, + request_id: serde_json::Value, + session_id: SessionId, + ) -> Option { + let session = self.native_session_snapshot(session_id).await?; + let stats = self.deps.db.get_stats(&session_id).ok().flatten(); + let rollout_occupancy = self.native_rollout_context_occupancy(session_id).await; + let last_context_occupancy = stats + .as_ref() + .and_then(|stats| stats.last_context_occupancy.clone()) + .or(rollout_occupancy); + let last_query_total_tokens = last_context_occupancy + .as_ref() + .map(|occupancy| occupancy.total_tokens) + .filter(|tokens| *tokens > 0) + .or_else(|| { + stats + .as_ref() + .map(|stats| stats.prompt_token_estimate as u64) + .filter(|tokens| *tokens > 0) + }); + Some( + serde_json::to_value(SuccessResponse { + id: request_id, + result: devo_protocol::native::rpc_session::SessionResumeResult { + session, + last_context_occupancy, + last_query_total_tokens, + }, + }) + .expect("serialize canonical session/resume response"), + ) + } + + async fn native_rollout_context_occupancy( + &self, + session_id: SessionId, + ) -> Option { + let rollout_path = self + .deps + .db + .get_session_index(&session_id) + .ok() + .flatten() + .and_then(|index| index.rollout_path) + .or_else(|| { + self.rollout_store + .find_rollout_by_session_id(&session_id) + .ok() + .flatten() + })?; + devo_core::read_canonical_history(&rollout_path) + .ok() + .and_then(|history| history.latest_context_occupancy) + } + pub(crate) async fn restore_existing_session_with_tool_registry_update( &self, connection_id: u64, diff --git a/crates/server/src/runtime/items.rs b/crates/server/src/runtime/items.rs index dc52de76..8f500076 100644 --- a/crates/server/src/runtime/items.rs +++ b/crates/server/src/runtime/items.rs @@ -2,6 +2,9 @@ use std::borrow::Cow; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; +use devo_protocol::native::item::Item as NativeItem; +use devo_protocol::native::legacy_wire_from_native_item; + use crate::titles::build_title_generation_request; use crate::titles::derive_provisional_title; use crate::titles::normalize_generated_title; @@ -324,6 +327,27 @@ impl ServerRuntime { .await; } + pub(super) async fn emit_turn_native_item( + &self, + session_id: SessionId, + turn_id: TurnId, + native_item: NativeItem, + turn_item: TurnItem, + ) { + let (item_id, item_seq) = self + .start_native_item(session_id, turn_id, native_item.clone()) + .await; + self.complete_native_item( + session_id, + turn_id, + item_id, + item_seq, + native_item, + turn_item, + ) + .await; + } + pub(super) async fn start_item( &self, session_id: SessionId, @@ -345,6 +369,19 @@ impl ServerRuntime { (item_id, item_seq) } + pub(super) async fn start_native_item( + &self, + session_id: SessionId, + turn_id: TurnId, + native_item: NativeItem, + ) -> (ItemId, u64) { + let item_id = ItemId::new(); + let item_seq = self.allocate_item_sequence(session_id).await; + self.emit_native_item_started(session_id, turn_id, item_id, Some(item_seq), native_item) + .await; + (item_id, item_seq) + } + #[allow(clippy::too_many_arguments)] pub(super) async fn emit_item_started( &self, @@ -399,6 +436,34 @@ impl ServerRuntime { .await; } + pub(super) async fn emit_native_item_started( + &self, + session_id: SessionId, + turn_id: TurnId, + item_id: ItemId, + item_seq: Option, + native_item: NativeItem, + ) { + let (item_kind, payload) = + legacy_wire_from_native_item(&native_item).expect("native item must reverse-project"); + self.emit_item_started(session_id, turn_id, item_id, item_seq, item_kind, payload) + .await; + } + + pub(super) async fn emit_native_item_completed( + &self, + session_id: SessionId, + turn_id: TurnId, + item_id: ItemId, + item_seq: Option, + native_item: NativeItem, + ) { + let (item_kind, payload) = + legacy_wire_from_native_item(&native_item).expect("native item must reverse-project"); + self.emit_item_completed(session_id, turn_id, item_id, item_seq, item_kind, payload) + .await; + } + #[allow(clippy::too_many_arguments)] pub(super) async fn complete_item( &self, @@ -431,6 +496,29 @@ impl ServerRuntime { .await; } + pub(super) async fn complete_native_item( + &self, + session_id: SessionId, + turn_id: TurnId, + item_id: ItemId, + item_seq: u64, + native_item: NativeItem, + turn_item: TurnItem, + ) { + self.persist_item( + session_id, + turn_id, + item_id, + item_seq, + turn_item, + Some(TurnStatus::Running), + None, + ) + .await; + self.emit_native_item_completed(session_id, turn_id, item_id, Some(item_seq), native_item) + .await; + } + #[allow(clippy::too_many_arguments)] pub(super) async fn persist_item( &self, diff --git a/crates/server/src/runtime/turn_exec/context_compaction.rs b/crates/server/src/runtime/turn_exec/context_compaction.rs index f7905fe1..8d3cbcdd 100644 --- a/crates/server/src/runtime/turn_exec/context_compaction.rs +++ b/crates/server/src/runtime/turn_exec/context_compaction.rs @@ -2,11 +2,12 @@ use std::sync::Arc; use devo_core::ResponseItem; use devo_core::{ItemId, SessionId, TurnId}; +use devo_protocol::native::item::{CompactionTrigger, ContextUsage, Item}; +use devo_protocol::native::legacy_wire_from_native_item; use super::super::ServerRuntime; use crate::{ - EventContext, ItemEnvelope, ItemEventPayload, ItemKind, ServerEvent, - SessionCompactionFailedPayload, + EventContext, ItemEnvelope, ItemEventPayload, ServerEvent, SessionCompactionFailedPayload, }; #[derive(Default)] @@ -33,7 +34,13 @@ impl ContextCompactionLifecycle { let item_id = ItemId::new(); self.item_id = Some(item_id); runtime - .broadcast_event(started_event(session_id, turn_id, item_id)) + .emit_native_item_started( + session_id, + turn_id, + item_id, + None, + compaction_started_item(), + ) .await; } @@ -51,7 +58,13 @@ impl ContextCompactionLifecycle { .persist_in_turn_compaction(session_id, turn_id, item_id, &compacted_items) .await; runtime - .broadcast_event(completed_event(session_id, turn_id, item_id, item_seq)) + .emit_native_item_completed( + session_id, + turn_id, + item_id, + item_seq, + compaction_completed_item(), + ) .await; } @@ -96,34 +109,79 @@ impl ContextCompactionLifecycle { } } +fn compaction_usage() -> ContextUsage { + ContextUsage { + measured: false, + ..ContextUsage::default() + } +} + +fn compaction_started_item() -> Item { + Item::ContextCompaction { + trigger: CompactionTrigger::AutoThreshold, + before: compaction_usage(), + after: None, + summary: Some("Compaction started".to_string()), + } +} + +fn compaction_completed_item() -> Item { + Item::ContextCompaction { + trigger: CompactionTrigger::AutoThreshold, + before: compaction_usage(), + after: None, + summary: Some("Context compacted".to_string()), + } +} + +fn compaction_failed_item(message: &str) -> Item { + Item::ContextCompaction { + trigger: CompactionTrigger::AutoThreshold, + before: compaction_usage(), + after: None, + summary: Some(format!("Compaction failed: {message}")), + } +} + +fn manual_compaction_item() -> Item { + Item::ContextCompaction { + trigger: CompactionTrigger::Manual, + before: compaction_usage(), + after: None, + summary: Some("Context Compaction".to_string()), + } +} + +#[cfg(test)] pub(super) fn started_event( session_id: SessionId, turn_id: TurnId, item_id: ItemId, ) -> ServerEvent { - item_event( + item_event_from_native( session_id, turn_id, item_id, None, ServerEvent::ItemStarted, - serde_json::json!({ "title": "Compaction started" }), + compaction_started_item(), ) } +#[cfg(test)] pub(super) fn completed_event( session_id: SessionId, turn_id: TurnId, item_id: ItemId, item_seq: Option, ) -> ServerEvent { - item_event( + item_event_from_native( session_id, turn_id, item_id, item_seq, ServerEvent::ItemCompleted, - serde_json::json!({ "title": "Context compacted" }), + compaction_completed_item(), ) } @@ -134,17 +192,13 @@ pub(super) fn failed_events( message: String, ) -> [ServerEvent; 2] { [ - item_event( + item_event_from_native( session_id, turn_id, item_id, None, ServerEvent::ItemCompleted, - serde_json::json!({ - "title": "Compaction failed", - "status": "failed", - "message": message.clone(), - }), + compaction_failed_item(&message), ), ServerEvent::SessionCompactionFailed(SessionCompactionFailedPayload { session_id, @@ -153,14 +207,48 @@ pub(super) fn failed_events( ] } -fn item_event( +pub(crate) fn manual_compaction_started_event( + session_id: SessionId, + turn_id: TurnId, + item_id: ItemId, + item_seq: u64, +) -> ServerEvent { + item_event_from_native( + session_id, + turn_id, + item_id, + Some(item_seq), + ServerEvent::ItemStarted, + manual_compaction_item(), + ) +} + +pub(crate) fn manual_compaction_completed_event( + session_id: SessionId, + turn_id: TurnId, + item_id: ItemId, + item_seq: u64, +) -> ServerEvent { + item_event_from_native( + session_id, + turn_id, + item_id, + Some(item_seq), + ServerEvent::ItemCompleted, + manual_compaction_item(), + ) +} + +fn item_event_from_native( session_id: SessionId, turn_id: TurnId, item_id: ItemId, item_seq: Option, wrap: impl FnOnce(ItemEventPayload) -> ServerEvent, - payload: serde_json::Value, + native_item: Item, ) -> ServerEvent { + let (item_kind, payload) = + legacy_wire_from_native_item(&native_item).expect("compaction item must reverse-project"); wrap(ItemEventPayload { context: EventContext { session_id, @@ -171,7 +259,7 @@ fn item_event( }, item: ItemEnvelope { item_id, - item_kind: ItemKind::ContextCompaction, + item_kind, payload, }, }) diff --git a/crates/server/src/runtime/turn_exec/event_stream.rs b/crates/server/src/runtime/turn_exec/event_stream.rs index 00c38b8c..0b891516 100644 --- a/crates/server/src/runtime/turn_exec/event_stream.rs +++ b/crates/server/src/runtime/turn_exec/event_stream.rs @@ -80,6 +80,8 @@ pub(crate) fn spawn_turn_event_stream( let mut reasoning_delta_seq = 0_u64; let mut command_output_delta_seqs: std::collections::HashMap = std::collections::HashMap::new(); + let mut tool_input_delta_seqs: std::collections::HashMap = + std::collections::HashMap::new(); let mut tool_names_by_id = std::collections::HashMap::new(); let mut pending_tool_calls: std::collections::HashMap = std::collections::HashMap::new(); @@ -217,6 +219,18 @@ pub(crate) fn spawn_turn_event_stream( ) .await; } + devo_core::QueryEvent::ToolUseInputDelta { id, partial_json } => { + handle_tool_input_delta( + &runtime, + session_id, + turn_for_events.turn_id, + id, + partial_json, + &pending_tool_calls, + &mut tool_input_delta_seqs, + ) + .await; + } devo_core::QueryEvent::ToolExecutionStart { id } => { runtime .broadcast_event(ServerEvent::ToolCallStatusUpdated( @@ -511,6 +525,11 @@ async fn handle_tool_use_start( event_tool_registry: &Arc, ) { tool_names_by_id.insert(id.clone(), name.clone()); + if let Some(pending) = pending_tool_calls.get_mut(&id) { + pending.input = input.clone(); + pending.command = command_display_from_input(&name, &input); + return; + } if let (Some(item_id), Some(item_seq)) = (reasoning_item_id.take(), reasoning_item_seq.take()) { complete_reasoning_item( runtime, @@ -716,6 +735,50 @@ async fn complete_pending_tool_calls_as_interrupted( } } +async fn handle_tool_input_delta( + runtime: &Arc, + session_id: SessionId, + turn_id: TurnId, + tool_use_id: String, + partial_json: String, + pending_tool_calls: &std::collections::HashMap, + tool_input_delta_seqs: &mut std::collections::HashMap, +) { + let Some(pending) = pending_tool_calls.get(&tool_use_id) else { + return; + }; + let Some(item_id) = pending.item_id.clone() else { + return; + }; + let chunk_index = tool_input_delta_seqs + .get(&tool_use_id) + .copied() + .unwrap_or(0); + tool_input_delta_seqs.insert(tool_use_id.clone(), chunk_index + 1); + let _ = runtime + .broadcast_event(ServerEvent::ItemDelta { + delta_kind: ItemDeltaKind::ToolCallInputDelta, + payload: ItemDeltaPayload { + context: crate::EventContext { + session_id, + turn_id: Some(turn_id), + item_id: Some(item_id), + seq: 0, + item_seq: None, + }, + delta: serde_json::json!({ + "tool_use_id": tool_use_id, + "partial_json": partial_json, + }) + .to_string(), + stream_index: None, + channel: None, + chunk_index: Some(chunk_index), + }, + }) + .await; +} + async fn handle_tool_progress( runtime: &Arc, session_id: SessionId, diff --git a/crates/server/src/runtime/turn_exec/item_stream.rs b/crates/server/src/runtime/turn_exec/item_stream.rs index 1cf1d819..d71dba59 100644 --- a/crates/server/src/runtime/turn_exec/item_stream.rs +++ b/crates/server/src/runtime/turn_exec/item_stream.rs @@ -1,11 +1,12 @@ use std::sync::Arc; use devo_core::{ItemId, SessionId, TextItem, TurnId, TurnItem}; +use devo_protocol::native::item::{Item, PlanEntry, PlanStepStatus}; use super::super::ServerRuntime; use super::super::proposed_plan::ProposedPlanSegment; use crate::runtime::session_actor::state::SessionStreamState; -use crate::{ItemDeltaKind, ItemDeltaPayload, ItemKind, ServerEvent}; +use crate::{ItemDeltaKind, ItemDeltaPayload, ServerEvent}; pub(super) async fn complete_reasoning_item( runtime: &Arc, @@ -16,14 +17,16 @@ pub(super) async fn complete_reasoning_item( text: String, ) { runtime - .complete_item( + .complete_native_item( session_id, turn_id, item_id, item_seq, - ItemKind::Reasoning, - TurnItem::Reasoning(TextItem { text: text.clone() }), - serde_json::json!({ "title": "Reasoning", "text": text }), + Item::Reasoning { + text: text.clone(), + provider_payload_ref: None, + }, + TurnItem::Reasoning(TextItem { text }), ) .await; } @@ -40,14 +43,16 @@ pub(super) async fn complete_assistant_item( return; } runtime - .complete_item( + .complete_native_item( session_id, turn_id, item_id, item_seq, - ItemKind::AgentMessage, - TurnItem::AgentMessage(TextItem { text: text.clone() }), - serde_json::json!({ "title": "Assistant", "text": text }), + Item::AssistantMessage { + text: text.clone(), + phase: None, + }, + TurnItem::AgentMessage(TextItem { text }), ) .await; } @@ -72,11 +77,15 @@ impl ProposedPlanStreamItem { return; } let (item_id, item_seq) = runtime - .start_item( + .start_native_item( session_id, turn_id, - ItemKind::Plan, - serde_json::json!({ "title": "Proposed Plan", "text": "" }), + Item::Plan { + entries: vec![PlanEntry { + step: String::new(), + status: PlanStepStatus::Completed, + }], + }, ) .await; self.item_id = Some(item_id); @@ -128,14 +137,18 @@ impl ProposedPlanStreamItem { }; let text = std::mem::take(&mut self.text); runtime - .complete_item( + .complete_native_item( session_id, turn_id, item_id, item_seq, - ItemKind::Plan, - TurnItem::Plan(TextItem { text: text.clone() }), - serde_json::json!({ "title": "Proposed Plan", "text": text }), + Item::Plan { + entries: vec![PlanEntry { + step: text.clone(), + status: PlanStepStatus::Completed, + }], + }, + TurnItem::Plan(TextItem { text }), ) .await; } @@ -160,11 +173,13 @@ pub(super) async fn push_assistant_text_delta( (Some(item_id), Some(item_seq)) => (item_id, item_seq), (None, None) => { let (item_id, item_seq) = runtime - .start_item( + .start_native_item( session_id, turn_id, - ItemKind::AgentMessage, - serde_json::json!({ "title": "Assistant", "text": "" }), + Item::AssistantMessage { + text: String::new(), + phase: None, + }, ) .await; *assistant_item_id = Some(item_id); diff --git a/crates/server/src/runtime/turn_exec/mod.rs b/crates/server/src/runtime/turn_exec/mod.rs index 3d51367d..1bede30d 100644 --- a/crates/server/src/runtime/turn_exec/mod.rs +++ b/crates/server/src/runtime/turn_exec/mod.rs @@ -10,6 +10,9 @@ mod tool_results; mod trace; mod types; +pub(crate) use context_compaction::{ + manual_compaction_completed_event, manual_compaction_started_event, +}; pub(crate) use event_stream::{QUERY_EVENT_CHANNEL_CAPACITY, spawn_turn_event_stream}; pub(crate) use finalize::FinalizeTurnParams; pub(crate) use query::TurnModelQueryParams; diff --git a/crates/server/src/runtime/turn_exec/tests.rs b/crates/server/src/runtime/turn_exec/tests.rs index ad4652e6..8b86cd3f 100644 --- a/crates/server/src/runtime/turn_exec/tests.rs +++ b/crates/server/src/runtime/turn_exec/tests.rs @@ -57,41 +57,23 @@ fn context_compaction_events_share_stable_item_lifecycle() { let turn_id = TurnId::new(); let item_id = ItemId::new(); + let started = started_event(session_id, turn_id, item_id); + let completed = completed_event(session_id, turn_id, item_id, None); + assert!(matches!(started, ServerEvent::ItemStarted(_))); + assert!(matches!(completed, ServerEvent::ItemCompleted(_))); assert_eq!( - vec![ - started_event(session_id, turn_id, item_id), - completed_event(session_id, turn_id, item_id, None), - ], - vec![ - ServerEvent::ItemStarted(ItemEventPayload { - context: EventContext { - session_id, - turn_id: Some(turn_id), - item_id: Some(item_id), - seq: 0, - item_seq: None, - }, - item: ItemEnvelope { - item_id, - item_kind: ItemKind::ContextCompaction, - payload: serde_json::json!({ "title": "Compaction started" }), - }, - }), - ServerEvent::ItemCompleted(ItemEventPayload { - context: EventContext { - session_id, - turn_id: Some(turn_id), - item_id: Some(item_id), - seq: 0, - item_seq: None, - }, - item: ItemEnvelope { - item_id, - item_kind: ItemKind::ContextCompaction, - payload: serde_json::json!({ "title": "Context compacted" }), - }, - }), - ] + match started { + ServerEvent::ItemStarted(payload) => payload.item.item_kind, + _ => unreachable!(), + }, + ItemKind::ContextCompaction + ); + assert_eq!( + match completed { + ServerEvent::ItemCompleted(payload) => payload.item.item_kind, + _ => unreachable!(), + }, + ItemKind::ContextCompaction ); } @@ -102,32 +84,14 @@ fn context_compaction_failure_closes_item_and_reports_visible_error() { let item_id = ItemId::new(); let message = "context limit".to_string(); + let events = failed_events(session_id, turn_id, item_id, message.clone()); + assert!(matches!(events[0], ServerEvent::ItemCompleted(_))); assert_eq!( - failed_events(session_id, turn_id, item_id, message.clone()), - [ - ServerEvent::ItemCompleted(ItemEventPayload { - context: EventContext { - session_id, - turn_id: Some(turn_id), - item_id: Some(item_id), - seq: 0, - item_seq: None, - }, - item: ItemEnvelope { - item_id, - item_kind: ItemKind::ContextCompaction, - payload: serde_json::json!({ - "title": "Compaction failed", - "status": "failed", - "message": message, - }), - }, - }), - ServerEvent::SessionCompactionFailed(SessionCompactionFailedPayload { - session_id, - message, - }), - ] + events[1], + ServerEvent::SessionCompactionFailed(SessionCompactionFailedPayload { + session_id, + message, + }) ); } diff --git a/crates/server/src/runtime/turn_exec/tool_results.rs b/crates/server/src/runtime/turn_exec/tool_results.rs index cf550b0b..a61c20f9 100644 --- a/crates/server/src/runtime/turn_exec/tool_results.rs +++ b/crates/server/src/runtime/turn_exec/tool_results.rs @@ -4,15 +4,15 @@ use devo_core::tools::ToolContent; use devo_core::{ CommandExecutionItem, SessionId, TextItem, ToolCallItem, ToolResultItem, TurnId, TurnItem, }; +use devo_protocol::native::item::{ + ExecOrigin, ExecutionMode, FileChangeEntry, FileChangeKind, Item, PlanEntry, PlanStepStatus, +}; use devo_util_git::extract_paths_from_patch; use super::super::*; use super::tool_display::{command_actions_from_tool_result, is_file_change_tool, is_plan_tool}; use super::types::PendingToolCall; -use crate::{ - CommandExecutionPayload, FileChangePayload, ItemKind, ToolCallPayload, ToolResultPayload, - TurnPlanStepPayload, TurnPlanUpdatedPayload, -}; +use crate::{ItemKind, ToolCallPayload, TurnPlanStepPayload, TurnPlanUpdatedPayload}; pub(super) fn tool_content_to_json(content: ToolContent) -> serde_json::Value { match content { @@ -147,19 +147,20 @@ async fn complete_plan_tool_call( .cloned() .unwrap_or_default(); runtime - .complete_item( + .complete_native_item( session_id, turn_id, pending_item_id, pending_item_seq, - ItemKind::Plan, + Item::Plan { + entries: vec![PlanEntry { + step: output_json.to_string(), + status: PlanStepStatus::Completed, + }], + }, TurnItem::Plan(TextItem { text: output_json.to_string(), }), - serde_json::json!({ - "title": "Plan", - "text": output_json.to_string(), - }), ) .await; runtime @@ -189,7 +190,7 @@ async fn complete_file_change_tool_call( turn_id: TurnId, tool_use_id: &str, tool_name: &str, - pending: &PendingToolCall, + _pending: &PendingToolCall, content: &ToolContent, display_content: Option, is_error: bool, @@ -199,12 +200,16 @@ async fn complete_file_change_tool_call( let output_json = tool_content_to_json(content.clone()); let changes = file_changes_from_output(&output_json); runtime - .complete_item( + .complete_native_item( session_id, turn_id, pending_item_id, pending_item_seq, - ItemKind::FileChange, + Item::FileChange { + call_id: tool_use_id.to_string(), + changes: legacy_file_changes_to_native(&changes), + sandbox: None, + }, TurnItem::ToolResult(ToolResultItem { tool_call_id: tool_use_id.to_string(), tool_name: Some(tool_name.to_string()), @@ -212,14 +217,6 @@ async fn complete_file_change_tool_call( display_content: display_content.clone(), is_error, }), - serde_json::to_value(FileChangePayload { - tool_call_id: tool_use_id.to_string(), - tool_name: Some(tool_name.to_string()), - input: Some(pending.input.clone()), - changes: changes.clone(), - is_error, - }) - .expect("serialize file change payload"), ) .await; runtime @@ -338,34 +335,31 @@ async fn complete_command_execution_tool_call( pending: &PendingToolCall, content: &ToolContent, is_error: bool, - summary: &str, + _summary: &str, pending_item_id: devo_core::ItemId, pending_item_seq: u64, ) { let output = tool_content_to_json(content.clone()); - let completed_payload = serde_json::to_value(CommandExecutionPayload { - tool_call_id: tool_use_id.to_string(), - tool_name: tool_name.to_string(), - command: pending.command.clone(), - input: Some(pending.input.clone()), - source: devo_protocol::protocol::ExecCommandSource::Agent, - command_actions: command_actions_from_tool_result( - tool_name, - &pending.command, - &pending.input, - summary, - ), - output: Some(output.clone()), - is_error, - }) - .expect("serialize command execution payload"); runtime - .complete_item( + .complete_native_item( session_id, turn_id, pending_item_id, pending_item_seq, - ItemKind::CommandExecution, + Item::CommandExecution { + call_id: tool_use_id.to_string(), + command: pending.command.clone(), + argv: None, + cwd: std::path::PathBuf::new(), + input: Some(pending.input.clone()), + output: Some(output.clone()), + exit_code: None, + execution_handle: None, + is_error, + execution_mode: ExecutionMode::Foreground, + origin: ExecOrigin::AgentTool, + sandbox: None, + }, TurnItem::CommandExecution(CommandExecutionItem { tool_call_id: tool_use_id.to_string(), tool_name: tool_name.to_string(), @@ -374,7 +368,6 @@ async fn complete_command_execution_tool_call( output, is_error, }), - completed_payload, ) .await; } @@ -427,38 +420,61 @@ pub(super) async fn emit_tool_result_item( turn_id: TurnId, tool_use_id: String, tool_name: Option, - result_input: Option, + _result_input: Option, content: ToolContent, display_content: Option, is_error: bool, - summary: String, + _summary: String, ) { runtime - .emit_turn_item( + .emit_turn_native_item( session_id, turn_id, - ItemKind::ToolResult, - TurnItem::ToolResult(ToolResultItem { - tool_call_id: tool_use_id.clone(), - tool_name: tool_name.clone(), + Item::ToolResult { + call_id: tool_use_id.clone(), output: tool_content_to_json(content.clone()), display_content: display_content.clone(), is_error, - }), - serde_json::to_value(ToolResultPayload { + truncated: false, + }, + TurnItem::ToolResult(ToolResultItem { tool_call_id: tool_use_id, - tool_name, - input: result_input, - content: tool_content_to_json(content), + tool_name: tool_name.clone(), + output: tool_content_to_json(content), display_content, is_error, - summary, - }) - .expect("serialize tool result payload"), + }), ) .await; } +fn legacy_file_changes_to_native( + changes: &[(std::path::PathBuf, devo_protocol::protocol::FileChange)], +) -> Vec { + changes + .iter() + .map(|(path, change)| FileChangeEntry { + path: path.clone(), + change: match change { + devo_protocol::protocol::FileChange::Add { content } => FileChangeKind::Add { + content: content.clone(), + }, + devo_protocol::protocol::FileChange::Delete { content } => FileChangeKind::Delete { + content: content.clone(), + }, + devo_protocol::protocol::FileChange::Update { + unified_diff, + move_path, + .. + } => FileChangeKind::Update { + unified_diff: unified_diff.clone(), + move_path: move_path.clone(), + }, + }, + }) + .collect() +} + #[cfg(test)] mod tests { use super::tool_content_to_json; diff --git a/crates/server/src/runtime/turn_exec/trace.rs b/crates/server/src/runtime/turn_exec/trace.rs index e40fbaa9..d23b48d5 100644 --- a/crates/server/src/runtime/turn_exec/trace.rs +++ b/crates/server/src/runtime/turn_exec/trace.rs @@ -28,6 +28,7 @@ pub(super) fn query_event_delivery_policy(event: &QueryEvent) -> QueryEventDeliv | QueryEvent::ReasoningDelta(_) | QueryEvent::ReasoningCompleted | QueryEvent::ToolUseStart { .. } + | QueryEvent::ToolUseInputDelta { .. } | QueryEvent::ToolExecutionStart { .. } | QueryEvent::ToolResult { .. } | QueryEvent::TurnComplete { .. } => QueryEventDeliveryPolicy::MustDeliver, @@ -52,6 +53,7 @@ pub(super) fn query_event_trace_kind(event: &QueryEvent) -> &'static str { QueryEvent::ReasoningDelta(_) => "reasoning_delta", QueryEvent::ReasoningCompleted => "reasoning_completed", QueryEvent::ToolUseStart { .. } => "tool_use_start", + QueryEvent::ToolUseInputDelta { .. } => "tool_use_input_delta", QueryEvent::ToolExecutionStart { .. } => "tool_execution_start", QueryEvent::ToolResult { .. } => "tool_result", QueryEvent::ToolProgress { .. } => "tool_progress", @@ -77,6 +79,7 @@ pub(super) fn query_event_trace_delta_len(event: &QueryEvent) -> usize { | QueryEvent::ContextCompactionFailed { .. } | QueryEvent::ReasoningCompleted | QueryEvent::ToolUseStart { .. } + | QueryEvent::ToolUseInputDelta { .. } | QueryEvent::ToolExecutionStart { .. } | QueryEvent::ToolResult { .. } | QueryEvent::UsageDelta { .. } @@ -95,6 +98,7 @@ pub(super) fn query_event_trace_token_preview(event: &QueryEvent) -> Option WorkerEvent -> host.rs --> ChatWidget.apply_worker_event - `chatwidget.rs` (~4200 lines) and `history_cell.rs` (~1600 lines) are known exceptions due to deeply coupled variant rendering. Do not add significant new code to these files; prefer extracting to new modules under `chatwidget/` or as standalone files. - `worker.rs` (~2900 lines) is the server child-process bridge and accumulates protocol translation logic. New protocol features should add well-bounded private methods rather than standalone modules unless the new code exceeds ~400 lines. +### Transcript projection (L2-DES-TUI-007) + +Tool rows in the live viewport and scrollback are driven by a single pipeline: + +``` +Native item/* (or adapted WorkerEvent) + → ItemLifecycleEvent (transcript/lifecycle.rs) + → TranscriptProjector (transcript/projector.rs) + → ToolCellModel / LiveTextCellModel + transcript/presentation.rs (semantic verbs) + → chatwidget/history_commit.rs (canonical finished-tool commit) + → transcript/render.rs → HistoryCell + → transcript_sync.rs → active viewport (tools + streaming text) +``` + +- **Historical tool commits** must go through `chatwidget/history_commit.rs`. Do not append tool history via ad-hoc `ToolResultCell` or `complete_exec_tool_from_committed` on commit. + - **Live turn** (`transcript_sync.rs`): `commit_committed_tool_to_live_turn` keeps exploration/exec groups in the live overlay until turn finish or compaction; `complete_exec_tool_from_committed` is only for in-flight exec output sync. + - **Resume rebuild** (`restored_session.rs`): `commit_committed_tool_to_history` appends directly to scrollback history (resume semantics are canonical for rendering). + - Decision order: `file_changes` → exploration (`ExecCell`) → exec-like shell (`ExecCell`) → paired `tool_io` (`ToolIoCell`) → semantic fallback (`ToolResultCell`). +- **Semantic verbs** live in `transcript/presentation.rs`: `Reading`/`Read`, `Writing`/`Wrote`, `Editing`/`Edited`, `Grepping`/`Grepped`, `Finding`/`Found`, `Running`/`Ran` (shell), `Loading`/`Loaded` (skill). Do not prepend a second generic `Running`/`Ran` prefix in renderers. +- **Exec-like tools** (`read`, `grep`, `glob`, shell) still route through `ExecCell` for grouping; `exec_cell/render.rs` uses the same verb pairs on action lines. +- **Assistant and reasoning text** use the same `ItemLifecycleEvent` pipeline (`TextStarted` / `TextDelta` / `TextCompleted`). `TranscriptProjector` is the single source of truth for live text bodies; `transcript/stream_text.rs` merges incremental and cumulative wire deltas. `transcript_sync.rs` mirrors projector state into `active_text_items` for viewport ordering and rendering only. Worker code emits `WorkerEvent::Transcript`; legacy `TextDelta`/`ReasoningDelta` are routed into the projector at the widget boundary. Tests use `worker_event_test_helpers::{text_item_started, text_item_delta, text_item_completed}`. +- **Tool input streaming**: `item/toolCall/inputDelta` maps to `ItemLifecycleEvent::ToolInputChunk` via `worker/tool_lifecycle.rs`. +- **Shell-family tools** (`shell_command`, `bash`, `exec_command`, `write_stdin`): inline viewport shows `Running`/`Ran` plus the model-provided `description`, then the command in dim gray; stdout/stderr stays in `transcript_lines` (Ctrl+T) only. User-composer `$` shell rows keep inline output. + ### Adding a New Module 1. Add `mod my_module;` to `lib.rs` with the appropriate visibility. diff --git a/crates/tui/src/agent_tool_cell.rs b/crates/tui/src/agent_tool_cell.rs new file mode 100644 index 00000000..60052e3f --- /dev/null +++ b/crates/tui/src/agent_tool_cell.rs @@ -0,0 +1,491 @@ +//! Structured transcript rendering for agent spawn, await, and list tools. + +use devo_protocol::AwaitTaskResult; +use devo_protocol::ListTasksResult; +use devo_protocol::SpawnAgentResult; +use devo_protocol::TaskInfo; +use devo_protocol::TaskKind; +use devo_protocol::TaskState; +use ratatui::prelude::*; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use serde_json::Value; + +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::transcript::model::ToolPhase; +use crate::transcript::presentation::tool_title_line; +use crate::transcript::presentation::tool_title_parts; +use crate::ui_consts::COMPLETED_COLOR; +use crate::ui_consts::REASONING_ACCENT_COLOR; + +const MUTED_COLOR: Color = Color::Rgb(160, 163, 168); +const RUNNING_COLOR: Color = Color::Rgb(106, 200, 255); +const FAILED_COLOR: Color = Color::Rgb(255, 100, 100); + +pub(crate) fn is_agent_task_tool_name(tool_name: &str) -> bool { + matches!( + tool_name, + "spawn_agent" + | "agent_spawn" + | "await_task" + | "wait_agent" + | "agent_wait" + | "list_tasks" + | "list_agents" + | "list_agent" + | "agent_list" + | "cancel_task" + | "close_agent" + | "agent_close" + ) +} + +#[derive(Debug)] +pub(crate) struct AgentToolCell { + tool_name: String, + phase: ToolPhase, + input: Option, + output: Option, + display_output: String, + dot_prefix: Line<'static>, +} + +impl AgentToolCell { + pub(crate) fn new( + tool_name: String, + phase: ToolPhase, + input: Option, + output: Option, + display_output: String, + dot_prefix: Line<'static>, + ) -> Self { + Self { + tool_name, + phase, + input, + output, + display_output, + dot_prefix, + } + } + + fn title_line(&self) -> Line<'static> { + let parts = tool_title_parts( + self.phase, + Some(self.tool_name.as_str()), + self.input.as_ref(), + &[], + false, + "", + ); + tool_title_line(self.phase, &parts) + } + + fn body_lines(&self, width: u16) -> Vec> { + let mut lines = match self.tool_name.as_str() { + "spawn_agent" | "agent_spawn" => self.spawn_body_lines(), + "await_task" | "wait_agent" | "agent_wait" => self.await_body_lines(), + "list_tasks" | "list_agents" | "list_agent" | "agent_list" => self.list_body_lines(), + "cancel_task" | "close_agent" | "agent_close" => self.cancel_body_lines(), + _ => self.fallback_body_lines(), + }; + for line in &mut lines { + *line = truncate_line_with_ellipsis_if_overflow(line.clone(), width as usize); + } + lines + } + + fn spawn_body_lines(&self) -> Vec> { + let message = spawn_message_from_input(self.input.as_ref()); + if let Some(result) = self + .output + .as_ref() + .and_then(|value| serde_json::from_value::(value.clone()).ok()) + { + let mut lines = vec![task_status_line( + &result.status, + &result.agent_nickname, + Some(&result.agent_path), + )]; + lines.push(meta_line("task", result.task_id.as_ref())); + if let Some(message) = message.filter(|text| !text.is_empty()) { + lines.push(quoted_preview_line(&message)); + } + return lines; + } + message + .filter(|text| !text.is_empty()) + .map(|text| vec![quoted_preview_line(&text)]) + .unwrap_or_default() + } + + fn await_body_lines(&self) -> Vec> { + let target = await_target_from_input(self.input.as_ref()); + if let Some(result) = self + .output + .as_ref() + .and_then(|value| serde_json::from_value::(value.clone()).ok()) + { + return match result { + AwaitTaskResult::Terminal { task, output } => { + let mut lines = vec![task_info_line(&task)]; + if let Some(output) = output.filter(|text| !text.trim().is_empty()) { + lines.push(quoted_preview_line(&output)); + } + lines + } + AwaitTaskResult::TimedOut { task } => { + let timeout = await_timeout_label(self.input.as_ref()); + vec![Line::from(vec![ + Span::styled("● ", task_state_marker_style(task.state)), + Span::styled(task_display_label(&task), Style::default().bold()), + Span::styled(format!(" {timeout}"), Style::default().fg(MUTED_COLOR)), + ])] + } + }; + } + target + .map(|label| vec![meta_line("target", &label)]) + .unwrap_or_default() + } + + fn list_body_lines(&self) -> Vec> { + if let Some(result) = self + .output + .as_ref() + .and_then(|value| serde_json::from_value::(value.clone()).ok()) + { + if result.tasks.is_empty() { + return vec![Line::from(Span::styled( + " No background tasks", + Style::default().fg(MUTED_COLOR).italic(), + ))]; + } + let mut lines = vec![Line::from(Span::styled( + format!( + " {} task{}", + result.tasks.len(), + if result.tasks.len() == 1 { "" } else { "s" } + ), + Style::default().fg(MUTED_COLOR), + ))]; + for task in &result.tasks { + lines.push(task_row_line(task)); + } + return lines; + } + self.fallback_body_lines() + } + + fn cancel_body_lines(&self) -> Vec> { + if let Some(task) = self + .output + .as_ref() + .and_then(|value| value.get("task")) + .and_then(|value| serde_json::from_value::(value.clone()).ok()) + { + return vec![task_info_line(&task)]; + } + await_target_from_input(self.input.as_ref()) + .map(|label| vec![meta_line("target", &label)]) + .unwrap_or_default() + } + + fn fallback_body_lines(&self) -> Vec> { + let text = self.display_output.trim(); + if text.is_empty() { + return Vec::new(); + } + vec![Line::from(Span::styled( + format!(" {text}"), + Style::default().fg(MUTED_COLOR), + ))] + } +} + +impl HistoryCell for AgentToolCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines = vec![self.title_line()]; + lines.extend(self.body_lines(width)); + AgentMessageCell::new_with_prefix(lines, self.dot_prefix.clone(), " ", false) + .display_lines(width) + } + + fn transcript_lines(&self, width: u16) -> Vec> { + self.display_lines(width) + } +} + +fn spawn_message_from_input(input: Option<&Value>) -> Option { + input + .and_then(|value| { + value + .get("message") + .or_else(|| value.get("prompt")) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToString::to_string) +} + +fn await_target_from_input(input: Option<&Value>) -> Option { + input.and_then(|value| { + value + .get("task_id") + .or_else(|| value.get("target")) + .or_else(|| value.get("agent_nickname")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToString::to_string) + }) +} + +fn await_timeout_label(input: Option<&Value>) -> String { + input + .and_then(|value| value.get("timeout_secs").and_then(Value::as_u64)) + .map(|secs| format!("timed out after {secs}s")) + .or_else(|| { + input + .and_then(|value| value.get("timeout").and_then(Value::as_str)) + .map(ToString::to_string) + }) + .unwrap_or_else(|| "timed out".to_string()) +} + +fn task_status_line(status: &str, nickname: &str, path: Option<&str>) -> Line<'static> { + Line::from(vec![ + Span::styled("● ", status_marker_style(status)), + Span::styled(nickname.to_string(), Style::default().bold()), + Span::styled( + path.map(|path| format!(" {path}")).unwrap_or_default(), + Style::default().fg(MUTED_COLOR), + ), + ]) +} + +fn task_info_line(task: &TaskInfo) -> Line<'static> { + let label = task_display_label(task); + let detail = task_detail_suffix(task); + Line::from(vec![ + Span::styled("● ", task_state_marker_style(task.state)), + Span::styled(label, Style::default().bold()), + Span::styled( + detail.map(|text| format!(" {text}")).unwrap_or_default(), + Style::default().fg(MUTED_COLOR), + ), + ]) +} + +fn task_row_line(task: &TaskInfo) -> Line<'static> { + let label = task_display_label(task); + let state = task_state_label(task.state); + let detail = task_detail_suffix(task).unwrap_or_default(); + Line::from(vec![ + Span::raw(" "), + Span::styled("● ", task_state_marker_style(task.state)), + Span::styled(format!("{label:<16}"), Style::default().bold()), + Span::styled(format!("{state:<11}"), task_state_text_style(task.state)), + Span::styled(detail, Style::default().fg(MUTED_COLOR)), + ]) +} + +fn task_display_label(task: &TaskInfo) -> String { + match task.kind { + TaskKind::Agent => task + .agent + .as_ref() + .map(|agent| agent.agent_nickname.clone()) + .unwrap_or_else(|| task.task_id.as_ref().to_string()), + TaskKind::Command => task + .command + .as_ref() + .map(|command| compact_command_label(&command.command)) + .unwrap_or_else(|| task.task_id.as_ref().to_string()), + } +} + +fn task_detail_suffix(task: &TaskInfo) -> Option { + match task.kind { + TaskKind::Agent => task.agent.as_ref().map(|agent| agent.agent_path.clone()), + TaskKind::Command => task + .command + .as_ref() + .and_then(|command| command.exit_code.map(|code| format!("exit {code}"))), + } +} + +fn compact_command_label(command: &str) -> String { + const MAX_CHARS: usize = 24; + let compact = command.split_whitespace().collect::>().join(" "); + if compact.chars().count() <= MAX_CHARS { + compact + } else { + format!( + "{}…", + compact + .chars() + .take(MAX_CHARS.saturating_sub(1)) + .collect::() + ) + } +} + +fn meta_line(label: &str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {label} "), Style::default().fg(MUTED_COLOR)), + Span::styled(value.to_string(), Style::default().dim()), + ]) +} + +fn quoted_preview_line(text: &str) -> Line<'static> { + Line::from(Span::styled( + format!(" “{text}”"), + Style::default().fg(MUTED_COLOR).italic(), + )) +} + +fn status_marker_style(status: &str) -> Style { + match status.to_ascii_lowercase().as_str() { + "completed" | "done" | "idle" => Style::default().fg(COMPLETED_COLOR).bold(), + "running" | "working" | "active" | "spawning" => Style::default().fg(RUNNING_COLOR).bold(), + "failed" => Style::default().fg(FAILED_COLOR).bold(), + "interrupted" | "canceled" | "closed" => Style::default().fg(MUTED_COLOR).bold(), + _ => Style::default().fg(REASONING_ACCENT_COLOR).bold(), + } +} + +fn task_state_marker_style(state: TaskState) -> Style { + match state { + TaskState::Completed => Style::default().fg(COMPLETED_COLOR).bold(), + TaskState::Running => Style::default().fg(RUNNING_COLOR).bold(), + TaskState::WaitingApproval => Style::default().fg(REASONING_ACCENT_COLOR).bold(), + TaskState::Failed => Style::default().fg(FAILED_COLOR).bold(), + TaskState::Canceled => Style::default().fg(MUTED_COLOR).bold(), + } +} + +fn task_state_text_style(state: TaskState) -> Style { + Style::default().fg(match state { + TaskState::Completed => COMPLETED_COLOR, + TaskState::Running => RUNNING_COLOR, + TaskState::WaitingApproval => REASONING_ACCENT_COLOR, + TaskState::Failed => FAILED_COLOR, + TaskState::Canceled => MUTED_COLOR, + }) +} + +fn task_state_label(state: TaskState) -> &'static str { + match state { + TaskState::WaitingApproval => "approval", + TaskState::Running => "running", + TaskState::Completed => "completed", + TaskState::Failed => "failed", + TaskState::Canceled => "canceled", + } +} + +#[cfg(test)] +mod tests { + use devo_core::SessionId; + use devo_protocol::TaskId; + + use super::*; + use ratatui::style::Stylize; + + fn sample_spawn_output() -> Value { + serde_json::to_value(SpawnAgentResult { + task_id: TaskId("task-1".to_string()), + child_session_id: SessionId::new(), + agent_path: "root/reviewer".to_string(), + agent_nickname: "reviewer".to_string(), + status: "running".to_string(), + }) + .expect("serialize spawn result") + } + + #[test] + fn spawn_agent_cell_renders_structured_body() { + let cell = AgentToolCell::new( + "spawn_agent".to_string(), + ToolPhase::Completed, + Some(serde_json::json!({ + "agent_nickname": "reviewer", + "message": "check usage" + })), + Some(sample_spawn_output()), + String::new(), + Line::from(vec![Span::styled("▌", Style::default().dim()), " ".into()]), + ); + let rendered = cell + .display_lines(100) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(rendered.contains("Spawned agent reviewer")); + assert!(rendered.contains("reviewer")); + assert!(rendered.contains("root/reviewer")); + assert!(rendered.contains("task task-1")); + assert!(rendered.contains("check usage")); + } + + #[test] + fn list_tasks_cell_renders_task_rows() { + let session_id = SessionId::new(); + let output = serde_json::to_value(ListTasksResult { + tasks: vec![ + TaskInfo { + task_id: TaskId::from(session_id), + kind: TaskKind::Agent, + state: TaskState::Running, + agent: Some(devo_protocol::AgentTaskMetadata { + session_id, + parent_session_id: None, + agent_path: "root/reviewer".to_string(), + agent_nickname: "reviewer".to_string(), + agent_role: "default".to_string(), + last_task_message: None, + }), + command: None, + }, + TaskInfo { + task_id: TaskId("cmd-1".to_string()), + kind: TaskKind::Command, + state: TaskState::Completed, + agent: None, + command: Some(devo_protocol::CommandTaskMetadata { + process_id: 42, + command: "cargo test".to_string(), + exit_code: Some(0), + }), + }, + ], + }) + .expect("serialize list result"); + let cell = AgentToolCell::new( + "list_tasks".to_string(), + ToolPhase::Completed, + Some(serde_json::json!({})), + Some(output), + String::new(), + Line::from(vec![Span::styled("▌", Style::default().dim()), " ".into()]), + ); + let rendered = cell + .display_lines(120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(rendered.contains("Listed tasks")); + assert!(rendered.contains("2 tasks")); + assert!(rendered.contains("reviewer")); + assert!(rendered.contains("running")); + assert!(rendered.contains("cargo test")); + assert!(rendered.contains("exit 0")); + } +} diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index ec59f56c..c139933e 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -36,8 +36,8 @@ use crate::history_cell::HistoryCell; use crate::onboarding_widget::OnboardingWidget; use crate::startup_header::STARTUP_HEADER_ANIMATION_INTERVAL; use crate::startup_logo_cell::StartupLogoCell; -use crate::streaming::chunking::AdaptiveChunkingPolicy; use crate::theme::ThemeSet; +use crate::transcript::TranscriptProjector; use crate::tui::frame_requester::FrameRequester; mod diff_rules; @@ -71,6 +71,8 @@ mod sandbox_profiles; mod text_stream; +mod history_commit; +mod transcript_sync; mod transcript_view; mod reasoning_effort; @@ -208,6 +210,7 @@ struct ActiveToolCall { title: String, lines: Vec>, output: String, + parsed_commands: Vec, exec_like: bool, start_time: Option, } @@ -244,6 +247,11 @@ pub(crate) struct ChatWidget { reasoning_effort_selection: Option, // sub widget, bottom pane, including such input textarea, slash command popup, status summary. bottom_pane: BottomPane, + /// Unified transcript projection (live + restored). + transcript_projector: TranscriptProjector, + /// Stable item ids for legacy wire events without server item ids. + legacy_assistant_item_id: ItemId, + legacy_reasoning_item_id: ItemId, active_cell: Option>, active_cell_revision: u64, last_terminal_assistant_visible_hash: Option<(String, u64)>, @@ -255,7 +263,6 @@ pub(crate) struct ChatWidget { external_editor_state: ExternalEditorState, status_message: String, active_text_items: Vec, - stream_chunking_policy: AdaptiveChunkingPolicy, available_models: Vec, saved_models: Vec, current_model_binding_id: Option, @@ -521,6 +528,9 @@ impl ChatWidget { session: initial_session, reasoning_effort_selection, bottom_pane, + transcript_projector: TranscriptProjector::default(), + legacy_assistant_item_id: ItemId::new(), + legacy_reasoning_item_id: ItemId::new(), active_cell: None, active_cell_revision: 0, last_terminal_assistant_visible_hash: None, @@ -532,7 +542,6 @@ impl ChatWidget { external_editor_state: ExternalEditorState::Closed, status_message: "Ready".to_string(), active_text_items: Vec::new(), - stream_chunking_policy: AdaptiveChunkingPolicy::default(), available_models, current_model_binding_id, saved_models, diff --git a/crates/tui/src/chatwidget/history_commit.rs b/crates/tui/src/chatwidget/history_commit.rs new file mode 100644 index 00000000..8f4f2446 --- /dev/null +++ b/crates/tui/src/chatwidget/history_commit.rs @@ -0,0 +1,548 @@ +//! Canonical history commit path for finished tool rows (live session + resume). + +use std::time::Duration; + +use devo_protocol::parse_command::ParsedCommand; +use devo_protocol::protocol::ExecCommandSource; +use ratatui::text::Line; +use serde_json::Value; + +use crate::exec_cell::CommandOutput; +use crate::exec_cell::ExecCell; +use crate::exec_cell::new_active_exec_command; +use crate::tool_result_cell::ToolResultCell; +use crate::transcript::model::CommittedCellModel; +use crate::transcript::model::ToolCellModel; +use crate::transcript::model::ToolPhase; +use crate::transcript::presentation::tool_title_line; +use crate::transcript::presentation::tool_title_parts; +use crate::transcript::tool_state::is_exec_like; + +use super::ChatWidget; + +pub(crate) fn is_exploration_tool(tool: &ToolCellModel) -> bool { + is_exec_like(&tool.parsed_commands) + && !matches!(tool.command_source, Some(ExecCommandSource::UserShell)) +} + +/// Where a finished tool row should land in the transcript. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolCommitTarget { + /// Keep exec/explore tools in the live overlay until turn finish or compaction. + LiveOverlay, + /// Append directly to scrollback history (session resume rebuild). + ScrollbackHistory, +} + +impl ChatWidget { + /// Commits a finished tool row to scrollback history using resume semantics. + pub(crate) fn commit_committed_tool_to_history(&mut self, tool: ToolCellModel) { + self.commit_committed_tool_to_history_with_target( + tool, + ToolCommitTarget::ScrollbackHistory, + ); + } + + /// Commits a finished tool row during an active live turn. + pub(crate) fn commit_committed_tool_to_live_turn(&mut self, tool: ToolCellModel) { + self.commit_committed_tool_to_history_with_target(tool, ToolCommitTarget::LiveOverlay); + } + + fn commit_committed_tool_to_history_with_target( + &mut self, + tool: ToolCellModel, + target: ToolCommitTarget, + ) { + if tool + .file_changes + .as_ref() + .is_some_and(|changes| !changes.is_empty()) + { + self.append_committed_tool_io_cell(tool); + return; + } + + if is_exploration_tool(&tool) { + self.commit_exploration_tool(tool, target); + return; + } + + if tool.exec_like { + self.commit_exec_tool(tool, target); + return; + } + + if tool.tool_name.is_some() && tool.input.is_some() { + self.append_committed_tool_io_cell(tool); + return; + } + + self.commit_tool_fallback_to_history(tool); + } + + fn append_committed_tool_io_cell(&mut self, tool: ToolCellModel) { + let dot_prefix = if tool.is_error { + Self::failed_dot_prefix() + } else { + Self::tool_dot_prefix() + }; + let history_cell = crate::transcript::render::committed_cell_to_history( + &CommittedCellModel::Tool(tool), + &self.session.cwd, + |title| Self::ran_tool_line(title), + dot_prefix, + Self::tool_text_style(), + ); + self.add_history_entry_without_redraw(history_cell); + } + + pub(crate) fn commit_exploration_tool_from_history_item( + &mut self, + tool_use_id: String, + command: String, + actions: Vec, + tool_name: Option, + input: Option, + output: Option, + display_content: Option, + is_error: bool, + ) { + let tool = ToolCellModel { + tool_use_id, + seq: 0, + phase: if is_error { + ToolPhase::Failed + } else { + ToolPhase::Completed + }, + summary: command.clone(), + tool_name, + input, + input_partial_json: String::new(), + parsed_commands: actions, + exec_like: true, + start_time: None, + output_preview: display_content.clone().unwrap_or_default(), + output_delta_lines: Vec::new(), + file_changes: None, + command: Some(command), + command_source: Some(ExecCommandSource::Agent), + command_output: None, + command_duration: None, + tool_output: output, + tool_display_content: display_content, + is_error, + truncated: false, + }; + self.commit_exploration_tool(tool, ToolCommitTarget::ScrollbackHistory); + } + + fn commit_exec_tool(&mut self, tool: ToolCellModel, target: ToolCommitTarget) { + if self.complete_exec_tool_from_committed(&tool) { + return; + } + + if target == ToolCommitTarget::LiveOverlay { + return; + } + + let exec = exec_cell_from_tool(&tool, &self.session.cwd); + self.add_history_entry_without_redraw(Box::new(exec)); + self.apply_tool_io_to_history_exec(&tool); + } + + fn commit_exploration_tool(&mut self, tool: ToolCellModel, target: ToolCommitTarget) { + match target { + ToolCommitTarget::LiveOverlay => self.commit_exploration_tool_to_live_overlay(tool), + ToolCommitTarget::ScrollbackHistory => { + self.commit_exploration_tool_to_scrollback_history(tool) + } + } + } + + fn commit_exploration_tool_to_live_overlay(&mut self, tool: ToolCellModel) { + self.apply_tool_io_to_active_exec(&tool); + + if self.active_exec_has_call(&tool.tool_use_id) { + return; + } + + if self.try_merge_exploration_into_active_exec(&tool) { + return; + } + + let exec = exec_cell_from_tool(&tool, &self.session.cwd); + if let Some(active) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && active.is_exploring_cell() + { + let mut actions = tool.parsed_commands.clone(); + crate::read_display::normalize_read_actions(&mut actions, &self.session.cwd); + let command = tool + .command + .clone() + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| tool.summary.clone()); + let command_tokens = crate::exec_command::split_command_string(&command); + if let Some(grouped) = active.with_added_call( + tool.tool_use_id.clone(), + command_tokens, + actions, + tool.command_source.unwrap_or(ExecCommandSource::Agent), + None, + ) { + *active = grouped; + self.apply_tool_io_to_active_exec(&tool); + } + return; + } + + self.active_cell = Some(Box::new(exec)); + self.apply_tool_io_to_active_exec(&tool); + } + + fn commit_exploration_tool_to_scrollback_history(&mut self, tool: ToolCellModel) { + self.apply_tool_io_to_active_exec(&tool); + + if self.try_merge_exploration_into_history_exec(&tool) { + self.clear_active_exec_call_if_present(&tool.tool_use_id); + return; + } + + if self.should_flush_active_exploring_cell() { + self.flush_active_cell(); + return; + } + + let exec = exec_cell_from_tool(&tool, &self.session.cwd); + self.add_history_entry_without_redraw(Box::new(exec)); + self.apply_tool_io_to_history_exec(&tool); + } + + fn active_exec_has_call(&self, call_id: &str) -> bool { + self.active_cell + .as_ref() + .and_then(|cell| cell.as_any().downcast_ref::()) + .is_some_and(|cell| cell.contains_call(call_id)) + } + + fn try_merge_exploration_into_active_exec(&mut self, tool: &ToolCellModel) -> bool { + let mut actions = tool.parsed_commands.clone(); + crate::read_display::normalize_read_actions(&mut actions, &self.session.cwd); + let command = tool + .command + .clone() + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| tool.summary.clone()); + let command_tokens = crate::exec_command::split_command_string(&command); + let call_id = tool.tool_use_id.clone(); + + let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + .filter(|cell| cell.is_exploring_cell()) + else { + return false; + }; + let Some(grouped) = cell.with_added_call( + call_id, + command_tokens, + actions, + tool.command_source.unwrap_or(ExecCommandSource::Agent), + None, + ) else { + return false; + }; + *cell = grouped; + self.apply_tool_io_to_active_exec(tool); + true + } + + fn try_merge_exploration_into_history_exec(&mut self, tool: &ToolCellModel) -> bool { + let mut actions = tool.parsed_commands.clone(); + crate::read_display::normalize_read_actions(&mut actions, &self.session.cwd); + let command = tool + .command + .clone() + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| tool.summary.clone()); + let command_tokens = crate::exec_command::split_command_string(&command); + let call_id = tool.tool_use_id.clone(); + + let Some(cell) = self + .history + .last_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + else { + return false; + }; + let Some(grouped) = cell.with_added_call( + call_id, + command_tokens, + actions, + tool.command_source.unwrap_or(ExecCommandSource::Agent), + None, + ) else { + return false; + }; + *cell = grouped; + self.apply_tool_io_to_history_exec(tool); + true + } + + fn should_flush_active_exploring_cell(&self) -> bool { + self.active_cell + .as_ref() + .and_then(|cell| cell.as_any().downcast_ref::()) + .is_some_and(|cell| { + cell.is_exploring_cell() && cell.calls.iter().all(|call| call.output.is_some()) + }) + } + + fn clear_active_exec_call_if_present(&mut self, call_id: &str) { + let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + else { + return; + }; + if cell.calls.len() == 1 && cell.calls[0].call_id == call_id { + self.active_cell = None; + return; + } + cell.calls.retain(|call| call.call_id != call_id); + if cell.calls.is_empty() { + self.active_cell = None; + } + } + + fn apply_tool_io_to_active_exec(&mut self, tool: &ToolCellModel) { + let (Some(tool_name), Some(input)) = (&tool.tool_name, &tool.input) else { + if let Some(output) = tool_output_for_commit(tool) { + self.complete_active_exec_call(tool, output); + } + return; + }; + let tool_use_id = tool.tool_use_id.as_str(); + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && cell.set_tool_io_input(tool_use_id, tool_name.clone(), input.clone()) + { + if let Some(output) = tool_output_for_commit(tool) { + let display_content = tool.tool_display_content.clone(); + let output_text = display_content + .clone() + .unwrap_or_else(|| value_text(&output)); + cell.complete_tool_io(tool_use_id, output, display_content.clone()); + cell.complete_call( + tool_use_id, + CommandOutput { + exit_code: if tool.is_error { 1 } else { 0 }, + aggregated_output: output_text.clone(), + formatted_output: output_text.clone(), + }, + Duration::from_millis(0), + ); + } + } + } + + fn apply_tool_io_to_history_exec(&mut self, tool: &ToolCellModel) { + let (Some(tool_name), Some(input)) = (&tool.tool_name, &tool.input) else { + if let Some(output) = tool_output_for_commit(tool) { + self.complete_history_exec_call(tool, output); + } + return; + }; + let tool_use_id = tool.tool_use_id.as_str(); + for cell in self + .history + .iter_mut() + .rev() + .filter_map(|cell| cell.as_any_mut().downcast_mut::()) + { + if !cell.set_tool_io_input(tool_use_id, tool_name.clone(), input.clone()) { + continue; + } + if let Some(output) = tool_output_for_commit(tool) { + let display_content = tool.tool_display_content.clone(); + let output_text = display_content + .clone() + .unwrap_or_else(|| value_text(&output)); + cell.complete_tool_io(tool_use_id, output, display_content.clone()); + cell.complete_call( + tool_use_id, + CommandOutput { + exit_code: if tool.is_error { 1 } else { 0 }, + aggregated_output: output_text.clone(), + formatted_output: output_text.clone(), + }, + Duration::from_millis(0), + ); + } + return; + } + } + + fn complete_active_exec_call(&mut self, tool: &ToolCellModel, output: Value) { + let tool_use_id = tool.tool_use_id.as_str(); + let output_text = tool + .tool_display_content + .clone() + .unwrap_or_else(|| value_text(&output)); + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && cell.complete_call( + tool_use_id, + CommandOutput { + exit_code: if tool.is_error { 1 } else { 0 }, + aggregated_output: output_text.clone(), + formatted_output: output_text, + }, + Duration::from_millis(0), + ) + { + let _ = cell; + } + } + + fn complete_history_exec_call(&mut self, tool: &ToolCellModel, output: Value) { + let tool_use_id = tool.tool_use_id.as_str(); + let output_text = tool + .tool_display_content + .clone() + .unwrap_or_else(|| value_text(&output)); + for cell in self + .history + .iter_mut() + .rev() + .filter_map(|cell| cell.as_any_mut().downcast_mut::()) + { + if cell.complete_call( + tool_use_id, + CommandOutput { + exit_code: if tool.is_error { 1 } else { 0 }, + aggregated_output: output_text.clone(), + formatted_output: output_text.clone(), + }, + Duration::from_millis(0), + ) { + return; + } + } + } + + fn commit_tool_fallback_to_history(&mut self, tool: ToolCellModel) { + let dot_prefix = if tool.is_error { + Self::failed_dot_prefix() + } else { + Self::tool_dot_prefix() + }; + let change_is_add = tool.file_changes.as_ref().is_some_and(|changes| { + changes + .values() + .any(|change| matches!(change, devo_protocol::protocol::FileChange::Add { .. })) + }); + let parts = tool_title_parts( + tool.phase, + tool.tool_name.as_deref(), + tool.input.as_ref(), + &tool.parsed_commands, + change_is_add, + &tool.summary, + ); + let title_line = tool_title_line(tool.phase, &parts); + let preview = tool + .tool_display_content + .clone() + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| tool.output_preview.clone()); + self.add_history_entry_without_redraw(Box::new(ToolResultCell::new( + Some(title_line), + preview, + dot_prefix, + Line::from(" "), + Self::tool_text_style(), + tool.truncated, + ))); + } +} + +fn tool_output_for_commit(tool: &ToolCellModel) -> Option { + tool.tool_output.clone().or_else(|| { + (!tool.output_preview.is_empty()).then(|| Value::String(tool.output_preview.clone())) + }) +} + +fn value_text(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + other => other.to_string(), + } +} + +fn exec_cell_from_tool(tool: &ToolCellModel, cwd: &std::path::Path) -> ExecCell { + let mut actions = tool.parsed_commands.clone(); + crate::read_display::normalize_read_actions(&mut actions, cwd); + let command = tool + .command + .clone() + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| tool.summary.clone()); + let command_tokens = crate::exec_command::split_command_string(&command); + new_active_exec_command( + tool.tool_use_id.clone(), + command_tokens, + actions, + tool.command_source.unwrap_or(ExecCommandSource::Agent), + None, + false, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transcript::tool_state::is_exec_like; + + #[test] + fn exploration_tool_detects_parsed_search_actions() { + let tool = ToolCellModel { + tool_use_id: "grep-1".into(), + seq: 0, + phase: ToolPhase::Completed, + summary: String::new(), + tool_name: Some("grep".into()), + input: Some(serde_json::json!({"pattern": "plan"})), + input_partial_json: String::new(), + parsed_commands: vec![ParsedCommand::Search { + cmd: "grep plan".into(), + query: Some("plan".into()), + path: None, + }], + exec_like: true, + start_time: None, + output_preview: String::new(), + output_delta_lines: Vec::new(), + file_changes: None, + command: None, + command_source: None, + command_output: None, + command_duration: None, + tool_output: None, + tool_display_content: None, + is_error: false, + truncated: false, + }; + assert!(is_exploration_tool(&tool)); + assert!(is_exec_like(&tool.parsed_commands)); + } +} diff --git a/crates/tui/src/chatwidget/restored_session.rs b/crates/tui/src/chatwidget/restored_session.rs index 09bd923d..147eafe3 100644 --- a/crates/tui/src/chatwidget/restored_session.rs +++ b/crates/tui/src/chatwidget/restored_session.rs @@ -9,22 +9,15 @@ use std::path::PathBuf; use crate::bottom_pane::InputMode; use crate::events::TranscriptItem; -use crate::exec_cell::CommandOutput; -use crate::exec_cell::ExecCell; -use crate::exec_cell::new_active_exec_command; use crate::history_cell; -use crate::tool_io_cell::FileChangeToolIoCell; -use crate::tool_io_cell::ToolIoCell; -use crate::tool_io_cell::ToolIoCellOptions; -use crate::tool_result_cell::ToolResultCell; +use crate::transcript::model::CommittedCellModel; +use crate::transcript::restore_session; use devo_protocol::SessionHistoryItem; use devo_protocol::SessionHistoryMetadata; use devo_protocol::SessionPlanStepStatus; -use ratatui::text::Line; use serde_json::Value; use super::ChatWidget; -use super::DotStatus; impl ChatWidget { pub(super) fn rebuild_restored_session_history( @@ -36,6 +29,9 @@ impl ChatWidget { ) { self.history.clear(); self.next_history_flush_index = 0; + self.transcript_projector = crate::transcript::TranscriptProjector::default(); + self.active_tool_calls.clear(); + self.pending_tool_calls.clear(); tracing::trace!( session_id, @@ -76,6 +72,9 @@ impl ChatWidget { ) -> bool { self.history.clear(); self.next_history_flush_index = 0; + self.transcript_projector = crate::transcript::TranscriptProjector::default(); + self.active_tool_calls.clear(); + self.pending_tool_calls.clear(); if history_items.is_empty() { self.add_history_entry_without_redraw(Box::new(history_cell::new_info_event( @@ -162,18 +161,32 @@ impl ChatWidget { } SessionHistoryMetadata::TurnSummary { .. } => false, SessionHistoryMetadata::Edited { changes } => { - self.add_restored_file_change_item(item, changes.clone()); + let tool_cell = + restore_session::completed_tool_from_edit(item, changes.clone(), 0); + self.commit_committed_tool_to_history(tool_cell); true } SessionHistoryMetadata::Explored { actions } => { - self.restore_explored_history_item(item, actions.clone()); let result_item = paired_result_index .map(|result_index| &history_items[result_index]) .or_else(|| { (item.kind != devo_protocol::SessionHistoryItemKind::ToolCall) .then_some(item) }); - self.apply_restored_exec_tool_io(item, result_item); + self.commit_exploration_tool_from_history_item( + item.tool_call_id + .clone() + .unwrap_or_else(|| "restored".to_string()), + item.title.clone(), + actions.clone(), + Self::restored_tool_io_name(item, result_item), + Self::restored_tool_io_input(item, result_item), + result_item.and_then(Self::restored_tool_io_output), + result_item.and_then(Self::restored_tool_io_display_content), + result_item.is_some_and(|item| { + item.kind == devo_protocol::SessionHistoryItemKind::Error + }), + ); true } }; @@ -183,7 +196,8 @@ impl ChatWidget { } if let Some(changes) = Self::edited_changes_from_history_item(item) { - self.add_restored_file_change_item(item, changes); + let tool_cell = restore_session::completed_tool_from_edit(item, changes, 0); + self.commit_committed_tool_to_history(tool_cell); continue; } @@ -196,19 +210,10 @@ impl ChatWidget { if let Some(result_index) = paired_result_by_call_id.get(tool_call_id).copied() { consumed_indexes.insert(result_index); let result_item = &history_items[result_index]; - if self.add_restored_tool_io_result_item(item, result_item) { - continue; + if let Some(tool_cell) = restore_session::paired_tool_cell(item, result_item, 0) + { + self.commit_committed_tool_to_history(tool_cell); } - let title_line = - (!item.title.is_empty()).then(|| Self::ran_tool_line(&item.title)); - self.add_history_entry_without_redraw(Box::new(ToolResultCell::new( - title_line, - result_item.body.clone(), - Self::tool_dot_prefix(), - Line::from(" "), - Self::tool_text_style(), - false, - ))); continue; } } @@ -217,50 +222,30 @@ impl ChatWidget { devo_protocol::SessionHistoryItemKind::User => { self.add_restored_user_prompt(item.body.clone()); } - devo_protocol::SessionHistoryItemKind::Assistant => { - self.add_markdown_history_without_redraw("Assistant", &item.body); - } - devo_protocol::SessionHistoryItemKind::Reasoning => { - self.add_markdown_history_without_redraw("Reasoning", &item.body); - } - devo_protocol::SessionHistoryItemKind::ToolCall => { - self.add_history_entry_without_redraw(Box::new( - history_cell::AgentMessageCell::new_with_prefix( - vec![Self::running_tool_line(&item.title)], - self.dot_prefix(DotStatus::Pending), - " ", - false, - ), - )); + devo_protocol::SessionHistoryItemKind::Assistant + | devo_protocol::SessionHistoryItemKind::Reasoning => { + if let Some(cell) = restore_session::restore_item_to_committed(item, 0) { + self.append_restored_committed_cell(cell); + } } + devo_protocol::SessionHistoryItemKind::ToolCall => {} devo_protocol::SessionHistoryItemKind::ToolResult | devo_protocol::SessionHistoryItemKind::CommandExecution => { - if self.add_restored_tool_io_result_item(item, item) { - continue; + if let Some(CommittedCellModel::Tool(tool)) = + restore_session::restore_item_to_committed(item, 0) + { + self.commit_committed_tool_to_history(tool); } - self.add_history_entry_without_redraw(Box::new(ToolResultCell::new( - (!item.title.is_empty()).then(|| Self::ran_tool_line(&item.title)), - item.body.clone(), - Self::tool_dot_prefix(), - Line::from(" "), - Self::tool_text_style(), - false, - ))); } devo_protocol::SessionHistoryItemKind::Error => { if item.tool_call_id.is_none() { self.add_history_entry_without_redraw(Box::new( history_cell::new_error_event(item.body.clone()), )); - } else { - self.add_history_entry_without_redraw(Box::new(ToolResultCell::new( - (!item.title.is_empty()).then(|| Self::ran_tool_line(&item.title)), - item.body.clone(), - Self::failed_dot_prefix(), - Line::from(" "), - Self::tool_text_style(), - false, - ))); + } else if let Some(CommittedCellModel::Tool(tool)) = + restore_session::restore_item_to_committed(item, 0) + { + self.commit_committed_tool_to_history(tool); } } devo_protocol::SessionHistoryItemKind::TurnSummary => { @@ -313,122 +298,6 @@ impl ChatWidget { ))); } - fn add_restored_file_change_item( - &mut self, - item: &SessionHistoryItem, - changes: HashMap, - ) { - if let (Some(tool_name), Some(input)) = ( - Self::restored_tool_io_name(item, None), - Self::restored_tool_io_input(item, None), - ) { - self.add_history_entry_without_redraw(Box::new(FileChangeToolIoCell::new( - (!item.title.is_empty()).then(|| Self::ran_tool_line(&item.title)), - tool_name, - input, - changes, - self.session.cwd.clone(), - ))); - } else { - self.add_history_entry_without_redraw(Box::new(history_cell::new_patch_event( - changes, - &self.session.cwd, - ))); - } - } - - fn add_restored_tool_io_result_item( - &mut self, - call_item: &SessionHistoryItem, - result_item: &SessionHistoryItem, - ) -> bool { - let (Some(tool_name), Some(input)) = ( - Self::restored_tool_io_name(call_item, Some(result_item)), - Self::restored_tool_io_input(call_item, Some(result_item)), - ) else { - return false; - }; - if result_item.kind == devo_protocol::SessionHistoryItemKind::ToolResult - && let Some(changes) = Self::edited_changes_from_history_item(result_item) - { - self.add_history_entry_without_redraw(Box::new(FileChangeToolIoCell::new( - (!call_item.title.is_empty()).then(|| Self::ran_tool_line(&call_item.title)), - tool_name, - input, - changes, - self.session.cwd.clone(), - ))); - return true; - } - self.add_history_entry_without_redraw(Box::new(ToolIoCell::new( - ToolIoCellOptions { - title_line: (!call_item.title.is_empty()) - .then(|| Self::ran_tool_line(&call_item.title)), - dot_prefix: if result_item.kind == devo_protocol::SessionHistoryItemKind::Error { - Self::failed_dot_prefix() - } else { - Self::tool_dot_prefix() - }, - subsequent_prefix: Line::from(" "), - output_style: Self::tool_text_style(), - show_empty_ellipsis: false, - }, - tool_name, - input, - Self::restored_tool_io_output(result_item), - Self::restored_tool_io_display_content(result_item), - ))); - true - } - - fn apply_restored_exec_tool_io( - &mut self, - call_item: &SessionHistoryItem, - result_item: Option<&SessionHistoryItem>, - ) { - let (Some(tool_call_id), Some(tool_name), Some(input)) = ( - call_item.tool_call_id.as_deref(), - Self::restored_tool_io_name(call_item, result_item), - Self::restored_tool_io_input(call_item, result_item), - ) else { - return; - }; - let output = result_item.and_then(Self::restored_tool_io_output); - let display_content = result_item.and_then(Self::restored_tool_io_display_content); - for cell in self - .history - .iter_mut() - .rev() - .filter_map(|cell| cell.as_any_mut().downcast_mut::()) - { - if !cell.set_tool_io_input(tool_call_id, tool_name.clone(), input.clone()) { - continue; - } - if let Some(output) = output.clone() { - let output_text = display_content - .clone() - .unwrap_or_else(|| Self::value_text(&output)); - cell.complete_tool_io(tool_call_id, output, display_content.clone()); - cell.complete_call( - tool_call_id, - CommandOutput { - exit_code: if result_item.is_some_and(|item| { - item.kind == devo_protocol::SessionHistoryItemKind::Error - }) { - 1 - } else { - 0 - }, - aggregated_output: output_text.clone(), - formatted_output: output_text, - }, - std::time::Duration::from_millis(0), - ); - } - return; - } - } - fn restored_tool_io_name( item: &SessionHistoryItem, result_item: Option<&SessionHistoryItem>, @@ -569,46 +438,6 @@ impl ChatWidget { } (!changes.is_empty()).then_some(changes) } - - pub(super) fn restore_explored_history_item( - &mut self, - item: &SessionHistoryItem, - actions: Vec, - ) { - let mut actions = actions; - crate::read_display::normalize_read_actions(&mut actions, &self.session.cwd); - let command = item.title.clone(); - let command_tokens = crate::exec_command::split_command_string(&command); - if let Some(cell) = self - .history - .last_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - && let Some(grouped) = cell.with_added_call( - item.tool_call_id - .clone() - .unwrap_or_else(|| "restored".to_string()), - command_tokens.clone(), - actions.clone(), - devo_protocol::protocol::ExecCommandSource::Agent, - None, - ) - { - *cell = grouped; - return; - } - - let exec = new_active_exec_command( - item.tool_call_id - .clone() - .unwrap_or_else(|| "restored".to_string()), - command_tokens, - actions, - devo_protocol::protocol::ExecCommandSource::Agent, - None, - false, - ); - self.add_history_entry_without_redraw(Box::new(exec)); - } } fn turn_summary_input_mode(item: &SessionHistoryItem) -> InputMode { diff --git a/crates/tui/src/chatwidget/session_header.rs b/crates/tui/src/chatwidget/session_header.rs index 3ba3223f..7e582288 100644 --- a/crates/tui/src/chatwidget/session_header.rs +++ b/crates/tui/src/chatwidget/session_header.rs @@ -28,8 +28,7 @@ use super::ChatWidget; use super::DotStatus; use super::STATUS_LINE_BRANCH_REFRESH_INTERVAL; -/// Blue used for the pending-state dot prefix. -pub(super) const PENDING_DOT_COLOR: Color = Color::Rgb(110, 200, 255); +use crate::ui_consts::REPLY_MARKER_COLOR; /// Blue used for running/active state text. pub(super) const RUNNING_COLOR: Color = Color::Rgb(106, 200, 255); /// Red used for failed/interrupted state. @@ -103,30 +102,28 @@ impl ChatWidget { } } + pub(super) fn muted_dot_prefix() -> Line<'static> { + Line::from(vec![Span::styled("▌", Style::default().dim()), " ".into()]) + } + + /// Accent marker for assistant reply text (live and committed). + pub(super) fn reply_dot_prefix() -> Line<'static> { + Self::pending_dot_prefix() + } + pub(super) fn completed_dot_prefix() -> Line<'static> { - Line::from(vec![ - Span::styled("▌", Style::default().fg(COMPLETED_COLOR)), - " ".into(), - ]) + Self::muted_dot_prefix() } pub(super) fn pending_dot_prefix() -> Line<'static> { Line::from(vec![ - Span::styled("▌", Style::default().fg(PENDING_DOT_COLOR)), + Span::styled("▌", Style::default().fg(REPLY_MARKER_COLOR)), " ".into(), ]) } - pub(super) fn reasoning_dot_prefix(status: DotStatus) -> Line<'static> { - let color = match status { - DotStatus::Pending => REASONING_ACCENT_COLOR, - DotStatus::Completed => COMPLETED_COLOR, - DotStatus::Failed => FAILED_COLOR, - }; - Line::from(vec![ - Span::styled("▌", Style::default().fg(color)), - " ".into(), - ]) + pub(super) fn reasoning_dot_prefix(_status: DotStatus) -> Line<'static> { + Self::muted_dot_prefix() } pub(super) fn truncate_display_text(value: &str, max_width: usize) -> String { @@ -211,24 +208,18 @@ impl ChatWidget { } pub(super) fn tool_dot_prefix() -> Line<'static> { - Line::from(vec![ - Span::styled("▌", Style::default().fg(COMPLETED_COLOR)), - " ".into(), - ]) + Self::muted_dot_prefix() } pub(super) fn failed_dot_prefix() -> Line<'static> { - Line::from(vec![ - Span::styled("▌", Style::default().fg(REASONING_ACCENT_COLOR)), - " ".into(), - ]) + Self::muted_dot_prefix() } pub(super) fn dot_prefix(&self, status: DotStatus) -> Line<'static> { match status { - DotStatus::Pending => Self::pending_dot_prefix(), - DotStatus::Completed => Self::completed_dot_prefix(), - DotStatus::Failed => Self::failed_dot_prefix(), + DotStatus::Pending => Self::reply_dot_prefix(), + DotStatus::Completed => Self::reply_dot_prefix(), + DotStatus::Failed => Self::reply_dot_prefix(), } } @@ -260,6 +251,8 @@ impl ChatWidget { }; let used = if self.last_query_total_tokens > 0 { self.last_query_total_tokens + } else if self.prompt_token_estimate > 0 { + self.prompt_token_estimate } else if let Some(occupancy) = self.last_context_occupancy.as_ref() { occupancy.total_tokens as usize } else { @@ -549,11 +542,10 @@ impl ChatWidget { } #[cfg(test)] - #[cfg(test)] - pub(crate) fn has_stream_controller(&self) -> bool { + pub(crate) fn has_live_assistant_text(&self) -> bool { self.active_text_items .iter() - .any(|item| item.stream_controller.is_some()) + .any(|item| item.kind == crate::events::TextItemKind::Assistant) } #[cfg(test)] @@ -604,7 +596,7 @@ impl ChatWidget { } pub(super) fn reasoning_completed_dot_prefix() -> Line<'static> { - Line::from(vec![Span::styled("▌", Style::default().dim()), " ".into()]) + Self::muted_dot_prefix() } pub(super) fn patch_lines_style(lines: &mut [Line<'static>], style: Style) { @@ -754,6 +746,44 @@ mod tests { assert_eq!(widget.context_usage(), Some((9, 190_000, 0))); } + #[test] + fn session_switched_restores_context_usage_from_resume_payload() { + let mut widget = widget_for_summary_bench(); + let occupancy = ContextOccupancy::from_category_tokens( + /*context_window_tokens*/ 190_000, /*base*/ 10_000, /*skills*/ 0, + /*tools_builtin*/ 0, /*tools_mcp*/ 0, /*conversation*/ 48_000, + ); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: PathBuf::from("."), + title: Some("Resumed".to_string()), + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 1_000, + total_output_tokens: 200, + total_tokens: 1_200, + total_cache_read_tokens: 0, + last_query_total_tokens: 58_000, + last_query_input_tokens: 40_000, + prompt_token_estimate: 40_000, + history_items: Vec::new(), + rich_history_items: Vec::new(), + loaded_item_count: 0, + pending_texts: Vec::new(), + collaboration_mode: devo_protocol::CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, + last_context_occupancy: Some(occupancy), + }); + + assert_eq!(widget.context_usage(), Some((58_000, 190_000, 31))); + assert!(widget.status_summary_text().contains("58.0k/190.0k")); + } + #[test] fn context_usage_uses_last_query_with_occupancy_window() { let mut widget = widget_for_summary_bench(); diff --git a/crates/tui/src/chatwidget/session_history.rs b/crates/tui/src/chatwidget/session_history.rs index 624adbee..a8eb6b08 100644 --- a/crates/tui/src/chatwidget/session_history.rs +++ b/crates/tui/src/chatwidget/session_history.rs @@ -3,7 +3,6 @@ //! This module converts protocol/session items into history cells and owns the //! bookkeeping for active cells, scrollback flushes, and restored transcripts. -use ratatui::style::Color; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; @@ -35,6 +34,7 @@ use super::DotStatus; impl ChatWidget { pub(super) fn clear_for_session_switch(&mut self) { + self.transcript_projector.reset_sync_cursor(); self.history.clear(); self.next_history_flush_index = 0; self.active_cell = None; @@ -62,7 +62,6 @@ impl ChatWidget { self.boundary_committed_assistant_items.clear(); self.active_proposed_plan = None; self.pending_proposed_plan_actions = false; - self.stream_chunking_policy.reset(); self.selection_mode = false; self.selected_user_cell_index = None; self.user_cell_history_indices.clear(); @@ -82,7 +81,7 @@ impl ChatWidget { self.last_plan_progress = (total > 0).then_some((completed, total)); let mut lines = vec![Line::from(vec![ - Span::styled("▌", Style::default().fg(Color::Rgb(120, 220, 160))), + Span::styled("▌", Style::default().dim()), " ".into(), "Updated Plan".bold(), ])]; @@ -303,9 +302,9 @@ impl ChatWidget { } if is_ai_message { let prefix = if title == "Reasoning" { - Self::reasoning_completed_dot_prefix() + Self::muted_dot_prefix() } else { - self.dot_prefix(status) + Self::reply_dot_prefix() }; self.add_history_entry_without_redraw(Box::new( history_cell::AgentMessageCell::new_ai_response_with_prefix( @@ -369,14 +368,7 @@ impl ChatWidget { self.add_markdown_history_without_redraw("Reasoning", &item.body); } TranscriptItemKind::ToolCall => { - self.add_history_entry_without_redraw(Box::new( - history_cell::AgentMessageCell::new_with_prefix( - vec![Self::running_tool_line(&item.title)], - self.dot_prefix(DotStatus::Pending), - " ", - false, - ), - )); + // Completed tools are rendered from the paired ToolResult row. } TranscriptItemKind::ToolResult => { self.add_history_entry_without_redraw(Box::new(ToolResultCell::new( diff --git a/crates/tui/src/chatwidget/text_stream.rs b/crates/tui/src/chatwidget/text_stream.rs index a9bb6321..330bae16 100644 --- a/crates/tui/src/chatwidget/text_stream.rs +++ b/crates/tui/src/chatwidget/text_stream.rs @@ -1,10 +1,9 @@ -//! Active assistant/reasoning text stream lifecycle for `ChatWidget`. +//! Active assistant/reasoning text view state for `ChatWidget`. //! -//! This module owns the ordering, live-cell synchronization, and final commit -//! behavior for streaming text items while `ChatWidget` keeps the actual state. +//! Text bodies live in [`TranscriptProjector`] only; this module tracks ordering, +//! live-cell rendering, and commit-to-history behavior. use std::sync::OnceLock; -use std::time::Duration; use std::time::Instant; use devo_core::ItemId; @@ -13,9 +12,7 @@ use ratatui::text::Span; use crate::events::TextItemKind; use crate::history_cell; use crate::markdown::append_markdown; -use crate::streaming::commit_tick::CommitTickScope; -use crate::streaming::commit_tick::run_commit_tick; -use crate::streaming::controller::StreamController; +use crate::transcript::lifecycle::ItemLifecycleEvent; use super::ChatWidget; use super::DotStatus; @@ -25,31 +22,74 @@ pub(super) struct ActiveTextItem { pub(super) kind: TextItemKind, pub(super) seq: u64, pub(super) status: DotStatus, - pub(super) stream_controller: Option, - last_renderable_delta_at: Option, - last_stream_commit_at: Option, - stream_stall_warned: bool, - delta_seq: u64, - raw_text: String, + commit_text: Option, pub(super) cell: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ActiveTextItemId { - Server(ItemId), - Legacy(TextItemKind), -} +pub(super) struct ActiveTextItemId(pub(crate) ItemId); impl ActiveTextItemId { + pub(super) fn item_id(self) -> ItemId { + self.0 + } + pub(super) fn log_label(self) -> String { - match self { - Self::Server(item_id) => item_id.to_string(), - Self::Legacy(kind) => format!("legacy-{kind:?}"), - } + self.0.to_string() } } impl ChatWidget { + pub(super) fn is_legacy_text_item(&self, item_id: ItemId) -> bool { + item_id == self.legacy_assistant_item_id || item_id == self.legacy_reasoning_item_id + } + + pub(super) fn legacy_text_item_id(&self, kind: TextItemKind) -> ItemId { + match kind { + TextItemKind::Assistant => self.legacy_assistant_item_id, + TextItemKind::Reasoning => self.legacy_reasoning_item_id, + } + } + + pub(super) fn live_text_body(&self, item_id: ItemId) -> &str { + self.transcript_projector + .live_text_for(item_id) + .unwrap_or("") + } + + pub(super) fn has_native_text_item(&self, kind: TextItemKind) -> bool { + self.transcript_projector + .live_text_items() + .any(|live| live.kind == kind && !self.is_legacy_text_item(live.item_id)) + } + + pub(super) fn apply_legacy_text_delta(&mut self, kind: TextItemKind, delta: String) { + if self.has_native_text_item(kind) { + return; + } + self.flush_active_cell(); + let item_id = self.legacy_text_item_id(kind); + if !self.transcript_projector.has_live_text(item_id) { + self.apply_item_lifecycle(ItemLifecycleEvent::TextStarted { item_id, kind }); + } + self.apply_item_lifecycle(ItemLifecycleEvent::TextDelta { + item_id, + kind, + delta, + }); + } + + pub(super) fn apply_legacy_text_completed(&mut self, kind: TextItemKind, final_text: String) { + if self.has_native_text_item(kind) { + return; + } + let item_id = self.legacy_text_item_id(kind); + self.apply_item_lifecycle(ItemLifecycleEvent::TextCompleted { + item_id, + kind, + final_text, + }); + } pub(super) fn commit_active_streams(&mut self, status: DotStatus) { tracing::debug!( status = ?status, @@ -58,9 +98,10 @@ impl ChatWidget { ); for item in &self.active_text_items { if item.kind == TextItemKind::Assistant - && let ActiveTextItemId::Server(item_id) = item.item_id + && !self.is_legacy_text_item(item.item_id.item_id()) { - self.boundary_committed_assistant_items.insert(item_id); + self.boundary_committed_assistant_items + .insert(item.item_id.item_id()); self.committed_server_assistant_in_turn = true; } } @@ -77,8 +118,9 @@ impl ChatWidget { index += 1; continue; } - if let ActiveTextItemId::Server(item_id) = item.item_id { - self.boundary_committed_assistant_items.insert(item_id); + if !self.is_legacy_text_item(item.item_id.item_id()) { + self.boundary_committed_assistant_items + .insert(item.item_id.item_id()); self.committed_server_assistant_in_turn = true; } self.commit_text_item_at(index, DotStatus::Completed); @@ -100,11 +142,6 @@ impl ChatWidget { } let seq = self.reserve_seq(); - let stream_controller = if kind == TextItemKind::Assistant { - Some(StreamController::new(None, &self.session.cwd)) - } else { - None - }; let insert_index = self.active_text_item_insert_index(kind); tracing::debug!( item_id = %item_id.log_label(), @@ -120,12 +157,7 @@ impl ChatWidget { kind, seq, status: DotStatus::Pending, - stream_controller, - last_renderable_delta_at: None, - last_stream_commit_at: None, - stream_stall_warned: false, - delta_seq: 0, - raw_text: String::new(), + commit_text: None, cell: None, }, ); @@ -133,85 +165,17 @@ impl ChatWidget { after = ?self.active_text_item_log_order(), "active text item order after start" ); - self.stream_chunking_policy.reset(); } - pub(super) fn push_text_item_delta( - &mut self, - item_id: ActiveTextItemId, - kind: TextItemKind, - delta: &str, - ) { - let index = self.ensure_text_item(item_id, kind); - let active_items = self.active_text_item_log_order(); - let active_cell_revision_before = self.active_cell_revision; - let delta_seq = { - let item = &mut self.active_text_items[index]; - item.delta_seq = item.delta_seq.saturating_add(1); - item.delta_seq + pub(super) fn sync_live_text_item(&mut self, item_id: ActiveTextItemId) { + let Some(index) = self + .active_text_items + .iter() + .position(|item| item.item_id == item_id) + else { + return; }; - let queued_lines_before = self.active_text_items[index] - .stream_controller - .as_ref() - .map(StreamController::queued_lines); - if let Some(assistant_token_text) = (kind == TextItemKind::Assistant) - .then(|| assistant_token_log_preview(delta)) - .flatten() - { - tracing::debug!( - stream_elapsed_ms = stream_trace_elapsed_ms(), - item_id = %item_id.log_label(), - kind = ?kind, - delta_seq, - delta_len = delta.len(), - queued_lines_before = ?queued_lines_before, - active_cell_revision_before, - active_items = ?active_items, - assistant_token_text = %assistant_token_text, - "received active text item delta" - ); - } else { - tracing::debug!( - stream_elapsed_ms = stream_trace_elapsed_ms(), - item_id = %item_id.log_label(), - kind = ?kind, - delta_seq, - delta_len = delta.len(), - queued_lines_before = ?queued_lines_before, - active_cell_revision_before, - active_items = ?active_items, - "received active text item delta" - ); - } - match kind { - TextItemKind::Assistant => { - if let Some(controller) = self.active_text_items[index].stream_controller.as_mut() { - let produced_renderable_lines = controller.push(delta); - if produced_renderable_lines { - let item = &mut self.active_text_items[index]; - item.last_renderable_delta_at = Some(Instant::now()); - item.stream_stall_warned = false; - } - } - } - TextItemKind::Reasoning => { - self.active_text_items[index].raw_text.push_str(delta); - } - } self.sync_text_item_cell(index); - tracing::debug!( - stream_elapsed_ms = stream_trace_elapsed_ms(), - item_id = %item_id.log_label(), - kind = ?kind, - delta_seq, - queued_lines_after = ?self.active_text_items[index] - .stream_controller - .as_ref() - .map(StreamController::queued_lines), - active_cell_revision_after = self.active_cell_revision, - "active text item delta synced" - ); - self.frame_requester.schedule_frame(); } pub(super) fn complete_text_item( @@ -222,8 +186,11 @@ impl ChatWidget { ) { let boundary_committed = matches!( (item_id, kind), - (ActiveTextItemId::Server(item_id), TextItemKind::Assistant) - if self.boundary_committed_assistant_items.contains(&item_id) + (_, TextItemKind::Assistant) + if self + .boundary_committed_assistant_items + .contains(&item_id.item_id()) + && !self.is_legacy_text_item(item_id.item_id()) ); let index = if boundary_committed { let Some(index) = self @@ -247,11 +214,11 @@ impl ChatWidget { ); self.active_text_items[index].status = DotStatus::Completed; if !boundary_committed && !final_text.trim().is_empty() { - self.active_text_items[index].raw_text = final_text; + self.active_text_items[index].commit_text = Some(final_text); } self.sync_text_item_cell(index); self.commit_completed_text_items(); - if matches!(item_id, ActiveTextItemId::Server(_)) && kind == TextItemKind::Assistant { + if !self.is_legacy_text_item(item_id.item_id()) && kind == TextItemKind::Assistant { self.committed_server_assistant_in_turn = true; } } @@ -273,19 +240,7 @@ impl ChatWidget { } pub(super) fn has_server_active_item(&self, kind: TextItemKind) -> bool { - self.active_text_items - .iter() - .any(|item| matches!(item.item_id, ActiveTextItemId::Server(_)) && item.kind == kind) - } - - #[cfg(test)] - pub(crate) fn assistant_stream_queued_lines_for_test(&self) -> usize { - self.active_text_items - .iter() - .filter(|item| item.kind == TextItemKind::Assistant) - .filter_map(|item| item.stream_controller.as_ref()) - .map(StreamController::queued_lines) - .sum() + self.has_native_text_item(kind) } fn commit_text_item_at(&mut self, index: usize, status: DotStatus) { @@ -294,6 +249,12 @@ impl ChatWidget { } let mut item = self.active_text_items.remove(index); + let body = item + .commit_text + .take() + .unwrap_or_else(|| self.live_text_body(item.item_id.item_id()).to_string()); + self.transcript_projector + .drop_live_text(item.item_id.item_id()); tracing::debug!( item_id = %item.item_id.log_label(), kind = ?item.kind, @@ -303,31 +264,20 @@ impl ChatWidget { ); match item.kind { TextItemKind::Assistant => { - if let Some(controller) = item.stream_controller.as_mut() { - let (_cell, source) = controller.finalize(); - if let Some(source) = source { - self.add_assistant_markdown_source(source, status); - } else if !item.raw_text.trim().is_empty() { - self.add_markdown_history_with_status_without_redraw( - "Assistant", - &item.raw_text, - status, - ); - } - } else if !item.raw_text.trim().is_empty() { + if !body.trim().is_empty() { self.add_markdown_history_with_status_without_redraw( "Assistant", - &item.raw_text, + &body, status, ); } } TextItemKind::Reasoning => { - if !item.raw_text.trim().is_empty() { + if !body.trim().is_empty() { if self.collapse_reasoning { self.add_history_entry_without_redraw( super::reasoning_view::collapsed_reasoning_history_cell( - item.raw_text, + body, &self.session.cwd, "Thought: ", Self::reasoning_completed_heading_style(), @@ -336,25 +286,11 @@ impl ChatWidget { ), ); } else { - self.add_markdown_history_with_status("Reasoning", &item.raw_text, status); + self.add_markdown_history_with_status("Reasoning", &body, status); } } } } - self.stream_chunking_policy.reset(); - } - - fn add_assistant_markdown_source(&mut self, source: String, status: DotStatus) { - if source.trim().is_empty() { - return; - } - - self.add_history_entry_without_redraw(Box::new(history_cell::AgentMarkdownCell::new( - source, - &self.session.cwd, - self.dot_prefix(status), - " ", - ))); } fn active_text_item_insert_index(&self, kind: TextItemKind) -> usize { @@ -428,72 +364,7 @@ impl ChatWidget { .collect() } - pub(super) fn run_stream_commit_tick(&mut self) { - let now = Instant::now(); - let mut output_cells = Vec::new(); - let mut needs_followup = false; - let mut changed_indexes = Vec::new(); - - for (index, item) in self.active_text_items.iter_mut().enumerate() { - let Some(controller) = item.stream_controller.as_mut() else { - continue; - }; - let queued_lines_before = controller.queued_lines(); - let output = run_commit_tick( - &mut self.stream_chunking_policy, - Some(controller), - CommitTickScope::AnyMode, - now, - ); - let queued_lines_after = controller.queued_lines(); - let emitted_cells = output.cells.len(); - tracing::debug!( - stream_elapsed_ms = stream_trace_elapsed_ms(), - item_id = %item.item_id.log_label(), - kind = ?item.kind, - delta_seq = item.delta_seq, - queued_lines_before, - queued_lines_after, - emitted_cells, - all_idle = output.all_idle, - "stream commit tick processed active text item" - ); - if matches!(item.kind, TextItemKind::Assistant) { - if !output.cells.is_empty() { - changed_indexes.push(index); - item.last_stream_commit_at = Some(now); - item.stream_stall_warned = false; - } else if item.kind == TextItemKind::Assistant { - maybe_warn_stream_commit_stall(item, queued_lines_after, now); - } - if !output.all_idle { - needs_followup = true; - } - continue; - } - if !output.cells.is_empty() { - output_cells.extend(output.cells); - changed_indexes.push(index); - } - if !output.all_idle { - needs_followup = true; - } - } - - for cell in output_cells { - self.add_history_entry_without_redraw(cell); - } - for index in changed_indexes { - self.sync_text_item_cell(index); - } - if needs_followup { - self.frame_requester - .schedule_frame_in(std::time::Duration::from_millis(16)); - } - if !self.active_text_items.is_empty() { - self.frame_requester.schedule_frame(); - } - } + pub(super) fn run_stream_commit_tick(&mut self) {} pub(super) fn sync_text_item_cell(&mut self, index: usize) { if index >= self.active_text_items.len() { @@ -512,39 +383,27 @@ impl ChatWidget { &self, item: &ActiveTextItem, ) -> Option> { - if let Some(controller) = &item.stream_controller { - let lines = controller.live_rendered_lines(); - if lines.iter().any(|line| !Self::is_blank_line(&line.line)) { - return Some(Box::new( - history_cell::AgentMessageCell::new_with_rendered_lines( - lines, - Self::pending_dot_prefix(), - " ", - false, - ), - )); - } - } else if !item.raw_text.trim().is_empty() { - return Some(Box::new( - self.bulleted_markdown_cell(&item.raw_text, Self::pending_dot_prefix()), - )); + let body = self.live_text_body(item.item_id.item_id()); + if body.trim().is_empty() { + return None; } - None + Some(Box::new( + self.bulleted_markdown_cell(body, Self::reply_dot_prefix()), + )) } fn reasoning_active_cell( &self, item: &ActiveTextItem, ) -> Option> { - if item.raw_text.trim().is_empty() { + let body = self.live_text_body(item.item_id.item_id()); + if body.trim().is_empty() { return None; } if self.collapse_reasoning { - // Width-aware live window: cap by wrapped visual rows, not logical - // newlines, so one long paragraph cannot blow past the budget. return Some(super::reasoning_view::collapsed_reasoning_live_cell( - item.raw_text.clone(), + body.to_string(), &self.session.cwd, "Thinking: ", Self::reasoning_heading_style(), @@ -554,12 +413,7 @@ impl ChatWidget { } let mut body_lines = Vec::new(); - append_markdown( - &item.raw_text, - None, - Some(&self.session.cwd), - &mut body_lines, - ); + append_markdown(body, None, Some(&self.session.cwd), &mut body_lines); Self::patch_lines_style(&mut body_lines, Self::reasoning_text_style()); if let Some(first_line) = body_lines.first_mut() { first_line.spans.insert( @@ -578,43 +432,6 @@ impl ChatWidget { } } -fn maybe_warn_stream_commit_stall(item: &mut ActiveTextItem, queued_lines: usize, now: Instant) { - if item.kind != TextItemKind::Assistant || item.stream_stall_warned || queued_lines == 0 { - return; - } - let Some(last_renderable_delta_at) = item.last_renderable_delta_at else { - return; - }; - let threshold = stream_stall_warning_threshold(); - let age = now.saturating_duration_since(last_renderable_delta_at); - if age < threshold { - return; - } - tracing::warn!( - stream_elapsed_ms = stream_trace_elapsed_ms(), - item_id = %item.item_id.log_label(), - queued_lines, - stalled_ms = age.as_millis(), - threshold_ms = threshold.as_millis(), - last_stream_commit_age_ms = item - .last_stream_commit_at - .map(|last_commit| now.saturating_duration_since(last_commit).as_millis()), - "assistant stream has queued renderable lines but no visible commit" - ); - item.stream_stall_warned = true; -} - -fn stream_stall_warning_threshold() -> Duration { - static STREAM_STALL_WARNING_THRESHOLD: OnceLock = OnceLock::new(); - *STREAM_STALL_WARNING_THRESHOLD.get_or_init(|| { - std::env::var("DEVO_TUI_STREAM_STALL_WARN_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .map(Duration::from_millis) - .unwrap_or_else(|| Duration::from_millis(750)) - }) -} - fn stream_trace_elapsed_ms() -> u128 { static STREAM_TRACE_START: OnceLock = OnceLock::new(); STREAM_TRACE_START @@ -718,20 +535,9 @@ mod tests { use std::hint::black_box; use std::time::Instant; - use crate::events::TextItemKind; - - use super::ActiveTextItemId; use super::assistant_token_log_preview_with_enabled; use super::format_assistant_token_log_preview; - #[test] - fn legacy_text_item_id_log_label_includes_kind() { - assert_eq!( - ActiveTextItemId::Legacy(TextItemKind::Assistant).log_label(), - "legacy-Assistant" - ); - } - #[test] fn assistant_token_log_preview_escapes_and_truncates_text() { assert_eq!( diff --git a/crates/tui/src/chatwidget/transcript_sync.rs b/crates/tui/src/chatwidget/transcript_sync.rs new file mode 100644 index 00000000..c63892d5 --- /dev/null +++ b/crates/tui/src/chatwidget/transcript_sync.rs @@ -0,0 +1,190 @@ +//! Syncs [`TranscriptProjector`] state into `ChatWidget` rendering containers. + +use std::collections::HashSet; + +use devo_core::ItemId; +use ratatui::text::Line; + +use crate::events::TextItemKind; +use crate::events::WorkerEvent; +use crate::transcript::lifecycle::ItemLifecycleEvent; +use crate::transcript::model::CommittedCellModel; +use crate::transcript::model::ToolPhase; + +use super::ActiveToolCall; +use super::ChatWidget; +use super::DotStatus; +use super::text_stream::ActiveTextItemId; + +impl ChatWidget { + /// Single entry point for transcript-affecting lifecycle events (P3). + pub(super) fn apply_item_lifecycle(&mut self, event: ItemLifecycleEvent) { + self.transcript_projector.apply(event); + self.sync_transcript_projection(); + } + + /// Routes transcript lifecycle events from the worker bus. + pub(super) fn route_worker_event_through_projector(&mut self, event: &WorkerEvent) -> bool { + if let WorkerEvent::Transcript(lifecycle) = event { + self.apply_item_lifecycle(lifecycle.clone()); + return true; + } + false + } + + pub(super) fn clear_turn_live_projection(&mut self) { + self.apply_item_lifecycle(ItemLifecycleEvent::TurnLiveToolsCleared); + } + + pub(super) fn sync_transcript_projection(&mut self) { + let committed: Vec<_> = self.transcript_projector.drain_unsynced_committed(); + for cell in committed { + match cell { + CommittedCellModel::Text(text) => { + let item_id = ActiveTextItemId(text.item_id); + if self + .active_text_items + .iter() + .any(|item| item.item_id == item_id) + { + self.complete_text_item(item_id, text.kind, text.text); + continue; + } + let skip_history = text.kind == TextItemKind::Assistant + && self + .boundary_committed_assistant_items + .contains(&text.item_id); + if text.kind == TextItemKind::Assistant { + self.committed_server_assistant_in_turn = true; + } + if skip_history { + continue; + } + let title = match text.kind { + TextItemKind::Assistant => "Assistant", + TextItemKind::Reasoning => "Reasoning", + }; + self.add_markdown_history_without_redraw(title, &text.text); + } + CommittedCellModel::Tool(tool) => { + self.commit_committed_tool_to_live_turn(tool); + } + } + } + + self.sync_live_text_from_projector(); + + self.active_tool_calls.clear(); + self.pending_tool_calls.clear(); + for tool in self.transcript_projector.live_tools() { + let tool_call = ActiveToolCall { + tool_use_id: tool.tool_use_id.clone(), + seq: tool.seq, + tool_name: tool.tool_name.clone(), + input: tool.input.clone(), + title: tool.summary.clone(), + lines: tool + .output_delta_lines + .iter() + .map(|line| Line::from(line.clone())) + .collect(), + output: tool.output_preview.clone(), + parsed_commands: tool.parsed_commands.clone(), + exec_like: tool.exec_like, + start_time: tool.start_time, + }; + if tool.phase == ToolPhase::Preparing { + self.pending_tool_calls.push(tool_call); + } else { + self.active_tool_calls + .insert(tool.tool_use_id.clone(), tool_call); + } + } + + self.sync_exec_cells_from_projector(); + + if self + .transcript_projector + .live_text_items() + .any(|text| text.kind == TextItemKind::Assistant) + { + self.set_status_message("Generating"); + } else if self + .transcript_projector + .live_text_items() + .any(|text| text.kind == TextItemKind::Reasoning) + { + self.set_status_message("Thinking"); + } + + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.frame_requester.schedule_frame(); + } + + fn sync_live_text_from_projector(&mut self) { + let live_items: Vec<_> = self + .transcript_projector + .live_text_items() + .cloned() + .collect(); + let live_ids: HashSet = live_items.iter().map(|live| live.item_id).collect(); + + for live in live_items { + let item_id = ActiveTextItemId(live.item_id); + if !self + .active_text_items + .iter() + .any(|item| item.item_id == item_id) + { + self.flush_active_cell(); + self.start_text_item(item_id, live.kind); + } + + self.sync_live_text_item(item_id); + } + + self.active_text_items.retain(|item| { + live_ids.contains(&item.item_id.item_id()) || item.status == DotStatus::Completed + }); + } + + pub(super) fn reset_transcript_projection(&mut self) { + self.transcript_projector.reset_sync_cursor(); + } + + pub(super) fn restore_transcript_projection( + &mut self, + items: &[devo_protocol::SessionHistoryItem], + ) { + self.transcript_projector = + crate::transcript::restore::restore_projector_from_history(items); + self.sync_transcript_projection(); + } + + pub(super) fn append_restored_committed_cell(&mut self, cell: CommittedCellModel) { + match cell { + CommittedCellModel::Text(text) => { + let title = match text.kind { + TextItemKind::Assistant => "Assistant", + TextItemKind::Reasoning => "Reasoning", + }; + self.add_markdown_history_without_redraw(title, &text.text); + } + CommittedCellModel::Tool(tool) => { + let dot_prefix = if tool.is_error { + Self::failed_dot_prefix() + } else { + Self::tool_dot_prefix() + }; + let history_cell = crate::transcript::render::committed_cell_to_history( + &CommittedCellModel::Tool(tool), + &self.session.cwd, + |title| Self::ran_tool_line(title), + dot_prefix, + Self::tool_text_style(), + ); + self.add_history_entry_without_redraw(history_cell); + } + } + } +} diff --git a/crates/tui/src/chatwidget/transcript_view.rs b/crates/tui/src/chatwidget/transcript_view.rs index 431e67b8..76c7220e 100644 --- a/crates/tui/src/chatwidget/transcript_view.rs +++ b/crates/tui/src/chatwidget/transcript_view.rs @@ -4,8 +4,9 @@ //! snapshots consumed by the Ctrl+T overlay, scrollback drain, and live view. use ratatui::text::Line; -use ratatui::text::Span; +use crate::agent_tool_cell::AgentToolCell; +use crate::agent_tool_cell::is_agent_task_tool_name; use crate::events::TextItemKind; use crate::history_cell; use crate::history_cell::HistoryCell; @@ -13,6 +14,9 @@ use crate::history_cell::ScrollbackLine; use crate::render::line_utils::is_horizontal_rule_line; use crate::tool_io_cell::ToolIoCell; use crate::tool_io_cell::ToolIoCellOptions; +use crate::transcript::model::ToolPhase; +use crate::transcript::presentation::tool_title_line; +use crate::transcript::presentation::tool_title_parts; use super::ChatWidget; use super::UserMessage; @@ -225,60 +229,41 @@ impl ChatWidget { } } for pending in &self.pending_tool_calls { - if let (Some(tool_name), Some(input)) = (&pending.tool_name, &pending.input) { - let tool_lines = ToolIoCell::from_text_output( - ToolIoCellOptions { - title_line: Some(Self::running_tool_line(&pending.title)), - dot_prefix: Self::pending_dot_prefix(), - subsequent_prefix: " ".into(), - output_style: Self::tool_text_style(), - show_empty_ellipsis: false, - }, - tool_name.clone(), - input.clone(), - pending.output.clone(), - ); - let tool_lines = match mode { - LiveViewportLineMode::Display => tool_lines.display_lines(width), - LiveViewportLineMode::Transcript => tool_lines.transcript_lines(width), - }; - Self::extend_lines_with_separator(&mut lines, tool_lines); - } else { - let pending_lines = if let Some(start_time) = pending.start_time { - let mut pending_lines = vec![Line::from(vec![ - crate::exec_cell::spinner(Some(start_time), true), - " ".into(), - Span::styled(pending.title.clone(), Self::tool_text_style()), - ])]; - pending_lines.extend(pending.lines.clone()); - pending_lines - } else { - pending.lines.clone() - }; - Self::extend_lines_with_separator( - &mut lines, - match mode { - LiveViewportLineMode::Display => { - history_cell::AgentMessageCell::new_with_prefix( - pending_lines, - Self::pending_dot_prefix(), - " ", - false, - ) - .display_lines(width) - } - LiveViewportLineMode::Transcript => { - history_cell::AgentMessageCell::new_with_prefix( - pending_lines, - Self::pending_dot_prefix(), - " ", - false, - ) - .transcript_lines(width) - } - }, - ); - } + let title_line = tool_title_line( + ToolPhase::Preparing, + &tool_title_parts( + ToolPhase::Preparing, + pending.tool_name.as_deref(), + pending.input.as_ref(), + &pending.parsed_commands, + false, + &pending.title, + ), + ); + let pending_lines = vec![title_line]; + Self::extend_lines_with_separator( + &mut lines, + match mode { + LiveViewportLineMode::Display => { + history_cell::AgentMessageCell::new_with_prefix( + pending_lines, + Self::tool_dot_prefix(), + " ", + false, + ) + .display_lines(width) + } + LiveViewportLineMode::Transcript => { + history_cell::AgentMessageCell::new_with_prefix( + pending_lines, + Self::tool_dot_prefix(), + " ", + false, + ) + .transcript_lines(width) + } + }, + ); } Self::trim_trailing_blank_lines(&mut lines); lines @@ -289,25 +274,60 @@ impl ChatWidget { tool_call: &super::ActiveToolCall, ) -> Vec> { match (&tool_call.tool_name, &tool_call.input) { - (Some(tool_name), Some(input)) => ToolIoCell::from_text_output( - ToolIoCellOptions { - title_line: Some(Self::running_tool_line(&tool_call.title)), - dot_prefix: Self::pending_dot_prefix(), - subsequent_prefix: " ".into(), - output_style: Self::tool_text_style(), - show_empty_ellipsis: false, - }, - tool_name.clone(), - input.clone(), - tool_call.output.clone(), - ) - .display_lines(width), + (Some(tool_name), Some(input)) if is_agent_task_tool_name(tool_name) => { + AgentToolCell::new( + tool_name.clone(), + ToolPhase::Running, + Some(input.clone()), + None, + tool_call.output.clone(), + Self::tool_dot_prefix(), + ) + .display_lines(width) + } + (Some(tool_name), Some(input)) => { + let title_line = tool_title_line( + ToolPhase::Running, + &tool_title_parts( + ToolPhase::Running, + Some(tool_name.as_str()), + Some(input), + &tool_call.parsed_commands, + false, + &tool_call.title, + ), + ); + ToolIoCell::from_text_output( + ToolIoCellOptions { + title_line: Some(title_line), + dot_prefix: Self::tool_dot_prefix(), + subsequent_prefix: " ".into(), + output_style: Self::tool_text_style(), + show_empty_ellipsis: false, + }, + tool_name.clone(), + input.clone(), + tool_call.output.clone(), + ) + .display_lines(width) + } _ => { - let mut lines = vec![Self::running_tool_line(&tool_call.title)]; + let title_line = tool_title_line( + ToolPhase::Running, + &tool_title_parts( + ToolPhase::Running, + tool_call.tool_name.as_deref(), + tool_call.input.as_ref(), + &tool_call.parsed_commands, + false, + &tool_call.title, + ), + ); + let mut lines = vec![title_line]; lines.extend(tool_call.lines.clone()); history_cell::AgentMessageCell::new_with_prefix( lines, - Self::pending_dot_prefix(), + Self::tool_dot_prefix(), " ", false, ) @@ -321,25 +341,60 @@ impl ChatWidget { tool_call: &super::ActiveToolCall, ) -> Vec> { match (&tool_call.tool_name, &tool_call.input) { - (Some(tool_name), Some(input)) => ToolIoCell::from_text_output( - ToolIoCellOptions { - title_line: Some(Self::running_tool_line(&tool_call.title)), - dot_prefix: Self::pending_dot_prefix(), - subsequent_prefix: " ".into(), - output_style: Self::tool_text_style(), - show_empty_ellipsis: false, - }, - tool_name.clone(), - input.clone(), - tool_call.output.clone(), - ) - .transcript_lines(width), + (Some(tool_name), Some(input)) if is_agent_task_tool_name(tool_name) => { + AgentToolCell::new( + tool_name.clone(), + ToolPhase::Running, + Some(input.clone()), + None, + tool_call.output.clone(), + Self::tool_dot_prefix(), + ) + .transcript_lines(width) + } + (Some(tool_name), Some(input)) => { + let title_line = tool_title_line( + ToolPhase::Running, + &tool_title_parts( + ToolPhase::Running, + Some(tool_name.as_str()), + Some(input), + &tool_call.parsed_commands, + false, + &tool_call.title, + ), + ); + ToolIoCell::from_text_output( + ToolIoCellOptions { + title_line: Some(title_line), + dot_prefix: Self::tool_dot_prefix(), + subsequent_prefix: " ".into(), + output_style: Self::tool_text_style(), + show_empty_ellipsis: false, + }, + tool_name.clone(), + input.clone(), + tool_call.output.clone(), + ) + .transcript_lines(width) + } _ => { - let mut lines = vec![Self::running_tool_line(&tool_call.title)]; + let title_line = tool_title_line( + ToolPhase::Running, + &tool_title_parts( + ToolPhase::Running, + tool_call.tool_name.as_deref(), + tool_call.input.as_ref(), + &tool_call.parsed_commands, + false, + &tool_call.title, + ), + ); + let mut lines = vec![title_line]; lines.extend(tool_call.lines.clone()); history_cell::AgentMessageCell::new_with_prefix( lines, - Self::pending_dot_prefix(), + Self::tool_dot_prefix(), " ", false, ) diff --git a/crates/tui/src/chatwidget/worker_events.rs b/crates/tui/src/chatwidget/worker_events.rs index 188dddef..f78047d9 100644 --- a/crates/tui/src/chatwidget/worker_events.rs +++ b/crates/tui/src/chatwidget/worker_events.rs @@ -3,7 +3,7 @@ //! This module keeps server/worker event handling out of the main chat surface //! while preserving the existing state transitions and rendering side effects. -use std::time::Instant; +use std::path::Path; use devo_protocol::CollaborationMode; use devo_protocol::ProviderRetryPhase; @@ -12,8 +12,6 @@ use devo_protocol::SessionHistoryItemKind; use devo_protocol::SessionHistoryMetadata; use devo_protocol::parse_command::ParsedCommand; use devo_protocol::protocol::ExecCommandSource; -use devo_protocol::protocol::FileChange; -use ratatui::text::Line; use crate::bottom_pane::ApprovalOverlay; use crate::bottom_pane::ApprovalOverlayRequest; @@ -24,17 +22,11 @@ use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; use crate::exec_cell::new_active_exec_command; use crate::history_cell; -use crate::tool_io_cell::FileChangeToolIoCell; -use crate::tool_io_cell::ToolIoCell; -use crate::tool_io_cell::ToolIoCellOptions; -use crate::tool_result_cell::ToolResultCell; -use devo_util_shell_command::parse_command::parse_command; use super::ActiveToolCall; use super::ChatWidget; use super::DotStatus; use super::PendingApprovalRequest; -use super::text_stream::ActiveTextItemId; fn format_retry_status_message(attempt: usize, backoff_ms: u64) -> String { let seconds = (backoff_ms as f64 / 1000.0).max(0.1); @@ -54,21 +46,26 @@ fn normalize_approval_action_summary(action_summary: String) -> String { action_summary } -fn has_visible_file_changes( - changes: &std::collections::HashMap, -) -> bool { - changes.values().any(|change| match change { - FileChange::Add { content } | FileChange::Delete { content } => !content.trim().is_empty(), - FileChange::Update { - unified_diff, - old_text, - new_text, - move_path, - } => !unified_diff.trim().is_empty() || old_text != new_text || move_path.is_some(), - }) +fn exec_call_is_unfinished(cell: &crate::exec_cell::ExecCell, tool_use_id: &str) -> bool { + cell.iter_calls() + .any(|call| call.call_id == tool_use_id && call.output.is_none()) } impl ChatWidget { + fn exec_tool_result_targets_unfinished_call(&self, tool_use_id: &str) -> bool { + let is_unfinished = |cell: &ExecCell| exec_call_is_unfinished(cell, tool_use_id); + self.active_cell + .as_ref() + .and_then(|cell| cell.as_any().downcast_ref::()) + .is_some_and(is_unfinished) + || self + .history + .iter() + .rev() + .filter_map(|cell| cell.as_any().downcast_ref::()) + .any(is_unfinished) + } + fn start_command_execution_cell( &mut self, tool_use_id: String, @@ -128,6 +125,7 @@ impl ChatWidget { title, lines: Vec::new(), output: String::new(), + parsed_commands: parsed.clone(), exec_like: true, start_time: None, }, @@ -139,6 +137,7 @@ impl ChatWidget { } self.flush_active_cell(); + let parsed_commands = parsed.clone(); let mut cell = new_active_exec_command(tool_use_id.clone(), command, parsed, source, None, true); if let Some(input) = input.clone() { @@ -156,6 +155,7 @@ impl ChatWidget { title, lines: Vec::new(), output: String::new(), + parsed_commands, exec_like: true, start_time: None, }, @@ -165,7 +165,217 @@ impl ChatWidget { self.set_status_message("Tool started"); } + fn exec_cell_has_call(&self, tool_use_id: &str) -> bool { + self.active_cell + .as_ref() + .and_then(|cell| cell.as_any().downcast_ref::()) + .is_some_and(|cell| cell.contains_call(tool_use_id)) + || self.history.iter().rev().any(|cell| { + cell.as_any() + .downcast_ref::() + .is_some_and(|cell| cell.contains_call(tool_use_id)) + }) + } + + fn exec_command_parts_for_tool( + tool: &crate::transcript::model::ToolCellModel, + cwd: &Path, + ) -> (String, Vec, Vec) { + let command = tool + .command + .clone() + .or_else(|| { + tool.input.as_ref().and_then(|input| { + input + .get("command") + .or_else(|| input.get("cmd")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + }) + .unwrap_or_else(|| { + tool.tool_name + .clone() + .unwrap_or_else(|| "exec_command".into()) + }); + let command_parts = crate::exec_command::split_command_string(&command); + let mut parsed = tool.parsed_commands.clone(); + crate::read_display::normalize_read_actions(&mut parsed, cwd); + (command, command_parts, parsed) + } + + fn update_exec_cell_call( + &mut self, + tool_use_id: &str, + command_parts: Vec, + parsed: Vec, + ) -> bool { + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && cell.update_call(tool_use_id, command_parts.clone(), parsed.clone()) + { + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.frame_requester.schedule_frame(); + return true; + } + self.history.iter_mut().rev().any(|cell| { + cell.as_any_mut() + .downcast_mut::() + .is_some_and(|cell| { + cell.update_call(tool_use_id, command_parts.clone(), parsed.clone()) + }) + }) + } + + pub(super) fn complete_exec_tool_from_committed( + &mut self, + tool: &crate::transcript::model::ToolCellModel, + ) -> bool { + if !tool.exec_like { + return false; + } + let tool_use_id = tool.tool_use_id.as_str(); + let preview = tool + .tool_display_content + .clone() + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| tool.output_preview.clone()); + let output = tool.tool_output.clone().unwrap_or_else(|| { + if preview.is_empty() { + serde_json::Value::Null + } else { + serde_json::Value::String(preview.clone()) + } + }); + let command_output = CommandOutput { + exit_code: if tool.is_error { 1 } else { 0 }, + aggregated_output: preview.clone(), + formatted_output: preview, + }; + let duration = std::time::Duration::from_millis(0); + + if let (Some(tool_name), Some(input)) = (&tool.tool_name, &tool.input) { + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + { + cell.set_tool_io_input(tool_use_id, tool_name.clone(), input.clone()); + cell.complete_tool_io( + tool_use_id, + output.clone(), + tool.tool_display_content.clone(), + ); + } + } + + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && cell.complete_call(tool_use_id, command_output.clone(), duration) + { + if cell.is_exploring_cell() { + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.frame_requester.schedule_frame(); + } else if cell.should_flush() { + self.flush_active_cell(); + } else { + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.frame_requester.schedule_frame(); + } + self.set_status_message(if tool.is_error { + "Tool returned an error" + } else { + "Tool completed" + }); + return true; + } + + for cell in self + .history + .iter_mut() + .rev() + .filter_map(|cell| cell.as_any_mut().downcast_mut::()) + { + if cell.complete_call(tool_use_id, command_output.clone(), duration) { + self.frame_requester.schedule_frame(); + return true; + } + } + + false + } + + pub(super) fn sync_exec_cells_from_projector(&mut self) { + use devo_protocol::protocol::ExecCommandSource; + + let exec_tools: Vec<_> = self + .transcript_projector + .live_tools() + .filter(|tool| tool.exec_like) + .cloned() + .collect(); + for tool in exec_tools { + let tool_use_id = tool.tool_use_id.clone(); + let (_command, command_parts, parsed) = + Self::exec_command_parts_for_tool(&tool, &self.session.cwd); + if self.exec_cell_has_call(&tool_use_id) { + let _ = self.update_exec_cell_call(&tool_use_id, command_parts, parsed); + if !tool.output_preview.is_empty() + && let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + { + let existing = cell + .iter_calls() + .find(|call| call.call_id == tool_use_id) + .and_then(|call| call.output.as_ref()) + .map(|output| output.aggregated_output.len()) + .unwrap_or(0); + if tool.output_preview.len() > existing { + let delta = tool.output_preview[existing..].to_string(); + let _ = cell.append_output(&tool_use_id, &delta); + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + } + } + } else if !self.exec_tool_result_targets_unfinished_call(&tool_use_id) + && !self.exec_cell_has_call(&tool_use_id) + { + let (command, command_parts, parsed) = + Self::exec_command_parts_for_tool(&tool, &self.session.cwd); + self.start_command_execution_cell( + tool_use_id.clone(), + command, + command_parts, + parsed, + tool.command_source.unwrap_or(ExecCommandSource::Agent), + tool.input.clone(), + ); + } else if let Some(call) = self.active_tool_calls.get_mut(&tool_use_id) + && tool.output_preview.len() > call.output.len() + { + let delta = tool.output_preview[call.output.len()..].to_string(); + call.output.push_str(&delta); + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + { + let _ = cell.append_output(&tool_use_id, &delta); + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + } + } + } + } + pub(crate) fn handle_worker_event(&mut self, event: WorkerEvent) { + if self.route_worker_event_through_projector(&event) { + return; + } match event { WorkerEvent::SessionActivated { .. } => {} WorkerEvent::TurnStarted { @@ -192,7 +402,6 @@ impl ChatWidget { self.busy = true; self.active_text_items.clear(); self.active_proposed_plan = None; - self.stream_chunking_policy.reset(); self.bottom_pane.set_task_running(true); } WorkerEvent::InterruptFailed { message } => { @@ -232,36 +441,6 @@ impl ChatWidget { } self.frame_requester.schedule_frame(); } - WorkerEvent::TextItemStarted { item_id, kind } => { - self.flush_active_cell(); - self.start_text_item(ActiveTextItemId::Server(item_id), kind); - self.set_status_message(match kind { - TextItemKind::Assistant => "Generating", - TextItemKind::Reasoning => "Thinking", - }); - } - WorkerEvent::TextItemDelta { - item_id, - kind, - delta, - } => { - self.push_text_item_delta(ActiveTextItemId::Server(item_id), kind, &delta); - self.set_status_message(match kind { - TextItemKind::Assistant => "Generating", - TextItemKind::Reasoning => "Thinking", - }); - } - WorkerEvent::TextItemCompleted { - item_id, - kind, - final_text, - } => { - self.complete_text_item(ActiveTextItemId::Server(item_id), kind, final_text); - self.set_status_message(match kind { - TextItemKind::Assistant => "Generating", - TextItemKind::Reasoning => "Thought", - }); - } WorkerEvent::ProposedPlanStarted { item_id } => { self.start_proposed_plan(item_id); } @@ -275,491 +454,31 @@ impl ChatWidget { self.complete_proposed_plan(item_id, final_text); } WorkerEvent::TextDelta(text) => { - if !self.has_server_active_item(TextItemKind::Assistant) { - self.flush_active_cell(); - self.push_text_item_delta( - ActiveTextItemId::Legacy(TextItemKind::Assistant), - TextItemKind::Assistant, - &text, - ); - } + self.apply_legacy_text_delta(TextItemKind::Assistant, text); self.set_status_message("Generating"); } WorkerEvent::ReasoningDelta(text) => { - if !self.has_server_active_item(TextItemKind::Reasoning) { - self.flush_active_cell(); - self.push_text_item_delta( - ActiveTextItemId::Legacy(TextItemKind::Reasoning), - TextItemKind::Reasoning, - &text, - ); - } + self.apply_legacy_text_delta(TextItemKind::Reasoning, text); self.set_status_message("Thinking"); } WorkerEvent::AssistantMessageCompleted(text) => { if !self.committed_server_assistant_in_turn - && !self.has_server_active_item(TextItemKind::Assistant) + && !self.has_native_text_item(TextItemKind::Assistant) && !self .active_text_items .iter() .any(|item| item.kind == TextItemKind::Assistant) { - self.complete_text_item( - ActiveTextItemId::Legacy(TextItemKind::Assistant), - TextItemKind::Assistant, - text, - ); + self.apply_legacy_text_completed(TextItemKind::Assistant, text); } self.set_status_message("Generating"); } WorkerEvent::ReasoningCompleted(text) => { - if !self.has_server_active_item(TextItemKind::Reasoning) { - self.complete_text_item( - ActiveTextItemId::Legacy(TextItemKind::Reasoning), - TextItemKind::Reasoning, - text, - ); + if !self.has_native_text_item(TextItemKind::Reasoning) { + self.apply_legacy_text_completed(TextItemKind::Reasoning, text); } self.set_status_message("Thought"); } - WorkerEvent::ToolCall { - tool_use_id, - summary, - preparing, - parsed_commands, - } => { - let command = crate::exec_command::split_command_string(&summary); - let mut parsed = parsed_commands.unwrap_or_else(|| parse_command(&command)); - crate::read_display::normalize_read_actions(&mut parsed, &self.session.cwd); - let exec_like = !parsed.is_empty() - && parsed.iter().all(|parsed| { - !matches!( - parsed, - devo_protocol::parse_command::ParsedCommand::Unknown { .. } - ) - }); - if exec_like && !preparing { - self.start_command_execution_cell( - tool_use_id, - summary, - command, - parsed, - ExecCommandSource::Agent, - None, - ); - return; - } - - let title = if preparing - && (summary.starts_with("write ") - || summary.starts_with("write:") - || summary == "apply_patch") - { - if summary == "apply_patch" { - "Preparing apply_patch...".to_string() - } else { - "Preparing write...".to_string() - } - } else { - summary - }; - let seq = self.reserve_seq(); - let tool_call = ActiveToolCall { - tool_use_id: tool_use_id.clone(), - seq, - tool_name: None, - input: None, - title: title.clone(), - lines: Vec::new(), - output: String::new(), - exec_like: false, - start_time: None, - }; - if preparing { - self.active_tool_calls.remove(&tool_use_id); - self.pending_tool_calls - .retain(|pending| pending.tool_use_id != tool_use_id); - self.pending_tool_calls.push(ActiveToolCall { - lines: Vec::new(), - start_time: Some(Instant::now()), - ..tool_call - }); - } else { - // Remove abandoned preparing entries from pending_tool_calls. - // When the agent sends a preparing ToolCall for write/apply_patch - // but then switches to a different tool, the preparing entry - // was never added to active_tool_calls and would never be - // cleaned up by ToolResultIo/ToolResult (which match by tool_use_id). - self.pending_tool_calls - .retain(|pc| self.active_tool_calls.contains_key(&pc.tool_use_id)); - self.active_tool_calls - .insert(tool_use_id.clone(), tool_call); - } - self.active_cell_revision = self.active_cell_revision.wrapping_add(1); - self.frame_requester.schedule_frame(); - self.set_status_message("Tool started"); - } - WorkerEvent::ToolCallDetails { - tool_use_id, - tool_name, - input, - } => { - if let Some(tool_call) = self.active_tool_calls.get_mut(&tool_use_id) { - tool_call.tool_name = Some(tool_name.clone()); - tool_call.input = Some(input.clone()); - } - if let Some(pending) = self - .pending_tool_calls - .iter_mut() - .find(|pending| pending.tool_use_id == tool_use_id) - { - pending.tool_name = Some(tool_name.clone()); - pending.input = Some(input.clone()); - } - let updated_active_cell = self - .active_cell - .as_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - .is_some_and(|cell| { - cell.set_tool_io_input(&tool_use_id, tool_name.clone(), input.clone()) - }); - if !updated_active_cell { - self.history.iter_mut().rev().any(|cell| { - cell.as_any_mut() - .downcast_mut::() - .is_some_and(|cell| { - cell.set_tool_io_input( - &tool_use_id, - tool_name.clone(), - input.clone(), - ) - }) - }); - } - self.active_cell_revision = self.active_cell_revision.wrapping_add(1); - self.frame_requester.schedule_frame(); - } - WorkerEvent::CommandExecutionStarted { - tool_use_id, - command, - input, - source, - mut command_actions, - } => { - let is_user_shell = matches!(&source, ExecCommandSource::UserShell); - crate::read_display::normalize_read_actions( - &mut command_actions, - &self.session.cwd, - ); - let command_parts = crate::exec_command::split_command_string(&command); - self.start_command_execution_cell( - tool_use_id, - command, - command_parts, - command_actions, - source, - input, - ); - if is_user_shell && self.active_turn_id.is_none() { - self.busy = true; - self.bottom_pane.set_task_running(true); - } - } - WorkerEvent::ToolCallUpdated { - tool_use_id, - summary, - mut parsed_commands, - } => { - crate::read_display::normalize_read_actions( - &mut parsed_commands, - &self.session.cwd, - ); - if let Some(tool_call) = self.active_tool_calls.get_mut(&tool_use_id) { - tool_call.title = summary.clone(); - tool_call.exec_like = true; - } - let command = crate::exec_command::split_command_string(&summary); - if let Some(cell) = self - .active_cell - .as_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - && cell.update_call(&tool_use_id, command.clone(), parsed_commands.clone()) - { - self.active_cell_revision = self.active_cell_revision.wrapping_add(1); - self.frame_requester.schedule_frame(); - self.set_status_message("Tool updated"); - return; - } - if self.history.iter_mut().rev().any(|cell| { - cell.as_any_mut() - .downcast_mut::() - .is_some_and(|cell| { - cell.update_call(&tool_use_id, command.clone(), parsed_commands.clone()) - }) - }) { - self.frame_requester.schedule_frame(); - self.set_status_message("Tool updated"); - } - } - WorkerEvent::ToolOutputDelta { tool_use_id, delta } => { - if let Some(tool_call) = self.active_tool_calls.get_mut(&tool_use_id) { - tool_call.output.push_str(&delta); - if tool_call.exec_like { - if let Some(cell) = self - .active_cell - .as_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - && cell.append_output(&tool_use_id, &delta) - { - self.active_cell_revision = self.active_cell_revision.wrapping_add(1); - self.frame_requester.schedule_frame(); - } - return; - } - let line = Line::from(delta).patch_style(Self::tool_text_style()); - if let Some(pending) = self - .pending_tool_calls - .iter_mut() - .find(|pending| pending.tool_use_id == tool_use_id) - { - pending.lines.push(line); - } else { - tool_call.lines.push(line); - } - self.active_cell_revision = self.active_cell_revision.wrapping_add(1); - self.frame_requester.schedule_frame(); - } - } - WorkerEvent::ToolResultIo { - tool_use_id, - tool_name, - title, - input, - output, - display_content, - is_error, - truncated, - } => { - self.pending_tool_calls - .retain(|pending| pending.tool_use_id != tool_use_id); - let dot_status = if is_error { - DotStatus::Failed - } else { - DotStatus::Completed - }; - let seq = self.reserve_seq(); - let resolved_tool_call = - self.active_tool_calls - .remove(&tool_use_id) - .unwrap_or(ActiveToolCall { - tool_use_id: tool_use_id.clone(), - seq, - tool_name: Some(tool_name.clone()), - input: Some(input.clone()), - title, - lines: Vec::new(), - output: String::new(), - exec_like: false, - start_time: None, - }); - let resolved_title = resolved_tool_call.title; - if resolved_tool_call.exec_like { - let preview = display_content.clone().unwrap_or_else(|| match &output { - serde_json::Value::String(text) => text.clone(), - other => other.to_string(), - }); - let command_output = CommandOutput { - exit_code: if is_error { 1 } else { 0 }, - aggregated_output: preview.clone(), - formatted_output: preview, - }; - let duration = std::time::Duration::from_millis(0); - if let Some(cell) = self - .active_cell - .as_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - { - cell.set_tool_io_input(&tool_use_id, tool_name.clone(), input.clone()); - cell.complete_tool_io( - &tool_use_id, - output.clone(), - display_content.clone(), - ); - if cell.complete_call(&tool_use_id, command_output.clone(), duration) { - if cell.is_exploring_cell() { - self.active_cell_revision = - self.active_cell_revision.wrapping_add(1); - self.frame_requester.schedule_frame(); - } else if cell.should_flush() { - self.flush_active_cell(); - } else { - self.active_cell_revision = - self.active_cell_revision.wrapping_add(1); - self.frame_requester.schedule_frame(); - } - self.set_status_message(if is_error { - "Tool returned an error" - } else { - "Tool completed" - }); - return; - } - } - for cell in self - .history - .iter_mut() - .rev() - .filter_map(|cell| cell.as_any_mut().downcast_mut::()) - { - cell.set_tool_io_input(&tool_use_id, tool_name.clone(), input.clone()); - cell.complete_tool_io( - &tool_use_id, - output.clone(), - display_content.clone(), - ); - if cell.complete_call(&tool_use_id, command_output.clone(), duration) { - self.frame_requester.schedule_frame(); - self.set_status_message(if is_error { - "Tool returned an error" - } else { - "Tool completed" - }); - return; - } - } - } - let title_line = - (!resolved_title.is_empty()).then(|| Self::ran_tool_line(&resolved_title)); - if title_line.is_some() - || display_content.is_some() - || !output.is_null() - || truncated - { - self.active_cell_revision = self.active_cell_revision.wrapping_add(1); - self.add_to_history(ToolIoCell::new( - ToolIoCellOptions { - title_line, - dot_prefix: self.dot_prefix(dot_status), - subsequent_prefix: Line::from(" "), - output_style: Self::tool_text_style(), - show_empty_ellipsis: truncated, - }, - tool_name, - input, - Some(output), - display_content, - )); - } - self.set_status_message(if is_error { - "Tool returned an error" - } else { - "Tool completed" - }); - } - WorkerEvent::ToolResult { - tool_use_id, - title, - preview, - is_error, - truncated, - } => { - // Remove from pending viewport entries — it will be committed to history below. - self.pending_tool_calls - .retain(|pending| pending.tool_use_id != tool_use_id); - let dot_status = if is_error { - DotStatus::Failed - } else { - DotStatus::Completed - }; - let seq = self.reserve_seq(); - let resolved_title = - self.active_tool_calls - .remove(&tool_use_id) - .unwrap_or(ActiveToolCall { - tool_use_id: tool_use_id.clone(), - seq, - tool_name: None, - input: None, - title, - lines: Vec::new(), - output: String::new(), - exec_like: false, - start_time: None, - }); - - if resolved_title.exec_like { - let output = CommandOutput { - exit_code: if is_error { 1 } else { 0 }, - aggregated_output: preview.clone(), - formatted_output: preview.clone(), - }; - let duration = std::time::Duration::from_millis(0); - if let Some(cell) = self - .active_cell - .as_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - { - let completed = cell.complete_call(&tool_use_id, output.clone(), duration); - if completed { - if cell.is_exploring_cell() { - self.active_cell_revision = - self.active_cell_revision.wrapping_add(1); - self.frame_requester.schedule_frame(); - } else if cell.should_flush() { - self.flush_active_cell(); - } else { - self.active_cell_revision = - self.active_cell_revision.wrapping_add(1); - self.frame_requester.schedule_frame(); - } - self.set_status_message(if is_error { - "Tool returned an error" - } else { - "Tool completed" - }); - return; - } - } - if let Some(cell) = self.history.iter_mut().rev().find_map(|cell| { - cell.as_any_mut() - .downcast_mut::() - .and_then(|cell| { - cell.complete_call(&tool_use_id, output.clone(), duration) - .then_some(cell) - }) - }) { - let _ = cell; - self.frame_requester.schedule_frame(); - self.set_status_message(if is_error { - "Tool returned an error" - } else { - "Tool completed" - }); - return; - } - } - - let resolved_title = resolved_title.title; - - let title_line = - (!resolved_title.is_empty()).then(|| Self::ran_tool_line(&resolved_title)); - if title_line.is_some() || !preview.is_empty() || truncated { - self.active_cell_revision = self.active_cell_revision.wrapping_add(1); - self.add_to_history(ToolResultCell::new( - title_line, - preview, - self.dot_prefix(dot_status), - Line::from(" "), - Self::tool_text_style(), - truncated, - )); - } - self.set_status_message(if is_error { - "Tool returned an error" - } else { - "Tool completed" - }); - } WorkerEvent::ShellCommandFinished { exit_code } => { let standalone_shell = self.active_turn_id.is_none(); let interrupted = exit_code.is_none(); @@ -791,38 +510,6 @@ impl ChatWidget { self.on_plan_updated(explanation, steps); self.set_status_message("Plan updated"); } - WorkerEvent::PatchAppliedIo { - tool_use_id, - tool_name, - input, - changes, - } => { - self.active_tool_calls.remove(&tool_use_id); - self.pending_tool_calls - .retain(|pending| pending.tool_use_id != tool_use_id); - if has_visible_file_changes(&changes) { - self.add_to_history(FileChangeToolIoCell::new( - Some(Self::ran_tool_line(&tool_name)), - tool_name, - input, - changes, - self.session.cwd.clone(), - )); - } - self.set_status_message("Patch applied"); - } - WorkerEvent::PatchApplied { - tool_use_id, - changes, - } => { - self.active_tool_calls.remove(&tool_use_id); - self.pending_tool_calls - .retain(|pending| pending.tool_use_id != tool_use_id); - if has_visible_file_changes(&changes) { - self.add_to_history(history_cell::new_patch_event(changes, &self.session.cwd)); - } - self.set_status_message("Patch applied"); - } WorkerEvent::ApprovalRequest { session_id, turn_id, @@ -1006,6 +693,9 @@ impl ChatWidget { }; self.commit_active_streams(stream_status); } + if !failed_turn_was_finalized { + self.clear_turn_live_projection(); + } if !failed_turn_was_finalized && (was_interrupted || was_failed) && let Some(cell) = self @@ -1113,6 +803,7 @@ impl ChatWidget { let failed_turn_was_finalized = self.failed_turn_visually_finalized; if !failed_turn_was_finalized { self.commit_active_streams(DotStatus::Failed); + self.clear_turn_live_projection(); if let Some(cell) = self .active_cell .as_mut() @@ -1391,7 +1082,6 @@ impl ChatWidget { self.editing_queue_item_id = None; self.bottom_pane.clear_pending_cells(); self.seen_approval_decisions.clear(); - self.stream_chunking_policy.reset(); self.busy = false; self.turn_count = 0; self.total_input_tokens = 0; @@ -1432,6 +1122,7 @@ impl ChatWidget { collaboration_mode, permission_preset, effective_context_window, + last_context_occupancy, } => { self.finish_session_resume(); self.session.cwd = cwd; @@ -1465,14 +1156,13 @@ impl ChatWidget { self.queued_input_modes.clear(); self.promoted_input_modes.clear(); self.editing_queue_item_id = None; - self.stream_chunking_policy.reset(); self.total_input_tokens = total_input_tokens; self.total_output_tokens = total_output_tokens; self.total_cache_read_tokens = total_cache_read_tokens; self.last_query_total_tokens = last_query_total_tokens; self.last_query_input_tokens = last_query_input_tokens; self.prompt_token_estimate = prompt_token_estimate; - self.last_context_occupancy = None; + self.last_context_occupancy = last_context_occupancy; self.effective_context_window = effective_context_window; if !self.rebuild_restored_session_history_from_rich_items( &rich_history_items, @@ -1500,6 +1190,9 @@ impl ChatWidget { } else { self.set_status_message("Session switched"); } + self.sync_bottom_pane_summary(); + self.refresh_header_box(); + self.frame_requester.schedule_frame(); } WorkerEvent::GoalStatusLoaded { goal } => { self.show_goal_status(goal); @@ -1670,6 +1363,7 @@ impl ChatWidget { WorkerEvent::SteerAccepted { .. } => { self.set_status_message("Steer accepted"); } + WorkerEvent::Transcript(_) => {} } } diff --git a/crates/tui/src/chatwidget_tail_follow_tests.rs b/crates/tui/src/chatwidget_tail_follow_tests.rs index 26bea43e..4d88db84 100644 --- a/crates/tui/src/chatwidget_tail_follow_tests.rs +++ b/crates/tui/src/chatwidget_tail_follow_tests.rs @@ -60,12 +60,7 @@ fn line_text(line: &Line<'static>) -> String { } fn drain_assistant_stream(widget: &mut ChatWidget) { - for _ in 0..16 { - if widget.assistant_stream_queued_lines_for_test() == 0 { - break; - } - widget.pre_draw_tick(); - } + widget.pre_draw_tick(); } #[test] @@ -87,17 +82,17 @@ fn overflowing_live_assistant_viewport_follows_latest_tail() { reasoning_effort: None, turn_id: Default::default(), }); - widget.handle_worker_event(WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: TextItemKind::Assistant, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + assistant_id, + TextItemKind::Assistant, + )); for index in 0..28 { - widget.handle_worker_event(WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: TextItemKind::Assistant, - delta: format!("stream-tail-line-{index:02}\n"), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + assistant_id, + TextItemKind::Assistant, + format!("stream-tail-line-{index:02}\n"), + )); widget.pre_draw_tick(); drain_assistant_stream(&mut widget); } @@ -148,17 +143,17 @@ fn working_keeps_content_sized_height_and_grows_with_stream() { assert!(widget.desired_height(80) >= idle_height); let assistant_id = ItemId::new(); - widget.handle_worker_event(WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: TextItemKind::Assistant, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + assistant_id, + TextItemKind::Assistant, + )); let height_before_stream = widget.desired_height(80); for index in 0..8 { - widget.handle_worker_event(WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: TextItemKind::Assistant, - delta: format!("pin-line-{index}\n"), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + assistant_id, + TextItemKind::Assistant, + format!("pin-line-{index}\n"), + )); widget.pre_draw_tick(); drain_assistant_stream(&mut widget); } diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index cd20ce47..a802de8c 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -250,6 +250,24 @@ fn line_texts(lines: Vec>) -> Vec { .collect() } +fn transcript_overlay_text(widget: &ChatWidget, width: u16) -> String { + line_texts(widget.transcript_overlay_lines(width)).join("\n") +} + +fn finalize_live_turn_for_history(widget: &mut ChatWidget) { + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "Completed".to_string(), + turn_count: 1, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + }); +} + fn indices_containing(lines: &[String], needles: &[&str]) -> Vec { needles .iter() @@ -736,6 +754,7 @@ fn session_switched_clears_resume_blocking_state() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); assert!(!widget.is_resuming_session_for_test()); @@ -1045,20 +1064,20 @@ fn approval_request_does_not_duplicate_already_committed_assistant_text() { let item_id = ItemId::new(); let text = "明白,我来随便加点内容,测试一下 apply_patch。".to_string(); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( item_id, - kind: crate::events::TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + crate::events::TextItemKind::Assistant, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( item_id, - kind: crate::events::TextItemKind::Assistant, - delta: text.clone(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { + crate::events::TextItemKind::Assistant, + text.clone(), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_completed( item_id, - kind: crate::events::TextItemKind::Assistant, - final_text: text.clone(), - }); + crate::events::TextItemKind::Assistant, + text.clone(), + )); widget.handle_worker_event(crate::events::WorkerEvent::AssistantMessageCompleted( text.clone(), )); @@ -1651,15 +1670,15 @@ fn queued_prompt_promotes_after_active_assistant_stream() { turn_id: TurnId::new(), }); let item_id = ItemId::new(); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( item_id, - kind: TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + TextItemKind::Assistant, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( item_id, - kind: TextItemKind::Assistant, - delta: "assistant before promotion".to_string(), - }); + TextItemKind::Assistant, + "assistant before promotion".to_string(), + )); paste_and_submit(&mut widget, "queued prompt"); let queue_item_id = devo_protocol::native::ids::QueueItemId::from_string("qit_prompt".into()); @@ -1691,11 +1710,11 @@ fn queued_prompt_promotes_after_active_assistant_stream() { !widget.bottom_pane_has_pending_for_test(), "drained entry should leave the pending queue UI" ); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_completed( item_id, - kind: TextItemKind::Assistant, - final_text: "assistant before promotion".to_string(), - }); + TextItemKind::Assistant, + "assistant before promotion".to_string(), + )); let history = scrollback_plain_lines(&widget.drain_scrollback_lines(100)); assert!( @@ -3016,15 +3035,15 @@ fn proposed_plan_keeps_assistant_preamble_before_plan() { let assistant_id = ItemId::new(); let plan_id = ItemId::new(); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: TextItemKind::Assistant, - delta: "现在我已经了解了代码库。以下是计划:\n".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + assistant_id, + TextItemKind::Assistant, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + assistant_id, + TextItemKind::Assistant, + "现在我已经了解了代码库。以下是计划:\n".to_string(), + )); widget .handle_worker_event(crate::events::WorkerEvent::ProposedPlanStarted { item_id: plan_id }); widget.handle_worker_event(crate::events::WorkerEvent::ProposedPlanDelta { @@ -3061,26 +3080,26 @@ fn proposed_plan_completion_does_not_duplicate_boundary_preamble() { let assistant_id = ItemId::new(); let plan_id = ItemId::new(); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: TextItemKind::Assistant, - delta: "Intro before plan.\n".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + assistant_id, + TextItemKind::Assistant, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + assistant_id, + TextItemKind::Assistant, + "Intro before plan.\n".to_string(), + )); widget .handle_worker_event(crate::events::WorkerEvent::ProposedPlanStarted { item_id: plan_id }); widget.handle_worker_event(crate::events::WorkerEvent::ProposedPlanCompleted { item_id: plan_id, final_text: "## Summary\n\nBuild the feature.".to_string(), }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { - item_id: assistant_id, - kind: TextItemKind::Assistant, - final_text: "Intro before plan.\n".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_completed( + assistant_id, + TextItemKind::Assistant, + "Intro before plan.\n".to_string(), + )); let rendered = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); assert_eq!(rendered.matches("Intro before plan.").count(), 1); @@ -3144,6 +3163,7 @@ fn session_switch_restores_plan_mode_and_proposed_plan_actions() { collaboration_mode: CollaborationMode::Plan, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); assert_eq!( @@ -3214,6 +3234,7 @@ fn session_switch_restores_plan_turn_summary_label() { collaboration_mode: CollaborationMode::Plan, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); assert_eq!( @@ -3292,6 +3313,7 @@ fn session_switch_restores_context_compaction_info_row() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let rendered = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -3353,6 +3375,7 @@ fn session_switch_after_implement_stays_in_build_without_plan_actions() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); assert_eq!( @@ -3510,6 +3533,7 @@ fn session_switch_restores_plan_metadata_into_progress() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); assert_eq!(widget.last_plan_progress_for_test(), Some((1, 2))); @@ -3563,6 +3587,7 @@ fn session_switch_restores_explored_metadata_into_history() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -3629,6 +3654,7 @@ fn session_switch_restores_edited_metadata_into_history() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -3703,6 +3729,7 @@ fn session_switch_merges_consecutive_explored_items() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -3716,7 +3743,8 @@ fn session_switch_merges_consecutive_explored_items() { "expected read entry, got:\n{blob}" ); assert!( - blob.contains("Search command_actions in crates/tui/src/worker.rs"), + blob.contains("Grepped command_actions in crates/tui/src/worker.rs") + || blob.contains("Grepping command_actions in crates/tui/src/worker.rs"), "expected search entry, got:\n{blob}" ); } @@ -3763,6 +3791,7 @@ fn session_switch_restores_error_via_tool_result_cell_style() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -3847,6 +3876,7 @@ fn rich_session_restore_orders_terminal_error_before_single_failed_footer() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let history = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -3882,13 +3912,13 @@ fn live_and_resume_error_share_same_rendering_chain() { let (mut live_widget, _live_rx) = widget_with_model(model.clone(), PathBuf::from(".")); let (mut resume_widget, _resume_rx) = widget_with_model(model, PathBuf::from(".")); - live_widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "bash error".to_string(), - preview: "permission denied".to_string(), - is_error: true, - truncated: false, - }); + live_widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "bash error".to_string(), + "permission denied".to_string(), + true, + false, + )); let live_blob = scrollback_plain_lines(&live_widget.drain_scrollback_lines(80)) .into_iter() .filter(|line| line.contains("Ran bash error") || line.contains("permission denied")) @@ -3927,6 +3957,7 @@ fn live_and_resume_error_share_same_rendering_chain() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let resume_blob = scrollback_plain_lines(&resume_widget.drain_scrollback_lines(80)) .into_iter() @@ -3940,6 +3971,361 @@ fn live_and_resume_error_share_same_rendering_chain() { ); } +#[test] +fn live_and_resume_native_grep_history_share_same_rendering_chain() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let cwd = PathBuf::from("."); + let (mut live_widget, _) = widget_with_model(model.clone(), cwd.clone()); + let (mut resume_widget, _) = widget_with_model(model, cwd); + + let grep_input = serde_json::json!({"pattern": "plan", "path": "crates"}); + live_widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_details( + "grep-1".to_string(), + "grep".to_string(), + grep_input.clone(), + )); + live_widget.handle_worker_event(crate::worker_event_test_helpers::tool_result_io( + "grep-1".to_string(), + "grep".to_string(), + "grep".to_string(), + grep_input, + serde_json::Value::String("src/lib.rs".to_string()), + None, + false, + false, + )); + finalize_live_turn_for_history(&mut live_widget); + + resume_widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: std::env::current_dir().expect("current directory is available"), + title: None, + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + history_items: vec![], + rich_history_items: vec![ + devo_protocol::SessionHistoryItem { + tool_call_id: Some("grep-1".to_string()), + kind: devo_protocol::SessionHistoryItemKind::ToolCall, + title: "grep".to_string(), + body: String::new(), + tool_io: Some(devo_protocol::SessionHistoryToolIo { + tool_name: "grep".to_string(), + input: serde_json::json!({"pattern": "plan", "path": "crates"}), + output: None, + display_content: None, + }), + metadata: Some(devo_protocol::SessionHistoryMetadata::Explored { + actions: vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep".to_string(), + query: Some("plan".to_string()), + path: Some("crates".to_string()), + }], + }), + duration_ms: None, + }, + devo_protocol::SessionHistoryItem { + tool_call_id: Some("grep-1".to_string()), + kind: devo_protocol::SessionHistoryItemKind::ToolResult, + title: String::new(), + body: "src/lib.rs".to_string(), + tool_io: Some(devo_protocol::SessionHistoryToolIo { + tool_name: String::new(), + input: serde_json::Value::Null, + output: Some(serde_json::Value::String("src/lib.rs".to_string())), + display_content: None, + }), + metadata: None, + duration_ms: None, + }, + ], + loaded_item_count: 2, + pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, + last_context_occupancy: None, + }); + + let filter_explore = |line: &str| { + line.contains("Explored") + || line.contains("Grepped") + || line.contains("plan") + || line.contains("src/lib.rs") + }; + let live_blob = scrollback_plain_lines(&live_widget.drain_scrollback_lines(100)) + .into_iter() + .filter(|line| filter_explore(line)) + .collect::>() + .join("\n"); + let resume_blob = scrollback_plain_lines(&resume_widget.drain_scrollback_lines(100)) + .into_iter() + .filter(|line| filter_explore(line)) + .collect::>() + .join("\n"); + + assert_eq!( + live_blob, resume_blob, + "live and resume native grep history diverged" + ); +} + +#[test] +fn live_and_resume_paired_read_tool_io_share_same_rendering_chain() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let cwd = PathBuf::from("."); + let (mut live_widget, _) = widget_with_model(model.clone(), cwd.clone()); + let (mut resume_widget, _) = widget_with_model(model, cwd); + + let read_input = serde_json::json!({"path": "src/lib.rs", "offset": 10, "limit": 3}); + live_widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_details( + "read-1".to_string(), + "read".to_string(), + read_input.clone(), + )); + live_widget.handle_worker_event(crate::worker_event_test_helpers::tool_result_io( + "read-1".to_string(), + "read".to_string(), + "read".to_string(), + read_input, + serde_json::Value::String("restored line 1\nrestored line 2".to_string()), + None, + false, + false, + )); + finalize_live_turn_for_history(&mut live_widget); + + resume_widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: std::env::current_dir().expect("current directory is available"), + title: None, + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + history_items: vec![], + rich_history_items: vec![ + devo_protocol::SessionHistoryItem { + tool_call_id: Some("read-1".to_string()), + kind: devo_protocol::SessionHistoryItemKind::ToolCall, + title: "read src/lib.rs".to_string(), + body: String::new(), + tool_io: Some(devo_protocol::SessionHistoryToolIo { + tool_name: "read".to_string(), + input: serde_json::json!({"path": "src/lib.rs", "offset": 10, "limit": 3}), + output: None, + display_content: None, + }), + metadata: Some(devo_protocol::SessionHistoryMetadata::Explored { + actions: vec![devo_protocol::parse_command::ParsedCommand::Read { + cmd: "read src/lib.rs".to_string(), + name: "src/lib.rs L:10-12".to_string(), + path: PathBuf::from("src/lib.rs"), + }], + }), + duration_ms: None, + }, + devo_protocol::SessionHistoryItem { + tool_call_id: Some("read-1".to_string()), + kind: devo_protocol::SessionHistoryItemKind::ToolResult, + title: "read output".to_string(), + body: "legacy preview".to_string(), + tool_io: Some(devo_protocol::SessionHistoryToolIo { + tool_name: "read".to_string(), + input: serde_json::Value::Null, + output: Some(serde_json::Value::String( + "restored line 1\nrestored line 2".to_string(), + )), + display_content: None, + }), + metadata: None, + duration_ms: None, + }, + ], + loaded_item_count: 2, + pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, + last_context_occupancy: None, + }); + + let filter_read = |line: &str| { + line.contains("worker.rs") || line.contains("restored line") || line.contains("Explored") + }; + let live_blob = scrollback_plain_lines(&live_widget.drain_scrollback_lines(100)) + .into_iter() + .filter(|line| filter_read(line)) + .collect::>() + .join("\n"); + let resume_blob = scrollback_plain_lines(&resume_widget.drain_scrollback_lines(100)) + .into_iter() + .filter(|line| filter_read(line)) + .collect::>() + .join("\n"); + + assert_eq!( + live_blob, resume_blob, + "live and resume paired read tool_io history diverged" + ); +} + +#[test] +fn live_and_resume_consecutive_explore_history_share_same_rendering_chain() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let cwd = PathBuf::from("."); + let (mut live_widget, _) = widget_with_model(model.clone(), cwd.clone()); + let (mut resume_widget, _) = widget_with_model(model, cwd); + + live_widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "call-1".to_string(), + "read crates/tui/src/worker.rs".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Read { + cmd: "read crates/tui/src/worker.rs".to_string(), + name: "worker.rs".to_string(), + path: PathBuf::from("crates/tui/src/worker.rs"), + }]), + )); + live_widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "call-1".to_string(), + "read crates/tui/src/worker.rs".to_string(), + String::new(), + false, + false, + )); + live_widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "call-2".to_string(), + "grep command_actions in crates/tui/src/worker.rs".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep command_actions in crates/tui/src/worker.rs".to_string(), + query: Some("command_actions".to_string()), + path: Some("crates/tui/src/worker.rs".to_string()), + }]), + )); + live_widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "call-2".to_string(), + "grep command_actions in crates/tui/src/worker.rs".to_string(), + String::new(), + false, + false, + )); + finalize_live_turn_for_history(&mut live_widget); + + resume_widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-1".to_string(), + cwd: std::env::current_dir().expect("current directory is available"), + title: None, + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + history_items: vec![], + rich_history_items: vec![ + devo_protocol::SessionHistoryItem { + tool_call_id: Some("call-1".to_string()), + kind: devo_protocol::SessionHistoryItemKind::ToolCall, + title: "read crates/tui/src/worker.rs".to_string(), + body: String::new(), + tool_io: None, + metadata: Some(devo_protocol::SessionHistoryMetadata::Explored { + actions: vec![devo_protocol::parse_command::ParsedCommand::Read { + cmd: "read crates/tui/src/worker.rs".to_string(), + name: "worker.rs".to_string(), + path: PathBuf::from("crates/tui/src/worker.rs"), + }], + }), + duration_ms: None, + }, + devo_protocol::SessionHistoryItem { + tool_call_id: Some("call-2".to_string()), + kind: devo_protocol::SessionHistoryItemKind::ToolCall, + title: "grep command_actions in crates/tui/src/worker.rs".to_string(), + body: String::new(), + tool_io: None, + metadata: Some(devo_protocol::SessionHistoryMetadata::Explored { + actions: vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep command_actions in crates/tui/src/worker.rs".to_string(), + query: Some("command_actions".to_string()), + path: Some("crates/tui/src/worker.rs".to_string()), + }], + }), + duration_ms: None, + }, + ], + loaded_item_count: 2, + pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, + permission_preset: None, + effective_context_window: None, + last_context_occupancy: None, + }); + + let explore_action_lines = |blob: &str| { + blob.lines() + .map(str::trim) + .filter(|line| { + !line.is_empty() + && (line.starts_with("Read ") + || line.starts_with("Grepped ") + || line.starts_with("Finding ") + || line.starts_with("Found ")) + }) + .collect::>() + .join("\n") + }; + let live_blob = explore_action_lines( + &scrollback_plain_lines(&live_widget.drain_scrollback_lines(120)).join("\n"), + ); + let resume_blob = explore_action_lines( + &scrollback_plain_lines(&resume_widget.drain_scrollback_lines(120)).join("\n"), + ); + + assert_eq!( + live_blob, resume_blob, + "live and resume consecutive explore history diverged:\nlive:\n{live_blob}\nresume:\n{resume_blob}" + ); +} + #[test] fn startup_header_mascot_animation_advances_on_pre_draw_tick() { let cwd = std::env::current_dir().expect("current directory is available"); @@ -4601,6 +4987,7 @@ fn session_switch_restores_header_and_spacing_before_user_input() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let committed_lines = widget.drain_scrollback_lines(80); @@ -4692,6 +5079,7 @@ fn restored_user_spacing_matches_live_turn_batch_spacing() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let restored_rows = scrollback_plain_lines(&restored_widget.drain_scrollback_lines(80)); @@ -4768,6 +5156,7 @@ fn rich_session_switch_restores_user_spacing_before_assistant_response() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let committed_rows = scrollback_plain_lines(&widget.drain_scrollback_lines(80)); @@ -4868,23 +5257,23 @@ fn user_shell_command_renders_direct_output_and_shell_summary() { let (mut widget, _app_event_rx) = widget_with_model(model, cwd); let _ = widget.drain_scrollback_lines(100); - widget.handle_worker_event(crate::events::WorkerEvent::CommandExecutionStarted { - tool_use_id: "user-shell-1".to_string(), - command: "ls".to_string(), - input: None, - source: devo_protocol::protocol::ExecCommandSource::UserShell, - command_actions: vec![devo_protocol::parse_command::ParsedCommand::ListFiles { + widget.handle_worker_event(crate::worker_event_test_helpers::command_execution_started( + "user-shell-1".to_string(), + "ls".to_string(), + None, + devo_protocol::protocol::ExecCommandSource::UserShell, + vec![devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "ls".to_string(), path: None, }], - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolOutputDelta { - tool_use_id: "user-shell-1".to_string(), - delta: "Cargo.toml + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_output_delta( + "user-shell-1".to_string(), + "Cargo.toml crates " .to_string(), - }); + )); let live = rendered_rows(&widget, 100, 16).join( " @@ -4911,16 +5300,16 @@ crates {live}" ); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "user-shell-1".to_string(), - title: "ls".to_string(), - preview: "Cargo.toml + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "user-shell-1".to_string(), + "ls".to_string(), + "Cargo.toml crates " .to_string(), - is_error: false, - truncated: false, - }); + false, + false, + )); widget.handle_worker_event(crate::events::WorkerEvent::ShellCommandFinished { exit_code: Some(0), }); @@ -4967,46 +5356,46 @@ fn two_shell_commands_render_as_separate_prompt_cells() { let (mut widget, _app_event_rx) = widget_with_model(model, cwd); let _ = widget.drain_scrollback_lines(100); - widget.handle_worker_event(crate::events::WorkerEvent::CommandExecutionStarted { - tool_use_id: "user-shell-1".to_string(), - command: "pwd".to_string(), - input: None, - source: devo_protocol::protocol::ExecCommandSource::UserShell, - command_actions: Vec::new(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolOutputDelta { - tool_use_id: "user-shell-1".to_string(), - delta: "/tmp/project\n".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "user-shell-1".to_string(), - title: "Shell".to_string(), - preview: "/tmp/project\n".to_string(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::command_execution_started( + "user-shell-1".to_string(), + "pwd".to_string(), + None, + devo_protocol::protocol::ExecCommandSource::UserShell, + Vec::new(), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_output_delta( + "user-shell-1".to_string(), + "/tmp/project\n".to_string(), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "user-shell-1".to_string(), + "Shell".to_string(), + "/tmp/project\n".to_string(), + false, + false, + )); widget.handle_worker_event(crate::events::WorkerEvent::ShellCommandFinished { exit_code: Some(0), }); - widget.handle_worker_event(crate::events::WorkerEvent::CommandExecutionStarted { - tool_use_id: "user-shell-2".to_string(), - command: "whoami".to_string(), - input: None, - source: devo_protocol::protocol::ExecCommandSource::UserShell, - command_actions: Vec::new(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolOutputDelta { - tool_use_id: "user-shell-2".to_string(), - delta: "tsiao\n".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "user-shell-2".to_string(), - title: "Shell".to_string(), - preview: "tsiao\n".to_string(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::command_execution_started( + "user-shell-2".to_string(), + "whoami".to_string(), + None, + devo_protocol::protocol::ExecCommandSource::UserShell, + Vec::new(), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_output_delta( + "user-shell-2".to_string(), + "tsiao\n".to_string(), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "user-shell-2".to_string(), + "Shell".to_string(), + "tsiao\n".to_string(), + false, + false, + )); widget.handle_worker_event(crate::events::WorkerEvent::ShellCommandFinished { exit_code: Some(0), }); @@ -5328,39 +5717,54 @@ fn tool_call_start_and_finish_are_both_visible_in_history() { reasoning_effort: None, turn_id: Default::default(), }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "powershell -NoProfile -Command Get-Date".to_string(), - preparing: false, - parsed_commands: None, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "powershell -NoProfile -Command Get-Date".to_string(), + false, + None, + )); let running = rendered_rows(&widget, 80, 12).join("\n"); assert!( - running.contains("Running powershell -NoProfile -Command Get-Date"), + running.contains("Running Get-Date"), "expected running tool cell, got:\n{running}" ); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "powershell -NoProfile -Command Get-Date".to_string(), - preview: "2026-05-09".to_string(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "powershell -NoProfile -Command Get-Date".to_string(), + "2026-05-09".to_string(), + false, + false, + )); let ran = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( - !ran.contains("Running powershell -NoProfile -Command Get-Date"), + !ran.contains("Running Get-Date"), "running tool cell should not remain in history, got:\n{ran}" ); assert!( - ran.contains("Ran powershell -NoProfile -Command Get-Date"), + ran.contains("Ran Get-Date"), "expected ran tool cell, got:\n{ran}" ); assert!( - ran.contains("2026-05-09"), - "expected tool output, got:\n{ran}" + !ran.contains("2026-05-09"), + "shell output should stay out of inline scrollback, got:\n{ran}" + ); + let transcript = widget + .transcript_overlay_lines(80) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!( + transcript.contains("2026-05-09"), + "shell output should appear in transcript overlay, got:\n{transcript}" ); } @@ -5383,12 +5787,12 @@ fn web_search_tool_call_renders_title_and_status_without_running_prefix() { reasoning_effort: None, turn_id: Default::default(), }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "Web Search(\"latest OpenAI API docs\")".to_string(), - preparing: false, - parsed_commands: None, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "Web Search(\"latest OpenAI API docs\")".to_string(), + false, + None, + )); let running = rendered_rows(&widget, 80, 12).join( " @@ -5405,13 +5809,13 @@ fn web_search_tool_call_renders_title_and_status_without_running_prefix() { {running}" ); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "Web Search(\"latest OpenAI API docs\")".to_string(), - preview: "status: completed".to_string(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "Web Search(\"latest OpenAI API docs\")".to_string(), + "status: completed".to_string(), + false, + false, + )); let rendered = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join( " @@ -5453,12 +5857,12 @@ fn web_fetch_tool_call_renders_title_and_status_without_running_prefix() { reasoning_effort: None, turn_id: Default::default(), }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "Web Fetch(\"https://example.test/docs\")".to_string(), - preparing: false, - parsed_commands: None, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "Web Fetch(\"https://example.test/docs\")".to_string(), + false, + None, + )); let running = rendered_rows(&widget, 80, 12).join( " @@ -5475,13 +5879,13 @@ fn web_fetch_tool_call_renders_title_and_status_without_running_prefix() { {running}" ); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "Web Fetch(\"https://example.test/docs\")".to_string(), - preview: "status: completed".to_string(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "Web Fetch(\"https://example.test/docs\")".to_string(), + "status: completed".to_string(), + false, + false, + )); let rendered = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join( " @@ -5514,12 +5918,12 @@ fn preparing_write_tool_call_is_visible_before_result() { }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "write src/lib.rs".to_string(), - preparing: true, - parsed_commands: None, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "write src/lib.rs".to_string(), + true, + None, + )); let display = rendered_rows(&widget, 80, 12).join("\n"); assert!( @@ -5538,12 +5942,12 @@ fn non_preparing_tool_call_keeps_existing_summary() { }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "grep 'plan' in crates".to_string(), - preparing: false, - parsed_commands: None, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "grep 'plan' in crates".to_string(), + false, + None, + )); let display = rendered_rows(&widget, 80, 12).join("\n"); assert!( @@ -5566,12 +5970,12 @@ fn generic_running_tool_call_disappears_after_result() { }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "code_search".to_string(), - preparing: false, - parsed_commands: Some(Vec::new()), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "code_search".to_string(), + false, + Some(Vec::new()), + )); let running = rendered_rows(&widget, 80, 12).join("\n"); assert!( @@ -5579,13 +5983,13 @@ fn generic_running_tool_call_disappears_after_result() { "expected running generic tool row:\n{running}" ); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "code_search".to_string(), - preview: "Missing necessary parameter display".to_string(), - is_error: true, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "code_search".to_string(), + "Missing necessary parameter display".to_string(), + true, + false, + )); let rendered = rendered_rows(&widget, 80, 16).join("\n"); assert!( @@ -5618,26 +6022,26 @@ fn edit_running_row_is_path_free_and_disappears_after_patch_result() { }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "edit-1".to_string(), - summary: "Edit".to_string(), - preparing: false, - parsed_commands: None, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCallDetails { - tool_use_id: "edit-1".to_string(), - tool_name: "edit".to_string(), - input: serde_json::json!({"filePath": "test_edit_test.md"}), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "edit-1".to_string(), + "Edit".to_string(), + false, + None, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_details( + "edit-1".to_string(), + "edit".to_string(), + serde_json::json!({"filePath": "test_edit_test.md"}), + )); let running = rendered_rows(&widget, 80, 12).join("\n"); assert!( - running.contains("Running Edit"), + running.contains("Editing") || running.contains("Preparing edit"), "expected live Edit row:\n{running}" ); assert!( - !running.contains("test_edit_test.md"), - "live Edit row should not repeat the path:\n{running}" + running.contains("test_edit_test.md"), + "live Edit row should show the path:\n{running}" ); let mut changes = std::collections::HashMap::new(); @@ -5650,16 +6054,16 @@ fn edit_running_row_is_path_free_and_disappears_after_patch_result() { move_path: None, }, ); - widget.handle_worker_event(crate::events::WorkerEvent::PatchAppliedIo { - tool_use_id: "edit-1".to_string(), - tool_name: "edit".to_string(), - input: serde_json::json!({"filePath": "test_edit_test.md"}), + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied_io( + "edit-1".to_string(), + "edit".to_string(), + serde_json::json!({"filePath": "test_edit_test.md"}), changes, - }); + )); let after = rendered_rows(&widget, 80, 16).join("\n"); assert!( - !after.contains("Running Edit"), + !after.contains("Editing"), "completed Edit should leave no live row:\n{after}" ); let history = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -5679,27 +6083,27 @@ fn patch_result_removes_only_matching_running_tool_row() { }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "edit-1".to_string(), - summary: "Edit".to_string(), - preparing: false, - parsed_commands: None, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "search-1".to_string(), - summary: "code_search".to_string(), - preparing: false, - parsed_commands: Some(Vec::new()), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "edit-1".to_string(), + "Edit".to_string(), + false, + None, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "search-1".to_string(), + "code_search".to_string(), + false, + Some(Vec::new()), + )); - widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { - tool_use_id: "edit-1".to_string(), - changes: std::collections::HashMap::new(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied( + "edit-1".to_string(), + std::collections::HashMap::new(), + )); let after = rendered_rows(&widget, 80, 16).join("\n"); assert!( - !after.contains("Running Edit"), + !after.contains("Running Edit") && !after.contains("Editing"), "Edit row should be removed:\n{after}" ); assert!( @@ -5727,23 +6131,23 @@ fn interrupted_turn_flushes_explored_cell_before_summary() { reasoning_effort: None, turn_id: Default::default(), }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "code_search update_plan tool handler".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "code_search update_plan tool handler".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "code_search update_plan tool handler".to_string(), query: Some("update_plan tool handler".to_string()), path: Some("crates/core/src/tools/handlers".to_string()), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "code_search update_plan tool handler".to_string(), - preview: "crates/core/src/tools/handlers/plan.rs".to_string(), - is_error: false, - truncated: false, - }); + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "code_search update_plan tool handler".to_string(), + "crates/core/src/tools/handlers/plan.rs".to_string(), + false, + false, + )); let live_display = rendered_rows(&widget, 100, 12).join("\n"); assert!( @@ -5804,23 +6208,23 @@ fn widget_with_live_explored_cell() -> ChatWidget { reasoning_effort: None, turn_id: TurnId::new(), }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "code_search update_plan tool handler".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "code_search update_plan tool handler".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "code_search update_plan tool handler".to_string(), query: Some("update_plan tool handler".to_string()), path: Some("crates/core/src/tools/handlers".to_string()), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "code_search update_plan tool handler".to_string(), - preview: "crates/core/src/tools/handlers/plan.rs".to_string(), - is_error: false, - truncated: false, - }); + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "code_search update_plan tool handler".to_string(), + "crates/core/src/tools/handlers/plan.rs".to_string(), + false, + false, + )); let live_display = rendered_rows(&widget, 100, 12).join("\n"); assert!( @@ -6041,12 +6445,12 @@ fn preparing_write_disappears_after_patch_applied() { }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "write src/lib.rs".to_string(), - preparing: true, - parsed_commands: None, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "write src/lib.rs".to_string(), + true, + None, + )); let before = rendered_rows(&widget, 80, 12).join("\n"); assert!( before.contains("Preparing write..."), @@ -6060,10 +6464,10 @@ fn preparing_write_disappears_after_patch_applied() { content: "pub fn demo() {}\n".to_string(), }, ); - widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { - tool_use_id: "tool-1".to_string(), + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied( + "tool-1".to_string(), changes, - }); + )); let after = rendered_rows(&widget, 80, 16).join("\n"); assert!( @@ -6088,12 +6492,12 @@ fn preparing_apply_patch_tool_call_is_visible_before_result() { }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "apply_patch".to_string(), - preparing: true, - parsed_commands: None, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "apply_patch".to_string(), + true, + None, + )); let display = rendered_rows(&widget, 80, 12).join("\n"); assert!( @@ -6112,12 +6516,12 @@ fn preparing_apply_patch_disappears_after_patch_applied() { }; let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "apply_patch".to_string(), - preparing: true, - parsed_commands: None, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "apply_patch".to_string(), + true, + None, + )); let before = rendered_rows(&widget, 80, 12).join("\n"); assert!( before.contains("Preparing apply_patch..."), @@ -6131,10 +6535,10 @@ fn preparing_apply_patch_disappears_after_patch_applied() { content: "pub fn demo() {}\n".to_string(), }, ); - widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { - tool_use_id: "tool-1".to_string(), + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied( + "tool-1".to_string(), changes, - }); + )); let after = rendered_rows(&widget, 80, 16).join("\n"); assert!( @@ -6143,32 +6547,6 @@ fn preparing_apply_patch_disappears_after_patch_applied() { ); } -#[test] -fn preparing_tool_row_animates_with_pre_draw_tick() { - let cwd = std::env::current_dir().expect("current directory is available"); - let model = Model { - slug: "test-model".to_string(), - display_name: "Test Model".to_string(), - ..Model::default() - }; - let (mut widget, _app_event_rx) = widget_with_model(model, cwd); - - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "write src/lib.rs".to_string(), - preparing: true, - parsed_commands: None, - }); - let before = rendered_rows(&widget, 80, 12).join("\n"); - std::thread::sleep(std::time::Duration::from_millis(80)); - widget.pre_draw_tick(); - let after = rendered_rows(&widget, 80, 12).join("\n"); - assert_ne!( - before, after, - "expected preparing row to animate across ticks" - ); -} - #[test] fn reasoning_text_commits_to_history_when_turn_finishes() { let cwd = std::env::current_dir().expect("current directory is available"); @@ -6253,6 +6631,7 @@ fn restored_reasoning_text_is_visible_in_transcript() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let scrollback = widget.drain_scrollback_lines(80); @@ -6327,33 +6706,140 @@ fn reasoning_and_assistant_stream_in_separate_cells() { "thinking".to_string(), )); - // Reasoning is now committed to scrollback on ReasoningCompleted, - // no longer visible in the live viewport. - let after = rendered_rows(&widget, 80, 16).join("\n"); + // Reasoning is now committed to scrollback on ReasoningCompleted, + // no longer visible in the live viewport. + let after = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + !after.contains("thinking"), + "reasoning text should commit to scrollback, not remain in viewport:\n{after}" + ); + + let committed_after_reasoning_complete = + trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); + let committed_after_text = committed_after_reasoning_complete + .iter() + .flat_map(|line| line.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert!( + committed_after_text.contains("Thought: thinking"), + "completed reasoning should use Thought label in scrollback: {committed_after_reasoning_complete:?}" + ); + assert!( + !committed_after_text.contains("reasoning_effort_selection: thinking"), + "completed reasoning should not keep Thinking label in scrollback: {committed_after_reasoning_complete:?}" + ); + let after_reasoning_rows = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + after_reasoning_rows.contains("final answer line 2"), + "undrained assistant output should remain active after reasoning completes:\n{after_reasoning_rows}" + ); +} + +#[test] +fn cumulative_text_deltas_do_not_duplicate_live_stream() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + let reasoning_id = ItemId::new(); + let assistant_id = ItemId::new(); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + reasoning_id, + crate::events::TextItemKind::Reasoning, + )); + for delta in ["I", "I'll", "I'll create", "I'll create a note"] { + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + reasoning_id, + crate::events::TextItemKind::Reasoning, + delta.to_string(), + )); + } + + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + assistant_id, + crate::events::TextItemKind::Assistant, + )); + for delta in [ + "Created", + "Created /Users", + "Created /Users/test", + "Created /Users/test/hello.txt", + ] { + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + assistant_id, + crate::events::TextItemKind::Assistant, + delta.to_string(), + )); + } + + let rows = rendered_rows(&widget, 100, 20).join("\n"); assert!( - !after.contains("thinking"), - "reasoning text should commit to scrollback, not remain in viewport:\n{after}" + !rows.contains("II'll") && !rows.contains("CreatedCreated"), + "cumulative snapshots must not duplicate streamed text:\n{rows}" ); - - let committed_after_reasoning_complete = - trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); - let committed_after_text = committed_after_reasoning_complete - .iter() - .flat_map(|line| line.line.spans.iter()) - .map(|span| span.content.as_ref()) - .collect::(); assert!( - committed_after_text.contains("Thought: thinking"), - "completed reasoning should use Thought label in scrollback: {committed_after_reasoning_complete:?}" + rows.contains("I'll create a note"), + "expected reasoning body in live viewport:\n{rows}" ); assert!( - !committed_after_text.contains("reasoning_effort_selection: thinking"), - "completed reasoning should not keep Thinking label in scrollback: {committed_after_reasoning_complete:?}" + rows.contains("Created /Users/test/hello.txt"), + "expected assistant body in live viewport:\n{rows}" ); - let after_reasoning_rows = rendered_rows(&widget, 80, 16).join("\n"); + assert_eq!( + rows.matches("I'll create a note").count(), + 1, + "reasoning body should appear once:\n{rows}" + ); + assert_eq!( + rows.matches("Created /Users/test/hello.txt").count(), + 1, + "assistant body should appear once:\n{rows}" + ); +} + +#[test] +fn legacy_reasoning_delta_accepts_cumulative_snapshots() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + for delta in ["I", "I'll", "I'll create a note"] { + widget.handle_worker_event(crate::events::WorkerEvent::ReasoningDelta( + delta.to_string(), + )); + } + + let rows = rendered_rows(&widget, 100, 12).join("\n"); assert!( - after_reasoning_rows.contains("final answer line 2"), - "undrained assistant output should remain active after reasoning completes:\n{after_reasoning_rows}" + !rows.contains("II'll"), + "legacy cumulative reasoning deltas must not duplicate:\n{rows}" + ); + assert!( + rows.contains("I'll create a note"), + "expected reasoning body:\n{rows}" ); } @@ -6377,24 +6863,24 @@ fn lifecycle_text_items_render_as_ordered_sibling_cells() { reasoning_effort: None, turn_id: Default::default(), }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - delta: "thinking".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - delta: "Line1\nLine2\n".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + reasoning_id, + crate::events::TextItemKind::Reasoning, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + reasoning_id, + crate::events::TextItemKind::Reasoning, + "thinking".to_string(), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + assistant_id, + crate::events::TextItemKind::Assistant, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + assistant_id, + crate::events::TextItemKind::Assistant, + "Line1\nLine2\n".to_string(), + )); let rows = rendered_rows(&widget, 80, 16); let reasoning_row = find_row_index(&rows, "thinking").expect("missing reasoning row"); @@ -6408,11 +6894,11 @@ fn lifecycle_text_items_render_as_ordered_sibling_cells() { ); assert_eq!(line2, line1 + 1, "unexpected rows:\n{}", rows.join("\n")); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - final_text: "thinking".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_completed( + reasoning_id, + crate::events::TextItemKind::Reasoning, + "thinking".to_string(), + )); let rows_after_reasoning = rendered_rows(&widget, 80, 16); assert!( !rows_after_reasoning @@ -6459,24 +6945,24 @@ fn lifecycle_text_items_keep_reasoning_before_assistant_when_events_arrive_out_o reasoning_effort: None, turn_id: Default::default(), }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - delta: "answer line\n".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - delta: "thinking text".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + assistant_id, + crate::events::TextItemKind::Assistant, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + assistant_id, + crate::events::TextItemKind::Assistant, + "answer line\n".to_string(), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + reasoning_id, + crate::events::TextItemKind::Reasoning, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + reasoning_id, + crate::events::TextItemKind::Reasoning, + "thinking text".to_string(), + )); let rows = rendered_rows(&widget, 80, 16); let reasoning_row = find_row_index(&rows, "thinking text").expect("missing reasoning row"); @@ -6487,22 +6973,22 @@ fn lifecycle_text_items_keep_reasoning_before_assistant_when_events_arrive_out_o rows.join("\n") ); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - final_text: "answer line".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_completed( + assistant_id, + crate::events::TextItemKind::Assistant, + "answer line".to_string(), + )); let committed_before_reasoning = widget.drain_scrollback_lines(80); assert!( !scrollback_contains_text(&committed_before_reasoning, "answer line"), "assistant should wait for prior reasoning before committing: {committed_before_reasoning:?}" ); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - final_text: "thinking text".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_completed( + reasoning_id, + crate::events::TextItemKind::Reasoning, + "thinking text".to_string(), + )); let committed = scrollback_plain_lines(&trim_trailing_blank_scrollback_lines( widget.drain_scrollback_lines(80), )) @@ -6532,38 +7018,38 @@ fn completed_assistant_flushes_before_next_reasoning_starts() { let assistant_id = ItemId::new(); let next_reasoning_id = ItemId::new(); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: stale_reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: stale_reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - delta: "first thought".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - delta: "first answer".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - final_text: "first answer".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: next_reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: next_reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - delta: "second thought".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + stale_reasoning_id, + crate::events::TextItemKind::Reasoning, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + stale_reasoning_id, + crate::events::TextItemKind::Reasoning, + "first thought".to_string(), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + assistant_id, + crate::events::TextItemKind::Assistant, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + assistant_id, + crate::events::TextItemKind::Assistant, + "first answer".to_string(), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_completed( + assistant_id, + crate::events::TextItemKind::Assistant, + "first answer".to_string(), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + next_reasoning_id, + crate::events::TextItemKind::Reasoning, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + next_reasoning_id, + crate::events::TextItemKind::Reasoning, + "second thought".to_string(), + )); let committed = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); let first_thought_index = committed @@ -6605,24 +7091,24 @@ fn assistant_stream_commit_tick_runs_while_reasoning_is_pending() { reasoning_effort: None, turn_id: Default::default(), }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: reasoning_id, - kind: crate::events::TextItemKind::Reasoning, - delta: "thinking text".to_string(), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - delta: "first line\nsecond line\n".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + reasoning_id, + crate::events::TextItemKind::Reasoning, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + reasoning_id, + crate::events::TextItemKind::Reasoning, + "thinking text".to_string(), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + assistant_id, + crate::events::TextItemKind::Assistant, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + assistant_id, + crate::events::TextItemKind::Assistant, + "first line\nsecond line\n".to_string(), + )); widget.pre_draw_tick(); let committed = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -6763,6 +7249,7 @@ fn session_switch_updates_session_identity_projection() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); assert_eq!(widget.current_cwd(), resumed_cwd.as_path()); @@ -6809,6 +7296,7 @@ fn status_summary_uses_last_turn_total_when_idle_and_live_estimate_while_busy() collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let idle_summary = widget.status_summary_text(); @@ -6890,6 +7378,7 @@ fn session_compacted_updates_context_bar_to_compacted_prompt_estimate() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); widget.handle_worker_event(crate::events::WorkerEvent::SessionCompacted { @@ -6941,6 +7430,7 @@ fn usage_updated_keeps_context_bar_on_last_query_not_cumulative_totals() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let idle_summary = widget.status_summary_text(); @@ -6981,12 +7471,12 @@ fn streaming_controller_is_initialized_and_commit_ticks_drain_lines() { reasoning_effort: None, turn_id: Default::default(), }); - assert!(!widget.has_stream_controller()); + assert!(!widget.has_live_assistant_text()); widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( "first line\nsecond line\n".to_string(), )); - assert!(widget.has_stream_controller()); + assert!(widget.has_live_assistant_text()); widget.pre_draw_tick(); let first_pass = rendered_rows(&widget, 80, 12).join("\n"); @@ -7019,10 +7509,10 @@ fn fragmented_random_assistant_stream_keeps_rendering_without_queue_stall() { reasoning_effort: None, turn_id: Default::default(), }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + assistant_id, + crate::events::TextItemKind::Assistant, + )); let mut seed = 0x9e37_79b9_7f4a_7c15_u64; let mut expected_lines = Vec::new(); @@ -7034,25 +7524,13 @@ fn fragmented_random_assistant_stream_keeps_rendering_without_queue_stall() { expected_lines.push(line); for delta in [&streamed_line[..split_at], &streamed_line[split_at..]] { - widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { - item_id: assistant_id, - kind: crate::events::TextItemKind::Assistant, - delta: delta.to_string(), - }); - widget.pre_draw_tick(); - } - - for _ in 0..8 { - if widget.assistant_stream_queued_lines_for_test() == 0 { - break; - } + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_delta( + assistant_id, + crate::events::TextItemKind::Assistant, + delta.to_string(), + )); widget.pre_draw_tick(); } - assert_eq!( - widget.assistant_stream_queued_lines_for_test(), - 0, - "assistant stream queue should drain after complete random line {index}" - ); let rows = rendered_rows(&widget, 120, 90).join("\n"); let latest_line = expected_lines.last().expect("line was generated"); @@ -7484,14 +7962,16 @@ fn context_compaction_item_lifecycle_emits_worker_events() { }; let (event_tx, mut event_rx) = mpsc::unbounded_channel(); - crate::worker::handle_started_item( + crate::worker::dispatch_legacy_item_event_for_test( + "item/started", devo_server::ItemEventPayload { context: context.clone(), item: item.clone(), }, &event_tx, ); - crate::worker::handle_completed_item( + crate::worker::dispatch_legacy_item_event_for_test( + "item/completed", devo_server::ItemEventPayload { context, item }, &event_tx, ); @@ -7512,7 +7992,8 @@ fn context_compaction_item_lifecycle_emits_worker_events() { fn failed_context_compaction_item_emits_failure_event() { let (event_tx, mut event_rx) = mpsc::unbounded_channel(); - crate::worker::handle_completed_item( + crate::worker::dispatch_legacy_item_event_for_test( + "item/completed", devo_server::ItemEventPayload { context: devo_server::EventContext { session_id: SessionId::new(), @@ -8164,6 +8645,7 @@ fn session_switch_sets_active_agent_footer_label() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let rows = rendered_rows(&widget, 160, 16); @@ -8209,6 +8691,7 @@ fn new_session_prepared_appends_header_after_existing_history_and_resets_status( collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); widget.add_to_history(crate::history_cell::new_info_event( "old session line".to_string(), @@ -8360,6 +8843,7 @@ fn new_session_prepared_restores_default_compaction_limit() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: Some(50_000), + last_context_occupancy: None, }); assert!( widget.status_summary_text().contains("50.0k"), @@ -8440,6 +8924,7 @@ fn new_session_prepared_restores_default_permissions_and_mode() { collaboration_mode: CollaborationMode::Build, permission_preset: Some(PermissionPreset::FullAccess), effective_context_window: None, + last_context_occupancy: None, }); assert_eq!(widget.input_mode_for_test(), InputMode::Build); assert_eq!( @@ -9269,19 +9754,19 @@ fn transcript_overlay_lines_include_full_completed_tool_output() { .collect::>() .join("\n"); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "bash".to_string(), - preparing: false, - parsed_commands: None, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "bash".to_string(), - preview: output, - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "bash".to_string(), + false, + None, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "bash".to_string(), + output, + false, + false, + )); let inline = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); let transcript = widget @@ -9297,16 +9782,12 @@ fn transcript_overlay_lines_include_full_completed_tool_output() { .join("\n"); assert!( - inline.contains("line 1") && inline.contains("line 2"), - "inline output should include the head of the preview: {inline}" - ); - assert!( - inline.contains("ctrl + t to view transcript"), - "inline output should include the transcript hint when truncated: {inline}" + !inline.contains("line 1") && !inline.contains("line 2"), + "inline shell view should hide command output: {inline}" ); assert!( - !inline.contains("line 3") && !inline.contains("line 7") && !inline.contains("line 8"), - "inline output should keep only the head plus fold hint: {inline}" + !inline.contains("ctrl + t to view transcript"), + "inline shell view should not show output fold hints: {inline}" ); assert!( transcript.contains("line 5") && transcript.contains("line 8"), @@ -9323,16 +9804,16 @@ fn transcript_overlay_lines_include_running_tool_output_delta() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "bash".to_string(), - preparing: false, - parsed_commands: None, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolOutputDelta { - tool_use_id: "tool-1".to_string(), - delta: "streamed output line".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "bash".to_string(), + false, + None, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_output_delta( + "tool-1".to_string(), + "streamed output line".to_string(), + )); let transcript = widget .transcript_overlay_lines(80) @@ -9361,21 +9842,21 @@ fn transcript_overlay_lines_include_running_tool_input_and_output_delta() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "custom job".to_string(), - preparing: false, - parsed_commands: None, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCallDetails { - tool_use_id: "tool-1".to_string(), - tool_name: "custom_tool".to_string(), - input: serde_json::json!({"alpha": 1, "target": "crate"}), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolOutputDelta { - tool_use_id: "tool-1".to_string(), - delta: "streamed output line".to_string(), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "custom job".to_string(), + false, + None, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_details( + "tool-1".to_string(), + "custom_tool".to_string(), + serde_json::json!({"alpha": 1, "target": "crate"}), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_output_delta( + "tool-1".to_string(), + "streamed output line".to_string(), + )); let transcript = line_texts(widget.transcript_overlay_lines(80)).join("\n"); @@ -9398,32 +9879,30 @@ fn generic_tool_call_has_one_running_render_owner() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "custom job".to_string(), - preparing: false, - parsed_commands: None, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCallDetails { - tool_use_id: "tool-1".to_string(), - tool_name: "custom_tool".to_string(), - input: serde_json::json!({"target": "crate"}), - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_details( + "tool-1".to_string(), + "custom_tool".to_string(), + serde_json::json!({"target": "crate"}), + )); let active = line_texts(widget.active_viewport_lines_for_test(100)).join("\n"); assert_eq!( - active.matches("Running custom job").count(), + active.matches('▌').count(), 1, "one tool_use_id should have one live render owner:\n{active}" ); + assert!( + active.contains("custom_tool") || active.contains("target"), + "expected generic tool row:\n{active}" + ); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "custom job".to_string(), - preview: "done".to_string(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "custom job".to_string(), + "done".to_string(), + false, + false, + )); let active = line_texts(widget.active_viewport_lines_for_test(100)).join("\n"); assert!(!active.contains("Running custom job"), "{active}"); } @@ -9436,20 +9915,20 @@ fn duplicate_command_execution_start_is_idempotent() { ..Model::default() }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - let started = crate::events::WorkerEvent::CommandExecutionStarted { - tool_use_id: "command-1".to_string(), - command: "pwd".to_string(), - input: None, - source: devo_protocol::protocol::ExecCommandSource::Agent, - command_actions: Vec::new(), - }; + let started = crate::worker_event_test_helpers::command_execution_started( + "command-1".to_string(), + "pwd".to_string(), + None, + devo_protocol::protocol::ExecCommandSource::Agent, + Vec::new(), + ); widget.handle_worker_event(started.clone()); widget.handle_worker_event(started); let transcript = line_texts(widget.transcript_overlay_lines(100)).join("\n"); assert_eq!( - transcript.matches("pwd").count(), + transcript.matches("Ran pwd").count(), 1, "duplicate starts should retain one command cell:\n{transcript}" ); @@ -9468,27 +9947,27 @@ fn transcript_overlay_lines_include_completed_tool_input_and_full_output() { .collect::>() .join("\n"); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "custom job".to_string(), - preparing: false, - parsed_commands: None, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCallDetails { - tool_use_id: "tool-1".to_string(), - tool_name: "custom_tool".to_string(), - input: serde_json::json!({"query": "needle", "path": "crates/tui"}), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResultIo { - tool_use_id: "tool-1".to_string(), - tool_name: "custom_tool".to_string(), - title: "custom job".to_string(), - input: serde_json::json!({"query": "needle", "path": "crates/tui"}), - output: serde_json::Value::String(output), - display_content: None, - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "custom job".to_string(), + false, + None, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_details( + "tool-1".to_string(), + "custom_tool".to_string(), + serde_json::json!({"query": "needle", "path": "crates/tui"}), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result_io( + "tool-1".to_string(), + "custom_tool".to_string(), + "custom job".to_string(), + serde_json::json!({"query": "needle", "path": "crates/tui"}), + serde_json::Value::String(output), + None, + false, + false, + )); let inline = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); let transcript = line_texts(widget.transcript_overlay_lines(80)).join("\n"); @@ -9516,37 +9995,38 @@ fn transcript_overlay_lines_include_completed_read_input_and_full_output() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "read src/lib.rs".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Read { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "read src/lib.rs".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Read { cmd: "read src/lib.rs".to_string(), name: "lib.rs".to_string(), path: PathBuf::from("src/lib.rs"), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCallDetails { - tool_use_id: "tool-1".to_string(), - tool_name: "read".to_string(), - input: serde_json::json!({"path": "src/lib.rs", "offset": 4, "limit": 2}), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResultIo { - tool_use_id: "tool-1".to_string(), - tool_name: "read".to_string(), - title: "read src/lib.rs".to_string(), - input: serde_json::json!({"path": "src/lib.rs", "offset": 4, "limit": 2}), - output: serde_json::Value::String("read output line 1\nread output line 2".to_string()), - display_content: None, - is_error: false, - truncated: false, - }); + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_details( + "tool-1".to_string(), + "read".to_string(), + serde_json::json!({"path": "src/lib.rs", "offset": 4, "limit": 2}), + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result_io( + "tool-1".to_string(), + "read".to_string(), + "read src/lib.rs".to_string(), + serde_json::json!({"path": "src/lib.rs", "offset": 4, "limit": 2}), + serde_json::Value::String("read output line 1\nread output line 2".to_string()), + None, + false, + false, + )); let inline = line_texts(widget.active_viewport_lines_for_test(80)).join("\n"); let transcript = line_texts(widget.transcript_overlay_lines(80)).join("\n"); assert!( - inline.contains("Explored") && inline.contains("Read src/lib.rs"), + inline.contains("Explored") && inline.contains("Read src/lib.rs") + || inline.contains("Exploring") && inline.contains("Reading src/lib.rs"), "inline read rendering should stay as the compact explored block: {inline}" ); assert!( @@ -9584,14 +10064,14 @@ fn transcript_overlay_lines_include_patch_input_and_diff_output() { }, ); - widget.handle_worker_event(crate::events::WorkerEvent::PatchAppliedIo { - tool_use_id: "tool-1".to_string(), - tool_name: "apply_patch".to_string(), - input: serde_json::json!({ + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied_io( + "tool-1".to_string(), + "apply_patch".to_string(), + serde_json::json!({ "patch": "*** Begin Patch\n*** Update File: foo.txt\n-old\n+new\n*** End Patch" }), changes, - }); + )); let transcript = line_texts(widget.transcript_overlay_lines(100)).join("\n"); @@ -9671,6 +10151,7 @@ fn restored_session_transcript_overlay_preserves_paired_tool_io() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let transcript = line_texts(widget.transcript_overlay_lines(100)).join("\n"); @@ -9740,6 +10221,7 @@ fn legacy_restored_session_without_tool_io_keeps_existing_tool_result_rendering( collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let transcript = line_texts(widget.transcript_overlay_lines(100)).join("\n"); @@ -9763,12 +10245,12 @@ fn read_tool_call_renders_as_explored_group_in_viewport() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "cat foo.txt".to_string(), - preparing: false, - parsed_commands: None, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "cat foo.txt".to_string(), + false, + None, + )); let live_display = widget .active_cell_display_lines_for_test(80) @@ -9787,17 +10269,17 @@ fn read_tool_call_renders_as_explored_group_in_viewport() { "expected read start to render immediately: {live_display}" ); assert!( - live_display.contains("Read foo.txt"), + live_display.contains("Reading foo.txt"), "expected live read summary: {live_display}" ); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "cat foo.txt".to_string(), - preview: "hello".to_string(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "cat foo.txt".to_string(), + "hello".to_string(), + false, + false, + )); let display = widget .active_cell_display_lines_for_test(80) @@ -9816,7 +10298,7 @@ fn read_tool_call_renders_as_explored_group_in_viewport() { "expected explored viewport grouping: {display}" ); assert!( - display.contains("Read foo.txt"), + display.contains("Read foo.txt") || display.contains("Reading foo.txt"), "expected read summary in explored viewport: {display}" ); assert!(display.contains("▌ Explored") || display.contains("▌ Exploring")); @@ -9831,23 +10313,23 @@ fn read_tool_call_renders_relative_path_with_line_range() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "read crates/core/src/query.rs".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Read { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "read crates/core/src/query.rs".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Read { cmd: "read crates/core/src/query.rs".to_string(), name: "crates/core/src/query.rs L:10-19".to_string(), path: PathBuf::from("crates/core/src/query.rs"), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "read crates/core/src/query.rs".to_string(), - preview: "impl Query {}".to_string(), - is_error: false, - truncated: false, - }); + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "read crates/core/src/query.rs".to_string(), + "impl Query {}".to_string(), + false, + false, + )); let display = widget .active_cell_display_lines_for_test(100) @@ -9876,23 +10358,23 @@ fn read_tool_call_falls_back_to_path_when_read_name_is_empty() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "read crates/tui/src/mod.rs".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Read { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "read crates/tui/src/mod.rs".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Read { cmd: "read crates/tui/src/mod.rs".to_string(), name: String::new(), path: PathBuf::from("crates/tui/src/mod.rs"), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "read crates/tui/src/mod.rs".to_string(), - preview: "mod tui;".to_string(), - is_error: false, - truncated: false, - }); + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "read crates/tui/src/mod.rs".to_string(), + "mod tui;".to_string(), + false, + false, + )); let display = widget .active_cell_display_lines_for_test(80) @@ -9925,16 +10407,16 @@ fn read_tool_call_updates_placeholder_from_completed_tool_call_metadata() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "read {}".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Read { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "read {}".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Read { cmd: String::new(), name: String::new(), path: PathBuf::new(), }]), - }); + )); let initial_display = widget .active_cell_display_lines_for_test(80) @@ -9961,15 +10443,15 @@ fn read_tool_call_updates_placeholder_from_completed_tool_call_metadata() { "read placeholder should not render as a generic running tool: {initial_display}" ); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCallUpdated { - tool_use_id: "tool-1".to_string(), - summary: "read crates/tui/src/mod.rs".to_string(), - parsed_commands: vec![devo_protocol::parse_command::ParsedCommand::Read { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_updated( + "tool-1".to_string(), + "read crates/tui/src/mod.rs".to_string(), + vec![devo_protocol::parse_command::ParsedCommand::Read { cmd: "read crates/tui/src/mod.rs".to_string(), name: "mod.rs".to_string(), path: PathBuf::from("crates/tui/src/mod.rs"), }], - }); + )); let updated_display = widget .active_cell_display_lines_for_test(80) @@ -9984,17 +10466,18 @@ fn read_tool_call_updates_placeholder_from_completed_tool_call_metadata() { .join("\n"); assert!( - updated_display.contains("Read crates/tui/src/mod.rs"), + updated_display.contains("Reading crates/tui/src/mod.rs") + || updated_display.contains("Read crates/tui/src/mod.rs"), "expected read placeholder to update in place: {updated_display}" ); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "read crates/tui/src/mod.rs".to_string(), - preview: "mod tui;".to_string(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "read crates/tui/src/mod.rs".to_string(), + "mod tui;".to_string(), + false, + false, + )); let completed_display = widget .active_cell_display_lines_for_test(80) @@ -10036,32 +10519,32 @@ fn consecutive_read_tool_calls_render_each_on_its_own_line() { for path in paths { let name = path.rsplit('/').next().expect("basename"); let tool_use_id = format!("tool-{name}"); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: tool_use_id.clone(), - summary: "read {}".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Read { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + tool_use_id.clone(), + "read {}".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Read { cmd: String::new(), name: String::new(), path: PathBuf::new(), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCallUpdated { - tool_use_id: tool_use_id.clone(), - summary: format!("read {path}"), - parsed_commands: vec![devo_protocol::parse_command::ParsedCommand::Read { + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_updated( + tool_use_id.clone(), + format!("read {path}"), + vec![devo_protocol::parse_command::ParsedCommand::Read { cmd: format!("read {path}"), name: name.to_string(), path: PathBuf::from(path), }], - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( tool_use_id, - title: format!("read {path}"), - preview: String::new(), - is_error: false, - truncated: false, - }); + format!("read {path}"), + "ok".to_string(), + false, + false, + )); } let display = widget @@ -10097,24 +10580,24 @@ fn glob_tool_call_renders_as_explored_group_in_viewport() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "glob **/Cargo.toml in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![ + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "glob **/Cargo.toml in crates".to_string(), + false, + Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/Cargo.toml in crates".to_string(), path: Some("crates".to_string()), }, ]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "glob **/Cargo.toml in crates".to_string(), - preview: "crates/tools/Cargo.toml".to_string(), - is_error: false, - truncated: false, - }); + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "glob **/Cargo.toml in crates".to_string(), + "crates/tools/Cargo.toml".to_string(), + false, + false, + )); let display = widget .active_cell_display_lines_for_test(80) @@ -10130,7 +10613,7 @@ fn glob_tool_call_renders_as_explored_group_in_viewport() { assert!(display.contains("Explored") || display.contains("Exploring")); assert!( - display.contains("List crates"), + display.contains("Finding crates") || display.contains("Found crates"), "expected list summary, got:\n{display}" ); } @@ -10144,16 +10627,16 @@ fn grep_tool_call_renders_as_explored_group_in_viewport() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "grep 'rebuild_restored_session' in crates/tui/src".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "grep 'rebuild_restored_session' in crates/tui/src".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'rebuild_restored_session' in crates/tui/src".to_string(), query: Some("rebuild_restored_session".to_string()), path: Some("crates/tui/src".to_string()), }]), - }); + )); let live_display = widget .active_cell_display_lines_for_test(80) @@ -10172,17 +10655,17 @@ fn grep_tool_call_renders_as_explored_group_in_viewport() { "expected grep start to render immediately: {live_display}" ); assert!( - live_display.contains("Search rebuild_restored_session in crates/tui/src"), + live_display.contains("Grepping rebuild_restored_session in crates/tui/src"), "expected live search summary, got:\n{live_display}" ); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "grep 'rebuild_restored_session' in crates/tui/src".to_string(), - preview: "chatwidget.rs".to_string(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "grep 'rebuild_restored_session' in crates/tui/src".to_string(), + "chatwidget.rs".to_string(), + false, + false, + )); let display = widget .active_cell_display_lines_for_test(80) @@ -10198,7 +10681,8 @@ fn grep_tool_call_renders_as_explored_group_in_viewport() { assert!(display.contains("Explored") || display.contains("Exploring")); assert!( - display.contains("Search rebuild_restored_session in crates/tui/src"), + display.contains("Grepped rebuild_restored_session in crates/tui/src") + || display.contains("Grepping rebuild_restored_session in crates/tui/src"), "expected search summary, got:\n{display}" ); } @@ -10212,16 +10696,16 @@ fn code_search_tool_call_renders_as_explored_group_in_viewport() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "code_search live tool feedback in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "code_search live tool feedback in crates".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "code_search live tool feedback in crates".to_string(), query: Some("live tool feedback".to_string()), path: Some("crates".to_string()), }]), - }); + )); let live_display = widget .active_cell_display_lines_for_test(80) @@ -10240,7 +10724,7 @@ fn code_search_tool_call_renders_as_explored_group_in_viewport() { "expected code_search start to render immediately: {live_display}" ); assert!( - live_display.contains("Search live tool feedback in crates"), + live_display.contains("Grepping live tool feedback in crates"), "expected live code_search summary, got:\n{live_display}" ); assert!( @@ -10258,25 +10742,25 @@ fn exploring_code_search_with_details_shows_input_in_active_cell() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "code_search live tool feedback in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "code_search live tool feedback in crates".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "code_search live tool feedback in crates".to_string(), query: Some("live tool feedback".to_string()), path: Some("crates".to_string()), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCallDetails { - tool_use_id: "tool-1".to_string(), - tool_name: "code_search".to_string(), - input: serde_json::json!({ + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_details( + "tool-1".to_string(), + "code_search".to_string(), + serde_json::json!({ "operation": "search", "query": "live tool feedback", "path": "crates" }), - }); + )); let live_display = widget .active_cell_display_lines_for_test(80) @@ -10295,12 +10779,8 @@ fn exploring_code_search_with_details_shows_input_in_active_cell() { "expected Exploring header: {live_display}" ); assert!( - live_display.contains("operation") && live_display.contains("search"), - "active ExecCell should show 'operation: search' while exploring:\n{live_display}" - ); - assert!( - live_display.contains("query") && live_display.contains("live tool feedback"), - "active ExecCell should show 'query: live tool feedback' while exploring:\n{live_display}" + live_display.contains("Grepping live tool feedback in crates"), + "expected code_search search line while exploring:\n{live_display}" ); } @@ -10313,42 +10793,42 @@ fn merged_explored_group_becomes_explored_after_all_results_arrive() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "grep 'plan' in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "grep 'plan' in crates".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), path: Some("crates".to_string()), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-2".to_string(), - summary: "glob **/plan.rs in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![ + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-2".to_string(), + "glob **/plan.rs in crates".to_string(), + false, + Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), path: Some("crates".to_string()), }, ]), - }); + )); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "grep 'plan' in crates".to_string(), - preview: "crates/tools/src/handlers/plan.rs".to_string(), - is_error: false, - truncated: false, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-2".to_string(), - title: "glob **/plan.rs in crates".to_string(), - preview: "crates/tools/src/handlers/plan.rs".to_string(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "grep 'plan' in crates".to_string(), + "crates/tools/src/handlers/plan.rs".to_string(), + false, + false, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-2".to_string(), + "glob **/plan.rs in crates".to_string(), + "crates/tools/src/handlers/plan.rs".to_string(), + false, + false, + )); let display = widget .active_cell_display_lines_for_test(80) @@ -10381,27 +10861,27 @@ fn live_viewport_shows_explored_group_while_active() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "grep 'plan' in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "grep 'plan' in crates".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), path: Some("crates".to_string()), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-2".to_string(), - summary: "glob **/plan.rs in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![ + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-2".to_string(), + "glob **/plan.rs in crates".to_string(), + false, + Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), path: Some("crates".to_string()), }, ]), - }); + )); let display = widget .active_viewport_lines_for_test(80) @@ -10420,11 +10900,11 @@ fn live_viewport_shows_explored_group_while_active() { "live viewport should show explored exec cell:\n{display}" ); assert!( - display.contains("Search plan in crates"), + display.contains("Grepping plan in crates") || display.contains("Grepped plan in crates"), "live viewport should include search summary:\n{display}" ); assert!( - display.contains("List crates"), + display.contains("Finding crates") || display.contains("Found crates"), "live viewport should include list summary:\n{display}" ); } @@ -10438,31 +10918,31 @@ fn reasoning_start_closes_current_explored_group() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "grep 'plan' in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "grep 'plan' in crates".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), path: Some("crates".to_string()), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: devo_core::ItemId::new(), - kind: crate::events::TextItemKind::Reasoning, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-2".to_string(), - summary: "glob **/plan.rs in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![ + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + devo_core::ItemId::new(), + crate::events::TextItemKind::Reasoning, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-2".to_string(), + "glob **/plan.rs in crates".to_string(), + false, + Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), path: Some("crates".to_string()), }, ]), - }); + )); let transcript = widget .transcript_overlay_lines(80) @@ -10492,31 +10972,31 @@ fn assistant_text_start_closes_current_explored_group() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "grep 'plan' in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "grep 'plan' in crates".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), path: Some("crates".to_string()), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { - item_id: devo_core::ItemId::new(), - kind: crate::events::TextItemKind::Assistant, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-2".to_string(), - summary: "glob **/plan.rs in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![ + )); + widget.handle_worker_event(crate::worker_event_test_helpers::text_item_started( + devo_core::ItemId::new(), + crate::events::TextItemKind::Assistant, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-2".to_string(), + "glob **/plan.rs in crates".to_string(), + false, + Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), path: Some("crates".to_string()), }, ]), - }); + )); let transcript = widget .transcript_overlay_lines(80) @@ -10546,56 +11026,56 @@ fn merged_explored_group_stays_completed_when_tool_results_arrive_after_tool_cal }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "grep 'plan' in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "grep 'plan' in crates".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), path: Some("crates".to_string()), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-2".to_string(), - summary: "glob **/plan.rs in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![ + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-2".to_string(), + "glob **/plan.rs in crates".to_string(), + false, + Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), path: Some("crates".to_string()), }, ]), - }); + )); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "grep 'plan' in crates".to_string(), - preview: String::new(), - is_error: false, - truncated: false, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-2".to_string(), - title: "glob **/plan.rs in crates".to_string(), - preview: String::new(), - is_error: false, - truncated: false, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "grep output".to_string(), - preview: "crates/tools/src/handlers/plan.rs".to_string(), - is_error: false, - truncated: false, - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-2".to_string(), - title: "glob output".to_string(), - preview: "crates/tools/src/handlers/plan.rs".to_string(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "grep 'plan' in crates".to_string(), + String::new(), + false, + false, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-2".to_string(), + "glob **/plan.rs in crates".to_string(), + String::new(), + false, + false, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "grep output".to_string(), + "crates/tools/src/handlers/plan.rs".to_string(), + false, + false, + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-2".to_string(), + "glob output".to_string(), + "crates/tools/src/handlers/plan.rs".to_string(), + false, + false, + )); let display = widget .active_cell_display_lines_for_test(80) @@ -10628,49 +11108,49 @@ fn explored_group_in_history_can_finish_late_completions() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-1".to_string(), - summary: "grep 'plan' in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-1".to_string(), + "grep 'plan' in crates".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Search { cmd: "grep 'plan' in crates".to_string(), query: Some("plan".to_string()), path: Some("crates".to_string()), }]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-2".to_string(), - summary: "glob **/plan.rs in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![ + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-2".to_string(), + "glob **/plan.rs in crates".to_string(), + false, + Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/plan.rs in crates".to_string(), path: Some("crates".to_string()), }, ]), - }); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-1".to_string(), - title: "grep 'plan' in crates".to_string(), - preview: String::new(), - is_error: false, - truncated: false, - }); + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-1".to_string(), + "grep 'plan' in crates".to_string(), + String::new(), + false, + false, + )); - widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { - tool_use_id: "tool-3".to_string(), - summary: "write src/main.rs".to_string(), - preparing: false, - parsed_commands: None, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "tool-3".to_string(), + "write src/main.rs".to_string(), + false, + None, + )); - widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { - tool_use_id: "tool-2".to_string(), - title: "glob **/plan.rs in crates".to_string(), - preview: String::new(), - is_error: false, - truncated: false, - }); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "tool-2".to_string(), + "glob **/plan.rs in crates".to_string(), + String::new(), + false, + false, + )); let history_blob = widget .transcript_overlay_lines(80) @@ -10736,10 +11216,10 @@ fn patch_applied_event_renders_edited_block() { }, ); - widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { - tool_use_id: "tool-1".to_string(), + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied( + "tool-1".to_string(), changes, - }); + )); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( @@ -10765,10 +11245,10 @@ fn added_file_patch_applied_event_renders_added_content_lines() { content: "pub fn quicksort() {\n println!(\"hi\");\n}\n".to_string(), }, ); - widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { - tool_use_id: "tool-1".to_string(), + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied( + "tool-1".to_string(), changes, - }); + )); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); assert!( @@ -10806,10 +11286,10 @@ fn apply_patch_style_full_git_diff_reports_non_zero_counts() { }, ); - widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { - tool_use_id: "tool-1".to_string(), + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied( + "tool-1".to_string(), changes, - }); + )); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( @@ -10860,10 +11340,10 @@ fn write_patch_applied_event_renders_edited_block() { }, ); - widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { - tool_use_id: "tool-1".to_string(), + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied( + "tool-1".to_string(), changes, - }); + )); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( @@ -10892,10 +11372,10 @@ fn write_patch_applied_event_reports_non_zero_counts() { }, ); - widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { - tool_use_id: "tool-1".to_string(), + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied( + "tool-1".to_string(), changes, - }); + )); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( @@ -10928,10 +11408,10 @@ fn patch_applied_event_with_diff_only_reports_non_zero_counts() { }, ); - widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { - tool_use_id: "tool-1".to_string(), + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied( + "tool-1".to_string(), changes, - }); + )); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( @@ -10960,10 +11440,10 @@ fn patch_applied_event_with_empty_update_is_not_rendered() { }, ); - widget.handle_worker_event(crate::events::WorkerEvent::PatchApplied { - tool_use_id: "tool-1".to_string(), + widget.handle_worker_event(crate::worker_event_test_helpers::patch_applied( + "tool-1".to_string(), changes, - }); + )); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); assert!( @@ -11009,6 +11489,7 @@ fn session_switch_without_rich_edited_metadata_degrades_to_tool_result_path() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -11067,6 +11548,7 @@ fn session_switch_restores_added_file_content_in_edited_block() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -11125,6 +11607,7 @@ fn session_switch_without_rich_edited_metadata_still_restores_edited_block() { collaboration_mode: CollaborationMode::Build, permission_preset: None, effective_context_window: None, + last_context_occupancy: None, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index bba44290..e57d0fed 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::PathBuf; use std::time::Instant; @@ -24,9 +23,6 @@ use devo_protocol::SessionHistoryItem; use devo_protocol::SessionRuntimeStatus; use devo_protocol::ThreadGoal; use devo_protocol::native::item::ContextOccupancy; -use devo_protocol::parse_command::ParsedCommand; -use devo_protocol::protocol::ExecCommandSource; -use devo_protocol::protocol::FileChange; const TOOL_RESULT_FOLD_FINAL_STAGE: u8 = 3; #[derive(Debug, Clone, PartialEq, Eq)] @@ -223,23 +219,6 @@ pub(crate) enum WorkerEvent { phase: ProviderRetryPhase, message: String, }, - /// A streamed assistant or reasoning text item started. - TextItemStarted { - item_id: ItemId, - kind: TextItemKind, - }, - /// Incremental text for a streamed assistant or reasoning item. - TextItemDelta { - item_id: ItemId, - kind: TextItemKind, - delta: String, - }, - /// A streamed assistant or reasoning text item completed. - TextItemCompleted { - item_id: ItemId, - kind: TextItemKind, - final_text: String, - }, /// A streamed Plan Mode proposal item started. ProposedPlanStarted { item_id: ItemId, @@ -262,93 +241,11 @@ pub(crate) enum WorkerEvent { AssistantMessageCompleted(String), /// Final reasoning text for a completed item. ReasoningCompleted(String), - /// A tool call started. - ToolCall { - /// Stable identifier used to match the later tool result. - tool_use_id: String, - /// Human-readable summary line for the tool execution. - summary: String, - /// Whether this early tool signal should render as a live-only preparing state. - preparing: bool, - /// Optional parsed command semantics for command-like and exploration-like tools. - parsed_commands: Option>, - }, - /// Full input metadata for a tool call shown by the Ctrl+T transcript. - ToolCallDetails { - tool_use_id: String, - tool_name: String, - input: serde_json::Value, - }, - /// A command-execution item started. - CommandExecutionStarted { - /// Stable identifier used to match later output and result events. - tool_use_id: String, - /// The command text executed by the server. - command: String, - /// Full command tool input for transcript rendering. - input: Option, - /// Whether this command came from the agent, Shell Mode, or unified exec. - source: ExecCommandSource, - /// Parsed command semantics supplied by the server. - command_actions: Vec, - }, - /// Updated metadata for a previously started tool call. - ToolCallUpdated { - /// Stable identifier matching the original tool call. - tool_use_id: String, - /// Updated human-readable summary line. - summary: String, - /// Parsed command semantics derived from finalized tool metadata. - parsed_commands: Vec, - }, - /// Incremental output delta from a running tool. - ToolOutputDelta { - /// Stable identifier matching the corresponding tool call. - tool_use_id: String, - /// Streaming output text chunk. - delta: String, - }, - /// A tool call finished. - ToolResult { - /// Stable identifier used to match the corresponding tool call. - tool_use_id: String, - /// Human-readable title for the tool result when no prior tool-call row is cached. - title: String, - /// Human-readable output preview shown in the transcript. - preview: String, - /// Whether the tool returned an error. - is_error: bool, - /// Whether the preview was truncated for display. - truncated: bool, - }, - /// Full input/output metadata for a completed generic tool call. - ToolResultIo { - tool_use_id: String, - tool_name: String, - title: String, - input: serde_json::Value, - output: serde_json::Value, - display_content: Option, - is_error: bool, - truncated: bool, - }, /// A user-shell command/process finished outside the model turn loop. ShellCommandFinished { /// Process exit code when known. exit_code: Option, }, - /// A structured patch/edit summary derived from apply_patch output. - PatchApplied { - tool_use_id: String, - changes: HashMap, - }, - /// A structured patch/edit summary with paired tool input for Ctrl+T. - PatchAppliedIo { - tool_use_id: String, - tool_name: String, - input: serde_json::Value, - changes: HashMap, - }, /// A structured plan or todo list update. PlanUpdated { explanation: Option, @@ -680,6 +577,8 @@ pub(crate) enum WorkerEvent { permission_preset: Option, /// Session auto-compaction token limit override, when one is set. effective_context_window: Option, + /// Latest context-window occupancy restored from rollout or session stats. + last_context_occupancy: Option, }, /// The current session title changed. SessionRenamed { @@ -747,6 +646,8 @@ pub(crate) enum WorkerEvent { /// History entry text, or `None` if there is no matching entry. text: Option, }, + /// Native-first transcript lifecycle event for [`crate::transcript::TranscriptProjector`]. + Transcript(crate::transcript::lifecycle::ItemLifecycleEvent), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/tui/src/exec_cell/model.rs b/crates/tui/src/exec_cell/model.rs index b2d027b3..cd36d94e 100644 --- a/crates/tui/src/exec_cell/model.rs +++ b/crates/tui/src/exec_cell/model.rs @@ -240,6 +240,16 @@ impl ExecCall { pub(crate) fn is_unified_exec_interaction(&self) -> bool { matches!(self.source, ExecCommandSource::UnifiedExecInteraction) } + + pub(crate) fn is_agent_shell_tool_call(&self) -> bool { + if self.is_user_shell_command() || self.is_unified_exec_interaction() { + return false; + } + match self.tool_name.as_deref() { + Some(name) => crate::transcript::tool_state::is_shell_tool_name(name), + None => matches!(self.source, ExecCommandSource::Agent), + } + } } #[cfg(test)] diff --git a/crates/tui/src/exec_cell/render.rs b/crates/tui/src/exec_cell/render.rs index 1d4b39b5..9b46708a 100644 --- a/crates/tui/src/exec_cell/render.rs +++ b/crates/tui/src/exec_cell/render.rs @@ -14,6 +14,10 @@ use crate::render::line_utils::push_owned_lines; use crate::tool_io_cell::ToolIoCell; use crate::tool_io_cell::ToolIoCellOptions; use crate::tool_io_cell::tool_input_lines; +use crate::transcript::presentation::tool_status_done_style; +use crate::transcript::presentation::tool_status_running_style; +use crate::transcript::tool_state::shell_command_from_input; +use crate::transcript::tool_state::shell_description_from_input; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::adaptive_wrap_lines; @@ -259,11 +263,18 @@ impl HistoryCell for ExecCell { } fn transcript_lines(&self, width: u16) -> Vec> { - if let Some(lines) = self.tool_io_transcript_lines(width) { + if self.is_exploring_cell() { + let mut lines = self.exploring_display_lines(width); + if let Some(tool_io_lines) = self.exploring_tool_io_transcript_lines(width) { + if !lines.is_empty() && !tool_io_lines.is_empty() { + lines.push(Line::from("")); + } + lines.extend(tool_io_lines); + } return lines; } - if self.is_exploring_cell() { - return self.exploring_display_lines(width); + if let Some(lines) = self.tool_io_transcript_lines(width) { + return lines; } let mut lines: Vec> = vec![]; for (i, call) in self.iter_calls().enumerate() { @@ -313,6 +324,9 @@ impl ExecCell { fn tool_io_transcript_lines(&self, width: u16) -> Option>> { let mut lines = Vec::new(); for call in self.iter_calls() { + if Self::is_exploring_call(call) { + continue; + } let (Some(tool_name), Some(input)) = (&call.tool_name, &call.tool_input) else { continue; }; @@ -331,14 +345,7 @@ impl ExecCell { serde_json::Value::String(text) }) }); - let dot_prefix = Line::from(vec![ - if call.output.is_some() { - "▌".dim() - } else { - spinner(call.start_time, self.animations_enabled()) - }, - " ".into(), - ]); + let dot_prefix = Line::from(vec!["▌".dim(), " ".into()]); lines.extend( ToolIoCell::new( ToolIoCellOptions { @@ -359,6 +366,55 @@ impl ExecCell { (!lines.is_empty()).then_some(lines) } + fn exploring_tool_io_transcript_lines(&self, width: u16) -> Option>> { + let mut lines = Vec::new(); + for call in self.iter_calls() { + if !Self::is_exploring_call(call) + || !Self::should_include_exploring_tool_io_transcript(call) + { + continue; + } + let (Some(tool_name), Some(input)) = (&call.tool_name, &call.tool_input) else { + continue; + }; + if !lines.is_empty() { + lines.push(Line::from("")); + } + let output = call.tool_output.clone().or_else(|| { + call.output.as_ref().map(|output| { + let text = if output.formatted_output.is_empty() { + output.aggregated_output.clone() + } else { + output.formatted_output.clone() + }; + serde_json::Value::String(text) + }) + }); + let dot_prefix = Line::from(vec!["▌".dim(), " ".into()]); + lines.extend( + ToolIoCell::new( + ToolIoCellOptions { + title_line: None, + dot_prefix, + subsequent_prefix: Line::from(" "), + output_style: Style::default(), + show_empty_ellipsis: false, + }, + tool_name.clone(), + input.clone(), + output, + call.tool_display_content.clone(), + ) + .transcript_lines(width), + ); + } + (!lines.is_empty()).then_some(lines) + } + + fn should_include_exploring_tool_io_transcript(call: &ExecCall) -> bool { + matches!(call.tool_name.as_deref(), Some("read")) + } + fn output_ellipsis_text(omitted: usize) -> String { format!("… +{omitted} lines ({TRANSCRIPT_HINT})") } @@ -407,6 +463,7 @@ impl ExecCell { .iter() .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })); + let call_active = call.output.is_none() && self.animations_enabled(); let call_lines: Vec<(&str, Vec>)> = if reads_only { let mut lines = Vec::new(); let mut seen = std::collections::HashSet::new(); @@ -416,7 +473,8 @@ impl ExecCell { }; let display = read_display_name(name, path, cmd); if seen.insert(display.clone()) { - lines.push(("Read", vec![display.into()])); + let verb = if call_active { "Reading" } else { "Read" }; + lines.push((verb, vec![display.into()])); } } lines @@ -425,12 +483,15 @@ impl ExecCell { for parsed in &call.parsed { match parsed { ParsedCommand::Read { cmd, name, path } => { - lines.push(("Read", vec![read_display_name(name, path, cmd).into()])); + let verb = if call_active { "Reading" } else { "Read" }; + lines.push((verb, vec![read_display_name(name, path, cmd).into()])); } ParsedCommand::ListFiles { cmd, path } => { - lines.push(("List", vec![path.clone().unwrap_or(cmd.clone()).into()])); + let verb = if call_active { "Finding" } else { "Found" }; + lines.push((verb, vec![path.clone().unwrap_or(cmd.clone()).into()])); } ParsedCommand::Search { cmd, query, path } => { + let verb = if call_active { "Grepping" } else { "Grepped" }; let spans = match (query, path) { (Some(q), Some(p)) => { vec![q.clone().into(), " in ".dim(), p.clone().into()] @@ -438,10 +499,11 @@ impl ExecCell { (Some(q), None) => vec![q.clone().into()], _ => vec![cmd.clone().into()], }; - lines.push(("Search", spans)); + lines.push((verb, spans)); } ParsedCommand::Unknown { cmd } => { - lines.push(("Run", vec![cmd.clone().into()])); + let verb = if call_active { "Running" } else { "Ran" }; + lines.push((verb, vec![cmd.clone().into()])); } } } @@ -479,28 +541,85 @@ impl ExecCell { let [call] = &self.calls.as_slice() else { panic!("Expected exactly one call in a command display cell"); }; + if call.is_agent_shell_tool_call() { + return self.agent_shell_display_lines(call, width); + } + self.legacy_command_display_lines(call, width) + } + + fn agent_shell_display_lines(&self, call: &ExecCall, width: u16) -> Vec> { let layout = EXEC_DISPLAY_LAYOUT; - let success = call.output.as_ref().map(|o| o.exit_code == 0); - let bullet = match success { - Some(true) => "▌".green().bold(), - Some(false) => "▌".red().bold(), - None => spinner(call.start_time, self.animations_enabled()), + let bullet = "▌".dim(); + let status = if self.is_active() { "Running" } else { "Ran" }; + let status_style = if self.is_active() { + tool_status_running_style() + } else { + tool_status_done_style() }; + let explanation = shell_description_from_input(call.tool_input.as_ref()) + .unwrap_or_else(|| strip_bash_lc_and_escape(&call.command)); + + let mut lines = vec![Line::from(vec![ + bullet, + " ".into(), + Span::styled(status, status_style), + " ".into(), + explanation.into(), + ])]; + + let command_text = call + .tool_input + .as_ref() + .and_then(|input| shell_command_from_input(input)) + .unwrap_or_else(|| strip_bash_lc_and_escape(&call.command)); + if !command_text.is_empty() { + let command_line = Line::from(Span::styled(command_text, Style::default().dim())); + let wrapped = adaptive_wrap_lines( + std::slice::from_ref(&command_line), + RtOptions::new(layout.command_continuation.wrap_width(width)) + .word_splitter(WordSplitter::NoHyphenation), + ); + lines.extend(prefix_lines( + wrapped, + Span::from(" ").dim(), + Span::from(" ").dim(), + )); + } + + lines + } + + fn legacy_command_display_lines(&self, call: &ExecCall, width: u16) -> Vec> { + let layout = EXEC_DISPLAY_LAYOUT; + let bullet = "▌".dim(); let is_interaction = call.is_unified_exec_interaction(); + let running = self.is_active(); let title = if is_interaction { "" } else if call.is_user_shell_command() { "$" - } else if self.is_active() { + } else if running { "Running" } else { "Ran" }; + let title_style = if is_interaction || call.is_user_shell_command() { + Style::default().bold() + } else if running { + tool_status_running_style() + } else { + tool_status_done_style() + }; let mut header_line = if is_interaction { Line::from(vec![bullet.clone(), " ".into()]) } else { - Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]) + Line::from(vec![ + bullet.clone(), + " ".into(), + Span::styled(title, title_style), + " ".into(), + ]) }; let header_prefix_width = header_line.width(); @@ -942,7 +1061,7 @@ mod tests { .map(render_line_text) .collect::>(); - assert_eq!(rendered, vec!["▌ Ran echo hi"]); + assert_eq!(rendered, vec!["▌ Ran echo hi", " echo hi"]); } #[test] diff --git a/crates/tui/src/history_cell.rs b/crates/tui/src/history_cell.rs index b592ce30..f05dc78b 100644 --- a/crates/tui/src/history_cell.rs +++ b/crates/tui/src/history_cell.rs @@ -817,9 +817,9 @@ impl HistoryCell for UnifiedExecInteractionCell { let waited_only = self.stdin.is_empty(); let mut header_spans = if waited_only { - vec!["▌ ".cyan(), "Waited for background terminal".bold()] + vec!["▌ ".dim(), "Waited for background terminal".bold()] } else { - vec!["▌ ".cyan(), "Interacted with background terminal".bold()] + vec!["▌ ".dim(), "Interacted with background terminal".bold()] }; if let Some(command) = &self.command_display && !command.is_empty() diff --git a/crates/tui/src/interactive.rs b/crates/tui/src/interactive.rs index 46eae1fd..3d913998 100644 --- a/crates/tui/src/interactive.rs +++ b/crates/tui/src/interactive.rs @@ -861,17 +861,13 @@ fn handle_worker_event( WorkerEvent::SessionActivated { session_id } => { loop_state.session_id = Some(*session_id); } - // Streaming deltas are handled entirely within the ChatWidget - WorkerEvent::ToolOutputDelta { .. } => {} - WorkerEvent::CommandExecutionStarted { source, .. } - if matches!( - source, - &devo_protocol::protocol::ExecCommandSource::UserShell - ) => - { + WorkerEvent::Transcript(crate::transcript::lifecycle::ItemLifecycleEvent::ToolOpened { + command_source: Some(devo_protocol::protocol::ExecCommandSource::UserShell), + .. + }) => { loop_state.busy = true; } - WorkerEvent::CommandExecutionStarted { .. } => {} + WorkerEvent::Transcript(_) => {} WorkerEvent::ShellCommandFinished { .. } => { loop_state.busy = false; } @@ -962,22 +958,12 @@ fn handle_worker_event( loop_state.total_cache_read_tokens = *total_cache_read_tokens; } WorkerEvent::TextDelta(_) - | WorkerEvent::TextItemStarted { .. } - | WorkerEvent::TextItemDelta { .. } - | WorkerEvent::TextItemCompleted { .. } | WorkerEvent::ProposedPlanStarted { .. } | WorkerEvent::ProposedPlanDelta { .. } | WorkerEvent::ProposedPlanCompleted { .. } | WorkerEvent::ReasoningDelta(_) | WorkerEvent::AssistantMessageCompleted(_) | WorkerEvent::ReasoningCompleted(_) - | WorkerEvent::ToolCall { .. } - | WorkerEvent::ToolCallDetails { .. } - | WorkerEvent::ToolCallUpdated { .. } - | WorkerEvent::ToolResult { .. } - | WorkerEvent::ToolResultIo { .. } - | WorkerEvent::PatchApplied { .. } - | WorkerEvent::PatchAppliedIo { .. } | WorkerEvent::PlanUpdated { .. } | WorkerEvent::ProviderVendorsListed { .. } | WorkerEvent::SessionsListed { .. } diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 66de9090..09516885 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -2,6 +2,7 @@ //! //! public entry point for launching the CLI TUI. #![allow(dead_code)] +mod agent_tool_cell; mod ansi_escape; mod app; pub(crate) mod app_command; @@ -74,11 +75,14 @@ mod tool_io_cell; #[cfg(test)] mod tool_rendering_e2e_tests; mod tool_result_cell; +mod transcript; mod tui; mod ui_consts; mod version; mod worker; #[cfg(test)] +mod worker_event_test_helpers; +#[cfg(test)] mod worker_queue_compaction_tests; mod wrapping; diff --git a/crates/tui/src/streaming/controller.rs b/crates/tui/src/streaming/controller.rs index bdde1ce3..93cd9dae 100644 --- a/crates/tui/src/streaming/controller.rs +++ b/crates/tui/src/streaming/controller.rs @@ -14,14 +14,15 @@ use crate::history_cell::HistoryCell; use crate::history_cell::{self}; use crate::markdown::render_markdown_with_metadata; use crate::markdown_render::RenderedMarkdownLine; -use ratatui::style::Stylize; -#[cfg(test)] use ratatui::text::Line; +use ratatui::text::Span; use std::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; +use crate::ui_consts::REPLY_MARKER_COLOR; + use super::StreamState; /// Shared source-retaining stream state for assistant output. @@ -315,7 +316,10 @@ impl StreamController { history_cell::AgentMessageCell::new_with_rendered_lines( lines, if is_first_line { - "▌ ".dim() + Line::from(vec![ + Span::styled("▌", ratatui::style::Style::default().fg(REPLY_MARKER_COLOR)), + " ".into(), + ]) } else { " ".into() }, diff --git a/crates/tui/src/tool_io_cell.rs b/crates/tui/src/tool_io_cell.rs index 4f0f266d..ca9a962d 100644 --- a/crates/tui/src/tool_io_cell.rs +++ b/crates/tui/src/tool_io_cell.rs @@ -17,7 +17,12 @@ use crate::ansi_escape::ansi_escape_line; use crate::diff_render::create_diff_summary; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; +use crate::render::line_utils::prefix_lines; use crate::tool_result_cell::ToolResultCell; +use crate::transcript::tool_state::shell_command_from_input; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; +use textwrap::WordSplitter; #[derive(Debug)] pub(crate) struct ToolIoCellOptions { @@ -107,10 +112,36 @@ impl ToolIoCell { } lines } + + fn shell_compact_display_lines(&self, width: u16) -> Vec> { + let mut body: Vec> = self.title_line.iter().cloned().collect(); + if let Some(command) = shell_command_from_input(&self.input) { + let command_line = Line::from(Span::styled(command, Style::default().dim())); + let wrapped = adaptive_wrap_lines( + std::slice::from_ref(&command_line), + RtOptions::new(width.max(1) as usize).word_splitter(WordSplitter::NoHyphenation), + ); + body.extend(prefix_lines( + wrapped, + Span::from(" ").dim(), + Span::from(" ").dim(), + )); + } + AgentMessageCell::new_with_prefix( + body, + self.dot_prefix.clone(), + self.subsequent_prefix.clone(), + false, + ) + .display_lines(width) + } } impl HistoryCell for ToolIoCell { fn display_lines(&self, width: u16) -> Vec> { + if crate::transcript::tool_state::is_shell_tool_name(&self.tool_name) { + return self.shell_compact_display_lines(width); + } self.legacy_cell().display_lines(width) } diff --git a/crates/tui/src/tool_rendering_e2e_tests.rs b/crates/tui/src/tool_rendering_e2e_tests.rs index 7a2f49b8..b4b2d1e4 100644 --- a/crates/tui/src/tool_rendering_e2e_tests.rs +++ b/crates/tui/src/tool_rendering_e2e_tests.rs @@ -9,7 +9,6 @@ use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::chatwidget::ChatWidgetInit; use crate::chatwidget::TuiSessionState; -use crate::events::WorkerEvent; use crate::tui::frame_requester::FrameRequester; fn widget_with_model( @@ -63,65 +62,65 @@ fn streaming_read_and_glob_updates_render_in_one_explored_cell() { }; let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from(".")); - widget.handle_worker_event(WorkerEvent::ToolCall { - tool_use_id: "read-1".to_string(), - summary: "read {}".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Read { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "read-1".to_string(), + "read {}".to_string(), + false, + Some(vec![devo_protocol::parse_command::ParsedCommand::Read { cmd: String::new(), name: String::new(), path: PathBuf::new(), }]), - }); + )); assert_eq!( active_display(&widget).contains("Running read {}"), false, "read start must render as explored placeholder" ); - widget.handle_worker_event(WorkerEvent::ToolCallUpdated { - tool_use_id: "read-1".to_string(), - summary: "read README.md".to_string(), - parsed_commands: vec![devo_protocol::parse_command::ParsedCommand::Read { + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_updated( + "read-1".to_string(), + "read README.md".to_string(), + vec![devo_protocol::parse_command::ParsedCommand::Read { cmd: "read README.md".to_string(), name: "README.md".to_string(), path: PathBuf::from("README.md"), }], - }); - widget.handle_worker_event(WorkerEvent::ToolResult { - tool_use_id: "read-1".to_string(), - title: "read README.md".to_string(), - preview: "# Devo".to_string(), - is_error: false, - truncated: false, - }); + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "read-1".to_string(), + "read README.md".to_string(), + "# Devo".to_string(), + false, + false, + )); - widget.handle_worker_event(WorkerEvent::ToolCall { - tool_use_id: "glob-1".to_string(), - summary: "glob {}".to_string(), - preparing: false, - parsed_commands: Some(vec![ + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call( + "glob-1".to_string(), + "glob {}".to_string(), + false, + Some(vec![ devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob".to_string(), path: Some("glob".to_string()), }, ]), - }); - widget.handle_worker_event(WorkerEvent::ToolCallUpdated { - tool_use_id: "glob-1".to_string(), - summary: "glob **/Cargo.toml in crates".to_string(), - parsed_commands: vec![devo_protocol::parse_command::ParsedCommand::ListFiles { + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_call_updated( + "glob-1".to_string(), + "glob **/Cargo.toml in crates".to_string(), + vec![devo_protocol::parse_command::ParsedCommand::ListFiles { cmd: "glob **/Cargo.toml in crates".to_string(), path: Some("**/Cargo.toml in crates".to_string()), }], - }); - widget.handle_worker_event(WorkerEvent::ToolResult { - tool_use_id: "glob-1".to_string(), - title: "glob **/Cargo.toml in crates".to_string(), - preview: "crates/tools/Cargo.toml".to_string(), - is_error: false, - truncated: false, - }); + )); + widget.handle_worker_event(crate::worker_event_test_helpers::tool_result( + "glob-1".to_string(), + "glob **/Cargo.toml in crates".to_string(), + "crates/tools/Cargo.toml".to_string(), + false, + false, + )); let display = active_display(&widget); assert!( @@ -133,7 +132,7 @@ fn streaming_read_and_glob_updates_render_in_one_explored_cell() { "expected final read file name:\n{display}" ); assert!( - display.contains("List **/Cargo.toml in crates"), + display.contains("Found **/Cargo.toml in crates"), "expected final glob parameters:\n{display}" ); assert!( diff --git a/crates/tui/src/transcript/file_change.rs b/crates/tui/src/transcript/file_change.rs new file mode 100644 index 00000000..69045797 --- /dev/null +++ b/crates/tui/src/transcript/file_change.rs @@ -0,0 +1,18 @@ +//! Helpers for file-change transcript projection. + +use std::collections::HashMap; +use std::path::PathBuf; + +use devo_protocol::protocol::FileChange; + +pub(crate) fn has_visible_file_changes(changes: &HashMap) -> bool { + changes.values().any(|change| match change { + FileChange::Add { content } | FileChange::Delete { content } => !content.trim().is_empty(), + FileChange::Update { + unified_diff, + old_text, + new_text, + move_path, + } => !unified_diff.trim().is_empty() || old_text != new_text || move_path.is_some(), + }) +} diff --git a/crates/tui/src/transcript/lifecycle.rs b/crates/tui/src/transcript/lifecycle.rs new file mode 100644 index 00000000..d003f693 --- /dev/null +++ b/crates/tui/src/transcript/lifecycle.rs @@ -0,0 +1,80 @@ +//! Transcript lifecycle events for [`super::TranscriptProjector`]. +//! +//! Tool rows use fact-only events (`ToolOpened`, chunks, `ToolClosed`). Verbs and +//! titles are derived at render time in [`super::presentation`]. + +use std::collections::HashMap; +use std::path::PathBuf; + +use devo_core::ItemId; +use devo_protocol::protocol::ExecCommandSource; +use devo_protocol::protocol::FileChange; + +use crate::events::PlanStep; +use crate::events::TextItemKind; + +/// One transcript-affecting lifecycle transition. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum ItemLifecycleEvent { + TextStarted { + item_id: ItemId, + kind: TextItemKind, + }, + TextDelta { + item_id: ItemId, + kind: TextItemKind, + delta: String, + }, + TextCompleted { + item_id: ItemId, + kind: TextItemKind, + final_text: String, + }, + ProposedPlanStarted { + item_id: ItemId, + }, + ProposedPlanDelta { + item_id: ItemId, + delta: String, + }, + ProposedPlanCompleted { + item_id: ItemId, + final_text: String, + }, + /// A tool row opened (model call, file change, or command execution). + ToolOpened { + tool_use_id: String, + tool_name: String, + input: serde_json::Value, + command: Option, + command_source: Option, + parsed_commands: Vec, + }, + /// Partial tool-call input JSON while parameters are still streaming. + ToolInputChunk { + tool_use_id: String, + chunk: String, + }, + /// Streaming tool output (command stdout, etc.). + ToolOutputChunk { + tool_use_id: String, + chunk: String, + }, + /// A tool row finished and should commit to history. + ToolClosed { + tool_use_id: String, + tool_name: String, + input: serde_json::Value, + output: Option, + display_content: Option, + file_changes: Option>, + is_error: bool, + truncated: bool, + }, + PlanUpdated { + explanation: Option, + steps: Vec, + }, + /// Clears live tool rows when a turn ends without individual completions. + TurnLiveToolsCleared, +} diff --git a/crates/tui/src/transcript/mod.rs b/crates/tui/src/transcript/mod.rs new file mode 100644 index 00000000..3fef6efa --- /dev/null +++ b/crates/tui/src/transcript/mod.rs @@ -0,0 +1,14 @@ +//! Transcript projection: one model for live and restored sessions (L2-DES-TUI-007). + +pub(crate) mod file_change; +pub(crate) mod lifecycle; +pub(crate) mod model; +pub(crate) mod presentation; +pub(crate) mod projector; +pub(crate) mod render; +pub(crate) mod restore; +pub(crate) mod restore_session; +pub(crate) mod stream_text; +pub(crate) mod tool_state; + +pub(crate) use projector::TranscriptProjector; diff --git a/crates/tui/src/transcript/model.rs b/crates/tui/src/transcript/model.rs new file mode 100644 index 00000000..30cc84f8 --- /dev/null +++ b/crates/tui/src/transcript/model.rs @@ -0,0 +1,202 @@ +//! Pure transcript cell models. Renderers consume these without mutating them. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +use devo_core::ItemId; +use devo_protocol::parse_command::ParsedCommand; +use devo_protocol::protocol::ExecCommandSource; +use devo_protocol::protocol::FileChange; + +use crate::events::TextItemKind; +use crate::exec_cell::CommandOutput; + +/// Lifecycle phase for any tool row in the transcript. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolPhase { + Preparing, + Running, + Completed, + Failed, +} + +/// Unified tool representation: write/edit, exec, and generic tools share one model. +#[derive(Debug, Clone)] +pub(crate) struct ToolCellModel { + pub(crate) tool_use_id: String, + pub(crate) seq: u64, + pub(crate) phase: ToolPhase, + pub(crate) summary: String, + pub(crate) tool_name: Option, + pub(crate) input: Option, + pub(crate) input_partial_json: String, + pub(crate) parsed_commands: Vec, + pub(crate) exec_like: bool, + pub(crate) start_time: Option, + pub(crate) output_preview: String, + pub(crate) output_delta_lines: Vec, + pub(crate) file_changes: Option>, + pub(crate) command: Option, + pub(crate) command_source: Option, + pub(crate) command_output: Option, + pub(crate) command_duration: Option, + pub(crate) tool_output: Option, + pub(crate) tool_display_content: Option, + pub(crate) is_error: bool, + pub(crate) truncated: bool, +} + +impl ToolCellModel { + pub(crate) fn new_opened( + tool_use_id: String, + seq: u64, + tool_name: String, + input: serde_json::Value, + command: Option, + command_source: Option, + parsed_commands: Vec, + ) -> Self { + use super::tool_state::{initial_phase, is_exec_like}; + + let exec_like = is_exec_like(&parsed_commands) + || command.is_some() + || matches!( + tool_name.as_str(), + "exec_command" | "shell_command" | "bash" | "shell" | "write_stdin" + ); + let phase = initial_phase(&tool_name, &input); + Self { + tool_use_id, + seq, + phase, + summary: String::new(), + tool_name: Some(tool_name), + input: Some(input), + input_partial_json: String::new(), + parsed_commands, + exec_like, + start_time: if phase == ToolPhase::Preparing { + Some(Instant::now()) + } else { + None + }, + output_preview: String::new(), + output_delta_lines: Vec::new(), + file_changes: None, + command, + command_source, + command_output: None, + command_duration: None, + tool_output: None, + tool_display_content: None, + is_error: false, + truncated: false, + } + } + + pub(crate) fn refresh_opened( + &mut self, + tool_name: String, + input: serde_json::Value, + command: Option, + command_source: Option, + parsed_commands: Vec, + ) { + use super::tool_state::{initial_phase, is_exec_like}; + + self.tool_name = Some(tool_name.clone()); + self.input = Some(input.clone()); + self.command = command; + self.command_source = command_source; + self.parsed_commands = parsed_commands; + self.exec_like = is_exec_like(&self.parsed_commands) + || self.command.is_some() + || matches!( + tool_name.as_str(), + "exec_command" | "shell_command" | "bash" | "shell" | "write_stdin" + ); + if self.phase == ToolPhase::Preparing && !super::tool_state::input_is_incomplete(&input) { + self.phase = ToolPhase::Running; + } else if self.phase == ToolPhase::Preparing { + self.phase = initial_phase(&tool_name, &input); + } + } + + pub(crate) fn new_running( + tool_use_id: String, + seq: u64, + summary: String, + preparing: bool, + parsed_commands: Option>, + ) -> Self { + let exec_like = parsed_commands.as_ref().is_some_and(|parsed| { + !parsed.is_empty() + && parsed.iter().all(|parsed| { + !matches!( + parsed, + devo_protocol::parse_command::ParsedCommand::Unknown { .. } + ) + }) + }); + Self { + tool_use_id, + seq, + phase: if preparing { + ToolPhase::Preparing + } else { + ToolPhase::Running + }, + summary, + tool_name: None, + input: None, + input_partial_json: String::new(), + parsed_commands: parsed_commands.unwrap_or_default(), + exec_like, + start_time: if preparing { + Some(Instant::now()) + } else { + None + }, + output_preview: String::new(), + output_delta_lines: Vec::new(), + file_changes: None, + command: None, + command_source: None, + command_output: None, + command_duration: None, + tool_output: None, + tool_display_content: None, + is_error: false, + truncated: false, + } + } + + pub(crate) fn is_live(&self) -> bool { + matches!(self.phase, ToolPhase::Preparing | ToolPhase::Running) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct TextCellModel { + pub(crate) item_id: ItemId, + pub(crate) kind: TextItemKind, + pub(crate) text: String, +} + +/// In-flight assistant or reasoning stream owned by the transcript projector. +#[derive(Debug, Clone)] +pub(crate) struct LiveTextCellModel { + pub(crate) item_id: ItemId, + pub(crate) kind: TextItemKind, + pub(crate) seq: u64, + pub(crate) text: String, +} + +/// One committed transcript entry produced by the projector. +#[derive(Debug, Clone)] +pub(crate) enum CommittedCellModel { + Tool(ToolCellModel), + Text(TextCellModel), +} diff --git a/crates/tui/src/transcript/presentation.rs b/crates/tui/src/transcript/presentation.rs new file mode 100644 index 00000000..156e5f66 --- /dev/null +++ b/crates/tui/src/transcript/presentation.rs @@ -0,0 +1,544 @@ +//! Semantic tool verb pairs and title formatting for the transcript. +//! +//! Maps tool names + parameters + lifecycle phase to user-facing labels such as +//! `Reading foo.rs` / `Read foo.rs`, independent of legacy summary strings. + +use std::path::Path; + +use devo_protocol::parse_command::ParsedCommand; +use ratatui::prelude::*; +use ratatui::style::Style; +use ratatui::style::Stylize; + +use crate::agent_tool_cell::is_agent_task_tool_name; +use crate::transcript::model::ToolPhase; +use crate::ui_consts::COMPLETED_COLOR; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolVerbKind { + Reasoning, + Read, + Write, + Edit, + Shell, + Grep, + Find, + Skill, + Generic, +} + +impl ToolVerbKind { + pub(crate) fn from_tool_name(tool_name: &str) -> Self { + match tool_name { + "read" => Self::Read, + "write" => Self::Write, + "edit" | "apply_patch" => Self::Edit, + "bash" | "shell_command" | "exec_command" | "write_stdin" => Self::Shell, + "grep" => Self::Grep, + "find" | "glob" => Self::Find, + "skill" => Self::Skill, + _ => Self::Generic, + } + } + + fn running_verb(self) -> &'static str { + match self { + Self::Reasoning => "Thinking", + Self::Read => "Reading", + Self::Write => "Writing", + Self::Edit => "Editing", + Self::Shell => "Running", + Self::Grep => "Grepping", + Self::Find => "Finding", + Self::Skill => "Loading", + Self::Generic => "Running", + } + } + + fn completed_verb(self, tool_name: &str, change_is_add: bool) -> &'static str { + match self { + Self::Reasoning => "Thought", + Self::Read => "Read", + Self::Write if change_is_add => "Wrote", + Self::Write => "Wrote", + Self::Edit => "Edited", + Self::Shell => "Ran", + Self::Grep => "Grepped", + Self::Find => "Found", + Self::Skill => "Loaded", + Self::Generic => { + if tool_name == "web_search" || tool_name == "websearch" { + return ""; + } + "Ran" + } + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ToolTitleParts { + pub(crate) verb: String, + pub(crate) detail: String, +} + +pub(crate) fn tool_title_parts( + phase: ToolPhase, + tool_name: Option<&str>, + input: Option<&serde_json::Value>, + parsed_commands: &[ParsedCommand], + completed_with_add: bool, + summary_fallback: &str, +) -> ToolTitleParts { + if phase == ToolPhase::Preparing { + if summary_fallback == "apply_patch" || tool_name == Some("apply_patch") { + return ToolTitleParts { + verb: "Preparing apply_patch...".to_string(), + detail: String::new(), + }; + } + if summary_fallback.starts_with("write ") + || summary_fallback.starts_with("write:") + || tool_name == Some("write") + { + let detail = path_from_input(input).unwrap_or_else(|| { + summary_fallback + .strip_prefix("write ") + .or_else(|| summary_fallback.strip_prefix("write:")) + .unwrap_or(summary_fallback) + .to_string() + }); + return ToolTitleParts { + verb: "Preparing write...".to_string(), + detail, + }; + } + if tool_name == Some("edit") { + return ToolTitleParts { + verb: "Preparing edit...".to_string(), + detail: path_from_input(input).unwrap_or_default(), + }; + } + return ToolTitleParts { + verb: "Preparing...".to_string(), + detail: String::new(), + }; + } + + if tool_name.is_some_and(is_agent_task_tool_name) { + return agent_task_title_parts(tool_name.unwrap(), phase, input); + } + + if tool_name.is_some_and(super::tool_state::is_shell_tool_name) { + let completed = matches!(phase, ToolPhase::Completed | ToolPhase::Failed); + let verb = if completed { "Ran" } else { "Running" }; + let detail = super::tool_state::shell_description_from_input(input).unwrap_or_else(|| { + input + .and_then(super::tool_state::shell_command_from_input) + .map(|command| compact_shell_explanation(&command)) + .unwrap_or_else(|| normalize_summary_fallback(summary_fallback)) + }); + return ToolTitleParts { + verb: verb.to_string(), + detail, + }; + } + + if let Some(parsed) = parsed_commands.first() { + return title_from_parsed_command(parsed, phase); + } + + if summary_fallback.starts_with("Web Search") || summary_fallback.starts_with("Web Fetch") { + return ToolTitleParts { + verb: String::new(), + detail: summary_fallback.to_string(), + }; + } + + if tool_name == Some("web_search") || tool_name == Some("websearch") { + if let Some(query) = input + .and_then(|value| value.get("query")) + .and_then(serde_json::Value::as_str) + { + return ToolTitleParts { + verb: String::new(), + detail: format!("Web Search(\"{query}\")"), + }; + } + } + if tool_name == Some("web_fetch") || tool_name == Some("webfetch") { + if let Some(url) = input + .and_then(|value| value.get("url")) + .and_then(serde_json::Value::as_str) + { + return ToolTitleParts { + verb: String::new(), + detail: format!("Web Fetch(\"{url}\")"), + }; + } + } + + let tool_name = tool_name.unwrap_or("tool"); + let kind = ToolVerbKind::from_tool_name(tool_name); + let completed = matches!(phase, ToolPhase::Completed | ToolPhase::Failed); + let verb = if completed { + kind.completed_verb(tool_name, completed_with_add) + .to_string() + } else { + kind.running_verb().to_string() + }; + let mut detail = detail_from_tool_input(tool_name, input); + if detail.is_empty() && kind == ToolVerbKind::Generic { + detail = normalize_summary_fallback(summary_fallback); + if detail.is_empty() { + detail = tool_name.to_string(); + } + } + ToolTitleParts { verb, detail } +} + +fn agent_task_title_parts( + tool_name: &str, + phase: ToolPhase, + input: Option<&serde_json::Value>, +) -> ToolTitleParts { + let completed = matches!(phase, ToolPhase::Completed | ToolPhase::Failed); + match tool_name { + "spawn_agent" | "agent_spawn" => { + let nickname = input + .and_then(|value| { + value + .get("agent_nickname") + .or_else(|| value.get("nickname")) + .or_else(|| value.get("agent_path")) + .and_then(serde_json::Value::as_str) + }) + .unwrap_or("agent"); + ToolTitleParts { + verb: if completed { + "Spawned agent".to_string() + } else { + "Spawning agent".to_string() + }, + detail: nickname.to_string(), + } + } + "await_task" | "wait_agent" | "agent_wait" => { + let target = input + .and_then(|value| { + value + .get("task_id") + .or_else(|| value.get("target")) + .or_else(|| value.get("agent_nickname")) + .and_then(serde_json::Value::as_str) + }) + .unwrap_or("task"); + ToolTitleParts { + verb: if completed { + "Awaited task".to_string() + } else { + "Waiting for task".to_string() + }, + detail: target.to_string(), + } + } + "list_tasks" | "list_agents" | "list_agent" | "agent_list" => ToolTitleParts { + verb: if completed { + "Listed tasks".to_string() + } else { + "Listing tasks".to_string() + }, + detail: String::new(), + }, + "cancel_task" | "close_agent" | "agent_close" => { + let target = input + .and_then(|value| { + value + .get("task_id") + .or_else(|| value.get("target")) + .or_else(|| value.get("agent_nickname")) + .and_then(serde_json::Value::as_str) + }) + .unwrap_or("task"); + ToolTitleParts { + verb: if completed { + "Canceled task".to_string() + } else { + "Canceling task".to_string() + }, + detail: target.to_string(), + } + } + _ => ToolTitleParts { + verb: if completed { "Ran" } else { "Running" }.to_string(), + detail: tool_name.to_string(), + }, + } +} + +fn normalize_summary_fallback(summary: &str) -> String { + summary + .strip_prefix("Running ") + .or_else(|| summary.strip_prefix("Ran ")) + .unwrap_or(summary) + .to_string() +} + +fn compact_shell_explanation(command: &str) -> String { + const MAX_CHARS: usize = 72; + if command.chars().count() <= MAX_CHARS { + return command.to_string(); + } + format!( + "{}…", + command + .chars() + .take(MAX_CHARS.saturating_sub(1)) + .collect::() + ) +} + +pub(crate) fn tool_title_line(phase: ToolPhase, parts: &ToolTitleParts) -> Line<'static> { + if phase == ToolPhase::Preparing { + return Line::from(vec![ + Span::styled(parts.verb.clone(), tool_status_running_style()), + if parts.detail.is_empty() { + Span::raw("") + } else { + Span::styled(format!(" {}", parts.detail), tool_text_style()) + }, + ]); + } + + let completed = matches!(phase, ToolPhase::Completed | ToolPhase::Failed); + let verb_style = if completed { + tool_status_done_style() + } else { + tool_status_running_style() + }; + + if parts.verb.is_empty() { + return Line::from(Span::styled(parts.detail.clone(), tool_text_style())); + } + + let detail = if parts.detail.is_empty() { + String::new() + } else { + format!(" {}", parts.detail) + }; + + Line::from(vec![ + Span::styled(parts.verb.clone(), verb_style), + Span::styled(detail, tool_text_style()), + ]) +} + +pub(crate) fn tool_status_running_style() -> Style { + Style::default().fg(COMPLETED_COLOR).bold() +} + +pub(crate) fn tool_status_done_style() -> Style { + Style::default().fg(COMPLETED_COLOR).bold() +} + +fn tool_text_style() -> Style { + Style::default() +} + +pub(crate) fn title_from_parsed_command( + parsed: &ParsedCommand, + phase: ToolPhase, +) -> ToolTitleParts { + let completed = matches!(phase, ToolPhase::Completed | ToolPhase::Failed); + match parsed { + ParsedCommand::Read { name, path, cmd } => { + let detail = read_display_name(name, path, cmd); + let verb = if completed { "Read" } else { "Reading" }; + ToolTitleParts { + verb: verb.to_string(), + detail, + } + } + ParsedCommand::Search { query, path, cmd } => { + let detail = match (query, path) { + (Some(q), Some(p)) => format!("'{q}' in {p}"), + (Some(q), None) => format!("'{q}'"), + _ => cmd.clone(), + }; + let verb = if completed { "Grepped" } else { "Grepping" }; + ToolTitleParts { + verb: verb.to_string(), + detail, + } + } + ParsedCommand::ListFiles { path, cmd } => { + let detail = path.clone().unwrap_or_else(|| cmd.clone()); + let verb = if completed { "Found" } else { "Finding" }; + ToolTitleParts { + verb: verb.to_string(), + detail, + } + } + ParsedCommand::Unknown { cmd } => { + let verb = if completed { "Ran" } else { "Running" }; + ToolTitleParts { + verb: verb.to_string(), + detail: cmd.clone(), + } + } + } +} + +fn detail_from_tool_input(tool_name: &str, input: Option<&serde_json::Value>) -> String { + let Some(input) = input else { + return String::new(); + }; + match ToolVerbKind::from_tool_name(tool_name) { + ToolVerbKind::Read => path_from_input(Some(input)) + .map(|path| format!("{path}{}", line_range_suffix(input))) + .unwrap_or_default(), + ToolVerbKind::Write | ToolVerbKind::Edit => { + path_from_input(Some(input)).unwrap_or_default() + } + ToolVerbKind::Shell => super::tool_state::shell_description_from_input(Some(input)) + .unwrap_or_else(|| { + super::tool_state::shell_command_from_input(input) + .map(|command| compact_shell_explanation(&command)) + .unwrap_or_default() + }), + ToolVerbKind::Grep => { + let pattern = input + .get("pattern") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + match input.get("path").and_then(serde_json::Value::as_str) { + Some(path) => format!("'{pattern}' in {path}"), + None => format!("'{pattern}'"), + } + } + ToolVerbKind::Find => input + .get("pattern") + .or_else(|| input.get("path")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + ToolVerbKind::Skill => { + let name = input + .get("name") + .or_else(|| input.get("skill")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let path = input + .get("path") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if path.is_empty() { + name.to_string() + } else if name.is_empty() { + path.to_string() + } else { + format!("{name} ({path})") + } + } + ToolVerbKind::Generic => input + .get("query") + .or_else(|| input.get("url")) + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + .unwrap_or_default(), + ToolVerbKind::Reasoning => String::new(), + } +} + +fn path_from_input(input: Option<&serde_json::Value>) -> Option { + input.and_then(|input| { + input + .get("filePath") + .or_else(|| input.get("path")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) +} + +fn line_range_suffix(input: &serde_json::Value) -> String { + let offset = input.get("offset").and_then(serde_json::Value::as_u64); + let limit = input.get("limit").and_then(serde_json::Value::as_u64); + match (offset, limit) { + (Some(start), Some(limit)) => format!(" L:{start}-{}", start.saturating_add(limit)), + (Some(start), None) => format!(" L:{start}"), + (None, Some(limit)) => format!(" L:0-{limit}"), + (None, None) => String::new(), + } +} + +fn read_display_name(name: &str, path: &Path, cmd: &str) -> String { + if !name.is_empty() { + return name.to_string(); + } + if let Some(file_name) = path.file_name() { + return file_name.to_string_lossy().to_string(); + } + let path = path.to_string_lossy(); + if !path.is_empty() { + return path.to_string(); + } + cmd.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn read_uses_reading_and_read_verbs() { + let input = serde_json::json!({"filePath": "src/lib.rs", "offset": 10, "limit": 5}); + let running = tool_title_parts( + ToolPhase::Running, + Some("read"), + Some(&input), + &[], + false, + "", + ); + assert_eq!(running.verb, "Reading"); + assert!(running.detail.contains("src/lib.rs")); + + let done = tool_title_parts( + ToolPhase::Completed, + Some("read"), + Some(&input), + &[], + false, + "", + ); + assert_eq!(done.verb, "Read"); + } + + #[test] + fn write_uses_writing_and_wrote_verbs() { + let input = serde_json::json!({"filePath": "src/lib.rs"}); + let running = tool_title_parts( + ToolPhase::Running, + Some("write"), + Some(&input), + &[], + false, + "", + ); + assert_eq!(running.verb, "Writing"); + assert_eq!(running.detail, "src/lib.rs"); + } + + #[test] + fn grep_uses_grepping_and_grepped_verbs() { + let parsed = vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "grep plan in crates".to_string(), + query: Some("plan".to_string()), + path: Some("crates".to_string()), + }]; + let running = tool_title_parts(ToolPhase::Running, Some("grep"), None, &parsed, false, ""); + assert_eq!(running.verb, "Grepping"); + assert_eq!(running.detail, "'plan' in crates"); + } +} diff --git a/crates/tui/src/transcript/projector.rs b/crates/tui/src/transcript/projector.rs new file mode 100644 index 00000000..05fcf118 --- /dev/null +++ b/crates/tui/src/transcript/projector.rs @@ -0,0 +1,452 @@ +//! Applies [`ItemLifecycleEvent`] values to a single transcript projection. + +use std::collections::HashMap; + +use crate::transcript::lifecycle::ItemLifecycleEvent; +use crate::transcript::model::CommittedCellModel; +use crate::transcript::model::LiveTextCellModel; +use crate::transcript::model::TextCellModel; +use crate::transcript::model::ToolCellModel; +use crate::transcript::model::ToolPhase; +use crate::transcript::tool_state::initial_phase; + +use super::file_change::has_visible_file_changes; +use super::model::ToolCellModel as ToolModel; +use super::stream_text::apply_stream_text_delta; + +/// Single source of truth for transcript lifecycle (text + tools). +#[derive(Debug, Default)] +pub(crate) struct TranscriptProjector { + tools: HashMap, + tool_order: Vec, + live_text: HashMap, + text_order: Vec, + next_seq: u64, + committed: Vec, + synced_committed: usize, +} + +impl TranscriptProjector { + pub(crate) fn apply(&mut self, event: ItemLifecycleEvent) { + match event { + ItemLifecycleEvent::ToolOpened { + tool_use_id, + tool_name, + input, + command, + command_source, + parsed_commands, + } => { + if let Some(tool) = self.tools.get_mut(&tool_use_id) { + tool.refresh_opened(tool_name, input, command, command_source, parsed_commands); + return; + } + let seq = self.reserve_seq(); + let tool = ToolModel::new_opened( + tool_use_id.clone(), + seq, + tool_name, + input, + command, + command_source, + parsed_commands, + ); + self.tool_order.push(tool_use_id.clone()); + self.tools.insert(tool_use_id, tool); + } + ItemLifecycleEvent::ToolInputChunk { tool_use_id, chunk } => { + if let Some(tool) = self.tools.get_mut(&tool_use_id) { + tool.input_partial_json.push_str(&chunk); + if let Ok(parsed) = + serde_json::from_str::(&tool.input_partial_json) + { + tool.input = Some(parsed.clone()); + if tool.phase == ToolPhase::Preparing + && let Some(name) = tool.tool_name.clone() + && !super::tool_state::input_is_incomplete(&parsed) + { + tool.phase = initial_phase(&name, &parsed); + } + } + } + } + ItemLifecycleEvent::ToolOutputChunk { tool_use_id, chunk } => { + if let Some(tool) = self.tools.get_mut(&tool_use_id) { + tool.output_preview.push_str(&chunk); + tool.output_delta_lines.push(chunk); + if tool.phase == ToolPhase::Preparing { + tool.phase = ToolPhase::Running; + } + } + } + ItemLifecycleEvent::ToolClosed { + tool_use_id, + tool_name, + input, + output, + display_content, + file_changes, + is_error, + truncated, + } => { + if let Some(changes) = file_changes.as_ref() + && !has_visible_file_changes(changes) + { + self.tools.remove(&tool_use_id); + self.tool_order.retain(|id| id != &tool_use_id); + return; + } + if let Some(mut tool) = self.tools.remove(&tool_use_id) { + self.tool_order.retain(|id| id != &tool_use_id); + if tool_name != "tool" { + tool.tool_name = Some(tool_name); + } + if !input.is_null() { + tool.input = Some(input); + } + tool.tool_output = output; + tool.tool_display_content = display_content.clone(); + if let Some(preview) = display_content { + tool.output_preview = preview; + } + if let Some(changes) = file_changes { + tool.file_changes = Some(changes); + } + tool.is_error = is_error; + tool.truncated = truncated; + tool.phase = if is_error { + ToolPhase::Failed + } else { + ToolPhase::Completed + }; + self.committed.push(CommittedCellModel::Tool(tool)); + } else { + let seq = self.reserve_seq(); + let phase = if is_error { + ToolPhase::Failed + } else { + ToolPhase::Completed + }; + self.committed.push(CommittedCellModel::Tool(ToolModel { + tool_use_id, + seq, + phase, + summary: String::new(), + tool_name: Some(tool_name), + input: Some(input), + input_partial_json: String::new(), + parsed_commands: Vec::new(), + exec_like: false, + start_time: None, + output_preview: display_content.clone().unwrap_or_default(), + output_delta_lines: Vec::new(), + file_changes, + command: None, + command_source: None, + command_output: None, + command_duration: None, + tool_output: output, + tool_display_content: display_content, + is_error, + truncated, + })); + } + } + ItemLifecycleEvent::TurnLiveToolsCleared => { + self.tools.clear(); + self.tool_order.clear(); + self.live_text.clear(); + self.text_order.clear(); + } + ItemLifecycleEvent::TextStarted { item_id, kind } => { + if self.live_text.contains_key(&item_id) { + return; + } + let seq = self.reserve_seq(); + self.text_order.push(item_id); + self.live_text.insert( + item_id, + LiveTextCellModel { + item_id, + kind, + seq, + text: String::new(), + }, + ); + } + ItemLifecycleEvent::TextDelta { + item_id, + kind, + delta, + } => { + if delta.is_empty() { + return; + } + if let Some(live) = self.live_text.get_mut(&item_id) { + apply_stream_text_delta(&mut live.text, &delta); + return; + } + let seq = self.reserve_seq(); + self.text_order.push(item_id); + self.live_text.insert( + item_id, + LiveTextCellModel { + item_id, + kind, + seq, + text: delta, + }, + ); + } + ItemLifecycleEvent::TextCompleted { + item_id, + kind, + final_text, + } => { + self.live_text.remove(&item_id); + self.text_order.retain(|id| *id != item_id); + if final_text.trim().is_empty() { + return; + } + self.committed.push(CommittedCellModel::Text(TextCellModel { + item_id, + kind, + text: final_text, + })); + } + ItemLifecycleEvent::ProposedPlanStarted { .. } + | ItemLifecycleEvent::ProposedPlanDelta { .. } + | ItemLifecycleEvent::ProposedPlanCompleted { .. } + | ItemLifecycleEvent::PlanUpdated { .. } => {} + } + } + + pub(crate) fn live_tools(&self) -> impl Iterator { + self.tool_order + .iter() + .filter_map(|id| self.tools.get(id)) + .filter(|tool| tool.is_live()) + } + + pub(crate) fn live_tool(&self, tool_use_id: &str) -> Option<&ToolCellModel> { + self.tools.get(tool_use_id).filter(|tool| tool.is_live()) + } + + pub(crate) fn live_text_items(&self) -> impl Iterator { + self.text_order + .iter() + .filter_map(|item_id| self.live_text.get(item_id)) + } + + pub(crate) fn live_text_for(&self, item_id: devo_core::ItemId) -> Option<&str> { + self.live_text.get(&item_id).map(|live| live.text.as_str()) + } + + pub(crate) fn has_live_text(&self, item_id: devo_core::ItemId) -> bool { + self.live_text.contains_key(&item_id) + } + + pub(crate) fn drop_live_text(&mut self, item_id: devo_core::ItemId) { + self.live_text.remove(&item_id); + self.text_order.retain(|id| *id != item_id); + } + + pub(crate) fn drain_unsynced_committed(&mut self) -> Vec { + let start = self.synced_committed; + let end = self.committed.len(); + self.synced_committed = end; + self.committed[start..end].to_vec() + } + + pub(crate) fn reset_sync_cursor(&mut self) { + self.synced_committed = 0; + self.committed.clear(); + self.tools.clear(); + self.tool_order.clear(); + self.live_text.clear(); + self.text_order.clear(); + } + + pub(crate) fn restore_committed(&mut self, cells: Vec) { + self.committed = cells; + self.synced_committed = 0; + } + + fn reserve_seq(&mut self) -> u64 { + let seq = self.next_seq; + self.next_seq = self.next_seq.wrapping_add(1); + seq + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + + use devo_protocol::protocol::FileChange; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn tool_close_preserves_opened_metadata_when_result_omits_tool_name() { + use crate::transcript::presentation::tool_title_line; + use crate::transcript::presentation::tool_title_parts; + + let mut projector = TranscriptProjector::default(); + projector.apply(ItemLifecycleEvent::ToolOpened { + tool_use_id: "grep-1".into(), + tool_name: "grep".into(), + input: serde_json::json!({"pattern": "plan", "path": "crates"}), + command: None, + command_source: None, + parsed_commands: Vec::new(), + }); + projector.apply(ItemLifecycleEvent::ToolClosed { + tool_use_id: "grep-1".into(), + tool_name: "tool".into(), + input: serde_json::Value::Null, + output: Some(serde_json::json!("matches")), + display_content: Some("matches".into()), + file_changes: None, + is_error: false, + truncated: false, + }); + + let committed = projector.drain_unsynced_committed(); + assert_eq!(committed.len(), 1); + let CommittedCellModel::Tool(tool) = &committed[0] else { + panic!("expected committed tool cell"); + }; + assert_eq!(tool.tool_name.as_deref(), Some("grep")); + assert_eq!( + tool.input, + Some(serde_json::json!({"pattern": "plan", "path": "crates"})) + ); + let parts = tool_title_parts( + tool.phase, + tool.tool_name.as_deref(), + tool.input.as_ref(), + &tool.parsed_commands, + false, + "", + ); + assert_eq!(parts.verb, "Grepped"); + assert_eq!(parts.detail, "'plan' in crates"); + let title = tool_title_line(tool.phase, &parts); + let title_text: String = title + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(title_text, "Grepped 'plan' in crates"); + } + + #[test] + fn file_change_closes_running_tool() { + let mut projector = TranscriptProjector::default(); + projector.apply(ItemLifecycleEvent::ToolOpened { + tool_use_id: "edit-1".into(), + tool_name: "edit".into(), + input: serde_json::json!({"filePath": "a.rs"}), + command: None, + command_source: None, + parsed_commands: Vec::new(), + }); + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("a.rs"), + FileChange::Update { + unified_diff: "@@\n".into(), + old_text: None, + new_text: None, + move_path: None, + }, + ); + projector.apply(ItemLifecycleEvent::ToolClosed { + tool_use_id: "edit-1".into(), + tool_name: "edit".into(), + input: serde_json::json!({"filePath": "a.rs"}), + output: None, + display_content: None, + file_changes: Some(changes), + is_error: false, + truncated: false, + }); + + assert_eq!(projector.live_tools().count(), 0); + assert_eq!(projector.committed.len(), 1); + } + + #[test] + fn text_delta_accepts_incremental_and_cumulative_chunks() { + let mut projector = TranscriptProjector::default(); + let item_id = devo_core::ItemId::new(); + projector.apply(ItemLifecycleEvent::TextStarted { + item_id, + kind: crate::events::TextItemKind::Reasoning, + }); + projector.apply(ItemLifecycleEvent::TextDelta { + item_id, + kind: crate::events::TextItemKind::Reasoning, + delta: "I".to_string(), + }); + projector.apply(ItemLifecycleEvent::TextDelta { + item_id, + kind: crate::events::TextItemKind::Reasoning, + delta: "'ll".to_string(), + }); + let text = projector + .live_text_items() + .next() + .map(|live| live.text.clone()) + .expect("live text"); + assert_eq!(text, "I'll"); + + projector.apply(ItemLifecycleEvent::TextDelta { + item_id, + kind: crate::events::TextItemKind::Reasoning, + delta: "I'll create".to_string(), + }); + let text = projector + .live_text_items() + .next() + .map(|live| live.text.clone()) + .expect("live text"); + assert_eq!(text, "I'll create"); + } + + #[test] + fn fragmented_line_splits_preserve_full_text() { + let mut projector = TranscriptProjector::default(); + let item_id = devo_core::ItemId::new(); + projector.apply(ItemLifecycleEvent::TextStarted { + item_id, + kind: crate::events::TextItemKind::Assistant, + }); + + let mut seed = 0x9e37_79b9_7f4a_7c15_u64; + let mut expected = String::new(); + for index in 0..=3 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let line = format!("line-{index:02}-{seed:016x}"); + let streamed_line = format!("{line}\n"); + let split_at = 1 + (seed as usize % (streamed_line.len() - 1)); + for delta in [&streamed_line[..split_at], &streamed_line[split_at..]] { + projector.apply(ItemLifecycleEvent::TextDelta { + item_id, + kind: crate::events::TextItemKind::Assistant, + delta: delta.to_string(), + }); + } + expected.push_str(&streamed_line); + let text = projector + .live_text_items() + .next() + .map(|live| live.text.clone()) + .expect("live text"); + assert_eq!(text, expected, "projector text mismatch after line {index}"); + } + } +} diff --git a/crates/tui/src/transcript/render.rs b/crates/tui/src/transcript/render.rs new file mode 100644 index 00000000..67838017 --- /dev/null +++ b/crates/tui/src/transcript/render.rs @@ -0,0 +1,153 @@ +//! Renders transcript cell models into history cells and styled lines. + +use std::path::Path; + +use ratatui::text::Line; + +use crate::agent_tool_cell::AgentToolCell; +use crate::agent_tool_cell::is_agent_task_tool_name; +use crate::history_cell; +use crate::history_cell::HistoryCell; +use crate::tool_io_cell::FileChangeToolIoCell; +use crate::tool_io_cell::ToolIoCell; +use crate::tool_io_cell::ToolIoCellOptions; +use crate::tool_result_cell::ToolResultCell; +use crate::transcript::model::CommittedCellModel; +use crate::transcript::model::ToolCellModel; +use crate::transcript::model::ToolPhase; +use crate::transcript::presentation::tool_title_line; +use crate::transcript::presentation::tool_title_parts; + +use super::model::TextCellModel; + +fn tool_title_for_cell(tool: &ToolCellModel) -> Line<'static> { + let change_is_add = tool.file_changes.as_ref().is_some_and(|changes| { + changes + .values() + .any(|change| matches!(change, devo_protocol::protocol::FileChange::Add { .. })) + }); + let parts = tool_title_parts( + tool.phase, + tool.tool_name.as_deref(), + tool.input.as_ref(), + &tool.parsed_commands, + change_is_add, + &tool.summary, + ); + tool_title_line(tool.phase, &parts) +} + +/// Converts a committed cell model into a renderable history cell. +pub(crate) fn committed_cell_to_history( + cell: &CommittedCellModel, + cwd: &Path, + _ran_tool_line: impl Fn(&str) -> Line<'static>, + tool_dot_prefix: Line<'static>, + tool_text_style: ratatui::style::Style, +) -> Box { + match cell { + CommittedCellModel::Tool(tool) => { + tool_cell_to_history(tool, cwd, tool_dot_prefix, tool_text_style) + } + CommittedCellModel::Text(text) => text_cell_to_history(text), + } +} + +fn tool_cell_to_history( + tool: &ToolCellModel, + cwd: &Path, + tool_dot_prefix: Line<'static>, + tool_text_style: ratatui::style::Style, +) -> Box { + if let Some(changes) = &tool.file_changes { + if let (Some(tool_name), Some(input)) = (&tool.tool_name, &tool.input) { + return Box::new(FileChangeToolIoCell::new( + None, + tool_name.clone(), + input.clone(), + changes.clone(), + cwd.to_path_buf(), + )); + } + return Box::new(history_cell::new_patch_event(changes.clone(), cwd)); + } + + if let (Some(tool_name), Some(input)) = (&tool.tool_name, &tool.input) { + if is_agent_task_tool_name(tool_name) { + return Box::new(AgentToolCell::new( + tool_name.clone(), + tool.phase, + Some(input.clone()), + tool.tool_output.clone(), + tool.tool_display_content + .clone() + .unwrap_or_else(|| tool.output_preview.clone()), + tool_dot_prefix.clone(), + )); + } + let title_line = Some(tool_title_for_cell(tool)); + return Box::new(ToolIoCell::new( + ToolIoCellOptions { + title_line, + dot_prefix: tool_dot_prefix.clone(), + subsequent_prefix: Line::from(" "), + output_style: tool_text_style, + show_empty_ellipsis: tool.truncated, + }, + tool_name.clone(), + input.clone(), + tool.tool_output.clone(), + tool.tool_display_content.clone(), + )); + } + + let title_line = Some(tool_title_for_cell(tool)); + Box::new(ToolResultCell::new( + title_line, + tool.output_preview.clone(), + tool_dot_prefix, + Line::from(" "), + tool_text_style, + tool.truncated, + )) +} + +fn text_cell_to_history(text: &TextCellModel) -> Box { + let _ = text; + Box::new(history_cell::PlainHistoryCell::new(Vec::new())) +} + +/// Live tool row for the inline viewport. +pub(crate) fn live_tool_display_lines( + tool: &ToolCellModel, + width: u16, + pending_dot_prefix: Line<'static>, + tool_text_style: ratatui::style::Style, +) -> Vec> { + let title_line = tool_title_for_cell(tool); + if tool.phase == ToolPhase::Preparing { + return vec![title_line]; + } + match (&tool.tool_name, &tool.input) { + (Some(tool_name), Some(input)) => ToolIoCell::from_text_output( + ToolIoCellOptions { + title_line: Some(title_line), + dot_prefix: pending_dot_prefix, + subsequent_prefix: " ".into(), + output_style: tool_text_style, + show_empty_ellipsis: false, + }, + tool_name.clone(), + input.clone(), + tool.output_preview.clone(), + ) + .display_lines(width), + _ => history_cell::AgentMessageCell::new_with_prefix( + vec![title_line], + pending_dot_prefix, + " ", + false, + ) + .display_lines(width), + } +} diff --git a/crates/tui/src/transcript/restore.rs b/crates/tui/src/transcript/restore.rs new file mode 100644 index 00000000..2c83f029 --- /dev/null +++ b/crates/tui/src/transcript/restore.rs @@ -0,0 +1,13 @@ +//! Restores durable history into the transcript projector. + +use devo_protocol::SessionHistoryItem; + +use crate::transcript::TranscriptProjector; + +/// Rebuilds a projector from rich session history items (live + restore share this path). +pub(crate) fn restore_projector_from_history(items: &[SessionHistoryItem]) -> TranscriptProjector { + let mut projector = TranscriptProjector::default(); + let committed = super::restore_session::committed_cells_from_history(items); + projector.restore_committed(committed); + projector +} diff --git a/crates/tui/src/transcript/restore_session.rs b/crates/tui/src/transcript/restore_session.rs new file mode 100644 index 00000000..0aab3df6 --- /dev/null +++ b/crates/tui/src/transcript/restore_session.rs @@ -0,0 +1,302 @@ +//! Session history → committed cell models (shared by live completion and restore). + +use std::collections::HashMap; + +use devo_protocol::SessionHistoryItem; +use devo_protocol::SessionHistoryItemKind; +use devo_protocol::SessionHistoryMetadata; +use devo_protocol::protocol::ExecCommandSource; +use devo_protocol::protocol::FileChange; + +use crate::events::TextItemKind; +use crate::transcript::model::CommittedCellModel; +use crate::transcript::model::TextCellModel; +use crate::transcript::model::ToolCellModel; +use crate::transcript::model::ToolPhase; +use crate::transcript::tool_state::is_shell_tool_name; +use crate::transcript::tool_state::shell_command_from_input; + +pub(crate) fn finalize_restored_tool_cell(mut tool: ToolCellModel) -> ToolCellModel { + if tool.tool_name.as_deref().is_some_and(is_shell_tool_name) { + tool.exec_like = true; + if tool.command.is_none() { + tool.command = tool + .input + .as_ref() + .and_then(shell_command_from_input) + .or_else(|| (!tool.summary.is_empty()).then(|| tool.summary.clone())); + } + tool.command_source = Some(ExecCommandSource::Agent); + } + tool +} + +pub(crate) fn committed_cells_from_history( + items: &[SessionHistoryItem], +) -> Vec { + let mut paired_result_by_call_id = HashMap::new(); + let mut consumed = std::collections::HashSet::new(); + + for (index, item) in items.iter().enumerate() { + if matches!( + item.kind, + SessionHistoryItemKind::ToolResult | SessionHistoryItemKind::Error + ) && let Some(tool_call_id) = item.tool_call_id.as_deref() + { + paired_result_by_call_id + .entry(tool_call_id.to_string()) + .or_insert(index); + } + } + + let mut committed = Vec::new(); + let mut seq = 0u64; + + for (index, item) in items.iter().enumerate() { + if consumed.contains(&index) { + continue; + } + + if let Some(SessionHistoryMetadata::Edited { changes }) = &item.metadata { + committed.push(CommittedCellModel::Tool(completed_tool_from_edit( + item, + changes.clone(), + seq, + ))); + seq = seq.wrapping_add(1); + continue; + } + + if item.kind == SessionHistoryItemKind::ToolCall + && let Some(tool_call_id) = item.tool_call_id.as_deref() + && let Some(result_index) = paired_result_by_call_id.get(tool_call_id).copied() + && result_index != index + { + consumed.insert(result_index); + let result_item = &items[result_index]; + if let Some(tool_cell) = paired_tool_cell(item, result_item, seq) { + committed.push(CommittedCellModel::Tool(tool_cell)); + seq = seq.wrapping_add(1); + } + continue; + } + + if let Some(cell) = restore_item_to_committed(item, seq) { + committed.push(cell); + seq = seq.wrapping_add(1); + } + } + + committed +} + +pub(crate) fn restore_item_to_committed( + item: &SessionHistoryItem, + seq: u64, +) -> Option { + match item.kind { + SessionHistoryItemKind::Assistant => Some(CommittedCellModel::Text(TextCellModel { + item_id: devo_core::ItemId::new(), + kind: TextItemKind::Assistant, + text: item.body.clone(), + })), + SessionHistoryItemKind::Reasoning => Some(CommittedCellModel::Text(TextCellModel { + item_id: devo_core::ItemId::new(), + kind: TextItemKind::Reasoning, + text: item.body.clone(), + })), + SessionHistoryItemKind::Error => Some(CommittedCellModel::Tool( + finalize_restored_tool_cell(ToolCellModel { + tool_use_id: item.tool_call_id.clone().unwrap_or_default(), + seq, + phase: ToolPhase::Failed, + summary: item.title.clone(), + tool_name: item.tool_io.as_ref().map(|io| io.tool_name.clone()), + input: item.tool_io.as_ref().map(|io| io.input.clone()), + input_partial_json: String::new(), + parsed_commands: Vec::new(), + exec_like: false, + start_time: None, + output_preview: item.body.clone(), + output_delta_lines: Vec::new(), + file_changes: edited_changes_from_history_item(item), + command: None, + command_source: None, + command_output: None, + command_duration: None, + tool_output: item.tool_io.as_ref().and_then(|io| io.output.clone()), + tool_display_content: item + .tool_io + .as_ref() + .and_then(|io| io.display_content.clone()), + is_error: true, + truncated: false, + }), + )), + SessionHistoryItemKind::ToolResult | SessionHistoryItemKind::CommandExecution => Some( + CommittedCellModel::Tool(finalize_restored_tool_cell(ToolCellModel { + tool_use_id: item.tool_call_id.clone().unwrap_or_default(), + seq, + phase: ToolPhase::Completed, + summary: item.title.clone(), + tool_name: item.tool_io.as_ref().map(|io| io.tool_name.clone()), + input: item.tool_io.as_ref().map(|io| io.input.clone()), + input_partial_json: String::new(), + parsed_commands: Vec::new(), + exec_like: false, + start_time: None, + output_preview: item.body.clone(), + output_delta_lines: Vec::new(), + file_changes: edited_changes_from_history_item(item), + command: None, + command_source: None, + command_output: None, + command_duration: None, + tool_output: item.tool_io.as_ref().and_then(|io| io.output.clone()), + tool_display_content: item + .tool_io + .as_ref() + .and_then(|io| io.display_content.clone()), + is_error: false, + truncated: false, + })), + ), + _ => None, + } +} + +pub(crate) fn paired_tool_cell( + call_item: &SessionHistoryItem, + result_item: &SessionHistoryItem, + seq: u64, +) -> Option { + let changes = edited_changes_from_history_item(result_item); + Some(finalize_restored_tool_cell(ToolCellModel { + tool_use_id: call_item.tool_call_id.clone().unwrap_or_default(), + seq, + phase: if result_item.kind == SessionHistoryItemKind::Error { + ToolPhase::Failed + } else { + ToolPhase::Completed + }, + summary: call_item.title.clone(), + tool_name: call_item + .tool_io + .as_ref() + .map(|io| io.tool_name.clone()) + .or_else(|| result_item.tool_io.as_ref().map(|io| io.tool_name.clone())), + input: call_item + .tool_io + .as_ref() + .map(|io| io.input.clone()) + .or_else(|| result_item.tool_io.as_ref().map(|io| io.input.clone())), + input_partial_json: String::new(), + parsed_commands: Vec::new(), + exec_like: false, + start_time: None, + output_preview: result_item.body.clone(), + output_delta_lines: Vec::new(), + file_changes: changes, + command: None, + command_source: None, + command_output: None, + command_duration: None, + tool_output: result_item + .tool_io + .as_ref() + .and_then(|io| io.output.clone()), + tool_display_content: result_item + .tool_io + .as_ref() + .and_then(|io| io.display_content.clone()), + is_error: result_item.kind == SessionHistoryItemKind::Error, + truncated: false, + })) +} + +pub(crate) fn completed_tool_from_edit( + item: &SessionHistoryItem, + changes: HashMap, + seq: u64, +) -> ToolCellModel { + ToolCellModel { + tool_use_id: item.tool_call_id.clone().unwrap_or_default(), + seq, + phase: ToolPhase::Completed, + summary: item.title.clone(), + tool_name: item.tool_io.as_ref().map(|io| io.tool_name.clone()), + input: item.tool_io.as_ref().map(|io| io.input.clone()), + input_partial_json: String::new(), + parsed_commands: Vec::new(), + exec_like: false, + start_time: None, + output_preview: String::new(), + output_delta_lines: Vec::new(), + file_changes: Some(changes), + command: None, + command_source: None, + command_output: None, + command_duration: None, + tool_output: None, + tool_display_content: None, + is_error: false, + truncated: false, + } +} + +fn edited_changes_from_history_item( + item: &SessionHistoryItem, +) -> Option> { + if let Some(SessionHistoryMetadata::Edited { changes }) = &item.metadata { + return Some(changes.clone()); + } + item.tool_io.as_ref().and_then(|io| { + io.output + .as_ref() + .and_then(|output| parse_file_changes_from_json(output)) + }) +} + +fn parse_file_changes_from_json( + output: &serde_json::Value, +) -> Option> { + let files = output.get("files")?.as_array()?; + let mut changes = HashMap::new(); + for file in files { + let path = std::path::PathBuf::from(file.get("path")?.as_str()?); + let kind = file.get("kind")?.as_str()?; + let change = match kind { + "add" => FileChange::Add { + content: file + .get("content") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + }, + "delete" => FileChange::Delete { + content: file + .get("content") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + }, + "update" | "move" => FileChange::Update { + unified_diff: file + .get("diff") + .or_else(|| file.get("patch")) + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + old_text: None, + new_text: None, + move_path: file + .get("move_path") + .and_then(serde_json::Value::as_str) + .map(std::path::PathBuf::from), + }, + _ => continue, + }; + changes.insert(path, change); + } + (!changes.is_empty()).then_some(changes) +} diff --git a/crates/tui/src/transcript/stream_text.rs b/crates/tui/src/transcript/stream_text.rs new file mode 100644 index 00000000..499a87a2 --- /dev/null +++ b/crates/tui/src/transcript/stream_text.rs @@ -0,0 +1,84 @@ +//! Wire-format text delta merge — single place for incremental vs cumulative semantics. + +/// Applies one streamed text chunk, accepting incremental tokens or full +/// cumulative snapshots from the wire. +pub(crate) fn apply_stream_text_delta(existing: &mut String, delta: &str) { + if delta.is_empty() { + return; + } + if delta.starts_with(existing.as_str()) { + *existing = delta.to_string(); + return; + } + if existing.starts_with(delta) && is_shorter_cumulative_snapshot(existing, delta) { + return; + } + existing.push_str(delta); +} + +/// Returns true when `delta` is a shorter cumulative snapshot of `existing`. +/// +/// Incremental tokens can be a prefix of the accumulated text from byte zero +/// (for example `"li"` against `"line-00-…"`) and must still append. Only +/// ignore the delta when it looks like a deliberate shorter snapshot. +fn is_shorter_cumulative_snapshot(existing: &str, delta: &str) -> bool { + if delta.len() >= existing.len() || !existing.starts_with(delta) { + return false; + } + let rest = &existing[delta.len()..]; + delta.contains('\n') + || rest.starts_with(' ') + || rest.starts_with('\n') + || delta.len() * 2 >= existing.len() +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn incremental_and_cumulative_chunks() { + let mut text = String::new(); + apply_stream_text_delta(&mut text, "I"); + apply_stream_text_delta(&mut text, "'ll"); + assert_eq!(text, "I'll"); + apply_stream_text_delta(&mut text, "I'll create"); + assert_eq!(text, "I'll create"); + } + + #[test] + fn incremental_prefix_token_appends_even_when_text_starts_with_it() { + let mut text = "line-00-abc\n".to_string(); + apply_stream_text_delta(&mut text, "li"); + assert_eq!(text, "line-00-abc\nli"); + apply_stream_text_delta(&mut text, "ne-03\n"); + assert_eq!(text, "line-00-abc\nline-03\n"); + } + + #[test] + fn shorter_cumulative_snapshot_is_ignored() { + let mut text = "Hello world".to_string(); + apply_stream_text_delta(&mut text, "Hello"); + assert_eq!(text, "Hello world"); + } + + #[test] + fn fragmented_line_splits_preserve_full_text() { + let mut text = String::new(); + let mut seed = 0x9e37_79b9_7f4a_7c15_u64; + let mut expected = String::new(); + for index in 0..=3 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let line = format!("line-{index:02}-{seed:016x}"); + let streamed_line = format!("{line}\n"); + let split_at = 1 + (seed as usize % (streamed_line.len() - 1)); + for delta in [&streamed_line[..split_at], &streamed_line[split_at..]] { + apply_stream_text_delta(&mut text, delta); + } + expected.push_str(&streamed_line); + assert_eq!(text, expected, "text mismatch after line {index}"); + } + } +} diff --git a/crates/tui/src/transcript/tool_state.rs b/crates/tui/src/transcript/tool_state.rs new file mode 100644 index 00000000..ebebc88b --- /dev/null +++ b/crates/tui/src/transcript/tool_state.rs @@ -0,0 +1,60 @@ +//! Derives projector tool state from structured tool facts (name + input). + +use devo_protocol::parse_command::ParsedCommand; +use devo_protocol::protocol::ExecCommandSource; + +use super::model::ToolPhase; + +pub(crate) fn is_streaming_param_tool(tool_name: &str) -> bool { + matches!(tool_name, "write" | "edit" | "apply_patch") +} + +pub(crate) fn input_is_incomplete(input: &serde_json::Value) -> bool { + input.is_null() || matches!(input, serde_json::Value::Object(map) if map.is_empty()) +} + +pub(crate) fn initial_phase(tool_name: &str, input: &serde_json::Value) -> ToolPhase { + if is_streaming_param_tool(tool_name) && input_is_incomplete(input) { + ToolPhase::Preparing + } else { + ToolPhase::Running + } +} + +pub(crate) fn is_exec_like(parsed_commands: &[ParsedCommand]) -> bool { + !parsed_commands.is_empty() + && parsed_commands + .iter() + .all(|parsed| !matches!(parsed, ParsedCommand::Unknown { .. })) +} + +pub(crate) fn shell_command_from_input(input: &serde_json::Value) -> Option { + input + .get("command") + .or_else(|| input.get("cmd")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + +pub(crate) fn shell_description_from_input(input: Option<&serde_json::Value>) -> Option { + input + .and_then(|value| value.get("description")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string) +} + +pub(crate) fn is_shell_tool_name(tool_name: &str) -> bool { + matches!( + tool_name, + "bash" | "shell_command" | "exec_command" | "write_stdin" | "shell" + ) +} + +pub(crate) fn command_source_from_tool_name(tool_name: &str) -> Option { + match tool_name { + "bash" | "shell_command" | "exec_command" | "write_stdin" => Some(ExecCommandSource::Agent), + _ => None, + } +} diff --git a/crates/tui/src/ui_consts.rs b/crates/tui/src/ui_consts.rs index 567e37d0..561edcd3 100644 --- a/crates/tui/src/ui_consts.rs +++ b/crates/tui/src/ui_consts.rs @@ -15,5 +15,8 @@ pub(crate) const REASONING_ACCENT_COLOR: ratatui::style::Color = ratatui::style::Color::Rgb(210, 150, 60); /// Orange used for soft alert history lines (`■ …`). pub(crate) const ALERT_COLOR: ratatui::style::Color = ratatui::style::Color::Rgb(245, 142, 53); +/// Blue used for the pending-state dot prefix and assistant reply marker. +pub(crate) const REPLY_MARKER_COLOR: ratatui::style::Color = + ratatui::style::Color::Rgb(110, 200, 255); /// Green used for completed, idle, and done indicators. pub(crate) const COMPLETED_COLOR: ratatui::style::Color = ratatui::style::Color::Rgb(120, 220, 160); diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index 62dd2083..c997d6b6 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -40,11 +40,8 @@ use devo_protocol::SpawnAgentParams; use devo_protocol::ThreadGoalStatus; use devo_protocol::TurnFailedPayload; use devo_protocol::native::rpc_session::RollbackMode; -use devo_server::ApprovalDecisionPayload; -use devo_server::ApprovalRequestPayload; use devo_server::ApprovalResponseParams; use devo_server::CollaborationMode; -use devo_server::CommandExecutionPayload; use devo_server::InputItem; use devo_server::ItemEnvelope; use devo_server::ItemEventPayload; @@ -55,8 +52,6 @@ use devo_server::SessionHistoryItemKind; use devo_server::SkillSource; use devo_server::StdioServerClient; use devo_server::StdioServerClientConfig; -use devo_server::ToolCallPayload; -use devo_server::ToolResultPayload; use devo_server::TurnEventPayload; use crate::app_command::GoalObjectiveMode; @@ -74,10 +69,31 @@ use crate::events::TextItemKind; use crate::events::TranscriptItem; use crate::events::TranscriptItemKind; use crate::events::WorkerEvent; - +use crate::transcript::lifecycle::ItemLifecycleEvent; + +mod approval_items; +mod compaction_items; +mod goals; +mod history; +mod item_dispatch; +mod native_items; +mod plan_items; +mod session_preview; +mod session_restore; +mod skills; mod subagent_events; +mod tool_lifecycle; +mod tool_summaries; mod typed_events; +#[cfg(test)] +pub(crate) use tool_summaries::exploration_actions_from_tool_input; +pub(crate) use tool_summaries::parse_plan_step_status; + +use session_restore::native_session_id; +use session_restore::restore_session_native; +use session_restore::session_switched_event_from_restore; + use subagent_events::subagent_monitor_events_from_unwrapped_server_notification; const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_millis(100); @@ -402,6 +418,7 @@ enum OperationCommand { #[derive(Debug, Clone, PartialEq)] struct ShellCommandExecStart { process_id: String, + command: String, started_event: WorkerEvent, params: CommandExecParams, } @@ -427,13 +444,14 @@ fn next_shell_command_exec_start( }); ShellCommandExecStart { process_id: process_id.clone(), - started_event: WorkerEvent::CommandExecutionStarted { - tool_use_id: process_id.clone(), - command: command.clone(), - input: Some(input), - source: devo_protocol::protocol::ExecCommandSource::UserShell, - command_actions: Vec::new(), - }, + command: command.clone(), + started_event: WorkerEvent::Transcript(tool_lifecycle::tool_opened_from_command_source( + process_id.clone(), + command.clone(), + Some(input), + devo_protocol::protocol::ExecCommandSource::UserShell, + Vec::new(), + )), params: CommandExecParams { session_id, process_id, @@ -1312,27 +1330,28 @@ async fn run_worker_inner( Ok(result) => { let process_id = result.item_id.as_str().to_string(); active_shell_process_ids.insert(process_id.clone()); - let _ = event_tx.send( - WorkerEvent::CommandExecutionStarted { - tool_use_id: process_id, - command: command.clone(), - input: Some(input), - source: devo_protocol::protocol::ExecCommandSource::UserShell, - command_actions: Vec::new(), - }, - ); + let _ = event_tx.send(WorkerEvent::Transcript( + tool_lifecycle::tool_opened_from_command_source( + process_id, + command.clone(), + Some(input), + devo_protocol::protocol::ExecCommandSource::UserShell, + Vec::new(), + ), + )); } Err(error) => { - let _ = event_tx.send(WorkerEvent::ToolResult { - tool_use_id: format!( - "user-shell-failed-{}", - next_shell_process_index + let _ = event_tx.send(WorkerEvent::Transcript( + tool_lifecycle::tool_closed_shell( + format!( + "user-shell-failed-{}", + next_shell_process_index + ), + "Shell".to_string(), + Some(error.to_string()), + true, ), - title: "Shell".to_string(), - preview: error.to_string(), - is_error: true, - truncated: false, - }); + )); next_shell_process_index += 1; } } @@ -1350,13 +1369,14 @@ async fn run_worker_inner( Ok(_) => {} Err(error) => { active_shell_process_ids.remove(&shell_start.process_id); - let _ = event_tx.send(WorkerEvent::ToolResult { - tool_use_id: shell_start.process_id, - title: "Shell".to_string(), - preview: error.to_string(), - is_error: true, - truncated: false, - }); + let _ = event_tx.send(WorkerEvent::Transcript( + tool_lifecycle::tool_closed_shell( + shell_start.process_id, + shell_start.command, + Some(error.to_string()), + true, + ), + )); } } } @@ -3415,9 +3435,30 @@ async fn run_worker_inner( if let Ok(item_id) = devo_protocol::ItemId::try_from(delta.item_id.as_str()) { - let _ = event_tx.send(WorkerEvent::TextItemDelta { + let _ = event_tx.send(WorkerEvent::Transcript( + ItemLifecycleEvent::TextDelta { + item_id, + kind, + delta: delta.delta, + }, + )); + } + } + continue; + } + "item/plan/delta" => { + if let Ok(delta) = serde_json::from_value::< + devo_protocol::native::event::ItemDelta, + >(params.clone()) + { + let delta_session = + SessionId::try_from(delta.session_id.as_str()).ok(); + if delta_session == session_id + && let Ok(item_id) = + devo_protocol::ItemId::try_from(delta.item_id.as_str()) + { + let _ = event_tx.send(WorkerEvent::ProposedPlanDelta { item_id, - kind, delta: delta.delta, }); } @@ -3441,14 +3482,29 @@ async fn run_worker_inner( .and_then(|v| v.as_str()) .unwrap_or(""); if !tool_use_id.is_empty() { - let _ = event_tx.send(WorkerEvent::ToolOutputDelta { - tool_use_id: tool_use_id.to_string(), - delta: text.to_string(), - }); + let _ = event_tx.send(WorkerEvent::Transcript( + tool_lifecycle::transcript_tool_output_chunk( + tool_use_id.to_string(), + text.to_string(), + ), + )); } } continue; } + "item/toolCall/inputDelta" => { + if let Ok(delta) = serde_json::from_value::< + devo_protocol::native::event::ItemDelta, + >(params) + && let Some(event) = + tool_lifecycle::transcript_tool_input_chunk_from_delta_payload( + &delta.delta, + ) + { + let _ = event_tx.send(WorkerEvent::Transcript(event)); + } + continue; + } "session/created" => { if let Ok(session) = serde_json::from_value::< devo_protocol::native::session::Session, @@ -3554,6 +3610,18 @@ async fn run_worker_inner( SessionId::try_from(payload.item.session_id.as_str()) .ok(); if item_session_id == session_id { + if method == "item/completed" + && let devo_protocol::native::item::Item::AssistantMessage { + text, + .. + } = &payload.item.item + { + let text = text.trim(); + if !text.is_empty() { + latest_completed_agent_message = + Some(text.to_string()); + } + } if method == "item/completed" && let devo_protocol::native::item::Item::UserInputRequest { request_id, @@ -3582,15 +3650,13 @@ async fn run_worker_inner( ) .await; } - if let Some(legacy) = - typed_events::legacy_item_event_from_typed(&payload) - { - if method == "item/started" { - handle_started_item(legacy, event_tx); - } else { - handle_completed_item(legacy, event_tx); - } - } + item_dispatch::dispatch_typed_item_lifecycle( + &method, + &payload, + devo_core::ItemId::try_from(payload.item.id.as_str()) + .expect("typed item id"), + event_tx, + ); } else if let Some(child_id) = item_session_id && child_agent_sessions.contains(&child_id) { @@ -3723,6 +3789,51 @@ async fn run_worker_inner( } continue; } + "context/compactionStarted" => { + let event_session_matches = params["sessionId"] + .as_str() + .and_then(|id| SessionId::try_from(id).ok()) + .is_some_and(|id| Some(id) == session_id); + if event_session_matches { + let _ = event_tx.send(WorkerEvent::SessionCompactionStarted); + } + continue; + } + "context/compactionCompleted" => { + let event_session_matches = params["sessionId"] + .as_str() + .and_then(|id| SessionId::try_from(id).ok()) + .is_some_and(|id| Some(id) == session_id); + if event_session_matches { + // Token totals arrive on the accompanying usage / + // session events; this surfaces busy-state clear. + let _ = event_tx.send(WorkerEvent::SessionCompacted { + total_input_tokens, + total_output_tokens, + total_tokens, + last_query_total_tokens, + last_query_input_tokens, + prompt_token_estimate: total_input_tokens, + }); + } + continue; + } + "context/compactionFailed" => { + let event_session_matches = params["sessionId"] + .as_str() + .and_then(|id| SessionId::try_from(id).ok()) + .is_some_and(|id| Some(id) == session_id); + if event_session_matches { + let message = params["message"] + .as_str() + .unwrap_or("Context compaction failed") + .to_string(); + let _ = event_tx.send( + WorkerEvent::SessionCompactionFailed { message }, + ); + } + continue; + } _ => {} } } @@ -3784,11 +3895,6 @@ async fn run_worker_inner( } latest_completed_agent_message = None; } - "item/started" => { - if let ServerEvent::ItemStarted(payload) = event { - handle_started_item(payload, event_tx); - } - } "item/agentMessage/delta" => { if let ServerEvent::ItemDelta { payload, .. } = event { if let Some(item_id) = payload.context.item_id { @@ -3816,26 +3922,18 @@ async fn run_worker_inner( "server assistant delta" ); } - let _ = event_tx.send(WorkerEvent::TextItemDelta { - item_id, - kind: TextItemKind::Assistant, - delta: payload.delta, - }); + let _ = event_tx.send(WorkerEvent::Transcript( + ItemLifecycleEvent::TextDelta { + item_id, + kind: TextItemKind::Assistant, + delta: payload.delta, + }, + )); } else { let _ = event_tx.send(WorkerEvent::TextDelta(payload.delta)); } } } - "item/plan/delta" => { - if let ServerEvent::ItemDelta { payload, .. } = event - && let Some(item_id) = payload.context.item_id - { - let _ = event_tx.send(WorkerEvent::ProposedPlanDelta { - item_id, - delta: payload.delta, - }); - } - } "item/commandExecution/outputDelta" => { if let ServerEvent::ItemDelta { payload, .. } = event { let delta_str = &payload.delta; @@ -3849,14 +3947,26 @@ async fn run_worker_inner( let text = val.get("text").and_then(|v| v.as_str()).unwrap_or(""); if !tool_use_id.is_empty() { - let _ = event_tx.send(WorkerEvent::ToolOutputDelta { - tool_use_id: tool_use_id.to_string(), - delta: text.to_string(), - }); + let _ = event_tx.send(WorkerEvent::Transcript( + tool_lifecycle::transcript_tool_output_chunk( + tool_use_id.to_string(), + text.to_string(), + ), + )); } } } } + "item/toolCall/inputDelta" => { + if let ServerEvent::ItemDelta { payload, .. } = event + && let Some(event) = + tool_lifecycle::transcript_tool_input_chunk_from_delta_payload( + &payload.delta, + ) + { + let _ = event_tx.send(WorkerEvent::Transcript(event)); + } + } "command/exec/outputDelta" => { if let ServerEvent::CommandExecOutputDelta(payload) = event { let CommandExecOutputDeltaPayload { @@ -3868,10 +3978,12 @@ async fn run_worker_inner( Ok(bytes) => { let delta = String::from_utf8_lossy(&bytes).to_string(); - let _ = event_tx.send(WorkerEvent::ToolOutputDelta { - tool_use_id: process_id, - delta, - }); + let _ = event_tx.send(WorkerEvent::Transcript( + tool_lifecycle::transcript_tool_output_chunk( + process_id, + delta, + ), + )); } Err(error) => { tracing::warn!( @@ -3890,13 +4002,14 @@ async fn run_worker_inner( .. } = payload; if active_shell_process_ids.remove(&process_id) { - let _ = event_tx.send(WorkerEvent::ToolResult { - tool_use_id: process_id, - title: "Shell".to_string(), - preview: String::new(), - is_error: false, - truncated: false, - }); + let _ = event_tx.send(WorkerEvent::Transcript( + tool_lifecycle::tool_closed_shell( + process_id, + String::new(), + Some(String::new()), + false, + ), + )); let _ = event_tx.send(WorkerEvent::ShellCommandFinished { exit_code, }); @@ -3913,31 +4026,18 @@ async fn run_worker_inner( channel = ?payload.channel, "server reasoning delta" ); - let _ = event_tx.send(WorkerEvent::TextItemDelta { - item_id, - kind: TextItemKind::Reasoning, - delta: payload.delta, - }); + let _ = event_tx.send(WorkerEvent::Transcript( + ItemLifecycleEvent::TextDelta { + item_id, + kind: TextItemKind::Reasoning, + delta: payload.delta, + }, + )); } else { let _ = event_tx.send(WorkerEvent::ReasoningDelta(payload.delta)); } } } - "item/completed" => { - if let ServerEvent::ItemCompleted(payload) = event { - tracing::debug!( - item_id = %payload.item.item_id, - item_kind = ?payload.item.item_kind, - "server item completed" - ); - if let Some(text) = completed_agent_message_text(&payload) { - latest_completed_agent_message = Some(text); - } - // Completed tool items are mapped into compact UI events - // with pre-rendered summaries and previews. - handle_completed_item(payload, event_tx); - } - } "turn/completed" => { if let ServerEvent::TurnCompleted(payload) = event { tracing::debug!( @@ -4398,20 +4498,6 @@ async fn prepare_session_for_command( Ok(active_session_id) } -/// Result of restoring a session through canonical APIs (resume + items -/// list + queue list), replacing the legacy `session/resume` aggregate -/// result (L2-DES-APP-008 Phase C). -struct NativeSessionRestore { - session: devo_protocol::native::session::Session, - history_items: Vec, - pending_texts: Vec, -} - -/// Restores a session through canonical APIs: `session/resume` (hydration), -/// `session/items/list` pages (transcript), and `session/queue/list` -/// (pending input previews). Approximations vs the legacy aggregate result: -/// `prompt_token_estimate` falls back to total input tokens, and the -/// per-query live meter starts at zero (it has no canonical source yet). /// Resolves a user-turn index (counting `Regular` turns in sequence order, /// matching the fork machinery's user-turn counting) into a turn id for /// canonical `session/fork` (L2-DES-APP-008 Phase C). @@ -4607,137 +4693,6 @@ fn restored_history_items( history_items } -async fn restore_session_native( - client: &mut StdioServerClient, - session_id: SessionId, -) -> Result { - let resumed = client.session_resume_native(session_id).await?; - let fallback_mode = resumed - .session - .settings - .mode - .as_deref() - .and_then(|mode| serde_json::from_value(serde_json::Value::String(mode.to_string())).ok()) - .unwrap_or_default(); - - let mut turns = Vec::new(); - let mut cursor = None; - loop { - let page = client - .session_turns_list_native(session_id, cursor.clone(), Some(200)) - .await?; - let page_len = page.data.len(); - let next_cursor = page.next_cursor; - turns.extend(page.data); - match (next_cursor, page_len) { - (Some(next), len) if len > 0 => cursor = Some(next), - _ => break, - } - } - - let mut items = Vec::new(); - let mut cursor = None; - loop { - let page = client - .session_items_list_native(session_id, cursor.clone(), Some(500)) - .await?; - let page_len = page.data.len(); - let next_cursor = page.next_cursor; - items.extend(page.data); - match (next_cursor, page_len) { - (Some(next), len) if len > 0 => cursor = Some(next), - _ => break, - } - } - let history_items = restored_history_items(turns, items, fallback_mode); - - let queue = client - .session_queue_list(devo_protocol::native::rpc_turn::SessionQueueListParams { - session_id: native_session_id(session_id), - }) - .await?; - let pending_texts = queue - .entries - .iter() - .map(|entry| entry.preview.clone()) - .collect(); - - Ok(NativeSessionRestore { - session: resumed.session, - history_items, - pending_texts, - }) -} - -/// Builds the `SessionSwitched` event from a canonical restore. Mapping -/// notes: `prompt_token_estimate` falls back to total input tokens (no -/// canonical source), and the last-query meter starts at zero (the -/// query-level usage event has no canonical vocabulary yet). -fn session_switched_event_from_restore( - session_id: SessionId, - restore: &NativeSessionRestore, -) -> WorkerEvent { - let session = &restore.session; - let active_agent_label = session.parent.as_ref().map(|parent| { - let label = match parent { - devo_protocol::native::session::SessionParent::Fork { .. } => "Fork".to_string(), - devo_protocol::native::session::SessionParent::Agent { role, .. } => { - role.clone().unwrap_or_else(|| "subagent".to_string()) - } - }; - format!("Agent: {label}") - }); - let total_usage = &session.usage.total; - let legacy_session_id = session_id; - WorkerEvent::SessionSwitched { - session_id: legacy_session_id.to_string(), - cwd: session.cwd.clone(), - title: session.title.clone(), - model: Some(session.model.model.clone()), - model_binding_id: (session.model.provider != "unknown") - .then(|| session.model.provider.clone()), - reasoning_effort_selection: session - .settings - .reasoning_effort - .map(|effort| effort.to_string()), - reasoning_effort: session.settings.reasoning_effort, - active_agent_label, - total_input_tokens: total_usage.input_tokens as usize, - total_output_tokens: total_usage.output_tokens as usize, - total_tokens: total_usage.total_tokens as usize, - total_cache_read_tokens: total_usage.cache_read_input_tokens as usize, - last_query_total_tokens: 0, - last_query_input_tokens: 0, - prompt_token_estimate: total_usage.input_tokens as usize, - history_items: project_history_items(&restore.history_items), - rich_history_items: restore.history_items.clone(), - loaded_item_count: restore.history_items.len() as u64, - pending_texts: restore.pending_texts.clone(), - collaboration_mode: session - .settings - .mode - .as_deref() - .and_then(|mode| { - serde_json::from_value(serde_json::Value::String(mode.to_string())).ok() - }) - .unwrap_or_default(), - permission_preset: Some(match session.settings.permission_profile { - devo_protocol::native::model::PermissionProfile::Default => PermissionPreset::Default, - devo_protocol::native::model::PermissionProfile::AutoReview => { - PermissionPreset::AutoReview - } - devo_protocol::native::model::PermissionProfile::FullAccess => { - PermissionPreset::FullAccess - } - }), - effective_context_window: session.settings.effective_context_window, - } -} - -fn native_session_id(session_id: SessionId) -> devo_protocol::native::ids::SessionId { - devo_protocol::native::ids::SessionId::from_string(session_id.to_string()) -} - /// Converts a canonical goal back into the legacy `ThreadGoal` shape the /// TUI's worker events still carry (L2-DES-APP-008 Phase C transition). /// `Blocked`/`UsageLimited` map to `Paused` and terminal `Failed`/`Canceled` @@ -5351,402 +5306,43 @@ async fn close_btw_agent(client: &mut StdioServerClient, child_session_id: Sessi let _ = client.agent_cancel_native(&item_id).await; } -fn emit_approval_request_item( - payload: serde_json::Value, - event_tx: &mpsc::UnboundedSender, -) { - let Ok(payload) = serde_json::from_value::(payload) else { - return; - }; - let Some(turn_id) = payload.request.turn_id else { - return; - }; - let _ = event_tx.send(WorkerEvent::ApprovalRequest { - session_id: payload.request.session_id, - turn_id, - approval_id: payload.approval_id.to_string(), - action_summary: payload.action_summary, - justification: payload.justification, - resource: payload.resource, - available_scopes: payload.available_scopes, - path: payload.path, - host: payload.host, - target: payload.target, - command_pattern: payload.command_pattern, - command_prefix: payload.command_prefix, - }); -} +fn project_history_items(items: &[SessionHistoryItem]) -> Vec { + use std::collections::{HashMap, HashSet}; -pub(crate) fn handle_started_item( - payload: ItemEventPayload, - event_tx: &mpsc::UnboundedSender, -) { - tracing::debug!( - item_id = %payload.item.item_id, - item_kind = ?payload.item.item_kind, - "server item started" - ); - let ItemEnvelope { - item_id, - item_kind, - payload, - } = payload.item; - match item_kind { - ItemKind::AgentMessage => { - let _ = event_tx.send(WorkerEvent::TextItemStarted { - item_id, - kind: TextItemKind::Assistant, - }); - } - ItemKind::Reasoning => { - let _ = event_tx.send(WorkerEvent::TextItemStarted { - item_id, - kind: TextItemKind::Reasoning, - }); - } - ItemKind::Plan => { - if is_proposed_plan_payload(&payload) { - let _ = event_tx.send(WorkerEvent::ProposedPlanStarted { item_id }); - } - } - ItemKind::CommandExecution => { - if let Ok(payload) = serde_json::from_value::(payload) { - let _ = event_tx.send(WorkerEvent::CommandExecutionStarted { - tool_use_id: payload.tool_call_id, - command: payload.command, - input: payload.input, - source: payload.source, - command_actions: payload.command_actions, - }); - } - } - ItemKind::ToolCall => { - if let Ok(payload) = serde_json::from_value::(payload) { - let details = WorkerEvent::ToolCallDetails { - tool_use_id: payload.tool_call_id.clone(), - tool_name: payload.tool_name.clone(), - input: payload.parameters.clone(), - }; - let _ = event_tx.send(tool_call_started_event(payload)); - let _ = event_tx.send(details); - } - } - ItemKind::ContextCompaction => { - let _ = event_tx.send(WorkerEvent::SessionCompactionStarted); + let mut paired_result_by_call_id = HashMap::new(); + let mut consumed_result_indexes = HashSet::new(); + + for (index, item) in items.iter().enumerate() { + if matches!( + item.kind, + SessionHistoryItemKind::ToolResult | SessionHistoryItemKind::Error + ) && let Some(tool_call_id) = item.tool_call_id.as_deref() + { + paired_result_by_call_id + .entry(tool_call_id.to_string()) + .or_insert(index); } - ItemKind::ApprovalRequest => emit_approval_request_item(payload, event_tx), - ItemKind::UserMessage - | ItemKind::ToolResult - | ItemKind::FileChange - | ItemKind::McpToolCall - | ItemKind::WebSearch - | ItemKind::ImageView - | ItemKind::ApprovalDecision => {} } -} -pub(crate) fn handle_completed_item( - payload: ItemEventPayload, - event_tx: &mpsc::UnboundedSender, -) { - match payload.item { - ItemEnvelope { - item_id, - item_kind: ItemKind::AgentMessage, - payload, - .. - } => { - let text = payload - .get("text") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|text| !text.is_empty()) - .map(ToOwned::to_owned); - if let Some(text) = text { - tracing::debug!( - item_id = %item_id, - final_text_len = text.len(), - "emitting assistant item completion" - ); - let _ = event_tx.send(WorkerEvent::TextItemCompleted { - item_id, - kind: TextItemKind::Assistant, - final_text: text, - }); - } - } - ItemEnvelope { - item_id, - item_kind: ItemKind::Reasoning, - payload, - .. - } => { - let text = payload - .get("text") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|text| !text.is_empty()) - .map(ToOwned::to_owned); - if let Some(text) = text { - tracing::debug!( - item_id = %item_id, - final_text_len = text.len(), - "emitting reasoning item completion" - ); - let _ = event_tx.send(WorkerEvent::TextItemCompleted { - item_id, - kind: TextItemKind::Reasoning, - final_text: text, - }); - } - } - ItemEnvelope { - item_kind: ItemKind::ToolCall, - payload, - .. - } => { - let Ok(payload) = serde_json::from_value::(payload) else { - return; - }; - let summary = summarize_tool_call_update(&payload); - let parsed_commands = tool_call_updated_actions(&payload, &summary); - let _ = event_tx.send(WorkerEvent::ToolCallDetails { - tool_use_id: payload.tool_call_id.clone(), - tool_name: payload.tool_name.clone(), - input: payload.parameters.clone(), - }); - if !parsed_commands.is_empty() { - let _ = event_tx.send(WorkerEvent::ToolCallUpdated { - tool_use_id: payload.tool_call_id, - summary, - parsed_commands, - }); - } - } - ItemEnvelope { - item_kind: ItemKind::FileChange, - payload, - .. - } => { - let Ok(payload) = serde_json::from_value::(payload) - else { - return; - }; - let changes = payload - .changes - .into_iter() - .collect::>(); - let tool_use_id = payload.tool_call_id; - let event = match (payload.tool_name, payload.input) { - (Some(tool_name), Some(input)) => WorkerEvent::PatchAppliedIo { - tool_use_id, - tool_name, - input, - changes, - }, - _ => WorkerEvent::PatchApplied { - tool_use_id, - changes, - }, - }; - let _ = event_tx.send(event); - } - ItemEnvelope { - item_id, - item_kind: ItemKind::Plan, - payload, - } if is_proposed_plan_payload(&payload) => { - let _ = event_tx.send(WorkerEvent::ProposedPlanCompleted { - item_id, - final_text: proposed_plan_text(&payload), - }); - } - ItemEnvelope { - item_kind: ItemKind::ContextCompaction, - payload, - .. - } => { - let error = payload.get("error").filter(|error| !error.is_null()); - let failed = payload - .get("is_error") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - || payload - .get("failed") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - || payload - .get("status") - .and_then(serde_json::Value::as_str) - .is_some_and(|status| { - status.eq_ignore_ascii_case("failed") - || status.eq_ignore_ascii_case("error") - }) - || payload - .get("title") - .and_then(serde_json::Value::as_str) - .is_some_and(|title| title.eq_ignore_ascii_case("Compaction failed")) - || error.is_some(); - if failed { - let message = error - .and_then(|error| { - error - .as_str() - .or_else(|| error.get("message").and_then(serde_json::Value::as_str)) - }) - .or_else(|| payload.get("message").and_then(serde_json::Value::as_str)) - .map(str::trim) - .filter(|message| !message.is_empty()) - .unwrap_or("Context compaction failed") - .to_string(); - let _ = event_tx.send(WorkerEvent::SessionCompactionFailed { message }); - return; - } - let title = payload - .get("title") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|title| !title.is_empty()) - .unwrap_or("Context Compaction") - .to_string(); - let _ = event_tx.send(WorkerEvent::ContextCompactionCompleted { title }); - } - ItemEnvelope { - item_kind: ItemKind::ToolResult, - payload, - .. - } => { - let Ok(payload) = serde_json::from_value::(payload) else { - return; - }; - // Compatibility fallback until all live file changes come through ItemKind::FileChange. - if let Some(patch_event) = patch_event_from_tool_result(&payload) { - let _ = event_tx.send(patch_event); - return; - } - // Compatibility fallback until all live plan updates come through turn/plan/updated. - if let Some(plan_event) = plan_event_from_tool_result(&payload) { - let _ = event_tx.send(plan_event); - return; - } - let title = if payload.summary.is_empty() { - summarize_tool_result_title(payload.tool_name.as_deref(), payload.is_error) - } else { - payload.summary - }; - let event = match payload.input { - Some(input) => WorkerEvent::ToolResultIo { - tool_use_id: payload.tool_call_id, - tool_name: payload.tool_name.unwrap_or_else(|| "tool".to_string()), - title, - input, - output: payload.content, - display_content: payload.display_content, - is_error: payload.is_error, - truncated: false, - }, - None => WorkerEvent::ToolResult { - tool_use_id: payload.tool_call_id, - title, - preview: payload - .display_content - .unwrap_or_else(|| render_json_value_text(&payload.content)), - is_error: payload.is_error, - truncated: false, - }, - }; - let _ = event_tx.send(event); - } - ItemEnvelope { - item_kind: ItemKind::CommandExecution, - payload, - .. - } => { - let Ok(payload) = serde_json::from_value::(payload) else { - return; - }; - let _ = event_tx.send(WorkerEvent::ToolResult { - tool_use_id: payload.tool_call_id, - title: payload.command, - preview: payload - .output - .as_ref() - .map(render_json_value_text) - .unwrap_or_default(), - is_error: payload.is_error, - truncated: false, - }); - } - ItemEnvelope { - item_kind: ItemKind::ApprovalRequest, - payload, - .. - } => emit_approval_request_item(payload, event_tx), - ItemEnvelope { - item_kind: ItemKind::ApprovalDecision, - payload, - .. - } => { - let tool_name = payload - .get("tool_name") - .and_then(serde_json::Value::as_str) - .map(str::to_string); - let rationale = payload - .get("rationale") - .and_then(serde_json::Value::as_str) - .map(str::to_string); - let Ok(payload) = serde_json::from_value::(payload) else { - return; - }; - let _ = event_tx.send(WorkerEvent::ApprovalDecision { - approval_id: payload.approval_id.to_string(), - decision: payload.decision, - scope: payload.scope, - tool_name, - rationale, - }); - } - _ => {} - } -} - -fn project_history_items(items: &[SessionHistoryItem]) -> Vec { - use std::collections::{HashMap, HashSet}; - - let mut paired_result_by_call_id = HashMap::new(); - let mut consumed_result_indexes = HashSet::new(); - - for (index, item) in items.iter().enumerate() { - if matches!( - item.kind, - SessionHistoryItemKind::ToolResult | SessionHistoryItemKind::Error - ) && let Some(tool_call_id) = item.tool_call_id.as_deref() - { - paired_result_by_call_id - .entry(tool_call_id.to_string()) - .or_insert(index); - } - } - - let metadata_owned_ids = items - .iter() - .filter_map(|item| { - item.tool_call_id - .clone() - .filter(|_| item.metadata.is_some()) - }) - .collect::>(); - let mut transcript = Vec::new(); - let mut index = 0usize; - - while index < items.len() { - let item = &items[index]; - if let Some(metadata) = &item.metadata { - if let Some(tool_call_id) = item.tool_call_id.as_deref() - && let Some(result_index) = paired_result_by_call_id.get(tool_call_id).copied() - && result_index != index - { - consumed_result_indexes.insert(result_index); + let metadata_owned_ids = items + .iter() + .filter_map(|item| { + item.tool_call_id + .clone() + .filter(|_| item.metadata.is_some()) + }) + .collect::>(); + let mut transcript = Vec::new(); + let mut index = 0usize; + + while index < items.len() { + let item = &items[index]; + if let Some(metadata) = &item.metadata { + if let Some(tool_call_id) = item.tool_call_id.as_deref() + && let Some(result_index) = paired_result_by_call_id.get(tool_call_id).copied() + && result_index != index + { + consumed_result_indexes.insert(result_index); } match metadata { SessionHistoryMetadata::PlanUpdate { explanation, steps } => { @@ -5880,756 +5476,6 @@ fn project_history_items(items: &[SessionHistoryItem]) -> Vec { transcript } -fn summarize_tool_result_title(tool_name: Option<&str>, is_error: bool) -> String { - match (tool_name, is_error) { - (Some(tool_name), true) => format!("{tool_name} error"), - (Some(tool_name), false) => format!("{tool_name} output"), - (None, true) => "Tool error".to_string(), - (None, false) => "Tool output".to_string(), - } -} - -fn tool_call_started_event(payload: ToolCallPayload) -> WorkerEvent { - let preparing = matches!(payload.tool_name.as_str(), "write" | "apply_patch"); - let summary = if preparing && payload.tool_name == "apply_patch" { - "apply_patch".to_string() - } else { - summarize_tool_call(&payload) - }; - let parsed_commands = tool_call_started_actions(&payload); - WorkerEvent::ToolCall { - tool_use_id: payload.tool_call_id, - summary, - preparing, - parsed_commands: Some(parsed_commands), - } -} - -fn summarize_tool_call(payload: &ToolCallPayload) -> String { - if is_web_search_tool_name(&payload.tool_name) - && let Some(query) = web_search_query(&payload.parameters) - { - return format!("Web Search({})", serde_json::Value::String(query)); - } - if is_web_fetch_tool_name(&payload.tool_name) - && let Some(url) = web_fetch_url(&payload.parameters) - { - return format!("Web Fetch({})", serde_json::Value::String(url)); - } - - match pretty_tool_call_summary(&payload.tool_name, &payload.parameters) { - Some(summary) => summary, - None => { - let detail = summarize_tool_input(&payload.tool_name, &payload.parameters); - if detail.is_empty() { - payload.tool_name.clone() - } else { - format!("{} {detail}", payload.tool_name) - } - } - } -} - -fn pretty_tool_call_summary(tool_name: &str, input: &serde_json::Value) -> Option { - let quote = |text: &str| serde_json::Value::String(compact_tool_summary(text, 96)).to_string(); - let path_value = || { - input - .get("filePath") - .and_then(serde_json::Value::as_str) - .or_else(|| input.get("path").and_then(serde_json::Value::as_str)) - .map(make_path_relative) - }; - match tool_name { - "bash" | "shell_command" | "exec_command" => input - .get("command") - .and_then(serde_json::Value::as_str) - .or_else(|| input.get("cmd").and_then(serde_json::Value::as_str)) - .map(|command| format!("Shell {}", compact_tool_summary(command, 96))), - "read" => path_value().map(|path| format!("Read {path}{}", fmt_line_range(input))), - "write" => path_value().map(|path| format!("Write {path}")), - "edit" => Some("Edit".to_string()), - "apply_patch" => path_value().map(|path| format!("Patch {path}")), - "find" | "glob" => input - .get("path") - .and_then(serde_json::Value::as_str) - .map(make_path_relative) - .or_else(|| { - input - .get("pattern") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string) - }) - .map(|path| format!("List {path}")), - "grep" => { - let pattern = input.get("pattern").and_then(serde_json::Value::as_str)?; - let query = quote(pattern); - match input - .get("path") - .and_then(serde_json::Value::as_str) - .map(make_path_relative) - { - Some(path) => Some(format!("Search {query} in {path}")), - None => Some(format!("Search {query}")), - } - } - "code_search" | "mcp__code_search__code_search" => { - let query = input - .get("query") - .and_then(serde_json::Value::as_str) - .or_else(|| input.get("pattern").and_then(serde_json::Value::as_str)) - .unwrap_or_default(); - let path = input - .get("path") - .and_then(serde_json::Value::as_str) - .or_else(|| input.get("file_path").and_then(serde_json::Value::as_str)) - .map(make_path_relative); - match (query.is_empty(), path) { - (false, Some(path)) => Some(format!("Code-Search {} in {path}", quote(query))), - (false, None) => Some(format!("Code-Search {}", quote(query))), - (true, Some(path)) => Some(format!("Code-Search in {path}")), - (true, None) => Some("Code-Search".to_string()), - } - } - "spawn_agent" | "agent_spawn" => { - let nickname = input - .get("agent_nickname") - .and_then(serde_json::Value::as_str) - .or_else(|| input.get("nickname").and_then(serde_json::Value::as_str)) - .or_else(|| input.get("agent_path").and_then(serde_json::Value::as_str)) - .unwrap_or("agent"); - let prompt = input - .get("message") - .and_then(serde_json::Value::as_str) - .or_else(|| input.get("prompt").and_then(serde_json::Value::as_str)) - .unwrap_or_default(); - Some(format!("Spawn-Agent {} {}", quote(nickname), quote(prompt))) - } - "await_task" | "wait_agent" | "agent_wait" => { - let target = input - .get("task_id") - .and_then(serde_json::Value::as_str) - .or_else(|| input.get("target").and_then(serde_json::Value::as_str)) - .or_else(|| { - input - .get("agent_nickname") - .and_then(serde_json::Value::as_str) - }) - .unwrap_or("agent"); - let timeout = input - .get("timeout_secs") - .and_then(serde_json::Value::as_u64) - .map(|secs| format!("{secs}s")) - .or_else(|| { - input - .get("timeout") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string) - }) - .unwrap_or_else(|| "default".to_string()); - Some(format!("Await-Task {} {}", quote(target), quote(&timeout))) - } - "cancel_task" | "close_agent" | "agent_close" => { - let target = input - .get("task_id") - .and_then(serde_json::Value::as_str) - .or_else(|| input.get("target").and_then(serde_json::Value::as_str)) - .or_else(|| { - input - .get("agent_nickname") - .and_then(serde_json::Value::as_str) - }) - .unwrap_or("agent"); - Some(format!("Cancel-Task {}", quote(target))) - } - "list_tasks" | "list_agents" | "list_agent" | "agent_list" => { - Some("List-Tasks".to_string()) - } - _ => None, - } -} - -fn is_web_search_tool_name(tool_name: &str) -> bool { - matches!(tool_name, "web_search" | "websearch" | "web-search") -} - -fn is_web_fetch_tool_name(tool_name: &str) -> bool { - matches!( - tool_name, - "webfetch" | "web_fetch" | "web-fetch" | "fetch_url" | "fetch-url" - ) -} - -fn web_search_query(input: &serde_json::Value) -> Option { - input - .get("query") - .and_then(serde_json::Value::as_str) - .filter(|query| !query.is_empty()) - .map(ToString::to_string) -} - -fn web_fetch_url(input: &serde_json::Value) -> Option { - input - .get("url") - .and_then(serde_json::Value::as_str) - .filter(|url| !url.is_empty()) - .map(ToString::to_string) -} - -fn summarize_tool_call_update(payload: &ToolCallPayload) -> String { - let summary = summarize_tool_call(payload); - if payload.tool_name == "read" - && summary == "read {}" - && let Some(cmd) = payload - .command_actions - .iter() - .find_map(|action| match action { - devo_protocol::parse_command::ParsedCommand::Read { cmd, .. } - if !cmd.is_empty() => - { - Some(cmd.clone()) - } - _ => None, - }) - { - return cmd; - } - if matches!(payload.tool_name.as_str(), "find" | "glob") - && (summary == "find {}" || summary == "glob {}") - && let Some(cmd) = payload - .command_actions - .iter() - .find_map(|action| match action { - devo_protocol::parse_command::ParsedCommand::ListFiles { cmd, .. } - if !cmd.is_empty() => - { - Some(cmd.clone()) - } - _ => None, - }) - { - return cmd; - } - summary -} - -fn read_command_action_from_parameters( - command: &str, - input: &serde_json::Value, -) -> Option { - let path = input - .get("filePath") - .or_else(|| input.get("path")) - .and_then(serde_json::Value::as_str)? - .trim(); - if path.is_empty() { - return None; - } - let mut name = path.to_string(); - let offset = input.get("offset").and_then(serde_json::Value::as_u64); - let limit = input.get("limit").and_then(serde_json::Value::as_u64); - match (offset, limit) { - (Some(offset), Some(limit)) => { - let end = offset.saturating_add(limit.saturating_sub(1)); - name.push_str(&format!(" L:{offset}-{end}")); - } - (Some(offset), None) => name.push_str(&format!(" L:{offset}-")), - (None, Some(limit)) => name.push_str(&format!(" L:1-{limit}")), - (None, None) => {} - } - Some(devo_protocol::parse_command::ParsedCommand::Read { - cmd: command.to_string(), - name, - path: PathBuf::from(path), - }) -} - -fn find_command_action_from_parameters( - command: &str, - input: &serde_json::Value, -) -> Option { - let pattern = input - .get("pattern") - .and_then(serde_json::Value::as_str) - .filter(|pattern| !pattern.is_empty())?; - let path = input.get("path").and_then(serde_json::Value::as_str); - let display = match path.filter(|path| !path.is_empty()) { - Some(path) => format!("{pattern} in {path}"), - None => pattern.to_string(), - }; - Some(devo_protocol::parse_command::ParsedCommand::ListFiles { - cmd: command.to_string(), - path: Some(display), - }) -} - -fn tool_call_started_actions( - payload: &ToolCallPayload, -) -> Vec { - if !payload.command_actions.is_empty() { - return payload.command_actions.clone(); - } - if payload.tool_name == "read" { - return vec![ - read_command_action_from_parameters("read", &payload.parameters).unwrap_or_else(|| { - devo_protocol::parse_command::ParsedCommand::Read { - cmd: String::new(), - name: String::new(), - path: PathBuf::new(), - } - }), - ]; - } - if matches!(payload.tool_name.as_str(), "find" | "glob") { - let command = payload.tool_name.as_str(); - return vec![ - find_command_action_from_parameters(command, &payload.parameters).unwrap_or_else( - || devo_protocol::parse_command::ParsedCommand::ListFiles { - cmd: command.to_string(), - path: Some(command.to_string()), - }, - ), - ]; - } - if payload.tool_name == "code_search" || payload.tool_name == "mcp__code_search__code_search" { - return code_search_command_action_from_parameters("code_search", &payload.parameters) - .into_iter() - .collect(); - } - Vec::new() -} - -fn tool_call_updated_actions( - payload: &ToolCallPayload, - summary: &str, -) -> Vec { - if !payload.command_actions.is_empty() { - return payload.command_actions.clone(); - } - match payload.tool_name.as_str() { - "read" => read_command_action_from_parameters(summary, &payload.parameters) - .into_iter() - .collect(), - "find" | "glob" => find_command_action_from_parameters(summary, &payload.parameters) - .into_iter() - .collect(), - "code_search" | "mcp__code_search__code_search" => { - code_search_command_action_from_parameters(summary, &payload.parameters) - .into_iter() - .collect() - } - _ => Vec::new(), - } -} - -fn code_search_command_action_from_parameters( - command: &str, - input: &serde_json::Value, -) -> Option { - match input - .get("operation") - .and_then(serde_json::Value::as_str) - .unwrap_or("search") - { - "find_related" => { - let path = input - .get("file_path") - .and_then(serde_json::Value::as_str) - .filter(|path| !path.is_empty())?; - let line = input - .get("line") - .and_then(serde_json::Value::as_u64) - .map(|line| line.to_string()) - .unwrap_or_else(|| "?".to_string()); - Some(devo_protocol::parse_command::ParsedCommand::Search { - cmd: command.to_string(), - query: Some(format!("related {path}:{line}")), - path: Some(path.to_string()), - }) - } - _ => { - let query = input - .get("query") - .and_then(serde_json::Value::as_str) - .filter(|query| !query.is_empty())?; - Some(devo_protocol::parse_command::ParsedCommand::Search { - cmd: command.to_string(), - query: Some(query.to_string()), - path: input - .get("path") - .and_then(serde_json::Value::as_str) - .map(ToOwned::to_owned), - }) - } - } -} - -fn make_path_relative(path: &str) -> String { - let p = std::path::PathBuf::from(path); - if p.is_absolute() - && let Ok(cwd) = std::env::current_dir() - && let Ok(rel) = p.strip_prefix(&cwd) - { - return rel.to_string_lossy().to_string(); - } - path.to_string() -} - -fn code_search_summary_from_input(input: &serde_json::Value) -> String { - match input - .get("operation") - .and_then(serde_json::Value::as_str) - .unwrap_or("search") - { - "find_related" => { - let path = input - .get("file_path") - .and_then(serde_json::Value::as_str) - .map(make_path_relative); - let line = input.get("line").and_then(serde_json::Value::as_u64); - match (path, line) { - (Some(path), Some(line)) => format!("related {path}:{line}"), - (Some(path), None) => format!("related {path}"), - (None, _) => "related".to_string(), - } - } - _ => { - let query = input - .get("query") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let path = input - .get("path") - .and_then(serde_json::Value::as_str) - .map(make_path_relative); - match (query.is_empty(), path) { - (false, Some(path)) => format!("{query} in {path}"), - (false, None) => query.to_string(), - (true, Some(path)) => format!("in {path}"), - (true, None) => String::new(), - } - } - } -} - -fn fmt_offset_limit(input: &serde_json::Value) -> String { - let offset = input.get("offset").and_then(|v| v.as_u64()); - let limit = input.get("limit").and_then(|v| v.as_u64()); - match (offset, limit) { - (Some(o), Some(l)) => format!(" (offset:{o}, limit:{l})"), - (Some(o), None) => format!(" (offset:{o})"), - (None, Some(l)) => format!(" (limit:{l})"), - (None, None) => String::new(), - } -} - -fn fmt_line_range(input: &serde_json::Value) -> String { - let offset = input.get("offset").and_then(serde_json::Value::as_u64); - let limit = input.get("limit").and_then(serde_json::Value::as_u64); - match (offset, limit) { - (Some(start), Some(limit)) => format!(" L:{start}-{}", start.saturating_add(limit)), - (Some(start), None) => format!(" L:{start}"), - (None, Some(limit)) => format!(" L:0-{limit}"), - (None, None) => String::new(), - } -} - -fn summarize_tool_input(tool_name: &str, input: &serde_json::Value) -> String { - let candidate = match tool_name { - "bash" | "shell_command" | "exec_command" => input - .get("command") - .and_then(serde_json::Value::as_str) - .or_else(|| input.get("cmd").and_then(serde_json::Value::as_str)) - .map(|s| s.to_string()), - "read" => input - .get("filePath") - .and_then(serde_json::Value::as_str) - .or_else(|| input.get("path").and_then(serde_json::Value::as_str)) - .map(|path| { - let rel = make_path_relative(path); - let ext = fmt_offset_limit(input); - format!("{rel}{ext}") - }), - "write" | "edit" | "apply_patch" => input - .get("path") - .and_then(serde_json::Value::as_str) - .or_else(|| input.get("filePath").and_then(serde_json::Value::as_str)) - .map(make_path_relative), - "grep" => { - let pattern = input - .get("pattern") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let path = input - .get("path") - .and_then(serde_json::Value::as_str) - .map(make_path_relative); - match path { - Some(p) => Some(format!("'{pattern}' in {p}")), - None => Some(format!("'{pattern}'")), - } - } - "find" | "glob" => { - let pattern = input - .get("pattern") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let path = input - .get("path") - .and_then(serde_json::Value::as_str) - .map(make_path_relative); - match path { - Some(p) => Some(format!("{pattern} in {p}")), - None => Some(pattern.to_string()), - } - } - "code_search" | "mcp__code_search__code_search" => { - Some(code_search_summary_from_input(input)) - } - "webfetch" | "web_fetch" | "web-fetch" | "fetch_url" | "fetch-url" => web_fetch_url(input), - "web_search" | "websearch" | "web-search" => web_search_query(input), - "lsp" => { - let path = input - .get("filePath") - .and_then(serde_json::Value::as_str) - .map(make_path_relative); - let line = input.get("line").and_then(|v| v.as_i64()); - let col = input.get("character").and_then(|v| v.as_i64()); - match (path, line, col) { - (Some(p), Some(l), Some(c)) => Some(format!("{p}:{l}:{c}")), - (Some(p), Some(l), None) => Some(format!("{p}:{l}")), - (Some(p), None, _) => Some(p), - _ => None, - } - } - "question" => None, - "skill" => input - .get("name") - .and_then(serde_json::Value::as_str) - .map(|s| s.to_string()), - "spawn_agent" => input - .get("message") - .and_then(serde_json::Value::as_str) - .filter(|message| !message.is_empty()) - .map(|message| message.to_string()), - _ => None, - }; - - candidate - .map(|text| compact_tool_summary(&text, 96)) - .unwrap_or_else(|| compact_tool_summary(&render_json_preview(input), 96)) -} - -fn compact_tool_summary(text: &str, max_chars: usize) -> String { - let compact = text.split_whitespace().collect::>().join(" "); - let truncated = compact.chars().count() > max_chars; - let mut out = compact.chars().take(max_chars).collect::(); - if truncated { - out.push('…'); - } - out -} - -fn render_json_preview(value: &serde_json::Value) -> String { - match value { - serde_json::Value::Null => String::new(), - serde_json::Value::String(text) => truncate_tool_output(text), - serde_json::Value::Object(_) | serde_json::Value::Array(_) => { - let pretty = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()); - truncate_tool_output(&pretty) - } - _ => truncate_tool_output(&value.to_string()), - } -} - -fn render_json_value_text(value: &serde_json::Value) -> String { - match value { - serde_json::Value::String(text) => text.clone(), - _ => value.to_string(), - } -} - -// Legacy compatibility fallback for sessions/items persisted before server-side -fn is_proposed_plan_payload(payload: &serde_json::Value) -> bool { - payload - .get("title") - .and_then(serde_json::Value::as_str) - .is_some_and(|title| title == "Proposed Plan") -} - -fn proposed_plan_text(payload: &serde_json::Value) -> String { - payload - .get("text") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string() -} - -fn plan_event_from_tool_result(payload: &ToolResultPayload) -> Option { - let tool_name = payload.tool_name.as_deref()?; - match tool_name { - "update_plan" => { - let plan = payload.content.get("plan")?.as_array()?; - let explanation = payload - .content - .get("explanation") - .and_then(serde_json::Value::as_str) - .map(ToOwned::to_owned) - .filter(|text| !text.trim().is_empty()); - let steps = plan - .iter() - .filter_map(|item| { - let text = item.get("step")?.as_str()?.to_string(); - let status = parse_plan_step_status( - item.get("status").and_then(serde_json::Value::as_str)?, - )?; - Some(PlanStep { text, status }) - }) - .collect::>(); - Some(WorkerEvent::PlanUpdated { explanation, steps }) - } - _ => None, - } -} - -// Legacy compatibility fallback for sessions/items persisted before server-side -// FileChange became the primary live source. -fn patch_event_from_tool_result(payload: &ToolResultPayload) -> Option { - if !matches!(payload.tool_name.as_deref()?, "apply_patch" | "write") { - return None; - } - let files = payload.content.get("files")?.as_array()?; - let mut changes = std::collections::HashMap::new(); - for file in files { - let path = std::path::PathBuf::from(file.get("path")?.as_str()?); - let kind = file.get("kind").and_then(serde_json::Value::as_str)?; - let additions = file - .get("additions") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let deletions = file - .get("deletions") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let change = match kind { - "add" => devo_protocol::protocol::FileChange::Add { - content: file - .get("content") - .and_then(serde_json::Value::as_str) - .map(ToOwned::to_owned) - .unwrap_or_else(|| "\n".repeat(additions as usize)), - }, - "delete" => devo_protocol::protocol::FileChange::Delete { - content: file - .get("content") - .and_then(serde_json::Value::as_str) - .map(ToOwned::to_owned) - .unwrap_or_else(|| "\n".repeat(deletions as usize)), - }, - "update" | "move" => devo_protocol::protocol::FileChange::Update { - unified_diff: file - .get("diff") - .or_else(|| file.get("patch")) - .or_else(|| payload.content.get("diff")) - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(), - old_text: file - .get("oldContent") - .or_else(|| file.get("preContent")) - .or_else(|| file.get("pre_content")) - .and_then(serde_json::Value::as_str) - .map(ToOwned::to_owned), - new_text: file - .get("postContent") - .or_else(|| file.get("post_content")) - .or_else(|| file.get("content")) - .and_then(serde_json::Value::as_str) - .map(ToOwned::to_owned), - move_path: file - .get("move_path") - .and_then(serde_json::Value::as_str) - .map(std::path::PathBuf::from), - }, - _ => continue, - }; - changes.insert(path, change); - } - if changes.is_empty() { - return None; - } - match (payload.tool_name.clone(), payload.input.clone()) { - (Some(tool_name), Some(input)) => Some(WorkerEvent::PatchAppliedIo { - tool_use_id: payload.tool_call_id.clone(), - tool_name, - input, - changes, - }), - _ => Some(WorkerEvent::PatchApplied { - tool_use_id: payload.tool_call_id.clone(), - changes, - }), - } -} - -fn parse_plan_step_status(status: &str) -> Option { - match status { - "pending" => Some(PlanStepStatus::Pending), - "in_progress" => Some(PlanStepStatus::InProgress), - "completed" => Some(PlanStepStatus::Completed), - "cancelled" => Some(PlanStepStatus::Cancelled), - _ => None, - } -} - -fn truncate_tool_output(content: &str) -> String { - const MAX_LINES: usize = 8; - const MAX_CHARS: usize = 1200; - let content = normalize_display_output(content); - let content = content.as_str(); - - let mut lines = Vec::new(); - let mut chars = 0usize; - for line in content.lines() { - if lines.len() >= MAX_LINES || chars >= MAX_CHARS { - break; - } - let remaining = MAX_CHARS.saturating_sub(chars); - if line.chars().count() > remaining { - let preview = line.chars().take(remaining).collect::(); - lines.push(preview); - break; - } - chars += line.chars().count(); - lines.push(line.to_string()); - } - - if lines.is_empty() && !content.is_empty() { - let preview = content.chars().take(MAX_CHARS).collect::(); - return if preview == content { - preview - } else { - format!("{preview}\n… ") - }; - } - - let preview = lines.join("\n"); - if preview == content { - preview - } else if preview.is_empty() { - "… ".to_string() - } else { - format!("{preview}\n… ") - } -} - -fn normalize_display_output(content: &str) -> String { - content - .replace("\r\n", "\n") - .replace('\r', "\n") - .trim_matches('\n') - .to_string() -} - fn map_join_error(error: JoinError) -> anyhow::Error { if error.is_cancelled() { anyhow::anyhow!("interactive worker task was cancelled") @@ -6648,6 +5494,55 @@ fn map_worker_join_result(result: std::result::Result<(), JoinError>) -> Result< } } +#[cfg(test)] +pub(crate) fn dispatch_legacy_item_event_for_test( + method: &str, + payload: ItemEventPayload, + event_tx: &mpsc::UnboundedSender, +) { + use chrono::Utc; + use devo_protocol::EventContext; + use devo_protocol::TypedItemEventPayload; + use devo_protocol::native::ids::{ + ItemId as NativeItemId, SessionId as NativeSessionId, TurnId as NativeTurnId, + }; + use devo_protocol::native::item::{ItemEnvelope as NativeItemEnvelope, ItemState}; + use devo_protocol::native::wire_projector::project_wire_item; + + let item_id = payload.item.item_id; + let session_id = payload.context.session_id; + let turn_id = payload.context.turn_id.unwrap_or_else(TurnId::new); + let projected_at = Utc::now(); + let native_item = + project_wire_item(&payload.item.item_kind, &payload.item.payload, projected_at) + .expect("legacy test payload must project to native item"); + let typed = TypedItemEventPayload { + context: EventContext { + session_id, + turn_id: Some(turn_id), + item_id: Some(item_id), + seq: payload.context.seq, + item_seq: payload.context.item_seq, + }, + item: NativeItemEnvelope { + id: NativeItemId::from_legacy_uuid(item_id.into()), + session_id: NativeSessionId::from_legacy_uuid(session_id.into()), + turn_id: NativeTurnId::from_legacy_uuid(turn_id.into()), + seq: payload.context.item_seq.unwrap_or(payload.context.seq), + revision: 1, + created_at: projected_at, + updated_at: projected_at, + state: if method == "item/completed" { + ItemState::Completed + } else { + ItemState::Running + }, + item: native_item, + }, + }; + item_dispatch::dispatch_typed_item_lifecycle(method, &typed, item_id, event_tx); +} + #[cfg(test)] mod tests { use super::ProviderValidationCancellation; @@ -6675,22 +5570,20 @@ mod tests { use super::append_preview_item; use super::btw_agent_prompt; use super::btw_spawn_params; - use super::handle_completed_item; - use super::handle_started_item; + use super::dispatch_legacy_item_event_for_test; use super::last_query_tokens_from_resume; use super::next_shell_command_exec_start; - use super::normalize_display_output; use super::project_history_items; use super::render_skill_list_body; use super::restored_history_items; use super::should_apply_terminal_turn_usage_fallback; use super::should_pause_goal_before_session_leave; - use super::summarize_tool_call; - use super::tool_call_started_actions; - use super::tool_call_started_event; - use super::truncate_tool_output; - use crate::events::PlanStep; - use crate::events::PlanStepStatus; + use super::tool_lifecycle; + use super::tool_summaries::normalize_display_output; + use super::tool_summaries::summarize_tool_call; + use super::tool_summaries::tool_call_started_actions; + use super::tool_summaries::tool_call_started_event; + use super::tool_summaries::truncate_tool_output; use crate::events::SessionListEntry; use crate::events::SubagentMonitorAgent; use crate::events::SubagentMonitorEvent; @@ -6708,6 +5601,7 @@ mod tests { use devo_protocol::ThreadGoal; use devo_protocol::ThreadGoalStatus; use devo_server::ApprovalRequestPayload; + use devo_server::FileChangePayload; use devo_server::ItemEnvelope; use devo_server::ItemEventPayload; use devo_server::ItemKind; @@ -6778,16 +5672,18 @@ mod tests { vec![ ShellCommandExecStart { process_id: "user-shell-1".to_string(), - started_event: WorkerEvent::CommandExecutionStarted { - tool_use_id: "user-shell-1".to_string(), - command: "pwd".to_string(), - input: Some(serde_json::json!({ - "cmd": "pwd", - "cwd": PathBuf::from("/tmp/project"), - })), - source: devo_protocol::protocol::ExecCommandSource::UserShell, - command_actions: Vec::new(), - }, + command: "pwd".to_string(), + started_event: + super::super::worker_event_test_helpers::command_execution_started( + "user-shell-1".to_string(), + "pwd".to_string(), + Some(serde_json::json!({ + "cmd": "pwd", + "cwd": PathBuf::from("/tmp/project"), + })), + devo_protocol::protocol::ExecCommandSource::UserShell, + Vec::new(), + ), params: devo_protocol::CommandExecParams { session_id: Some(session_id), process_id: "user-shell-1".to_string(), @@ -6800,16 +5696,18 @@ mod tests { }, ShellCommandExecStart { process_id: "user-shell-2".to_string(), - started_event: WorkerEvent::CommandExecutionStarted { - tool_use_id: "user-shell-2".to_string(), - command: "whoami".to_string(), - input: Some(serde_json::json!({ - "cmd": "whoami", - "cwd": PathBuf::from("/tmp/project"), - })), - source: devo_protocol::protocol::ExecCommandSource::UserShell, - command_actions: Vec::new(), - }, + command: "whoami".to_string(), + started_event: + super::super::worker_event_test_helpers::command_execution_started( + "user-shell-2".to_string(), + "whoami".to_string(), + Some(serde_json::json!({ + "cmd": "whoami", + "cwd": PathBuf::from("/tmp/project"), + })), + devo_protocol::protocol::ExecCommandSource::UserShell, + Vec::new(), + ), params: devo_protocol::CommandExecParams { session_id: None, process_id: "user-shell-2".to_string(), @@ -6848,7 +5746,7 @@ mod tests { ( "read", serde_json::json!({ "path": "/tmp/project/src/lib.rs", "offset": 9, "limit": 4 }), - "Read /tmp/project/src/lib.rs L:9-13", + "Read /tmp/project/src/lib.rs L:9-12", ), ( "write", @@ -7026,7 +5924,8 @@ mod tests { #[test] fn completed_tool_result_uses_display_content_preview() { let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - handle_completed_item( + dispatch_legacy_item_event_for_test( + "item/completed", ItemEventPayload { context: devo_server::EventContext { session_id: SessionId::new(), @@ -7057,13 +5956,17 @@ mod tests { assert_eq!( event_rx.try_recv().expect("worker event"), - WorkerEvent::ToolResult { - tool_use_id: "call-1".to_string(), - title: "read output".to_string(), - preview: "canonical".to_string(), - is_error: false, - truncated: false, - } + WorkerEvent::Transcript(tool_lifecycle::tool_closed_from_result( + &ToolResultPayload { + tool_call_id: "call-1".to_string(), + tool_name: None, + input: None, + content: serde_json::Value::String("canonical".to_string(),), + display_content: Some("canonical".to_string()), + is_error: false, + summary: String::new(), + }, + )) ); } @@ -7072,7 +5975,8 @@ mod tests { let session_id = SessionId::new(); let turn_id = TurnId::new(); let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - handle_started_item( + dispatch_legacy_item_event_for_test( + "item/started", ItemEventPayload { context: devo_server::EventContext { session_id, @@ -7163,7 +6067,7 @@ mod tests { assert_eq!( tool_call_started_actions(&payload), vec![devo_protocol::parse_command::ParsedCommand::Read { - cmd: "read".to_string(), + cmd: "Read crates/core/src/query.rs L:10-14".to_string(), name: "crates/core/src/query.rs L:10-14".to_string(), path: PathBuf::from("crates/core/src/query.rs"), }] @@ -7184,17 +6088,8 @@ mod tests { }; assert_eq!( - tool_call_started_event(payload), - WorkerEvent::ToolCall { - tool_use_id: "call-1".to_string(), - summary: "Code-Search \"live tool feedback\" in crates".to_string(), - preparing: false, - parsed_commands: Some(vec![devo_protocol::parse_command::ParsedCommand::Search { - cmd: "code_search".to_string(), - query: Some("live tool feedback".to_string()), - path: Some("crates".to_string()), - }]), - } + tool_call_started_event(payload.clone()), + WorkerEvent::Transcript(tool_lifecycle::tool_opened_from_call(&payload)), ); } @@ -7208,13 +6103,8 @@ mod tests { }; assert_eq!( - tool_call_started_event(payload), - WorkerEvent::ToolCall { - tool_use_id: "call-1".to_string(), - summary: "Code-Search".to_string(), - preparing: false, - parsed_commands: Some(Vec::new()), - } + tool_call_started_event(payload.clone()), + WorkerEvent::Transcript(tool_lifecycle::tool_opened_from_call(&payload)), ); } @@ -7228,13 +6118,8 @@ mod tests { }; assert_eq!( - tool_call_started_event(payload), - WorkerEvent::ToolCall { - tool_use_id: "call-1".to_string(), - summary: "apply_patch".to_string(), - preparing: true, - parsed_commands: Some(Vec::new()), - } + tool_call_started_event(payload.clone()), + WorkerEvent::Transcript(tool_lifecycle::tool_opened_from_call(&payload)), ); } @@ -7248,20 +6133,16 @@ mod tests { }; assert_eq!( - tool_call_started_event(payload), - WorkerEvent::ToolCall { - tool_use_id: "call-1".to_string(), - summary: "Edit".to_string(), - preparing: false, - parsed_commands: Some(Vec::new()), - } + tool_call_started_event(payload.clone()), + WorkerEvent::Transcript(tool_lifecycle::tool_opened_from_call(&payload)), ); } #[test] fn completed_read_tool_call_emits_update_event() { let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - handle_completed_item( + dispatch_legacy_item_event_for_test( + "item/completed", ItemEventPayload { context: devo_server::EventContext { session_id: SessionId::new(), @@ -7276,12 +6157,10 @@ mod tests { payload: serde_json::to_value(ToolCallPayload { tool_call_id: "call-1".to_string(), tool_name: "read".to_string(), - parameters: serde_json::json!({}), - command_actions: vec![devo_protocol::parse_command::ParsedCommand::Read { - cmd: "read crates/tui/src/mod.rs".to_string(), - name: "mod.rs".to_string(), - path: PathBuf::from("crates/tui/src/mod.rs"), - }], + parameters: serde_json::json!({ + "filePath": "crates/tui/src/mod.rs" + }), + command_actions: Vec::new(), }) .expect("serialize tool call payload"), }, @@ -7289,32 +6168,32 @@ mod tests { &event_tx, ); + use crate::transcript::lifecycle::ItemLifecycleEvent; + assert_eq!( - event_rx.try_recv().expect("worker details event"), - WorkerEvent::ToolCallDetails { + event_rx.try_recv().expect("worker refresh event"), + WorkerEvent::Transcript(ItemLifecycleEvent::ToolOpened { tool_use_id: "call-1".to_string(), tool_name: "read".to_string(), - input: serde_json::json!({}), - } - ); - assert_eq!( - event_rx.try_recv().expect("worker update event"), - WorkerEvent::ToolCallUpdated { - tool_use_id: "call-1".to_string(), - summary: "read crates/tui/src/mod.rs".to_string(), + input: serde_json::json!({ + "filePath": "crates/tui/src/mod.rs" + }), + command: None, + command_source: None, parsed_commands: vec![devo_protocol::parse_command::ParsedCommand::Read { - cmd: "read crates/tui/src/mod.rs".to_string(), - name: "mod.rs".to_string(), + cmd: "Read crates/tui/src/mod.rs".to_string(), + name: "crates/tui/src/mod.rs".to_string(), path: PathBuf::from("crates/tui/src/mod.rs"), }], - } + }) ); } #[test] fn completed_glob_tool_call_emits_update_with_pattern_and_path() { let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - handle_completed_item( + dispatch_legacy_item_event_for_test( + "item/completed", ItemEventPayload { context: devo_server::EventContext { session_id: SessionId::new(), @@ -7342,33 +6221,31 @@ mod tests { ); assert_eq!( - event_rx.try_recv().expect("worker details event"), - WorkerEvent::ToolCallDetails { - tool_use_id: "call-1".to_string(), - tool_name: "glob".to_string(), - input: serde_json::json!({ - "pattern": "**/Cargo.toml", - "path": "crates" - }), - } - ); - assert_eq!( - event_rx.try_recv().expect("worker update event"), - WorkerEvent::ToolCallUpdated { - tool_use_id: "call-1".to_string(), - summary: "List crates".to_string(), - parsed_commands: vec![devo_protocol::parse_command::ParsedCommand::ListFiles { - cmd: "List crates".to_string(), - path: Some("**/Cargo.toml in crates".to_string()), - }], - } + event_rx.try_recv().expect("worker refresh event"), + WorkerEvent::Transcript( + crate::transcript::lifecycle::ItemLifecycleEvent::ToolOpened { + tool_use_id: "call-1".to_string(), + tool_name: "glob".to_string(), + input: serde_json::json!({ + "pattern": "**/Cargo.toml", + "path": "crates" + }), + command: None, + command_source: None, + parsed_commands: vec![devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: "List crates".to_string(), + path: Some("**/Cargo.toml in crates".to_string()), + }], + } + ) ); } #[test] fn completed_tool_result_falls_back_to_content_preview() { let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - handle_completed_item( + dispatch_legacy_item_event_for_test( + "item/completed", ItemEventPayload { context: devo_server::EventContext { session_id: SessionId::new(), @@ -7399,20 +6276,25 @@ mod tests { assert_eq!( event_rx.try_recv().expect("worker event"), - WorkerEvent::ToolResult { - tool_use_id: "call-1".to_string(), - title: "read output".to_string(), - preview: "canonical".to_string(), - is_error: false, - truncated: false, - } + WorkerEvent::Transcript(tool_lifecycle::tool_closed_from_result( + &ToolResultPayload { + tool_call_id: "call-1".to_string(), + tool_name: None, + input: None, + content: serde_json::Value::String("canonical".to_string(),), + display_content: None, + is_error: false, + summary: String::new(), + }, + )) ); } #[test] - fn completed_update_plan_tool_result_emits_plan_updated() { + fn completed_file_change_item_dispatches_via_legacy_projection() { let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - handle_completed_item( + dispatch_legacy_item_event_for_test( + "item/completed", ItemEventPayload { context: devo_server::EventContext { session_id: SessionId::new(), @@ -7423,44 +6305,38 @@ mod tests { }, item: ItemEnvelope { item_id: ItemId::new(), - item_kind: ItemKind::ToolResult, - payload: serde_json::to_value(ToolResultPayload { + item_kind: ItemKind::FileChange, + payload: serde_json::to_value(FileChangePayload { tool_call_id: "call-1".to_string(), - tool_name: Some("update_plan".to_string()), + tool_name: Some("apply_patch".to_string()), input: None, - content: serde_json::json!({ - "explanation": "Working through the task", - "plan": [ - { "step": "Inspect code", "status": "completed" }, - { "step": "Patch bug", "status": "in_progress" } - ] - }), - display_content: None, + changes: vec![( + PathBuf::from("foo.txt"), + devo_protocol::protocol::FileChange::Update { + unified_diff: "diff --git a/foo.txt b/foo.txt\n--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n".to_string(), + old_text: None, + new_text: None, + move_path: None, + }, + )], is_error: false, - summary: "update_plan".to_string(), }) - .expect("serialize tool result payload"), + .expect("serialize file change payload"), }, }, &event_tx, ); - assert_eq!( - event_rx.try_recv().expect("worker event"), - WorkerEvent::PlanUpdated { - explanation: Some("Working through the task".to_string()), - steps: vec![ - PlanStep { - text: "Inspect code".to_string(), - status: PlanStepStatus::Completed, - }, - PlanStep { - text: "Patch bug".to_string(), - status: PlanStepStatus::InProgress, - }, - ], - } - ); + let WorkerEvent::Transcript(crate::transcript::lifecycle::ItemLifecycleEvent::ToolClosed { + tool_use_id, + file_changes: Some(changes), + .. + }) = event_rx.try_recv().expect("worker event") + else { + panic!("expected file change tool closed event"); + }; + assert_eq!(tool_use_id, "call-1"); + assert!(changes.contains_key(&PathBuf::from("foo.txt"))); } #[test] @@ -7477,223 +6353,6 @@ mod tests { )); } - #[test] - fn completed_apply_patch_tool_result_emits_patch_applied() { - let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - handle_completed_item( - ItemEventPayload { - context: devo_server::EventContext { - session_id: SessionId::new(), - turn_id: None, - item_id: None, - seq: 1, - item_seq: None, - }, - item: ItemEnvelope { - item_id: ItemId::new(), - item_kind: ItemKind::ToolResult, - payload: serde_json::to_value(ToolResultPayload { - tool_call_id: "call-1".to_string(), - tool_name: Some("apply_patch".to_string()), - input: None, - content: serde_json::json!({ - "diff": "--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n", - "files": [ - { - "path": "foo.txt", - "kind": "update", - "additions": 1, - "deletions": 1 - } - ] - }), - display_content: None, - is_error: false, - summary: "apply_patch".to_string(), - }) - .expect("serialize tool result payload"), - }, - }, - &event_tx, - ); - - let WorkerEvent::PatchApplied { - tool_use_id, - changes, - } = event_rx.try_recv().expect("worker event") - else { - panic!("expected patch applied event"); - }; - assert_eq!(tool_use_id, "call-1"); - assert!(changes.contains_key(&std::path::PathBuf::from("foo.txt"))); - } - - #[test] - fn completed_write_tool_result_emits_patch_applied() { - let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - handle_completed_item( - ItemEventPayload { - context: devo_server::EventContext { - session_id: SessionId::new(), - turn_id: None, - item_id: None, - seq: 1, - item_seq: None, - }, - item: ItemEnvelope { - item_id: ItemId::new(), - item_kind: ItemKind::ToolResult, - payload: serde_json::to_value(ToolResultPayload { - tool_call_id: "call-1".to_string(), - tool_name: Some("write".to_string()), - input: None, - content: serde_json::json!({ - "diff": "diff --git a/foo.txt b/foo.txt\n--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n", - "files": [ - { - "path": "foo.txt", - "kind": "update", - "additions": 1, - "deletions": 1 - } - ] - }), - display_content: None, - is_error: false, - summary: "write foo.txt".to_string(), - }) - .expect("serialize tool result payload"), - }, - }, - &event_tx, - ); - - let WorkerEvent::PatchApplied { - tool_use_id, - changes, - } = event_rx.try_recv().expect("worker event") - else { - panic!("expected patch applied event"); - }; - assert_eq!(tool_use_id, "call-1"); - assert!(changes.contains_key(&std::path::PathBuf::from("foo.txt"))); - } - - #[test] - fn completed_apply_patch_tool_result_with_real_metadata_shape_emits_patch_applied() { - let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - handle_completed_item( - ItemEventPayload { - context: devo_server::EventContext { - session_id: SessionId::new(), - turn_id: None, - item_id: None, - seq: 1, - item_seq: None, - }, - item: ItemEnvelope { - item_id: ItemId::new(), - item_kind: ItemKind::ToolResult, - payload: serde_json::to_value(ToolResultPayload { - tool_call_id: "call-1".to_string(), - tool_name: Some("apply_patch".to_string()), - input: None, - content: serde_json::json!({ - "diff": "diff --git a/update.txt b/update.txt\n--- a/update.txt\n+++ b/update.txt\n@@ -1 +1 @@\n-old\n+new\n", - "files": [ - { - "path": "update.txt", - "filePath": "/tmp/update.txt", - "relativePath": "update.txt", - "kind": "update", - "type": "update", - "diff": "diff --git a/update.txt b/update.txt\n--- a/update.txt\n+++ b/update.txt\n@@ -1 +1 @@\n-old\n+new\n", - "patch": "diff --git a/update.txt b/update.txt\n--- a/update.txt\n+++ b/update.txt\n@@ -1 +1 @@\n-old\n+new\n", - "additions": 1, - "deletions": 1 - } - ] - }), - display_content: None, - is_error: false, - summary: "apply_patch".to_string(), - }) - .expect("serialize tool result payload"), - }, - }, - &event_tx, - ); - - let WorkerEvent::PatchApplied { - tool_use_id, - changes, - } = event_rx.try_recv().expect("worker event") - else { - panic!("expected patch applied event"); - }; - assert_eq!(tool_use_id, "call-1"); - assert!(changes.contains_key(&std::path::PathBuf::from("update.txt"))); - } - - #[test] - fn completed_apply_patch_prefers_file_local_diff_over_top_level_diff() { - let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - handle_completed_item( - ItemEventPayload { - context: devo_server::EventContext { - session_id: SessionId::new(), - turn_id: None, - item_id: None, - seq: 1, - item_seq: None, - }, - item: ItemEnvelope { - item_id: ItemId::new(), - item_kind: ItemKind::ToolResult, - payload: serde_json::to_value(ToolResultPayload { - tool_call_id: "call-1".to_string(), - tool_name: Some("apply_patch".to_string()), - input: None, - content: serde_json::json!({ - "diff": "BROKEN TOP LEVEL DIFF", - "files": [ - { - "path": "update.txt", - "kind": "update", - "diff": "diff --git a/update.txt b/update.txt\n--- a/update.txt\n+++ b/update.txt\n@@ -1 +1 @@\n-old\n+new\n", - "additions": 1, - "deletions": 1 - } - ] - }), - display_content: None, - is_error: false, - summary: "apply_patch".to_string(), - }) - .expect("serialize tool result payload"), - }, - }, - &event_tx, - ); - - let WorkerEvent::PatchApplied { - tool_use_id, - changes, - } = event_rx.try_recv().expect("worker event") - else { - panic!("expected patch applied event"); - }; - assert_eq!(tool_use_id, "call-1"); - let devo_protocol::protocol::FileChange::Update { unified_diff, .. } = changes - .get(&std::path::PathBuf::from("update.txt")) - .expect("update change") - else { - panic!("expected update change"); - }; - assert!(unified_diff.contains("--- a/update.txt")); - assert!(!unified_diff.contains("BROKEN TOP LEVEL DIFF")); - } - #[test] fn command_execution_started_event_uses_server_command_actions() { let payload = CommandExecutionPayload { @@ -7714,20 +6373,20 @@ mod tests { }; assert_eq!( - WorkerEvent::CommandExecutionStarted { - tool_use_id: payload.tool_call_id.clone(), - command: payload.command.clone(), - input: payload.input.clone(), - source: payload.source, - command_actions: payload.command_actions.clone(), - }, - WorkerEvent::CommandExecutionStarted { - tool_use_id: payload.tool_call_id, - command: payload.command, - input: payload.input, - source: devo_protocol::protocol::ExecCommandSource::Agent, - command_actions: payload.command_actions, - } + super::super::worker_event_test_helpers::command_execution_started( + payload.tool_call_id.clone(), + payload.command.clone(), + payload.input.clone(), + payload.source, + payload.command_actions.clone(), + ), + super::super::worker_event_test_helpers::command_execution_started( + payload.tool_call_id, + payload.command, + payload.input, + devo_protocol::protocol::ExecCommandSource::Agent, + payload.command_actions, + ) ); } diff --git a/crates/tui/src/worker/approval_items.rs b/crates/tui/src/worker/approval_items.rs new file mode 100644 index 00000000..eae6bc46 --- /dev/null +++ b/crates/tui/src/worker/approval_items.rs @@ -0,0 +1,99 @@ +//! Native `Item::Approval` → approval request/decision worker events. + +use devo_protocol::native::item::ApprovalDecisionKind; +use devo_protocol::native::item::ApprovalScope; +use devo_protocol::native::item::ApprovalTarget; +use devo_protocol::native::item::Item; +use tokio::sync::mpsc; + +use crate::events::WorkerEvent; + +pub(crate) fn handle_started( + item: &Item, + session_id: devo_core::SessionId, + turn_id: Option, + event_tx: &mpsc::UnboundedSender, +) -> bool { + let Item::Approval { + approval_id, + action_summary, + justification, + resource, + available_scopes, + command_pattern, + command_prefix, + target, + decision, + .. + } = item + else { + return false; + }; + if decision.is_some() { + return false; + } + let Some(turn_id) = turn_id else { + return false; + }; + let (path, host, target) = approval_target_parts(target.as_ref()); + let _ = event_tx.send(WorkerEvent::ApprovalRequest { + session_id, + turn_id, + approval_id: approval_id.clone(), + action_summary: action_summary.clone(), + justification: justification.clone(), + resource: resource.clone(), + available_scopes: available_scopes.clone(), + path, + host, + target, + command_pattern: command_pattern.clone(), + command_prefix: command_prefix.clone(), + }); + true +} + +pub(crate) fn handle_completed(item: &Item, event_tx: &mpsc::UnboundedSender) -> bool { + let Item::Approval { + approval_id, + decision: Some(decision), + .. + } = item + else { + return false; + }; + let decision_label = match decision.decision { + ApprovalDecisionKind::Approved => "approve", + ApprovalDecisionKind::Denied => "deny", + ApprovalDecisionKind::Cancelled => "cancel", + }; + let scope = match decision.scope { + ApprovalScope::Once => "once", + ApprovalScope::Turn => "turn", + ApprovalScope::Session => "session", + ApprovalScope::PathPrefix => "path_prefix", + ApprovalScope::Host => "host", + ApprovalScope::Tool => "tool", + ApprovalScope::CommandPrefix => "command_prefix", + ApprovalScope::CommandPrefixPersist => "command_prefix_persist", + }; + let _ = event_tx.send(WorkerEvent::ApprovalDecision { + approval_id: approval_id.clone(), + decision: decision_label.to_string(), + scope: scope.to_string(), + tool_name: None, + rationale: None, + }); + true +} + +fn approval_target_parts( + target: Option<&ApprovalTarget>, +) -> (Option, Option, Option) { + match target { + Some(ApprovalTarget::Path { path }) => (Some(path.display().to_string()), None, None), + Some(ApprovalTarget::Host { host }) => (None, Some(host.clone()), None), + Some(ApprovalTarget::Command { command }) => (None, None, Some(command.clone())), + None => (None, None, None), + } +} diff --git a/crates/tui/src/worker/compaction_items.rs b/crates/tui/src/worker/compaction_items.rs new file mode 100644 index 00000000..44939b17 --- /dev/null +++ b/crates/tui/src/worker/compaction_items.rs @@ -0,0 +1,44 @@ +//! Native `Item::ContextCompaction` → compaction worker events. + +use devo_protocol::native::item::Item; +use tokio::sync::mpsc; + +use crate::events::WorkerEvent; + +pub(crate) fn handle_started(item: &Item, event_tx: &mpsc::UnboundedSender) -> bool { + if !matches!(item, Item::ContextCompaction { .. }) { + return false; + } + let _ = event_tx.send(WorkerEvent::SessionCompactionStarted); + true +} + +pub(crate) fn handle_completed(item: &Item, event_tx: &mpsc::UnboundedSender) -> bool { + let Item::ContextCompaction { summary, .. } = item else { + return false; + }; + let summary = summary.as_deref().map(str::trim).unwrap_or(""); + let failed = summary.eq_ignore_ascii_case("Compaction failed") + || summary.starts_with("Compaction failed") + || summary + .to_ascii_lowercase() + .contains("\"status\":\"failed\""); + if failed { + let message = summary + .strip_prefix("Compaction failed:") + .or_else(|| summary.strip_prefix("Compaction failed")) + .map(str::trim) + .filter(|message| !message.is_empty()) + .unwrap_or("Context compaction failed") + .to_string(); + let _ = event_tx.send(WorkerEvent::SessionCompactionFailed { message }); + return true; + } + let title = if summary.is_empty() { + "Context Compaction".to_string() + } else { + summary.to_string() + }; + let _ = event_tx.send(WorkerEvent::ContextCompactionCompleted { title }); + true +} diff --git a/crates/tui/src/worker/goals.rs b/crates/tui/src/worker/goals.rs new file mode 100644 index 00000000..7fef3165 --- /dev/null +++ b/crates/tui/src/worker/goals.rs @@ -0,0 +1,106 @@ +//! Goal lifecycle helpers for session leave and restore. + +use anyhow::Context; +use anyhow::Result; +use devo_core::SessionId; +use devo_core::TurnId; +use devo_protocol::ThreadGoalStatus; +use devo_server::StdioServerClient; +use tokio::sync::mpsc; + +use crate::events::WorkerEvent; + +use super::session_restore; + +pub(crate) fn thread_goal_from_native( + goal: &devo_protocol::native::goal::Goal, +) -> devo_protocol::ThreadGoal { + let status = match goal.status { + devo_protocol::native::goal::GoalStatus::Active => ThreadGoalStatus::Active, + devo_protocol::native::goal::GoalStatus::Paused + | devo_protocol::native::goal::GoalStatus::Blocked + | devo_protocol::native::goal::GoalStatus::UsageLimited => ThreadGoalStatus::Paused, + devo_protocol::native::goal::GoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, + devo_protocol::native::goal::GoalStatus::Completed + | devo_protocol::native::goal::GoalStatus::Failed + | devo_protocol::native::goal::GoalStatus::Canceled => ThreadGoalStatus::Complete, + }; + let Ok(thread_id) = SessionId::try_from(goal.session_id.as_str()) else { + unreachable!("canonical goal carries a legacy session id"); + }; + devo_protocol::ThreadGoal { + thread_id, + objective: goal.objective.clone(), + status, + token_budget: goal + .token_budget + .and_then(|budget| i64::try_from(budget).ok()), + tokens_used: i64::try_from(goal.tokens_used).unwrap_or(i64::MAX), + time_used_seconds: i64::try_from(goal.time_used_seconds).unwrap_or(i64::MAX), + created_at: goal.created_at.timestamp(), + updated_at: goal.updated_at.timestamp(), + } +} + +pub(crate) async fn pause_active_goal_before_session_leave( + client: &mut StdioServerClient, + session_id: SessionId, + active_turn_id: Option, +) -> Result<()> { + let goal_status = client + .session_goal_read_native(session_id) + .await + .context("failed to load goal before leaving session")?; + let goal = goal_status.goal.as_ref().map(thread_goal_from_native); + if !should_pause_goal_before_session_leave(goal.as_ref()) { + return Ok(()); + } + + let goal_id = goal_status + .goal + .as_ref() + .map(|goal| goal.id.clone()) + .context("goal disappeared before pause")?; + client + .session_goal_transition_native( + session_id, + &goal_id, + devo_client::GoalLifecycleTransition::Pause, + ) + .await + .context("failed to pause active goal before leaving session")?; + + if active_turn_id.is_some() + && let Err(error) = client + .session_interrupt_native( + devo_protocol::native::rpc_session::SessionInterruptScope::Session { + session_id: session_restore::native_session_id(session_id), + }, + ) + .await + { + return Err(error).context("failed to interrupt active goal work before leaving session"); + } + + Ok(()) +} + +pub(crate) fn should_pause_goal_before_session_leave( + goal: Option<&devo_protocol::ThreadGoal>, +) -> bool { + goal.is_some_and(|goal| { + matches!( + goal.status, + ThreadGoalStatus::Active | ThreadGoalStatus::BudgetLimited + ) + }) +} + +pub(crate) fn emit_goal_leave_failure( + event_tx: &mpsc::UnboundedSender, + error: anyhow::Error, +) { + let _ = event_tx.send(WorkerEvent::GoalOperationFailed { + message: error.to_string(), + }); +} diff --git a/crates/tui/src/worker/history.rs b/crates/tui/src/worker/history.rs new file mode 100644 index 00000000..945f7cb3 --- /dev/null +++ b/crates/tui/src/worker/history.rs @@ -0,0 +1,217 @@ +//! Session history projection for restore and legacy transcript items. + +use std::collections::HashMap; + +use devo_protocol::SessionHistoryItem; +use devo_protocol::SessionHistoryItemKind; +use devo_protocol::SessionHistoryMetadata; +use devo_protocol::SessionPlanStepStatus; + +use crate::events::TranscriptItem; +use crate::events::TranscriptItemKind; + +use super::typed_events; + +pub(crate) fn project_history_items(items: &[SessionHistoryItem]) -> Vec { + use std::collections::{HashMap, HashSet}; + + let mut paired_result_by_call_id = HashMap::new(); + let mut consumed_result_indexes = HashSet::new(); + + for (index, item) in items.iter().enumerate() { + if matches!( + item.kind, + SessionHistoryItemKind::ToolResult | SessionHistoryItemKind::Error + ) && let Some(tool_call_id) = item.tool_call_id.as_deref() + { + paired_result_by_call_id + .entry(tool_call_id.to_string()) + .or_insert(index); + } + } + + let metadata_owned_ids = items + .iter() + .filter_map(|item| { + item.tool_call_id + .clone() + .filter(|_| item.metadata.is_some()) + }) + .collect::>(); + let mut transcript = Vec::new(); + let mut index = 0usize; + + while index < items.len() { + let item = &items[index]; + if let Some(metadata) = &item.metadata { + if let Some(tool_call_id) = item.tool_call_id.as_deref() + && let Some(result_index) = paired_result_by_call_id.get(tool_call_id).copied() + && result_index != index + { + consumed_result_indexes.insert(result_index); + } + match metadata { + SessionHistoryMetadata::PlanUpdate { explanation, steps } => { + transcript.push(TranscriptItem::new( + TranscriptItemKind::System, + explanation.clone().unwrap_or_default(), + steps + .iter() + .map(|step| { + let status = match step.status { + SessionPlanStepStatus::Pending => "pending", + SessionPlanStepStatus::InProgress => "in_progress", + SessionPlanStepStatus::Completed => "completed", + SessionPlanStepStatus::Cancelled => "cancelled", + }; + format!("{status}: {}", step.text) + }) + .collect::>() + .join("\n"), + )); + index += 1; + continue; + } + SessionHistoryMetadata::ProposedPlan => { + transcript.push(TranscriptItem::new( + TranscriptItemKind::Assistant, + "Proposed Plan".to_string(), + item.body.clone(), + )); + index += 1; + continue; + } + SessionHistoryMetadata::TurnSummary { .. } + | SessionHistoryMetadata::Edited { .. } => {} + SessionHistoryMetadata::Explored { actions } => { + let title = item.title.clone(); + let body = actions + .iter() + .map(|action| format!("{action:?}")) + .collect::>() + .join("\n"); + transcript.push(TranscriptItem::restored_tool_result(title, body)); + index += 1; + continue; + } + } + } + if item.kind == SessionHistoryItemKind::ToolCall + && let Some(tool_call_id) = item.tool_call_id.as_deref() + { + if metadata_owned_ids.contains(tool_call_id) { + index += 1; + continue; + } + if let Some(result_index) = paired_result_by_call_id.get(tool_call_id).copied() { + let result_item = &items[result_index]; + consumed_result_indexes.insert(result_index); + let mut ti = if result_item.kind == SessionHistoryItemKind::Error { + TranscriptItem::tool_error(item.title.clone(), result_item.body.clone()) + } else { + TranscriptItem::restored_tool_result( + item.title.clone(), + result_item.body.clone(), + ) + }; + if let Some(duration_ms) = result_item.duration_ms { + ti = ti.with_duration(duration_ms); + } + transcript.push(ti); + index += 1; + continue; + } + } + + if consumed_result_indexes.contains(&index) { + index += 1; + continue; + } + + let kind = match item.kind { + SessionHistoryItemKind::User => TranscriptItemKind::User, + SessionHistoryItemKind::Assistant => TranscriptItemKind::Assistant, + SessionHistoryItemKind::Reasoning => TranscriptItemKind::Reasoning, + SessionHistoryItemKind::ToolCall => TranscriptItemKind::ToolCall, + SessionHistoryItemKind::ToolResult => TranscriptItemKind::ToolResult, + SessionHistoryItemKind::CommandExecution => TranscriptItemKind::ToolResult, + SessionHistoryItemKind::Error => TranscriptItemKind::Error, + SessionHistoryItemKind::TurnSummary => TranscriptItemKind::TurnSummary, + SessionHistoryItemKind::ContextCompaction => TranscriptItemKind::System, + }; + let mut transcript_item = match item.kind { + SessionHistoryItemKind::ToolCall => TranscriptItem::tool_call(item.title.clone()), + SessionHistoryItemKind::ToolResult => { + TranscriptItem::restored_tool_result(item.title.clone(), item.body.clone()) + } + SessionHistoryItemKind::CommandExecution => { + TranscriptItem::restored_tool_result(item.title.clone(), item.body.clone()) + } + SessionHistoryItemKind::Error => { + if item.tool_call_id.is_some() { + TranscriptItem::tool_error(item.title.clone(), item.body.clone()) + } else { + TranscriptItem::new(kind, String::new(), item.body.clone()) + } + } + SessionHistoryItemKind::TurnSummary => { + // TurnSummary uses title for model name, duration_ms for duration in seconds + TranscriptItem::new(kind, item.title.clone(), item.body.clone()) + } + SessionHistoryItemKind::ContextCompaction => { + let title = if item.title.is_empty() { + "Context compacted".to_string() + } else { + item.title.clone() + }; + TranscriptItem::new(kind, title, String::new()) + } + SessionHistoryItemKind::User + | SessionHistoryItemKind::Assistant + | SessionHistoryItemKind::Reasoning => { + TranscriptItem::new(kind, item.title.clone(), item.body.clone()) + } + }; + if let Some(duration_ms) = item.duration_ms { + transcript_item = transcript_item.with_duration(duration_ms); + } + transcript.push(transcript_item); + index += 1; + } + + transcript +} +pub(crate) fn restored_history_items( + turns: Vec, + items: Vec, + fallback_mode: devo_protocol::CollaborationMode, +) -> Vec { + let mut items_by_turn = HashMap::>::new(); + for item in items { + items_by_turn + .entry(item.turn_id.as_str().to_string()) + .or_default() + .push(item); + } + let mut history_items = Vec::new(); + for turn in &turns { + if let Some(turn_items) = items_by_turn.remove(turn.id.as_str()) { + history_items.extend( + turn_items + .iter() + .filter_map(typed_events::history_item_from_native_item), + ); + } + if let Some(summary) = typed_events::history_item_from_native_turn(turn, fallback_mode) { + history_items.push(summary); + } + } + let mut orphan_items = items_by_turn.into_values().flatten().collect::>(); + orphan_items.sort_by_key(|item| item.seq); + history_items.extend( + orphan_items + .iter() + .filter_map(typed_events::history_item_from_native_item), + ); + history_items +} diff --git a/crates/tui/src/worker/item_dispatch.rs b/crates/tui/src/worker/item_dispatch.rs new file mode 100644 index 00000000..2a46ef27 --- /dev/null +++ b/crates/tui/src/worker/item_dispatch.rs @@ -0,0 +1,57 @@ +//! Native typed `item/started` and `item/completed` dispatch. + +use devo_core::ItemId; +use devo_protocol::TypedItemEventPayload; +use tokio::sync::mpsc; + +use crate::events::WorkerEvent; + +use super::approval_items; +use super::compaction_items; +use super::native_items; +use super::plan_items; + +/// Projects a native typed item lifecycle notification into worker events. +pub(crate) fn dispatch_typed_item_lifecycle( + method: &str, + payload: &TypedItemEventPayload, + item_id: ItemId, + event_tx: &mpsc::UnboundedSender, +) { + let item = &payload.item.item; + let transcript_events = if method == "item/started" { + native_items::started_events(item, item_id) + } else { + native_items::completed_events(item, item_id) + }; + let had_transcript = !transcript_events.is_empty(); + for event in transcript_events { + let _ = event_tx.send(WorkerEvent::Transcript(event)); + } + if had_transcript { + return; + } + + if method == "item/started" { + if plan_items::handle_started(item, item_id, event_tx) { + return; + } + if compaction_items::handle_started(item, event_tx) { + return; + } + let turn_id = payload + .context + .turn_id + .or_else(|| devo_core::TurnId::try_from(payload.item.turn_id.as_str()).ok()); + let _ = approval_items::handle_started(item, payload.context.session_id, turn_id, event_tx); + return; + } + + if plan_items::handle_completed(item, item_id, event_tx) { + return; + } + if compaction_items::handle_completed(item, event_tx) { + return; + } + let _ = approval_items::handle_completed(item, event_tx); +} diff --git a/crates/tui/src/worker/native_items.rs b/crates/tui/src/worker/native_items.rs new file mode 100644 index 00000000..aea4e204 --- /dev/null +++ b/crates/tui/src/worker/native_items.rs @@ -0,0 +1,151 @@ +//! Native `Item` → [`ItemLifecycleEvent`] projection (P0: no legacy `ItemKind` shim). + +use devo_core::ItemId; +use devo_protocol::ToolCallPayload; +use devo_protocol::ToolResultPayload; +use devo_protocol::native::item::Item; + +use crate::events::TextItemKind; +use crate::transcript::lifecycle::ItemLifecycleEvent; + +use super::tool_lifecycle::{ + native_file_changes, tool_closed_from_command, tool_closed_from_file_change, + tool_closed_from_result, tool_opened_from_call, tool_opened_from_command, + tool_opened_refresh_from_call, +}; + +/// Projects a native item `item/started` notification into lifecycle events. +pub(crate) fn started_events(item: &Item, item_id: ItemId) -> Vec { + match item { + Item::AssistantMessage { .. } => vec![ItemLifecycleEvent::TextStarted { + item_id, + kind: TextItemKind::Assistant, + }], + Item::Reasoning { .. } => vec![ItemLifecycleEvent::TextStarted { + item_id, + kind: TextItemKind::Reasoning, + }], + Item::ToolCall { + call_id, + tool_name, + input, + .. + } => { + let payload = ToolCallPayload { + tool_call_id: call_id.clone(), + tool_name: tool_name.clone(), + parameters: input.clone().unwrap_or(serde_json::Value::Null), + command_actions: Vec::new(), + }; + vec![tool_opened_from_call(&payload)] + } + Item::CommandExecution { + call_id, + command, + input, + origin, + .. + } => vec![tool_opened_from_command( + call_id.clone(), + command.clone(), + input.clone(), + *origin, + Vec::new(), + )], + _ => Vec::new(), + } +} + +/// Projects a native item `item/completed` notification into lifecycle events. +pub(crate) fn completed_events(item: &Item, item_id: ItemId) -> Vec { + match item { + Item::AssistantMessage { text, .. } => { + let final_text = text.trim().to_string(); + if final_text.is_empty() { + Vec::new() + } else { + vec![ItemLifecycleEvent::TextCompleted { + item_id, + kind: TextItemKind::Assistant, + final_text, + }] + } + } + Item::Reasoning { text, .. } => { + let final_text = text.trim().to_string(); + if final_text.is_empty() { + Vec::new() + } else { + vec![ItemLifecycleEvent::TextCompleted { + item_id, + kind: TextItemKind::Reasoning, + final_text, + }] + } + } + Item::ToolCall { + call_id, + tool_name, + input, + .. + } => { + let payload = ToolCallPayload { + tool_call_id: call_id.clone(), + tool_name: tool_name.clone(), + parameters: input.clone().unwrap_or(serde_json::Value::Null), + command_actions: Vec::new(), + }; + vec![tool_opened_refresh_from_call(&payload)] + } + Item::FileChange { + call_id, changes, .. + } => vec![tool_closed_from_file_change( + call_id.clone(), + None, + None, + native_file_changes(changes), + )], + Item::ToolResult { + call_id, + output, + display_content, + is_error, + truncated, + .. + } => { + let payload = ToolResultPayload { + tool_call_id: call_id.clone(), + tool_name: None, + input: None, + content: output.clone(), + display_content: display_content.clone(), + is_error: *is_error, + summary: String::new(), + }; + let mut event = tool_closed_from_result(&payload); + if let ItemLifecycleEvent::ToolClosed { + truncated: truncated_slot, + .. + } = &mut event + { + *truncated_slot = *truncated; + } + vec![event] + } + Item::CommandExecution { + call_id, + command, + input, + output, + is_error, + .. + } => vec![tool_closed_from_command( + call_id.clone(), + command.clone(), + input.clone(), + output.clone(), + *is_error, + )], + _ => Vec::new(), + } +} diff --git a/crates/tui/src/worker/plan_items.rs b/crates/tui/src/worker/plan_items.rs new file mode 100644 index 00000000..f7e566d0 --- /dev/null +++ b/crates/tui/src/worker/plan_items.rs @@ -0,0 +1,49 @@ +//! Native `Item::Plan` → proposed-plan worker events. + +use devo_core::ItemId; +use devo_protocol::native::item::Item; +use tokio::sync::mpsc; + +use crate::events::WorkerEvent; + +/// Whether this plan item should drive the proposed-plan streaming UI. +/// +/// After wire projection, the legacy `"Proposed Plan"` title is lost; both +/// proposed-plan streams and `update_plan` tool Plan items become +/// `Item::Plan { entries }`. Emitting proposed-plan events for all Plan +/// lifecycle notifications matches the prior typed→legacy shim behavior. +fn is_proposed_plan_item(item: &Item) -> bool { + matches!(item, Item::Plan { .. }) +} + +pub(crate) fn handle_started( + item: &Item, + item_id: ItemId, + event_tx: &mpsc::UnboundedSender, +) -> bool { + if !is_proposed_plan_item(item) { + return false; + } + let _ = event_tx.send(WorkerEvent::ProposedPlanStarted { item_id }); + true +} + +pub(crate) fn handle_completed( + item: &Item, + item_id: ItemId, + event_tx: &mpsc::UnboundedSender, +) -> bool { + let Item::Plan { entries } = item else { + return false; + }; + let final_text = entries + .iter() + .map(|entry| entry.step.as_str()) + .collect::>() + .join("\n"); + let _ = event_tx.send(WorkerEvent::ProposedPlanCompleted { + item_id, + final_text, + }); + true +} diff --git a/crates/tui/src/worker/session_preview.rs b/crates/tui/src/worker/session_preview.rs new file mode 100644 index 00000000..09d7fd0c --- /dev/null +++ b/crates/tui/src/worker/session_preview.rs @@ -0,0 +1,119 @@ +//! Session preview and input-history helpers for the TUI worker. + +use std::collections::VecDeque; + +use anyhow::Result; +use devo_core::SessionId; +use devo_server::StdioServerClient; + +use crate::events::SessionPreviewMessage; +use crate::events::SessionPreviewRole; + +const MAX_PREVIEW_MESSAGES: usize = 4; + +pub(crate) async fn collect_user_input_texts( + client: &mut StdioServerClient, + session_id: SessionId, +) -> Result> { + let mut texts = Vec::new(); + let mut cursor = None; + loop { + let page = client + .session_items_list_native(session_id, cursor.clone(), Some(500)) + .await?; + let page_len = page.data.len(); + let next_cursor = page.next_cursor; + for item in &page.data { + if let devo_protocol::native::item::Item::UserMessage { content, .. } = &item.item { + let text = content + .iter() + .filter_map(|input| match input { + devo_protocol::native::item::UserInput::Text { text } => Some(text.clone()), + _ => None, + }) + .collect::>() + .join("\n"); + if !text.trim().is_empty() { + texts.push(text); + } + } + } + match (next_cursor, page_len) { + (Some(next), len) if len > 0 => cursor = Some(next), + _ => break, + } + } + Ok(texts) +} + +/// Loads only the recent user/assistant dialogue needed by the inline resume picker. +pub(crate) async fn collect_session_preview( + client: &mut StdioServerClient, + session_id: SessionId, +) -> Result> { + let mut messages = VecDeque::with_capacity(MAX_PREVIEW_MESSAGES); + let mut cursor = None; + loop { + let page = client + .session_items_list_native(session_id, cursor.clone(), Some(500)) + .await?; + let page_len = page.data.len(); + let next_cursor = page.next_cursor; + for item in page.data { + append_preview_item(&mut messages, item.item); + } + match (next_cursor, page_len) { + (Some(next), len) if len > 0 => cursor = Some(next), + _ => break, + } + } + Ok(messages.into_iter().collect()) +} + +pub(crate) fn append_preview_item( + messages: &mut VecDeque, + item: devo_protocol::native::item::Item, +) { + let message = match item { + devo_protocol::native::item::Item::UserMessage { content, .. } => { + let text = content + .into_iter() + .filter_map(|input| match input { + devo_protocol::native::item::UserInput::Text { text } => Some(text), + _ => None, + }) + .collect::>() + .join("\n"); + (!text.trim().is_empty()).then_some(SessionPreviewMessage { + role: SessionPreviewRole::User, + text, + }) + } + devo_protocol::native::item::Item::AssistantMessage { text, .. } => { + (!text.trim().is_empty()).then_some(SessionPreviewMessage { + role: SessionPreviewRole::Assistant, + text, + }) + } + devo_protocol::native::item::Item::Reasoning { .. } + | devo_protocol::native::item::Item::Plan { .. } + | devo_protocol::native::item::Item::ToolCall { .. } + | devo_protocol::native::item::Item::ToolResult { .. } + | devo_protocol::native::item::Item::CommandExecution { .. } + | devo_protocol::native::item::Item::HostedToolCall { .. } + | devo_protocol::native::item::Item::FileChange { .. } + | devo_protocol::native::item::Item::Approval { .. } + | devo_protocol::native::item::Item::UserInputRequest { .. } + | devo_protocol::native::item::Item::SubAgent { .. } + | devo_protocol::native::item::Item::BackgroundTask { .. } + | devo_protocol::native::item::Item::ContextCompaction { .. } + | devo_protocol::native::item::Item::GoalProgress { .. } + | devo_protocol::native::item::Item::Warning { .. } => None, + }; + if let Some(message) = message { + if messages.len() == MAX_PREVIEW_MESSAGES { + messages.pop_front(); + } + messages.push_back(message); + } +} diff --git a/crates/tui/src/worker/session_restore.rs b/crates/tui/src/worker/session_restore.rs new file mode 100644 index 00000000..2830fb5d --- /dev/null +++ b/crates/tui/src/worker/session_restore.rs @@ -0,0 +1,183 @@ +//! Canonical session restore (`session/resume` + items/turns list). + +use anyhow::Result; + +use devo_core::PermissionPreset; +use devo_core::SessionId; +use devo_server::StdioServerClient; + +use crate::events::WorkerEvent; + +use super::history; + +/// Result of restoring a session through canonical APIs (resume + items +/// list + queue list), replacing the legacy `session/resume` aggregate +/// result (L2-DES-APP-008 Phase C). +pub(crate) struct NativeSessionRestore { + pub(crate) session: devo_protocol::native::session::Session, + pub(crate) history_items: Vec, + pub(crate) pending_texts: Vec, + pub(crate) last_context_occupancy: Option, + pub(crate) last_query_total_tokens: usize, + pub(crate) last_query_input_tokens: usize, +} + +pub(crate) fn native_session_id(session_id: SessionId) -> devo_protocol::native::ids::SessionId { + devo_protocol::native::ids::SessionId::from_string(session_id.to_string()) +} + +pub(crate) async fn restore_session_native( + client: &mut StdioServerClient, + session_id: SessionId, +) -> Result { + let resumed = client.session_resume_native(session_id).await?; + let fallback_mode = resumed + .session + .settings + .mode + .as_deref() + .and_then(|mode| serde_json::from_value(serde_json::Value::String(mode.to_string())).ok()) + .unwrap_or_default(); + + let mut turns = Vec::new(); + let mut cursor = None; + loop { + let page = client + .session_turns_list_native(session_id, cursor.clone(), Some(200)) + .await?; + let page_len = page.data.len(); + let next_cursor = page.next_cursor; + turns.extend(page.data); + match (next_cursor, page_len) { + (Some(next), len) if len > 0 => cursor = Some(next), + _ => break, + } + } + + let mut items = Vec::new(); + let mut cursor = None; + loop { + let page = client + .session_items_list_native(session_id, cursor.clone(), Some(500)) + .await?; + let page_len = page.data.len(); + let next_cursor = page.next_cursor; + items.extend(page.data); + match (next_cursor, page_len) { + (Some(next), len) if len > 0 => cursor = Some(next), + _ => break, + } + } + let (turn_query_total, turn_query_input) = resume_query_tokens_from_turns(&turns); + let history_items = history::restored_history_items(turns, items, fallback_mode); + + let queue = client + .session_queue_list(devo_protocol::native::rpc_turn::SessionQueueListParams { + session_id: native_session_id(session_id), + }) + .await?; + let pending_texts = queue + .entries + .iter() + .map(|entry| entry.preview.clone()) + .collect(); + + Ok(NativeSessionRestore { + session: resumed.session, + history_items, + pending_texts, + last_context_occupancy: resumed.last_context_occupancy, + last_query_total_tokens: resumed + .last_query_total_tokens + .map(|tokens| tokens as usize) + .filter(|tokens| *tokens > 0) + .unwrap_or(turn_query_total), + last_query_input_tokens: turn_query_input, + }) +} + +fn resume_query_tokens_from_turns(turns: &[devo_protocol::native::turn::Turn]) -> (usize, usize) { + for turn in turns.iter().rev() { + let Some(usage) = turn.usage.as_ref() else { + continue; + }; + let query = &usage.query; + let total = if query.total_tokens > 0 { + query.total_tokens as usize + } else { + (query.input_tokens + query.output_tokens) as usize + }; + if total > 0 || query.input_tokens > 0 { + return (total, query.input_tokens as usize); + } + } + (0, 0) +} + +/// Builds the `SessionSwitched` event from a canonical restore. Mapping +/// notes: `last_query_total_tokens` and `prompt_token_estimate` both seed +/// from session cumulative input usage until a live query usage event arrives. +pub(crate) fn session_switched_event_from_restore( + session_id: SessionId, + restore: &NativeSessionRestore, +) -> WorkerEvent { + let session = &restore.session; + let active_agent_label = session.parent.as_ref().map(|parent| { + let label = match parent { + devo_protocol::native::session::SessionParent::Fork { .. } => "Fork".to_string(), + devo_protocol::native::session::SessionParent::Agent { role, .. } => { + role.clone().unwrap_or_else(|| "subagent".to_string()) + } + }; + format!("Agent: {label}") + }); + let total_usage = &session.usage.total; + let last_query_total_tokens = restore.last_query_total_tokens; + let last_query_input_tokens = restore.last_query_input_tokens; + let prompt_token_estimate = last_query_input_tokens.max(total_usage.input_tokens as usize); + let legacy_session_id = session_id; + WorkerEvent::SessionSwitched { + session_id: legacy_session_id.to_string(), + cwd: session.cwd.clone(), + title: session.title.clone(), + model: Some(session.model.model.clone()), + model_binding_id: (session.model.provider != "unknown") + .then(|| session.model.provider.clone()), + reasoning_effort_selection: session + .settings + .reasoning_effort + .map(|effort| effort.to_string()), + reasoning_effort: session.settings.reasoning_effort, + active_agent_label, + total_input_tokens: total_usage.input_tokens as usize, + total_output_tokens: total_usage.output_tokens as usize, + total_tokens: total_usage.total_tokens as usize, + total_cache_read_tokens: total_usage.cache_read_input_tokens as usize, + last_query_total_tokens, + last_query_input_tokens, + prompt_token_estimate, + history_items: history::project_history_items(&restore.history_items), + rich_history_items: restore.history_items.clone(), + loaded_item_count: restore.history_items.len() as u64, + pending_texts: restore.pending_texts.clone(), + collaboration_mode: session + .settings + .mode + .as_deref() + .and_then(|mode| { + serde_json::from_value(serde_json::Value::String(mode.to_string())).ok() + }) + .unwrap_or_default(), + permission_preset: Some(match session.settings.permission_profile { + devo_protocol::native::model::PermissionProfile::Default => PermissionPreset::Default, + devo_protocol::native::model::PermissionProfile::AutoReview => { + PermissionPreset::AutoReview + } + devo_protocol::native::model::PermissionProfile::FullAccess => { + PermissionPreset::FullAccess + } + }), + effective_context_window: session.settings.effective_context_window, + last_context_occupancy: restore.last_context_occupancy.clone(), + } +} diff --git a/crates/tui/src/worker/skills.rs b/crates/tui/src/worker/skills.rs new file mode 100644 index 00000000..ddd10e7d --- /dev/null +++ b/crates/tui/src/worker/skills.rs @@ -0,0 +1,104 @@ +//! Skills list loading for the TUI worker. + +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use devo_server::SkillRecord; +use devo_server::SkillSource; +use devo_server::StdioServerClient; +use tokio::sync::mpsc; + +use crate::bottom_pane::SkillInterfaceMetadata; +use crate::bottom_pane::SkillMetadata; +use crate::events::WorkerEvent; + +pub(crate) async fn emit_skills_list( + client: &mut StdioServerClient, + cwd: &Path, + event_tx: &mpsc::UnboundedSender, + open_picker: bool, +) -> Result<()> { + let result = tokio::time::timeout( + Duration::from_secs(5), + client.skill_list_native(Some(cwd.to_path_buf()), false), + ) + .await + .context("skills list request timed out")??; + emit_skills_list_result( + result.skills.into_iter().map(SkillRecord::from).collect(), + event_tx, + open_picker, + ); + Ok(()) +} + +pub(crate) fn emit_skills_list_result( + skills: Vec, + event_tx: &mpsc::UnboundedSender, + open_picker: bool, +) { + let picker_skills = skills + .iter() + .map(crate::skills_picker::skill_picker_entry_from_record) + .collect(); + let skills = skills + .iter() + .filter(|skill| skill.enabled) + .map(skill_metadata_from_record) + .collect(); + let _ = event_tx.send(WorkerEvent::SkillsListed { + skills, + picker_skills, + open_picker, + }); +} + +pub(crate) fn render_skill_list_body(skills: &[SkillRecord]) -> String { + if skills.is_empty() { + return "_No skills found._".to_string(); + } + + skills + .iter() + .map(|skill| { + let enabled = if skill.enabled { "yes" } else { "no" }; + format!( + "- `{}` - {}\n enabled: {}\n source: {}\n path: `{}`", + skill.name, + skill.description, + enabled, + render_skill_source(&skill.source), + skill.path.display() + ) + }) + .collect::>() + .join("\n\n") +} + +fn skill_metadata_from_record(skill: &SkillRecord) -> SkillMetadata { + SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + short_description: skill.short_description.clone(), + interface: skill + .interface + .as_ref() + .map(|interface| SkillInterfaceMetadata { + display_name: interface.display_name.clone(), + short_description: interface.short_description.clone(), + }), + path_to_skills_md: skill.path.clone(), + } +} + +fn render_skill_source(source: &SkillSource) -> String { + match source { + SkillSource::User => "user".to_string(), + SkillSource::Workspace { cwd } => format!("workspace ({})", cwd.display()), + SkillSource::Plugin { plugin_id } => format!("plugin ({plugin_id})"), + SkillSource::System => "system".to_string(), + SkillSource::Admin => "admin".to_string(), + } +} diff --git a/crates/tui/src/worker/tool_lifecycle.rs b/crates/tui/src/worker/tool_lifecycle.rs new file mode 100644 index 00000000..ec6d90f8 --- /dev/null +++ b/crates/tui/src/worker/tool_lifecycle.rs @@ -0,0 +1,221 @@ +//! Builds fact-only [`ItemLifecycleEvent`] values from tool payloads. + +use std::collections::HashMap; +use std::path::PathBuf; + +use devo_protocol::ToolCallPayload; +use devo_protocol::ToolResultPayload; +use devo_protocol::native::item::ExecOrigin; +use devo_protocol::native::item::FileChangeEntry; +use devo_protocol::native::item::FileChangeKind; +use devo_protocol::protocol::ExecCommandSource; +use devo_protocol::protocol::FileChange; + +use crate::transcript::lifecycle::ItemLifecycleEvent; +use crate::transcript::tool_state::command_source_from_tool_name; +use crate::transcript::tool_state::shell_command_from_input; + +use super::tool_summaries::tool_call_started_actions; +use super::tool_summaries::tool_call_updated_actions; + +pub(crate) fn tool_opened_from_call(payload: &ToolCallPayload) -> ItemLifecycleEvent { + ItemLifecycleEvent::ToolOpened { + tool_use_id: payload.tool_call_id.clone(), + tool_name: payload.tool_name.clone(), + input: payload.parameters.clone(), + command: shell_command_from_input(&payload.parameters), + command_source: command_source_from_tool_name(&payload.tool_name), + parsed_commands: tool_call_started_actions(payload), + } +} + +pub(crate) fn tool_opened_refresh_from_call(payload: &ToolCallPayload) -> ItemLifecycleEvent { + let summary = super::tool_summaries::summarize_tool_call_update(payload); + ItemLifecycleEvent::ToolOpened { + tool_use_id: payload.tool_call_id.clone(), + tool_name: payload.tool_name.clone(), + input: payload.parameters.clone(), + command: shell_command_from_input(&payload.parameters), + command_source: command_source_from_tool_name(&payload.tool_name), + parsed_commands: tool_call_updated_actions(payload, &summary), + } +} + +pub(crate) fn tool_closed_from_result(payload: &ToolResultPayload) -> ItemLifecycleEvent { + ItemLifecycleEvent::ToolClosed { + tool_use_id: payload.tool_call_id.clone(), + tool_name: payload + .tool_name + .clone() + .unwrap_or_else(|| "tool".to_string()), + input: payload.input.clone().unwrap_or(serde_json::Value::Null), + output: Some(payload.content.clone()), + display_content: payload.display_content.clone(), + file_changes: None, + is_error: payload.is_error, + truncated: false, + } +} + +pub(crate) fn tool_opened_from_command_source( + call_id: String, + command: String, + input: Option, + source: ExecCommandSource, + command_actions: Vec, +) -> ItemLifecycleEvent { + let input = input.unwrap_or_else(|| serde_json::json!({ "command": command })); + ItemLifecycleEvent::ToolOpened { + tool_use_id: call_id, + tool_name: "exec_command".to_string(), + input, + command: Some(command), + command_source: Some(source), + parsed_commands: command_actions, + } +} + +pub(crate) fn tool_opened_from_command( + call_id: String, + command: String, + input: Option, + origin: ExecOrigin, + command_actions: Vec, +) -> ItemLifecycleEvent { + let source = match origin { + ExecOrigin::AgentTool => ExecCommandSource::Agent, + ExecOrigin::UserShell => ExecCommandSource::UserShell, + }; + tool_opened_from_command_source(call_id, command, input, source, command_actions) +} + +pub(crate) fn tool_closed_from_file_change( + call_id: String, + tool_name: Option, + input: Option, + changes: HashMap, +) -> ItemLifecycleEvent { + ItemLifecycleEvent::ToolClosed { + tool_use_id: call_id, + tool_name: tool_name.unwrap_or_else(|| "apply_patch".to_string()), + input: input.unwrap_or(serde_json::Value::Null), + output: None, + display_content: None, + file_changes: Some(changes), + is_error: false, + truncated: false, + } +} + +pub(crate) fn tool_closed_from_command( + call_id: String, + command: String, + input: Option, + output: Option, + is_error: bool, +) -> ItemLifecycleEvent { + let display_content = output.as_ref().map(|value| value.to_string()); + ItemLifecycleEvent::ToolClosed { + tool_use_id: call_id, + tool_name: "exec_command".to_string(), + input: input.unwrap_or_else(|| serde_json::json!({ "command": command })), + output, + display_content, + file_changes: None, + is_error, + truncated: false, + } +} + +pub(crate) fn native_file_changes(changes: &[FileChangeEntry]) -> HashMap { + changes + .iter() + .map(|entry| { + let change = match &entry.change { + FileChangeKind::Add { content } => FileChange::Add { + content: content.clone(), + }, + FileChangeKind::Delete { content } => FileChange::Delete { + content: content.clone(), + }, + FileChangeKind::Update { + unified_diff, + move_path, + } => FileChange::Update { + unified_diff: unified_diff.clone(), + old_text: None, + new_text: None, + move_path: move_path.clone(), + }, + }; + (entry.path.clone(), change) + }) + .collect() +} + +pub(crate) fn tool_closed_shell( + tool_use_id: String, + command: String, + output: Option, + is_error: bool, +) -> ItemLifecycleEvent { + let display = output.clone().unwrap_or_default(); + ItemLifecycleEvent::ToolClosed { + tool_use_id, + tool_name: "shell_command".to_string(), + input: serde_json::json!({ "command": command }), + output: output.map(serde_json::Value::String), + display_content: Some(display), + file_changes: None, + is_error, + truncated: false, + } +} + +pub(crate) fn transcript_tool_input_chunk( + tool_use_id: String, + chunk: String, +) -> ItemLifecycleEvent { + ItemLifecycleEvent::ToolInputChunk { tool_use_id, chunk } +} + +/// Parses the JSON payload embedded in an `item/toolCall/inputDelta` notification. +pub(crate) fn transcript_tool_input_chunk_from_delta_payload( + delta_str: &str, +) -> Option { + let value = serde_json::from_str::(delta_str).ok()?; + let tool_use_id = value.get("tool_use_id")?.as_str()?.to_string(); + let partial_json = value.get("partial_json")?.as_str()?.to_string(); + Some(transcript_tool_input_chunk(tool_use_id, partial_json)) +} + +pub(crate) fn transcript_tool_output_chunk( + tool_use_id: String, + chunk: String, +) -> ItemLifecycleEvent { + ItemLifecycleEvent::ToolOutputChunk { tool_use_id, chunk } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn tool_input_delta_payload_maps_to_transcript_chunk() { + let delta = serde_json::json!({ + "tool_use_id": "call-1", + "partial_json": "{\"command\": \"touch foo\"}" + }) + .to_string(); + + assert_eq!( + transcript_tool_input_chunk_from_delta_payload(&delta), + Some(ItemLifecycleEvent::ToolInputChunk { + tool_use_id: "call-1".to_string(), + chunk: "{\"command\": \"touch foo\"}".to_string(), + }) + ); + } +} diff --git a/crates/tui/src/worker/tool_summaries.rs b/crates/tui/src/worker/tool_summaries.rs new file mode 100644 index 00000000..160298c8 --- /dev/null +++ b/crates/tui/src/worker/tool_summaries.rs @@ -0,0 +1,697 @@ +//! Tool call summary strings and lifecycle projection helpers. + +use std::path::PathBuf; + +use devo_protocol::ToolCallPayload; + +use crate::events::PlanStepStatus; +use crate::events::WorkerEvent; +use crate::transcript::lifecycle::ItemLifecycleEvent; + +pub(crate) fn summarize_tool_result_title(tool_name: Option<&str>, is_error: bool) -> String { + match (tool_name, is_error) { + (Some(tool_name), true) => format!("{tool_name} error"), + (Some(tool_name), false) => format!("{tool_name} output"), + (None, true) => "Tool error".to_string(), + (None, false) => "Tool output".to_string(), + } +} + +pub(crate) fn tool_call_started_event(payload: ToolCallPayload) -> WorkerEvent { + WorkerEvent::Transcript(super::tool_lifecycle::tool_opened_from_call(&payload)) +} + +pub(crate) fn summarize_tool_call(payload: &ToolCallPayload) -> String { + if is_web_search_tool_name(&payload.tool_name) + && let Some(query) = web_search_query(&payload.parameters) + { + return format!("Web Search({})", serde_json::Value::String(query)); + } + if is_web_fetch_tool_name(&payload.tool_name) + && let Some(url) = web_fetch_url(&payload.parameters) + { + return format!("Web Fetch({})", serde_json::Value::String(url)); + } + + match pretty_tool_call_summary(&payload.tool_name, &payload.parameters) { + Some(summary) => summary, + None => { + let detail = summarize_tool_input(&payload.tool_name, &payload.parameters); + if detail.is_empty() { + payload.tool_name.clone() + } else { + format!("{} {detail}", payload.tool_name) + } + } + } +} + +fn pretty_tool_call_summary(tool_name: &str, input: &serde_json::Value) -> Option { + let quote = |text: &str| serde_json::Value::String(compact_tool_summary(text, 96)).to_string(); + let path_value = || { + input + .get("filePath") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("path").and_then(serde_json::Value::as_str)) + .map(make_path_relative) + }; + match tool_name { + "bash" | "shell_command" | "exec_command" => input + .get("command") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("cmd").and_then(serde_json::Value::as_str)) + .map(|command| format!("Shell {}", compact_tool_summary(command, 96))), + "read" => path_value().map(|path| format!("Read {path}{}", fmt_line_range(input))), + "write" => path_value().map(|path| format!("Write {path}")), + "edit" => Some("Edit".to_string()), + "apply_patch" => path_value().map(|path| format!("Patch {path}")), + "find" | "glob" => input + .get("path") + .and_then(serde_json::Value::as_str) + .map(make_path_relative) + .or_else(|| { + input + .get("pattern") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + }) + .map(|path| format!("List {path}")), + "grep" => { + let pattern = input.get("pattern").and_then(serde_json::Value::as_str)?; + let query = quote(pattern); + match input + .get("path") + .and_then(serde_json::Value::as_str) + .map(make_path_relative) + { + Some(path) => Some(format!("Search {query} in {path}")), + None => Some(format!("Search {query}")), + } + } + "code_search" | "mcp__code_search__code_search" => { + let query = input + .get("query") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("pattern").and_then(serde_json::Value::as_str)) + .unwrap_or_default(); + let path = input + .get("path") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("file_path").and_then(serde_json::Value::as_str)) + .map(make_path_relative); + match (query.is_empty(), path) { + (false, Some(path)) => Some(format!("Code-Search {} in {path}", quote(query))), + (false, None) => Some(format!("Code-Search {}", quote(query))), + (true, Some(path)) => Some(format!("Code-Search in {path}")), + (true, None) => Some("Code-Search".to_string()), + } + } + "spawn_agent" | "agent_spawn" => { + let nickname = input + .get("agent_nickname") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("nickname").and_then(serde_json::Value::as_str)) + .or_else(|| input.get("agent_path").and_then(serde_json::Value::as_str)) + .unwrap_or("agent"); + let prompt = input + .get("message") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("prompt").and_then(serde_json::Value::as_str)) + .unwrap_or_default(); + Some(format!("Spawn-Agent {} {}", quote(nickname), quote(prompt))) + } + "await_task" | "wait_agent" | "agent_wait" => { + let target = input + .get("task_id") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("target").and_then(serde_json::Value::as_str)) + .or_else(|| { + input + .get("agent_nickname") + .and_then(serde_json::Value::as_str) + }) + .unwrap_or("agent"); + let timeout = input + .get("timeout_secs") + .and_then(serde_json::Value::as_u64) + .map(|secs| format!("{secs}s")) + .or_else(|| { + input + .get("timeout") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + }) + .unwrap_or_else(|| "default".to_string()); + Some(format!("Await-Task {} {}", quote(target), quote(&timeout))) + } + "cancel_task" | "close_agent" | "agent_close" => { + let target = input + .get("task_id") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("target").and_then(serde_json::Value::as_str)) + .or_else(|| { + input + .get("agent_nickname") + .and_then(serde_json::Value::as_str) + }) + .unwrap_or("agent"); + Some(format!("Cancel-Task {}", quote(target))) + } + "list_tasks" | "list_agents" | "list_agent" | "agent_list" => { + Some("List-Tasks".to_string()) + } + _ => None, + } +} + +fn is_web_search_tool_name(tool_name: &str) -> bool { + matches!(tool_name, "web_search" | "websearch" | "web-search") +} + +fn is_web_fetch_tool_name(tool_name: &str) -> bool { + matches!( + tool_name, + "webfetch" | "web_fetch" | "web-fetch" | "fetch_url" | "fetch-url" + ) +} + +fn web_search_query(input: &serde_json::Value) -> Option { + input + .get("query") + .and_then(serde_json::Value::as_str) + .filter(|query| !query.is_empty()) + .map(ToString::to_string) +} + +fn web_fetch_url(input: &serde_json::Value) -> Option { + input + .get("url") + .and_then(serde_json::Value::as_str) + .filter(|url| !url.is_empty()) + .map(ToString::to_string) +} + +pub(crate) fn summarize_tool_call_update(payload: &ToolCallPayload) -> String { + let summary = summarize_tool_call(payload); + if payload.tool_name == "read" + && summary == "read {}" + && let Some(cmd) = payload + .command_actions + .iter() + .find_map(|action| match action { + devo_protocol::parse_command::ParsedCommand::Read { cmd, .. } + if !cmd.is_empty() => + { + Some(cmd.clone()) + } + _ => None, + }) + { + return cmd; + } + if matches!(payload.tool_name.as_str(), "find" | "glob") + && (summary == "find {}" || summary == "glob {}") + && let Some(cmd) = payload + .command_actions + .iter() + .find_map(|action| match action { + devo_protocol::parse_command::ParsedCommand::ListFiles { cmd, .. } + if !cmd.is_empty() => + { + Some(cmd.clone()) + } + _ => None, + }) + { + return cmd; + } + summary +} + +fn read_command_action_from_parameters( + command: &str, + input: &serde_json::Value, +) -> Option { + let path = input + .get("filePath") + .or_else(|| input.get("path")) + .and_then(serde_json::Value::as_str)? + .trim(); + if path.is_empty() { + return None; + } + let mut name = path.to_string(); + let offset = input.get("offset").and_then(serde_json::Value::as_u64); + let limit = input.get("limit").and_then(serde_json::Value::as_u64); + match (offset, limit) { + (Some(offset), Some(limit)) => { + let end = offset.saturating_add(limit.saturating_sub(1)); + name.push_str(&format!(" L:{offset}-{end}")); + } + (Some(offset), None) => name.push_str(&format!(" L:{offset}-")), + (None, Some(limit)) => name.push_str(&format!(" L:1-{limit}")), + (None, None) => {} + } + Some(devo_protocol::parse_command::ParsedCommand::Read { + cmd: command.to_string(), + name, + path: PathBuf::from(path), + }) +} + +fn find_command_action_from_parameters( + command: &str, + input: &serde_json::Value, +) -> Option { + let pattern = input + .get("pattern") + .and_then(serde_json::Value::as_str) + .filter(|pattern| !pattern.is_empty())?; + let path = input.get("path").and_then(serde_json::Value::as_str); + let display = match path.filter(|path| !path.is_empty()) { + Some(path) => format!("{pattern} in {path}"), + None => pattern.to_string(), + }; + Some(devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: command.to_string(), + path: Some(display), + }) +} + +/// Mirrors `server/tool_actions.rs::exploration_actions_from_tool_input` for live-session parity with resume. +pub(crate) fn exploration_actions_from_tool_input( + tool_name: &str, + command: &str, + input: &serde_json::Value, +) -> Vec { + match tool_name { + "read" => read_command_action_from_parameters(command, input) + .into_iter() + .collect(), + "find" | "glob" => vec![devo_protocol::parse_command::ParsedCommand::ListFiles { + cmd: command.to_string(), + path: find_command_action_from_parameters(tool_name, input).and_then(|parsed| { + match parsed { + devo_protocol::parse_command::ParsedCommand::ListFiles { path, .. } => path, + _ => None, + } + }), + }], + "grep" => vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: command.to_string(), + query: input + .get("pattern") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned), + path: input + .get("path") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned), + }], + "code_search" | "mcp__code_search__code_search" => { + code_search_command_action_from_parameters(command, input) + .into_iter() + .collect() + } + _ => Vec::new(), + } +} + +pub(crate) fn tool_call_started_actions( + payload: &ToolCallPayload, +) -> Vec { + if !payload.command_actions.is_empty() { + return payload.command_actions.clone(); + } + let command = summarize_tool_call(payload); + let explored = + exploration_actions_from_tool_input(&payload.tool_name, &command, &payload.parameters); + if !explored.is_empty() { + return explored; + } + if payload.tool_name == "read" { + return vec![ + read_command_action_from_parameters("read", &payload.parameters).unwrap_or_else(|| { + devo_protocol::parse_command::ParsedCommand::Read { + cmd: String::new(), + name: String::new(), + path: PathBuf::new(), + } + }), + ]; + } + Vec::new() +} + +pub(crate) fn tool_call_updated_actions( + payload: &ToolCallPayload, + summary: &str, +) -> Vec { + if !payload.command_actions.is_empty() { + return payload.command_actions.clone(); + } + let explored = + exploration_actions_from_tool_input(&payload.tool_name, summary, &payload.parameters); + if !explored.is_empty() { + return explored; + } + match payload.tool_name.as_str() { + "read" => read_command_action_from_parameters(summary, &payload.parameters) + .into_iter() + .collect(), + "find" | "glob" => find_command_action_from_parameters(summary, &payload.parameters) + .into_iter() + .collect(), + "code_search" | "mcp__code_search__code_search" => { + code_search_command_action_from_parameters(summary, &payload.parameters) + .into_iter() + .collect() + } + _ => Vec::new(), + } +} + +fn code_search_command_action_from_parameters( + command: &str, + input: &serde_json::Value, +) -> Option { + match input + .get("operation") + .and_then(serde_json::Value::as_str) + .unwrap_or("search") + { + "find_related" => { + let path = input + .get("file_path") + .and_then(serde_json::Value::as_str) + .filter(|path| !path.is_empty())?; + let line = input + .get("line") + .and_then(serde_json::Value::as_u64) + .map(|line| line.to_string()) + .unwrap_or_else(|| "?".to_string()); + Some(devo_protocol::parse_command::ParsedCommand::Search { + cmd: command.to_string(), + query: Some(format!("related {path}:{line}")), + path: Some(path.to_string()), + }) + } + _ => { + let query = input + .get("query") + .and_then(serde_json::Value::as_str) + .filter(|query| !query.is_empty())?; + Some(devo_protocol::parse_command::ParsedCommand::Search { + cmd: command.to_string(), + query: Some(query.to_string()), + path: input + .get("path") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned), + }) + } + } +} + +fn make_path_relative(path: &str) -> String { + let p = std::path::PathBuf::from(path); + if p.is_absolute() + && let Ok(cwd) = std::env::current_dir() + && let Ok(rel) = p.strip_prefix(&cwd) + { + return rel.to_string_lossy().to_string(); + } + path.to_string() +} + +fn code_search_summary_from_input(input: &serde_json::Value) -> String { + match input + .get("operation") + .and_then(serde_json::Value::as_str) + .unwrap_or("search") + { + "find_related" => { + let path = input + .get("file_path") + .and_then(serde_json::Value::as_str) + .map(make_path_relative); + let line = input.get("line").and_then(serde_json::Value::as_u64); + match (path, line) { + (Some(path), Some(line)) => format!("related {path}:{line}"), + (Some(path), None) => format!("related {path}"), + (None, _) => "related".to_string(), + } + } + _ => { + let query = input + .get("query") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let path = input + .get("path") + .and_then(serde_json::Value::as_str) + .map(make_path_relative); + match (query.is_empty(), path) { + (false, Some(path)) => format!("{query} in {path}"), + (false, None) => query.to_string(), + (true, Some(path)) => format!("in {path}"), + (true, None) => String::new(), + } + } + } +} + +fn fmt_offset_limit(input: &serde_json::Value) -> String { + let offset = input.get("offset").and_then(|v| v.as_u64()); + let limit = input.get("limit").and_then(|v| v.as_u64()); + match (offset, limit) { + (Some(o), Some(l)) => format!(" (offset:{o}, limit:{l})"), + (Some(o), None) => format!(" (offset:{o})"), + (None, Some(l)) => format!(" (limit:{l})"), + (None, None) => String::new(), + } +} + +fn fmt_line_range(input: &serde_json::Value) -> String { + let offset = input.get("offset").and_then(serde_json::Value::as_u64); + let limit = input.get("limit").and_then(serde_json::Value::as_u64); + match (offset, limit) { + (Some(start), Some(limit)) => { + let end = start.saturating_add(limit.saturating_sub(1)); + format!(" L:{start}-{end}") + } + (Some(start), None) => format!(" L:{start}"), + (None, Some(limit)) => format!(" L:0-{limit}"), + (None, None) => String::new(), + } +} + +fn summarize_tool_input(tool_name: &str, input: &serde_json::Value) -> String { + let candidate = match tool_name { + "bash" | "shell_command" | "exec_command" => input + .get("command") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("cmd").and_then(serde_json::Value::as_str)) + .map(|s| s.to_string()), + "read" => input + .get("filePath") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("path").and_then(serde_json::Value::as_str)) + .map(|path| { + let rel = make_path_relative(path); + let ext = fmt_offset_limit(input); + format!("{rel}{ext}") + }), + "write" | "edit" | "apply_patch" => input + .get("path") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("filePath").and_then(serde_json::Value::as_str)) + .map(make_path_relative), + "grep" => { + let pattern = input + .get("pattern") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let path = input + .get("path") + .and_then(serde_json::Value::as_str) + .map(make_path_relative); + match path { + Some(p) => Some(format!("'{pattern}' in {p}")), + None => Some(format!("'{pattern}'")), + } + } + "find" | "glob" => { + let pattern = input + .get("pattern") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let path = input + .get("path") + .and_then(serde_json::Value::as_str) + .map(make_path_relative); + match path { + Some(p) => Some(format!("{pattern} in {p}")), + None => Some(pattern.to_string()), + } + } + "code_search" | "mcp__code_search__code_search" => { + Some(code_search_summary_from_input(input)) + } + "webfetch" | "web_fetch" | "web-fetch" | "fetch_url" | "fetch-url" => web_fetch_url(input), + "web_search" | "websearch" | "web-search" => web_search_query(input), + "lsp" => { + let path = input + .get("filePath") + .and_then(serde_json::Value::as_str) + .map(make_path_relative); + let line = input.get("line").and_then(|v| v.as_i64()); + let col = input.get("character").and_then(|v| v.as_i64()); + match (path, line, col) { + (Some(p), Some(l), Some(c)) => Some(format!("{p}:{l}:{c}")), + (Some(p), Some(l), None) => Some(format!("{p}:{l}")), + (Some(p), None, _) => Some(p), + _ => None, + } + } + "question" => None, + "skill" => input + .get("name") + .and_then(serde_json::Value::as_str) + .map(|s| s.to_string()), + "spawn_agent" => input + .get("message") + .and_then(serde_json::Value::as_str) + .filter(|message| !message.is_empty()) + .map(|message| message.to_string()), + _ => None, + }; + + candidate + .map(|text| compact_tool_summary(&text, 96)) + .unwrap_or_else(|| compact_tool_summary(&render_json_preview(input), 96)) +} + +fn compact_tool_summary(text: &str, max_chars: usize) -> String { + let compact = text.split_whitespace().collect::>().join(" "); + let truncated = compact.chars().count() > max_chars; + let mut out = compact.chars().take(max_chars).collect::(); + if truncated { + out.push('…'); + } + out +} + +fn render_json_preview(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(text) => truncate_tool_output(text), + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + let pretty = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()); + truncate_tool_output(&pretty) + } + _ => truncate_tool_output(&value.to_string()), + } +} + +pub(crate) fn render_json_value_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + _ => value.to_string(), + } +} + +pub(crate) fn parse_plan_step_status(status: &str) -> Option { + match status { + "pending" => Some(PlanStepStatus::Pending), + "in_progress" => Some(PlanStepStatus::InProgress), + "completed" => Some(PlanStepStatus::Completed), + "cancelled" => Some(PlanStepStatus::Cancelled), + _ => None, + } +} + +pub(crate) fn truncate_tool_output(content: &str) -> String { + const MAX_LINES: usize = 8; + const MAX_CHARS: usize = 1200; + let content = normalize_display_output(content); + let content = content.as_str(); + + let mut lines = Vec::new(); + let mut chars = 0usize; + for line in content.lines() { + if lines.len() >= MAX_LINES || chars >= MAX_CHARS { + break; + } + let remaining = MAX_CHARS.saturating_sub(chars); + if line.chars().count() > remaining { + let preview = line.chars().take(remaining).collect::(); + lines.push(preview); + break; + } + chars += line.chars().count(); + lines.push(line.to_string()); + } + + if lines.is_empty() && !content.is_empty() { + let preview = content.chars().take(MAX_CHARS).collect::(); + return if preview == content { + preview + } else { + format!("{preview}\n… ") + }; + } + + let preview = lines.join("\n"); + if preview == content { + preview + } else if preview.is_empty() { + "… ".to_string() + } else { + format!("{preview}\n… ") + } +} + +pub(crate) fn normalize_display_output(content: &str) -> String { + content + .replace("\r\n", "\n") + .replace('\r', "\n") + .trim_matches('\n') + .to_string() +} + +/// Native-first lifecycle projection for tool call start. +pub(crate) fn lifecycle_from_tool_call_started( + payload: &ToolCallPayload, +) -> Vec { + vec![super::tool_lifecycle::tool_opened_from_call(payload)] +} + +/// Native-first lifecycle projection for finalized tool call metadata. +pub(crate) fn lifecycle_from_tool_call_completed( + payload: &ToolCallPayload, +) -> Vec { + vec![super::tool_lifecycle::tool_opened_refresh_from_call( + payload, + )] +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn grep_exploration_actions_match_server_projection_shape() { + let input = serde_json::json!({"pattern": "plan", "path": "crates"}); + assert_eq!( + exploration_actions_from_tool_input("grep", "Search plan in crates", &input), + vec![devo_protocol::parse_command::ParsedCommand::Search { + cmd: "Search plan in crates".to_string(), + query: Some("plan".to_string()), + path: Some("crates".to_string()), + }] + ); + } +} diff --git a/crates/tui/src/worker/typed_events.rs b/crates/tui/src/worker/typed_events.rs index d858b699..ffa0eb29 100644 --- a/crates/tui/src/worker/typed_events.rs +++ b/crates/tui/src/worker/typed_events.rs @@ -1,20 +1,12 @@ -//! Native typed item events → legacy item payload conversion -//! (L2-DES-APP-009 DD-5). The existing `handle_started_item` / -//! `handle_completed_item` handlers consume the converted payloads -//! unchanged, so typed and legacy shapes render identically. +//! Native typed item event projection for the TUI worker (L2-DES-APP-009). +//! +//! Transcript items route through [`crate::worker::native_items`] into +//! [`crate::transcript::lifecycle::ItemLifecycleEvent`]. Session history restore +//! uses [`history_item_from_native_item`] and [`history_item_from_native_turn`]. -use devo_protocol::ItemEnvelope as LegacyItemEnvelope; -use devo_protocol::ItemEventPayload; -use devo_protocol::ItemKind; -use devo_protocol::PendingServerRequestContext; -use devo_protocol::ServerRequestKind; use devo_protocol::SessionHistoryItem; use devo_protocol::SessionHistoryItemKind; use devo_protocol::SessionHistoryMetadata; -use devo_protocol::TypedItemEventPayload; -use devo_protocol::native::item::ApprovalDecisionKind; -use devo_protocol::native::item::ApprovalScope; -use devo_protocol::native::item::ApprovalTarget; use devo_protocol::native::item::Item; use devo_protocol::native::turn::Turn; use devo_protocol::native::turn::TurnStatus; @@ -178,364 +170,50 @@ pub(super) fn history_item_from_native_turn( }) } -/// Converts a canonical typed item event into the legacy item-event shape -/// understood by the TUI's item handlers. Variants the TUI does not render -/// (hosted tools, background work, warnings, ...) yield `None`, -/// matching the legacy handler's ignore list. -pub(super) fn legacy_item_event_from_typed( - payload: &TypedItemEventPayload, -) -> Option { - let item_id = devo_protocol::ItemId::try_from(payload.item.id.as_str()).ok()?; - let (item_kind, legacy_payload) = match &payload.item.item { - Item::AssistantMessage { text, .. } => ( - ItemKind::AgentMessage, - serde_json::json!({ "title": "Assistant", "text": text }), - ), - Item::Reasoning { text, .. } => ( - ItemKind::Reasoning, - serde_json::json!({ "title": "Reasoning", "text": text }), - ), - Item::Plan { entries } => ( - ItemKind::Plan, - serde_json::json!({ - "title": "Proposed Plan", - "text": serde_json::to_value(entries).expect("serialize plan entries"), - }), - ), - Item::ToolCall { - call_id, - tool_name, - input, - .. - } => ( - ItemKind::ToolCall, - serde_json::to_value(devo_protocol::ToolCallPayload { - tool_call_id: call_id.clone(), - tool_name: tool_name.clone(), - parameters: input.clone().unwrap_or(serde_json::Value::Null), - command_actions: Vec::new(), - }) - .expect("serialize legacy tool call payload"), - ), - Item::ToolResult { - call_id, - output, - display_content, - is_error, - .. - } => ( - ItemKind::ToolResult, - serde_json::to_value(devo_protocol::ToolResultPayload { - tool_call_id: call_id.clone(), - tool_name: None, - input: None, - content: output.clone(), - display_content: display_content.clone(), - is_error: *is_error, - summary: String::new(), - }) - .expect("serialize legacy tool result payload"), - ), - Item::CommandExecution { - call_id, - command, - input, - output, - is_error, - .. - } => ( - ItemKind::CommandExecution, - serde_json::to_value(devo_protocol::CommandExecutionPayload { - tool_call_id: call_id.clone(), - tool_name: "exec_command".to_string(), - command: command.clone(), - input: input.clone(), - source: Default::default(), - command_actions: Vec::new(), - output: output.clone(), - is_error: *is_error, - }) - .expect("serialize legacy command execution payload"), - ), - Item::Approval { - approval_id, - action_summary, - justification, - resource, - available_scopes, - command_pattern, - command_prefix, - target, - decision, - .. - } => approval_legacy_payload( - payload, - approval_id, - action_summary, - justification, - resource, - available_scopes, - command_pattern, - command_prefix, - target.as_ref(), - decision.as_ref(), - )?, - Item::ContextCompaction { .. } => (ItemKind::ContextCompaction, serde_json::json!({})), - Item::UserMessage { .. } - | Item::HostedToolCall { .. } - | Item::FileChange { .. } - | Item::UserInputRequest { .. } - | Item::SubAgent { .. } - | Item::BackgroundTask { .. } - | Item::GoalProgress { .. } - | Item::Warning { .. } => return None, - }; - Some(ItemEventPayload { - context: payload.context.clone(), - item: LegacyItemEnvelope { - item_id, - item_kind, - payload: legacy_payload, - }, - }) -} - -#[allow(clippy::too_many_arguments)] -fn approval_legacy_payload( - payload: &TypedItemEventPayload, - approval_id: &str, - action_summary: &str, - justification: &str, - resource: &Option, - available_scopes: &[String], - command_pattern: &Option>, - command_prefix: &Option>, - target: Option<&ApprovalTarget>, - decision: Option<&devo_protocol::native::item::ApprovalDecision>, -) -> Option<(ItemKind, serde_json::Value)> { - if let Some(decision) = decision { - let decision_label = match decision.decision { - ApprovalDecisionKind::Approved => "approve", - ApprovalDecisionKind::Denied => "deny", - ApprovalDecisionKind::Cancelled => "cancel", - }; - let scope = match decision.scope { - ApprovalScope::Once => "once", - ApprovalScope::Turn => "turn", - ApprovalScope::Session => "session", - ApprovalScope::PathPrefix => "path_prefix", - ApprovalScope::Host => "host", - ApprovalScope::Tool => "tool", - ApprovalScope::CommandPrefix => "command_prefix", - ApprovalScope::CommandPrefixPersist => "command_prefix_persist", - }; - return Some(( - ItemKind::ApprovalDecision, - serde_json::to_value(devo_protocol::ApprovalDecisionPayload { - approval_id: approval_id.to_string().into(), - decision: decision_label.to_string(), - scope: scope.to_string(), - decision_source: Some(decision.decision_source), - }) - .expect("serialize legacy approval decision payload"), - )); - } - - let turn_id = payload - .context - .turn_id - .or_else(|| devo_protocol::TurnId::try_from(payload.item.turn_id.as_str()).ok())?; - let (path, host, target) = match target { - Some(ApprovalTarget::Path { path }) => (Some(path.display().to_string()), None, None), - Some(ApprovalTarget::Host { host }) => (None, Some(host.clone()), None), - Some(ApprovalTarget::Command { command }) => (None, None, Some(command.clone())), - None => (None, None, None), - }; - Some(( - ItemKind::ApprovalRequest, - serde_json::to_value(devo_protocol::ApprovalRequestPayload { - request: PendingServerRequestContext { - request_id: approval_id.to_string().into(), - request_kind: approval_request_kind(resource.as_deref()), - session_id: payload.context.session_id, - turn_id: Some(turn_id), - item_id: payload.context.item_id, - }, - approval_id: approval_id.to_string().into(), - action_summary: action_summary.to_string(), - justification: justification.to_string(), - resource: resource.clone(), - available_scopes: available_scopes.to_vec(), - path, - host, - target, - command_pattern: command_pattern.clone(), - command_prefix: command_prefix.clone(), - }) - .expect("serialize legacy approval request payload"), - )) -} - -fn approval_request_kind(resource: Option<&str>) -> ServerRequestKind { - match resource { - Some("ShellExec") => ServerRequestKind::ItemCommandExecutionRequestApproval, - Some("FileWrite") => ServerRequestKind::ItemFileChangeRequestApproval, - Some(_) | None => ServerRequestKind::ItemPermissionsRequestApproval, - } -} - #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use super::*; - use devo_protocol::EventContext; - use devo_protocol::native::ids::ItemId as NativeItemId; use devo_protocol::native::ids::SessionId as NativeSessionId; use devo_protocol::native::ids::TurnId as NativeTurnId; - use devo_protocol::native::item::ItemEnvelope as NativeItemEnvelope; - use devo_protocol::native::item::ItemState; - - fn typed_payload(item: Item) -> TypedItemEventPayload { - let item_id = devo_protocol::ItemId::new(); - TypedItemEventPayload { - context: EventContext { - session_id: devo_protocol::SessionId::new(), - turn_id: Some(devo_protocol::TurnId::new()), - item_id: Some(item_id), - seq: 0, - item_seq: None, - }, - item: NativeItemEnvelope { - id: NativeItemId::from_legacy_uuid(item_id.into()), - session_id: NativeSessionId::from_legacy_uuid( - devo_protocol::SessionId::new().into(), - ), - turn_id: NativeTurnId::from_legacy_uuid(devo_protocol::TurnId::new().into()), - seq: 1, - revision: 1, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - state: ItemState::Running, - item, - }, - } - } - - /// Trace: L2-DES-APP-009 - /// Verifies: typed assistant/tool-call items convert into the legacy - /// payload shapes the TUI handlers render. + /// Verifies: native FileChange items project into lifecycle events. #[test] - fn typed_items_convert_to_legacy_payloads() { - let legacy = legacy_item_event_from_typed(&typed_payload(Item::AssistantMessage { - text: "hello".into(), - phase: None, - })) - .expect("assistant message converts"); - assert_eq!(legacy.item.item_kind, ItemKind::AgentMessage); - assert_eq!( - legacy.item.payload.get("text").and_then(|v| v.as_str()), - Some("hello") - ); - - let legacy = legacy_item_event_from_typed(&typed_payload(Item::ToolCall { - call_id: "call-1".into(), - tool_name: "exec_command".into(), - source: devo_protocol::native::item::ToolSource::Builtin, - server_name: None, - input: Some(serde_json::json!({ "cmd": "ls" })), - })) - .expect("tool call converts"); - assert_eq!(legacy.item.item_kind, ItemKind::ToolCall); - let payload: devo_protocol::ToolCallPayload = - serde_json::from_value(legacy.item.payload).expect("legacy tool call payload"); - assert_eq!(payload.tool_call_id, "call-1"); - assert_eq!(payload.tool_name, "exec_command"); - assert_eq!(payload.parameters, serde_json::json!({ "cmd": "ls" })); - } - - #[test] - fn typed_approval_converts_to_tui_request_payload() { - let typed = typed_payload(Item::Approval { - approval_id: "call-approval-1".into(), - target_item_id: None, - action_summary: "Run cargo test".into(), - justification: "Verify the change".into(), - resource: Some("ShellExec".into()), - available_scopes: vec!["once".into(), "command_prefix_persist".into()], - command_pattern: Some(vec!["cargo".into(), "test".into()]), - command_prefix: Some(vec!["cargo".into(), "test".into()]), - target: Some(ApprovalTarget::Command { - command: "cargo test".into(), - }), - decision: None, - }); - let legacy = legacy_item_event_from_typed(&typed).expect("approval converts"); - let request: devo_protocol::ApprovalRequestPayload = - serde_json::from_value(legacy.item.payload).expect("approval request payload"); - - assert_eq!(legacy.item.item_kind, ItemKind::ApprovalRequest); - assert_eq!( - request, - devo_protocol::ApprovalRequestPayload { - request: PendingServerRequestContext { - request_id: "call-approval-1".into(), - request_kind: ServerRequestKind::ItemCommandExecutionRequestApproval, - session_id: typed.context.session_id, - turn_id: typed.context.turn_id, - item_id: typed.context.item_id, - }, - approval_id: "call-approval-1".into(), - action_summary: "Run cargo test".into(), - justification: "Verify the change".into(), - resource: Some("ShellExec".into()), - available_scopes: vec!["once".into(), "command_prefix_persist".into()], - path: None, - host: None, - target: Some("cargo test".into()), - command_pattern: Some(vec!["cargo".into(), "test".into()]), - command_prefix: Some(vec!["cargo".into(), "test".into()]), - } + fn native_file_change_projects_to_lifecycle_event() { + use std::path::PathBuf; + + use devo_protocol::native::item::FileChangeEntry; + use devo_protocol::native::item::FileChangeKind; + + use crate::transcript::lifecycle::ItemLifecycleEvent; + use crate::worker::native_items; + + let events = native_items::completed_events( + &Item::FileChange { + call_id: "edit-1".into(), + changes: vec![FileChangeEntry { + path: PathBuf::from("src/main.rs"), + change: FileChangeKind::Update { + unified_diff: "@@ -1 +1 @@\n-old\n+new\n".into(), + move_path: None, + }, + }], + sandbox: None, + }, + devo_core::ItemId::new(), ); - } - - #[test] - fn typed_approval_decision_converts_to_tui_decision_payload() { - let mut typed = typed_payload(Item::Approval { - approval_id: "call-approval-2".into(), - target_item_id: None, - action_summary: "Run cargo test".into(), - justification: String::new(), - resource: Some("ShellExec".into()), - available_scopes: vec!["once".into()], - command_pattern: None, - command_prefix: None, - target: None, - decision: Some(devo_protocol::native::item::ApprovalDecision { - decision: ApprovalDecisionKind::Cancelled, - scope: ApprovalScope::Once, - decision_source: - devo_protocol::native::item::ApprovalDecisionSource::ExternalPolicy, - decided_at: chrono::Utc::now(), - }), - }); - typed.item.state = ItemState::Completed; - let legacy = legacy_item_event_from_typed(&typed).expect("approval decision converts"); - let decision: devo_protocol::ApprovalDecisionPayload = - serde_json::from_value(legacy.item.payload).expect("approval decision payload"); - - assert_eq!(legacy.item.item_kind, ItemKind::ApprovalDecision); - assert_eq!( - decision, - devo_protocol::ApprovalDecisionPayload { - approval_id: "call-approval-2".into(), - decision: "cancel".into(), - scope: "once".into(), - decision_source: Some( - devo_protocol::native::item::ApprovalDecisionSource::ExternalPolicy, - ), + assert_eq!(events.len(), 1); + match &events[0] { + ItemLifecycleEvent::ToolClosed { + tool_use_id, + file_changes: Some(changes), + .. + } => { + assert_eq!(tool_use_id, "edit-1"); + assert_eq!(changes.len(), 1); } - ); + other => panic!("unexpected lifecycle event: {other:?}"), + } } #[test] diff --git a/crates/tui/src/worker_event_test_helpers.rs b/crates/tui/src/worker_event_test_helpers.rs new file mode 100644 index 00000000..821504a1 --- /dev/null +++ b/crates/tui/src/worker_event_test_helpers.rs @@ -0,0 +1,344 @@ +//! Test helpers that emit [`WorkerEvent::Transcript`] lifecycle events. +//! +//! Replaces removed tool-specific [`WorkerEvent`] variants in tests. + +use std::collections::HashMap; +use std::path::PathBuf; + +use devo_protocol::parse_command::ParsedCommand; +use devo_protocol::protocol::ExecCommandSource; +use devo_protocol::protocol::FileChange; + +use devo_util_shell_command::parse_command::parse_command; + +use devo_core::ItemId; + +use crate::events::TextItemKind; +use crate::events::WorkerEvent; +use crate::transcript::lifecycle::ItemLifecycleEvent; +use crate::transcript::tool_state::command_source_from_tool_name; +use crate::transcript::tool_state::shell_command_from_input; + +fn transcript(event: ItemLifecycleEvent) -> WorkerEvent { + WorkerEvent::Transcript(event) +} + +/// Shim for removed `WorkerEvent::TextItemStarted`. +pub(crate) fn text_item_started(item_id: ItemId, kind: TextItemKind) -> WorkerEvent { + transcript(ItemLifecycleEvent::TextStarted { item_id, kind }) +} + +/// Shim for removed `WorkerEvent::TextItemDelta`. +pub(crate) fn text_item_delta( + item_id: ItemId, + kind: TextItemKind, + delta: impl Into, +) -> WorkerEvent { + transcript(ItemLifecycleEvent::TextDelta { + item_id, + kind, + delta: delta.into(), + }) +} + +/// Shim for removed `WorkerEvent::TextItemCompleted`. +pub(crate) fn text_item_completed( + item_id: ItemId, + kind: TextItemKind, + final_text: impl Into, +) -> WorkerEvent { + transcript(ItemLifecycleEvent::TextCompleted { + item_id, + kind, + final_text: final_text.into(), + }) +} + +fn infer_tool_from_summary(summary: &str, preparing: bool) -> (String, serde_json::Value) { + if preparing { + if summary.strip_prefix("write ").is_some() { + return ("write".to_string(), serde_json::json!({})); + } + if summary == "Edit" { + return ("edit".to_string(), serde_json::json!({})); + } + return ("apply_patch".to_string(), serde_json::json!({})); + } + if let Some(query) = summary + .strip_prefix("Web Search(") + .and_then(|rest| rest.strip_suffix(')')) + { + return ( + "web_search".to_string(), + serde_json::json!({ "query": query.trim_matches('"') }), + ); + } + if let Some(url) = summary + .strip_prefix("Web Fetch(") + .and_then(|rest| rest.strip_suffix(')')) + { + return ( + "web_fetch".to_string(), + serde_json::json!({ "url": url.trim_matches('"') }), + ); + } + if summary == "Edit" { + return ("edit".to_string(), serde_json::json!({})); + } + if summary.starts_with("Code-Search") { + return ("code_search".to_string(), serde_json::json!({})); + } + if let Some(cmd) = summary.strip_prefix("Shell ") { + return ("bash".to_string(), serde_json::json!({ "command": cmd })); + } + if let Some(path) = summary.strip_prefix("Read ") { + return ( + "read".to_string(), + serde_json::json!({ "path": path.split_whitespace().next().unwrap_or(path) }), + ); + } + if let Some(path) = summary.strip_prefix("List ") { + return ("glob".to_string(), serde_json::json!({ "path": path })); + } + if let Some(path) = summary.strip_prefix("Patch ") { + return ( + "apply_patch".to_string(), + serde_json::json!({ "path": path }), + ); + } + if summary == "read {}" || summary == "glob {}" || summary == "find {}" { + let tool_name = summary.split_whitespace().next().unwrap_or("tool"); + return (tool_name.to_string(), serde_json::json!({})); + } + if summary.contains("powershell") + || summary.starts_with("Get-") + || summary.contains(" -NoProfile") + || summary.contains(" -Command") + { + return ( + "bash".to_string(), + serde_json::json!({ "command": summary }), + ); + } + if matches!( + summary, + "apply_patch" | "code_search" | "read" | "glob" | "grep" | "write" | "edit" + ) { + return (summary.to_string(), serde_json::json!({})); + } + (summary.to_string(), serde_json::json!({})) +} + +fn parsed_commands_for_tool( + summary: &str, + input: &serde_json::Value, + tool_name: &str, + parsed_commands: Option>, +) -> Vec { + if let Some(parsed_commands) = parsed_commands.filter(|parsed| !parsed.is_empty()) { + return parsed_commands; + } + let explored = crate::worker::exploration_actions_from_tool_input(tool_name, summary, input); + if !explored.is_empty() { + return explored; + } + if matches!( + tool_name, + "web_search" | "web_fetch" | "websearch" | "web-search" | "webfetch" + ) { + return Vec::new(); + } + if let Some(command) = shell_command_from_input(input) { + return parse_command(&crate::exec_command::split_command_string(&command)); + } + if summary.trim().is_empty() { + return Vec::new(); + } + parse_command(&crate::exec_command::split_command_string(summary)) +} + +fn tool_opened_event( + tool_use_id: String, + tool_name: String, + input: serde_json::Value, + parsed_commands: Option>, + summary: &str, +) -> WorkerEvent { + let parsed_commands = parsed_commands_for_tool(summary, &input, &tool_name, parsed_commands); + let command = shell_command_from_input(&input).or_else(|| { + if matches!( + tool_name.as_str(), + "bash" | "shell_command" | "exec_command" | "shell" | "write_stdin" + ) { + Some(summary.to_string()) + } else { + None + } + }); + transcript(ItemLifecycleEvent::ToolOpened { + tool_use_id, + tool_name: tool_name.clone(), + command, + command_source: command_source_from_tool_name(&tool_name), + input, + parsed_commands, + }) +} + +/// Shim for removed `WorkerEvent::ToolCall`. +pub(crate) fn tool_call( + tool_use_id: String, + summary: String, + preparing: bool, + parsed_commands: Option>, +) -> WorkerEvent { + let (tool_name, input) = infer_tool_from_summary(&summary, preparing); + tool_opened_event(tool_use_id, tool_name, input, parsed_commands, &summary) +} + +/// Shim for removed `WorkerEvent::ToolCallUpdated`. +pub(crate) fn tool_call_updated( + tool_use_id: String, + summary: String, + parsed_commands: Vec, +) -> WorkerEvent { + let (tool_name, input) = infer_tool_from_summary(&summary, /*preparing*/ false); + tool_opened_event( + tool_use_id, + tool_name, + input, + Some(parsed_commands), + &summary, + ) +} + +/// Shim for removed `WorkerEvent::ToolCallDetails`. +pub(crate) fn tool_call_details( + tool_use_id: String, + tool_name: String, + input: serde_json::Value, +) -> WorkerEvent { + tool_opened_event(tool_use_id, tool_name, input, None, "") +} + +/// Shim for removed `WorkerEvent::ToolResult`. +pub(crate) fn tool_result( + tool_use_id: String, + title: String, + preview: String, + is_error: bool, + truncated: bool, +) -> WorkerEvent { + let (tool_name, input) = infer_tool_from_summary(&title, /*preparing*/ false); + transcript(ItemLifecycleEvent::ToolClosed { + tool_use_id, + tool_name, + input, + output: Some(serde_json::Value::String(preview.clone())), + display_content: Some(preview), + file_changes: None, + is_error, + truncated, + }) +} + +/// Shim for removed `WorkerEvent::ToolResultIo`. +pub(crate) fn tool_result_io( + tool_use_id: String, + tool_name: String, + title: String, + input: serde_json::Value, + output: serde_json::Value, + display_content: Option, + is_error: bool, + truncated: bool, +) -> WorkerEvent { + let _ = title; + let display = display_content.or_else(|| { + output + .as_str() + .map(str::to_string) + .or_else(|| Some(output.to_string())) + }); + transcript(ItemLifecycleEvent::ToolClosed { + tool_use_id, + tool_name, + input, + output: Some(output), + display_content: display, + file_changes: None, + is_error, + truncated, + }) +} + +/// Shim for removed `WorkerEvent::ToolOutputDelta`. +pub(crate) fn tool_output_delta(tool_use_id: String, delta: String) -> WorkerEvent { + transcript(ItemLifecycleEvent::ToolOutputChunk { + tool_use_id, + chunk: delta, + }) +} + +/// Shim for removed `WorkerEvent::ToolInputDelta`. +pub(crate) fn tool_input_delta(tool_use_id: String, delta: String) -> WorkerEvent { + transcript(ItemLifecycleEvent::ToolInputChunk { + tool_use_id, + chunk: delta, + }) +} + +/// Shim for removed `WorkerEvent::CommandExecutionStarted`. +pub(crate) fn command_execution_started( + tool_use_id: String, + command: String, + input: Option, + source: ExecCommandSource, + command_actions: Vec, +) -> WorkerEvent { + let input = input.unwrap_or_else(|| serde_json::json!({ "command": command.clone() })); + transcript(ItemLifecycleEvent::ToolOpened { + tool_use_id, + tool_name: "exec_command".to_string(), + input, + command: Some(command), + command_source: Some(source), + parsed_commands: command_actions, + }) +} + +/// Shim for removed `WorkerEvent::PatchApplied`. +pub(crate) fn patch_applied( + tool_use_id: String, + changes: HashMap, +) -> WorkerEvent { + transcript(ItemLifecycleEvent::ToolClosed { + tool_use_id, + tool_name: "apply_patch".to_string(), + input: serde_json::Value::Null, + output: None, + display_content: None, + file_changes: Some(changes), + is_error: false, + truncated: false, + }) +} + +/// Shim for removed `WorkerEvent::PatchAppliedIo`. +pub(crate) fn patch_applied_io( + tool_use_id: String, + tool_name: String, + input: serde_json::Value, + changes: HashMap, +) -> WorkerEvent { + transcript(ItemLifecycleEvent::ToolClosed { + tool_use_id, + tool_name, + input, + output: None, + display_content: None, + file_changes: Some(changes), + is_error: false, + truncated: false, + }) +} diff --git a/crates/tui/src/worker_queue_compaction_tests.rs b/crates/tui/src/worker_queue_compaction_tests.rs index 9a7b669c..dcbbf293 100644 --- a/crates/tui/src/worker_queue_compaction_tests.rs +++ b/crates/tui/src/worker_queue_compaction_tests.rs @@ -125,7 +125,8 @@ fn context_compaction_worker_event_adds_history_item() { #[test] fn completed_context_compaction_item_emits_worker_event() { let (event_tx, mut event_rx) = mpsc::unbounded_channel(); - crate::worker::handle_completed_item( + crate::worker::dispatch_legacy_item_event_for_test( + "item/completed", ItemEventPayload { context: devo_server::EventContext { session_id: SessionId::new(), From fe1aed44f34021e3fe618dc8b96f4441c9bcc5c2 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 29 Aug 2026 16:18:22 +0800 Subject: [PATCH 2/8] Keep session mailbox free during turns and restore live client status. Run turns on a TurnWorkingSet task with MergeTurn, overlay ActiveTurnRegistry onto session/list|read, accept Ollama's reasoning field, and stop list-refill from clearing busy UI. Also drop jump-to-response and unused onboarding migration steps. Co-authored-by: Cursor --- AGENTS.md | 1 + .../src/v2/client-native-interactions.test.ts | 17 + .../packages/devo-ai-sdk/src/v2/client.ts | 13 +- .../renderer/components/chat/chat-view.tsx | 79 +-- .../onboarding/onboarding-overlay.tsx | 130 +---- .../onboarding/steps/complete-step.test.tsx | 23 + .../onboarding/steps/complete-step.tsx | 138 +---- .../onboarding/steps/migration-offer-step.tsx | 491 ------------------ .../steps/migration-preview-step.tsx | 307 ----------- .../components/settings/setup-settings.tsx | 8 +- .../provider/src/openai/chat_completions.rs | 35 +- .../src/openai/chat_completions/stream.rs | 49 +- crates/server/AGENTS.md | 27 +- crates/server/src/runtime/active_turn.rs | 10 +- crates/server/src/runtime/approval.rs | 6 +- crates/server/src/runtime/connection.rs | 284 +++++++--- .../server/src/runtime/goal_continuation.rs | 5 +- .../server/src/runtime/handlers/compaction.rs | 133 +++-- crates/server/src/runtime/handlers/history.rs | 26 +- crates/server/src/runtime/handlers/queue.rs | 7 +- crates/server/src/runtime/handlers/session.rs | 45 +- crates/server/src/runtime/handlers/turn.rs | 24 +- .../src/runtime/handlers/turn_interrupt.rs | 98 ++-- .../src/runtime/handlers/workspace_changes.rs | 8 +- crates/server/src/runtime/items.rs | 19 +- .../src/runtime/session_actor/actor_loop.rs | 54 +- .../src/runtime/session_actor/commands.rs | 14 +- .../src/runtime/session_actor/handle.rs | 52 +- .../server/src/runtime/session_actor/mod.rs | 9 +- .../src/runtime/session_actor/registry.rs | 33 +- .../server/src/runtime/session_actor/state.rs | 4 +- .../server/src/runtime/session_actor/turn.rs | 68 +-- .../src/runtime/session_actor/turn_inline.rs | 7 +- .../src/runtime/session_actor/turn_working.rs | 140 +++++ crates/server/src/runtime/subagent_usage.rs | 13 +- .../server/src/runtime/turn_exec/finalize.rs | 3 +- crates/server/src/runtime/turn_exec/mod.rs | 36 +- crates/server/src/runtime/turn_exec/query.rs | 11 +- crates/server/src/runtime/turn_lifecycle.rs | 10 +- ...DES-CONV-002-two-plane-session-settings.md | 18 +- ...SERVER-002-session-actor-turn-isolation.md | 96 ++++ specs/traceability/l1_to_l2.md | 2 + specs/traceability/verification.md | 1 + 43 files changed, 1038 insertions(+), 1516 deletions(-) create mode 100644 apps/desktop/src/renderer/components/onboarding/steps/complete-step.test.tsx delete mode 100644 apps/desktop/src/renderer/components/onboarding/steps/migration-offer-step.tsx delete mode 100644 apps/desktop/src/renderer/components/onboarding/steps/migration-preview-step.tsx create mode 100644 crates/server/src/runtime/session_actor/turn_working.rs create mode 100644 specs/L2/server/L2-DES-SERVER-002-session-actor-turn-isolation.md diff --git a/AGENTS.md b/AGENTS.md index 23e0fa46..63c49083 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,3 +48,4 @@ Per L2-DES-APP-008 and L2-DES-CONV-002 (both Approved): - Session settings changes go through canonical `session/metadata/update` with `SessionSettingsPatch` (partial semantics: only present fields change). Do not add per-concern settings methods. - Settings writes are persist-first: field-level rollout lines (`InternalRecordV2::SessionSettings`) written synchronously by the handler, which never waits on the session actor; actor notification is best-effort (`SessionHandle::notify_*`). Replay prefers field lines over whole-record `SessionMeta` values. - Mid-turn effect rides the turn-inline override (`TurnInlineState.live_turn_settings`, `sandbox_profile_live`). Every live setting must declare its decision point and mid-turn semantics in the DD-6 promise matrix of L2-DES-CONV-002 before implementation. +- Session actor vs turn isolation: see `L2-DES-SERVER-002` and `crates/server/AGENTS.md`. Actor mailbox commands must stay short; turns run on a spawned task with `TurnWorkingSet` and re-enter via `MergeTurn`. diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts index ae8f8e9c..6ec9562e 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts @@ -631,4 +631,21 @@ describe("Native desktop SDK interactions", () => { expect((await client.session.status()).data["session-1"]).toEqual({ type: "idle" }) }) + + test("session/list does not clear busy status while a turn is in flight", async () => { + const transport = new FakeNativeTransport() + const client = createDevoClient({ directory: "/repo", transport }) + await client.session.create() + + await client.session.promptAsync({ + sessionID: "session-1", + parts: [{ type: "text", text: "hello" }], + }) + expect((await client.session.status()).data["session-1"]).toEqual({ type: "busy" }) + + // Delete-refill and sidebar pagination re-list; durable snapshots stay Idle. + await client.session.list({ limit: 5, roots: true }) + + expect((await client.session.status()).data["session-1"]).toEqual({ type: "busy" }) + }) }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index 79b3debc..87ec2f09 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -1480,7 +1480,18 @@ class NativeClient { } this.sessions.set(id, session) this.sessionDirectories.set(id, session.directory ?? defaultCwd()) - this.sessionStatuses.set(id, String(info.status).toLowerCase() === "active" ? { type: "busy" } : { type: "idle" }) + // Durable snapshots (session/list, resume, metadata) often report Idle + // even while a turn is live; live busy/idle rides turn/* and + // session/statusChanged. Never downgrade a known in-flight status from + // a snapshot — otherwise delete-refill list calls clear "working" UI. + const snapshotBusy = String(info.status).toLowerCase() === "active" + const existingStatus = this.sessionStatuses.get(id) + const existingInFlight = + existingStatus?.type === "busy" || existingStatus?.type === "retry" + this.sessionStatuses.set( + id, + snapshotBusy ? { type: "busy" } : existingInFlight ? existingStatus : { type: "idle" }, + ) return session } diff --git a/apps/desktop/src/renderer/components/chat/chat-view.tsx b/apps/desktop/src/renderer/components/chat/chat-view.tsx index 1d628912..d11c1523 100644 --- a/apps/desktop/src/renderer/components/chat/chat-view.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-view.tsx @@ -20,7 +20,6 @@ import { cn } from "@devo/ui/lib/utils" import { useVirtualizer } from "@tanstack/react-virtual" import { useAtom, useAtomValue, useSetAtom } from "jotai" import { - ArrowUpToLineIcon, GitForkIcon, GoalIcon, ListTodoIcon, @@ -330,7 +329,7 @@ interface ScrollHandle { * Bridge that exposes the StickToBottom `scrollToBottom` to the parent * via a ref so imperative callers (handleSend, question reply, etc.) * can force a scroll-to-bottom even when the user has scrolled away. - * Also exposes scroll position helpers for the "jump to start" feature. + * Also exposes scroll position helpers for load-earlier anchor restore. */ function ScrollBridge({ scrollRef }: { scrollRef: React.RefObject }) { const ctx = useStickToBottomContext() @@ -525,81 +524,6 @@ function VirtualizedTurnList({ turns, renderTurn }: VirtualizedTurnListProps) { ) } -/** - * Floating pill button that appears when the agent finishes working. - * Scrolls to the beginning of the last assistant response so the user - * can read it from the top. Dismisses on click or after 8 seconds. - * - * Captures the scroll container's scrollHeight when the agent starts - * working (idle-to-working transition). This position corresponds to - * "where the new response began" regardless of whether the agent - * started from a fresh message, a question answer, or a permission grant. - * - * Must be rendered inside `` to position correctly. - */ -function ScrollToResponseStart({ - isWorking, - scrollRef, -}: { - isWorking: boolean - scrollRef: React.RefObject -}) { - const [visible, setVisible] = useState(false) - const prevWorkingRef = useRef(isWorking) - // Saved scrollHeight at the moment the agent started working. - // This is the Y position where the new response content begins. - const savedScrollTopRef = useRef(0) - - useEffect(() => { - const wasWorking = prevWorkingRef.current - prevWorkingRef.current = isWorking - - if (!wasWorking && isWorking) { - // Agent just started working -- snapshot where the response will begin. - // scrollHeight is the total content height; subtracting a small offset - // so the scroll lands slightly above the first new content. - const handle = scrollRef.current - if (handle) { - savedScrollTopRef.current = Math.max(0, handle.getScrollHeight() - 80) - } - } - - if (wasWorking && !isWorking) { - // Agent finished -- show the pill - setVisible(true) - } - - if (isWorking) { - setVisible(false) - } - }, [isWorking, scrollRef]) - - // Auto-dismiss after 8 seconds - useEffect(() => { - if (!visible) return - const timer = setTimeout(() => setVisible(false), 8000) - return () => clearTimeout(timer) - }, [visible]) - - const handleClick = useCallback(() => { - scrollRef.current?.scrollToPosition(savedScrollTopRef.current) - setVisible(false) - }, [scrollRef]) - - if (!visible) return null - - return ( - - ) -} - /** * Bridge component that syncs the PromptInputProvider's text state * to the persisted draft store (debounced). Must be rendered inside @@ -1108,7 +1032,6 @@ export function ChatView({ )} - diff --git a/apps/desktop/src/renderer/components/onboarding/onboarding-overlay.tsx b/apps/desktop/src/renderer/components/onboarding/onboarding-overlay.tsx index 93b3712a..c758222a 100644 --- a/apps/desktop/src/renderer/components/onboarding/onboarding-overlay.tsx +++ b/apps/desktop/src/renderer/components/onboarding/onboarding-overlay.tsx @@ -4,20 +4,15 @@ * Renders a multi-step first-run experience that gates the main app. * Uses Framer Motion for step transitions and a progress indicator at the top. * - * Core flow: Welcome -> Environment Check -> Complete (3 steps). - * Migration from any detected provider (Claude Code, Cursor, Devo, OpenCode) is an - * optional detour the user can trigger from the Complete screen. + * Core flow: Welcome -> Environment Check -> Provider Setup -> Complete. */ import { AnimatePresence, motion } from "motion/react" import { useCallback, useState } from "react" -import type { MigrationPreview, MigrationProvider, MigrationResult } from "../../../preload/api" import { APP_BAR_HEIGHT } from "../app-bar" import { OnboardingProgress } from "./onboarding-progress" import { CompleteStep } from "./steps/complete-step" import { EnvironmentCheckStep } from "./steps/environment-check-step" -import { MigrationOfferStep } from "./steps/migration-offer-step" -import { MigrationPreviewStep } from "./steps/migration-preview-step" import { ProviderSetupStep } from "./steps/provider-setup-step" import { WelcomeStep } from "./steps/welcome-step" @@ -25,13 +20,7 @@ import { WelcomeStep } from "./steps/welcome-step" // Types // ============================================================ -export type OnboardingStep = - | "welcome" - | "environment" - | "providers" - | "complete" - | "migration-offer" - | "migration-preview" +export type OnboardingStep = "welcome" | "environment" | "providers" | "complete" interface OnboardingOverlayProps { onComplete: (state: { @@ -47,7 +36,6 @@ interface OnboardingOverlayProps { // Constants // ============================================================ -/** Core steps shown in the progress indicator. Migration steps are a detour. */ const CORE_STEPS: OnboardingStep[] = ["welcome", "environment", "providers", "complete"] const STEP_TRANSITION = { @@ -66,19 +54,8 @@ export function OnboardingOverlay({ onComplete }: OnboardingOverlayProps) { const [skippedSteps, setSkippedSteps] = useState([]) const [devoVersion, setDevoVersion] = useState(null) const [providersConnected, setProvidersConnected] = useState(0) - const [migratedProviders, setMigratedProviders] = useState([]) - // Migration state (only populated if user opts in from complete screen) - const [activeProvider, setActiveProvider] = useState(null) - const [scanResult, setScanResult] = useState(null) - const [selectedCategories, setSelectedCategories] = useState([]) - const [migrationPreview, setMigrationPreview] = useState(null) - const [migrationResult, setMigrationResult] = useState(null) - - // For progress indicator, only show core steps - const coreStepIndex = CORE_STEPS.indexOf(currentStep) - // Migration steps show the same progress as "complete" (last dot) - const displayIndex = coreStepIndex >= 0 ? coreStepIndex : CORE_STEPS.length - 1 + const displayIndex = CORE_STEPS.indexOf(currentStep) const goToStep = useCallback((step: OnboardingStep) => { setCurrentStep(step) @@ -88,8 +65,6 @@ export function OnboardingOverlay({ onComplete }: OnboardingOverlayProps) { setSkippedSteps((prev) => [...prev, stepId]) }, []) - // --- Step handlers --- - const handleWelcomeContinue = useCallback(() => { goToStep("environment") }, [goToStep]) @@ -115,66 +90,15 @@ export function OnboardingOverlay({ onComplete }: OnboardingOverlayProps) { goToStep("complete") }, [goToStep, skipStep]) - // Migration opt-in from complete screen (now with provider selection) - const handleStartMigration = useCallback( - (provider: MigrationProvider) => { - setActiveProvider(provider) - // Reset migration state for this new provider - setScanResult(null) - setSelectedCategories([]) - setMigrationPreview(null) - goToStep("migration-offer") - }, - [goToStep], - ) - - const handleMigrationOfferPreview = useCallback( - (scan: unknown, categories: string[], preview: MigrationPreview) => { - setScanResult(scan) - setSelectedCategories(categories) - setMigrationPreview(preview) - goToStep("migration-preview") - }, - [goToStep], - ) - - const handleMigrationOfferSkip = useCallback(() => { - setActiveProvider(null) - goToStep("complete") - }, [goToStep]) - - const handleMigrationComplete = useCallback( - (result: MigrationResult) => { - setMigrationResult(result) - if (activeProvider) { - setMigratedProviders((prev) => - prev.includes(activeProvider) ? prev : [...prev, activeProvider], - ) - } - setActiveProvider(null) - goToStep("complete") - }, - [goToStep, activeProvider], - ) - - const handleMigrationBack = useCallback(() => { - goToStep("migration-offer") - }, [goToStep]) - - const handleMigrationSkip = useCallback(() => { - setActiveProvider(null) - goToStep("complete") - }, [goToStep]) - const handleFinish = useCallback(() => { onComplete({ skippedSteps, - migrationPerformed: migratedProviders.length > 0, - migratedFrom: migratedProviders, + migrationPerformed: false, + migratedFrom: [], devoVersion, providersConnected, }) - }, [onComplete, skippedSteps, migratedProviders, devoVersion, providersConnected]) + }, [onComplete, skippedSteps, devoVersion, providersConnected]) return (
- {/* Progress indicator (core steps only) */} + {/* Progress indicator */}
- - - )} - - {currentStep === "migration-offer" && activeProvider && ( - - - - )} - - {currentStep === "migration-preview" && activeProvider && ( - - + )} diff --git a/apps/desktop/src/renderer/components/onboarding/steps/complete-step.test.tsx b/apps/desktop/src/renderer/components/onboarding/steps/complete-step.test.tsx new file mode 100644 index 00000000..b750b031 --- /dev/null +++ b/apps/desktop/src/renderer/components/onboarding/steps/complete-step.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { renderToStaticMarkup } from "react-dom/server" +import { CompleteStep } from "./complete-step" + +describe("CompleteStep", () => { + test("does not offer Claude Code or OpenCode import", () => { + const markup = renderToStaticMarkup( {}} />) + + expect({ + hasReadyTitle: markup.includes("all set"), + hasStartCta: markup.includes("Start Building"), + mentionsClaudeCode: markup.toLowerCase().includes("claude code"), + mentionsOpenCode: markup.toLowerCase().includes("opencode"), + mentionsMigrate: markup.toLowerCase().includes("migrate"), + }).toEqual({ + hasReadyTitle: true, + hasStartCta: true, + mentionsClaudeCode: false, + mentionsOpenCode: false, + mentionsMigrate: false, + }) + }) +}) diff --git a/apps/desktop/src/renderer/components/onboarding/steps/complete-step.tsx b/apps/desktop/src/renderer/components/onboarding/steps/complete-step.tsx index f4974f6c..abf18934 100644 --- a/apps/desktop/src/renderer/components/onboarding/steps/complete-step.tsx +++ b/apps/desktop/src/renderer/components/onboarding/steps/complete-step.tsx @@ -1,19 +1,13 @@ /** * Onboarding: Complete / Ready. * - * Shows a success state, quick tips, and optional prompts to migrate - * from detected providers (Claude Code, Cursor, Devo, OpenCode). Migration - * cards only appear for providers that have config on disk and haven't - * already been migrated. + * Shows a success state and quick tips. Provider config import is not offered + * from first-run onboarding. */ -import { Badge } from "@devo/ui/components/badge" import { Button } from "@devo/ui/components/button" -import { Spinner } from "@devo/ui/components/spinner" -import { ArrowRightIcon, CheckCircle2Icon, CommandIcon, FlaskConicalIcon } from "lucide-react" +import { CheckCircle2Icon, CommandIcon } from "lucide-react" import { motion } from "motion/react" -import { useEffect, useRef, useState } from "react" -import type { MigrationProvider, MigrationResult, ProviderDetection } from "../../../../preload/api" // ============================================================ // Types @@ -21,9 +15,6 @@ import type { MigrationProvider, MigrationResult, ProviderDetection } from "../. interface CompleteStepProps { devoVersion: string | null - migratedProviders: string[] - migrationResult: MigrationResult | null - onStartMigration: (provider: MigrationProvider) => void onFinish: () => void } @@ -34,42 +25,9 @@ interface CompleteStepProps { const isElectron = typeof window !== "undefined" && "devo" in window const isMac = isElectron && window.devo.platform === "darwin" -export function CompleteStep({ - devoVersion, - migratedProviders, - migrationResult, - onStartMigration, - onFinish, -}: CompleteStepProps) { +export function CompleteStep({ devoVersion, onFinish }: CompleteStepProps) { const modKey = isMac ? "Cmd" : "Ctrl" - // Detect available providers on mount - const [providers, setProviders] = useState([]) - const [detecting, setDetecting] = useState(false) - const hasDetected = useRef(false) - - useEffect(() => { - if (!isElectron || hasDetected.current) return - hasDetected.current = true - setDetecting(true) - - window.devo.onboarding - .detectProviders() - .then((detections) => { - // Only show providers that were found and aren't Devo itself - // (no point migrating Devo -> Devo) - setProviders(detections.filter((d) => d.found && d.provider !== "devo")) - setDetecting(false) - }) - .catch(() => { - setDetecting(false) - }) - }, []) - - // Filter out already-migrated providers - const availableProviders = providers.filter((p) => !migratedProviders.includes(p.provider)) - const hasMigrated = migratedProviders.length > 0 - return (
@@ -100,95 +58,11 @@ export function CompleteStep({

You're all set.

{devoVersion - ? `Devo is connected to Devo ${formatVersion(devoVersion)}` - : "Devo is ready to go"} - {hasMigrated ? " and your configuration has been migrated." : "."} + ? `Devo is connected to Devo ${formatVersion(devoVersion)}.` + : "Devo is ready to go."}

- {/* Migration summary (shown after migration completes) */} - {migrationResult && ( - -
- {migrationResult.filesWritten.length > 0 && ( -

{migrationResult.filesWritten.length} file(s) created

- )} - {migrationResult.filesSkipped.length > 0 && ( -

{migrationResult.filesSkipped.length} file(s) skipped (already exist)

- )} - {migrationResult.historyDuplicatesSkipped > 0 && ( -

- {migrationResult.historyDuplicatesSkipped} session(s) skipped (already imported) -

- )} - {migrationResult.backupDir &&

Backup saved

} - {migrationResult.manualActions.length > 0 && ( -

- {migrationResult.manualActions.length} item(s) need manual attention -

- )} -
-
- )} - - {/* Provider migration cards */} - {detecting && ( - - - Checking for existing configurations... - - )} - - {!detecting && availableProviders.length > 0 && ( - - {availableProviders.map((provider) => ( - - ))} - - )} - {/* Quick tips */} void - onSkip: () => void -} - -// ============================================================ -// Provider display metadata -// ============================================================ - -const PROVIDER_LABELS: Record = { - "claude-code": "Claude Code", - cursor: "Cursor", - devo: "Devo", - opencode: "OpenCode", -} - -// ============================================================ -// Component -// ============================================================ - -export function MigrationOfferStep({ provider, onPreview, onSkip }: MigrationOfferStepProps) { - const [categories, setCategories] = useState([]) - const [scanning, setScanning] = useState(false) - const [scanError, setScanError] = useState(null) - const [previewing, setPreviewing] = useState(false) - const hasScanned = useRef(false) - const scanResultRef = useRef(null) - - const isElectron = typeof window !== "undefined" && "devo" in window - const label = PROVIDER_LABELS[provider] - - // Run full scan on mount (user explicitly opted in) - useEffect(() => { - if (!isElectron || hasScanned.current) return - hasScanned.current = true - setScanning(true) - - window.devo.onboarding - .scanProvider(provider) - .then(({ detection, scanResult }) => { - scanResultRef.current = scanResult - setCategories(buildCategories(provider, detection)) - setScanning(false) - }) - .catch((err) => { - setScanError(err instanceof Error ? err.message : "Scan failed") - setScanning(false) - }) - }, [isElectron, provider]) - - const toggleCategory = useCallback((id: string) => { - setCategories((prev) => prev.map((c) => (c.id === id ? { ...c, enabled: !c.enabled } : c))) - }, []) - - const handlePreview = useCallback(async () => { - if (!isElectron || !scanResultRef.current) return - setPreviewing(true) - setScanError(null) - - const selectedIds = categories.filter((c) => c.enabled).map((c) => c.id) - - try { - const preview = await window.devo.onboarding.previewMigration( - provider, - scanResultRef.current, - selectedIds, - ) - onPreview(scanResultRef.current, selectedIds, preview) - } catch (err) { - setScanError(err instanceof Error ? err.message : "Preview failed") - } finally { - setPreviewing(false) - } - }, [isElectron, provider, categories, onPreview]) - - const enabledCount = categories.filter((c) => c.enabled).length - - return ( -
-
-
-

Migrate from {label}

-

- We detected an existing {label} setup. Devo can migrate your configuration to Devo - format. -

-
- - {/* Loading state */} - {scanning && ( -
- - Scanning {label} configuration... -
- )} - - {/* Category checkboxes */} - {!scanning && categories.length > 0 && ( -
- {categories.map((cat) => { - if (cat.count === 0) return null - const Icon = cat.icon - return ( - - ) - })} -
- )} - - {/* Info about what migration does */} - {!scanning && categories.length > 0 && ( -
-

- {getMigrationDescription(provider)} -

-
- )} - - {/* Error */} - {scanError && ( -
- {scanError} -
- )} - - {/* Actions */} -
- - {!scanning && categories.length > 0 && ( - - )} -
-
-
- ) -} - -// ============================================================ -// Helpers -// ============================================================ - -function getMigrationDescription(provider: MigrationProvider): string { - switch (provider) { - case "claude-code": - return "Model IDs are translated automatically. MCP servers are converted to Devo format. Agent frontmatter is adapted. A backup is created before any changes, and you can undo at any time from Settings." - case "cursor": - return "MCP servers, rules (.mdc), agents, and commands are converted to Devo format. Cursor-specific features like OAuth and rule modes are adapted where possible. A backup is created before any changes." - case "devo": - return "Configuration, agents, commands, and rules are imported. A backup is created before any changes, and you can undo at any time from Settings." - case "opencode": - return "OpenCode providers and model bindings are imported through Devo provider settings. API keys are stored through Devo's provider credential flow." - } -} - -function buildCategories( - provider: MigrationProvider, - detection: ProviderDetection, -): MigrationCategory[] { - switch (provider) { - case "claude-code": - return buildClaudeCodeCategories(detection) - case "cursor": - return buildCursorCategories(detection) - case "devo": - return buildDevoCategories(detection) - case "opencode": - return buildOpenCodeCategories(detection) - } -} - -function buildClaudeCodeCategories(detection: ProviderDetection): MigrationCategory[] { - const historyParts: string[] = [] - if (detection.projectCount > 0) { - historyParts.push(`${detection.projectCount} project${detection.projectCount === 1 ? "" : "s"}`) - } - if (detection.totalSessions > 0) { - historyParts.push( - `${detection.totalSessions} session${detection.totalSessions === 1 ? "" : "s"}`, - ) - } - - return [ - { - id: "config", - label: "Global settings & model preferences", - description: "Model IDs, provider config, auto-update settings", - icon: CogIcon, - count: detection.hasGlobalSettings ? 1 : 0, - enabled: true, - }, - { - id: "mcp", - label: "MCP server configurations", - description: "Local and remote MCP server definitions", - icon: ServerIcon, - count: detection.mcpServerCount, - enabled: true, - }, - { - id: "history", - label: "Projects & sessions", - description: historyParts.length > 0 ? historyParts.join(", ") : "No sessions found", - icon: FolderOpenIcon, - count: detection.totalSessions, - enabled: detection.totalSessions > 0, - }, - { - id: "agents", - label: "Custom agents", - description: "Agent definitions with tools and model preferences", - icon: BotIcon, - count: detection.agentCount, - enabled: true, - }, - { - id: "commands", - label: "Custom commands", - description: "Command templates with parameters", - icon: TerminalIcon, - count: detection.commandCount, - enabled: true, - }, - { - id: "rules", - label: "Project rules (CLAUDE.md)", - description: "Copied as AGENTS.md for Devo", - icon: ScrollTextIcon, - count: detection.ruleCount, - enabled: true, - }, - { - id: "permissions", - label: "Permission settings", - description: "Tool allow/deny/ask rules", - icon: ShieldIcon, - count: detection.hasGlobalSettings ? 1 : 0, - enabled: true, - }, - { - id: "hooks", - label: "Hooks", - description: "Converted to TypeScript plugin stubs (manual finishing needed)", - icon: PlugIcon, - count: detection.hasHooks ? 1 : 0, - enabled: true, - }, - { - id: "skills", - label: "Skills", - description: "Verified for compatibility", - icon: FileTextIcon, - count: detection.skillCount, - enabled: true, - }, - ] -} - -function buildCursorCategories(detection: ProviderDetection): MigrationCategory[] { - const historyParts: string[] = [] - if (detection.totalSessions > 0) { - historyParts.push( - `${detection.totalSessions} session${detection.totalSessions === 1 ? "" : "s"}`, - ) - } - if (detection.totalMessages > 0) { - historyParts.push( - `${detection.totalMessages} message${detection.totalMessages === 1 ? "" : "s"}`, - ) - } - - return [ - { - id: "config", - label: "Global settings & permissions", - description: "CLI permissions and configuration", - icon: CogIcon, - count: detection.hasGlobalSettings ? 1 : 0, - enabled: true, - }, - { - id: "mcp", - label: "MCP server configurations", - description: "Local and remote MCP server definitions", - icon: ServerIcon, - count: detection.mcpServerCount, - enabled: true, - }, - { - id: "history", - label: "Chat history", - description: historyParts.length > 0 ? historyParts.join(", ") : "No chat sessions found", - icon: FolderOpenIcon, - count: detection.totalSessions, - enabled: detection.totalSessions > 0, - }, - { - id: "agents", - label: "Custom agents", - description: "Agent definitions from .cursor/agents/", - icon: BotIcon, - count: detection.agentCount, - enabled: true, - }, - { - id: "commands", - label: "Custom commands", - description: "Command files from .cursor/commands/", - icon: TerminalIcon, - count: detection.commandCount, - enabled: true, - }, - { - id: "rules", - label: "Rules (.mdc files)", - description: "Cursor rules converted to AGENTS.md format", - icon: ScrollTextIcon, - count: detection.ruleCount, - enabled: true, - }, - { - id: "permissions", - label: "Permission settings", - description: "CLI agent permissions from cli-config.json", - icon: ShieldIcon, - count: detection.hasPermissions ? 1 : 0, - enabled: true, - }, - { - id: "skills", - label: "Skills", - description: "Verified for compatibility", - icon: FileTextIcon, - count: detection.skillCount, - enabled: true, - }, - ] -} - -function buildDevoCategories(detection: ProviderDetection): MigrationCategory[] { - return [ - { - id: "config", - label: "Global configuration", - description: "devo.json settings and model preferences", - icon: CogIcon, - count: detection.hasGlobalSettings ? 1 : 0, - enabled: true, - }, - { - id: "mcp", - label: "MCP server configurations", - description: "Local and remote MCP server definitions", - icon: ServerIcon, - count: detection.mcpServerCount, - enabled: true, - }, - { - id: "agents", - label: "Custom agents", - description: "Agent definitions from .devo/agents/", - icon: BotIcon, - count: detection.agentCount, - enabled: true, - }, - { - id: "commands", - label: "Custom commands", - description: "Command files from .devo/commands/", - icon: TerminalIcon, - count: detection.commandCount, - enabled: true, - }, - { - id: "rules", - label: "Rules (AGENTS.md)", - description: "Agent instructions and project rules", - icon: ScrollTextIcon, - count: detection.ruleCount, - enabled: true, - }, - { - id: "skills", - label: "Skills", - description: "Verified for compatibility", - icon: FileTextIcon, - count: detection.skillCount, - enabled: true, - }, - ] -} - -function buildOpenCodeCategories(detection: ProviderDetection): MigrationCategory[] { - return [ - { - id: "config", - label: "Providers & models", - description: "Provider base URLs, API keys, and model bindings", - icon: CogIcon, - count: detection.hasGlobalSettings ? 1 : 0, - enabled: true, - }, - ] -} diff --git a/apps/desktop/src/renderer/components/onboarding/steps/migration-preview-step.tsx b/apps/desktop/src/renderer/components/onboarding/steps/migration-preview-step.tsx deleted file mode 100644 index 4a444552..00000000 --- a/apps/desktop/src/renderer/components/onboarding/steps/migration-preview-step.tsx +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Migration Preview & Execute step. - * - * Shows a file tree of what will be created/modified, a diff preview of - * selected files, and executes the migration with backup on confirmation. - * Supports all migration providers (Claude Code, Cursor, Devo, OpenCode). - */ - -import { Button } from "@devo/ui/components/button" -import { Spinner } from "@devo/ui/components/spinner" -import { - AlertTriangleIcon, - ArrowLeftIcon, - FileIcon, - FolderIcon, - FolderOpenIcon, - PlayIcon, -} from "lucide-react" -import { useCallback, useEffect, useState } from "react" -import type { - MigrationPreview, - MigrationProgress, - MigrationProvider, - MigrationResult, -} from "../../../../preload/api" - -// ============================================================ -// Types -// ============================================================ - -interface MigrationPreviewStepProps { - provider: MigrationProvider - scanResult: unknown - categories: string[] - preview: MigrationPreview | null - onComplete: (result: MigrationResult) => void - onBack: () => void - onSkip: () => void -} - -// ============================================================ -// Provider display metadata -// ============================================================ - -const PROVIDER_LABELS: Record = { - "claude-code": "Claude Code", - cursor: "Cursor", - devo: "Devo", - opencode: "OpenCode", -} - -// ============================================================ -// Component -// ============================================================ - -export function MigrationPreviewStep({ - provider, - scanResult, - categories, - preview, - onComplete, - onBack, - onSkip, -}: MigrationPreviewStepProps) { - const [selectedFile, setSelectedFile] = useState(null) - const [executing, setExecuting] = useState(false) - const [error, setError] = useState(null) - const [progress, setProgress] = useState(null) - - const isElectron = typeof window !== "undefined" && "devo" in window - const label = PROVIDER_LABELS[provider] - - // Subscribe to migration progress events during execution - useEffect(() => { - if (!isElectron || !executing) return - const unsub = window.devo.onboarding.onMigrationProgress((p) => { - setProgress(p as MigrationProgress) - }) - return unsub - }, [isElectron, executing]) - - const handleExecute = useCallback(async () => { - if (!isElectron || !scanResult) return - setExecuting(true) - setError(null) - setProgress(null) - - try { - const result = await window.devo.onboarding.executeMigration( - provider, - scanResult, - categories, - ) - onComplete(result) - } catch (err) { - setError(err instanceof Error ? err.message : "Migration failed") - setExecuting(false) - } - }, [isElectron, provider, scanResult, categories, onComplete]) - - if (!preview) return null - - // Find the selected file's content for the diff preview - const selectedFileContent = (() => { - for (const cat of preview.categories) { - for (const file of cat.files) { - if (file.path === selectedFile) return file.content - } - } - return null - })() - - return ( -
-
- {/* Header */} -
-

{label} Migration Preview

-

- {preview.fileCount} file(s) will be created. Review the changes below. -

-
- - {/* Session import summary */} - {preview.sessionCount > 0 && ( -
-
- )} - - {/* File tree + preview split */} -
- {/* File tree */} -
- {preview.categories.map((cat) => ( -
-
-
- {cat.files.map((file) => ( - - ))} -
- ))} -
- - {/* File preview */} -
- {selectedFileContent ? ( -
{selectedFileContent}
- ) : ( -
- Select a file to preview -
- )} -
-
- - {/* Warnings */} - {preview.warnings.length > 0 && ( -
-
-
- {preview.warnings.map((w) => ( -

- {w} -

- ))} -
- )} - - {/* Manual actions */} - {preview.manualActions.length > 0 && ( -
-
- Needs manual attention after migration: -
- {preview.manualActions.map((a) => ( -

- - {a} -

- ))} -
- )} - - {/* Error */} - {error && ( -
- {error} -
- )} - - {/* Backup notice */} -

- A backup will be saved to ~/.config/devo/backups/ before any changes. -

- - {/* Actions */} -
- - - -
-
-
- ) -} - -// ============================================================ -// Helpers -// ============================================================ - -/** Format migration progress into a short label for the button. */ -function formatProgressLabel(progress: MigrationProgress | null): string { - if (!progress) return "Migrating..." - - switch (progress.phase) { - case "converting": - return "Converting sessions..." - case "dedup-check": - return "Checking for duplicates..." - case "writing": - if (progress.total > 0) { - return `Writing session ${progress.current}/${progress.total}...` - } - return "Writing sessions..." - case "complete": - return "Finishing..." - default: - return "Migrating..." - } -} - -/** Shorten a file path for display by replacing the home directory with ~. */ -function shortenPath(filePath: string): string { - // Try to shorten common prefixes - const homePatterns = ["/Users/", "/home/", "C:\\Users\\"] - for (const pattern of homePatterns) { - const idx = filePath.indexOf(pattern) - if (idx !== -1) { - const afterHome = filePath.slice(idx + pattern.length) - const slashIdx = - afterHome.indexOf("/") !== -1 ? afterHome.indexOf("/") : afterHome.indexOf("\\") - if (slashIdx !== -1) { - return `~${afterHome.slice(slashIdx)}` - } - } - } - return filePath -} diff --git a/apps/desktop/src/renderer/components/settings/setup-settings.tsx b/apps/desktop/src/renderer/components/settings/setup-settings.tsx index 0ffd1da5..66a7a677 100644 --- a/apps/desktop/src/renderer/components/settings/setup-settings.tsx +++ b/apps/desktop/src/renderer/components/settings/setup-settings.tsx @@ -141,13 +141,7 @@ function MigrationSection() { const migratedFrom = onboardingState.migratedFrom ?? [] if (!onboardingState.migrationPerformed || migratedFrom.length === 0) { - return ( - - - N/A - - - ) + return null } const migratedLabels = migratedFrom.map((p) => PROVIDER_LABELS[p] ?? p).join(", ") diff --git a/crates/provider/src/openai/chat_completions.rs b/crates/provider/src/openai/chat_completions.rs index 952dc4c7..f92e2cf2 100644 --- a/crates/provider/src/openai/chat_completions.rs +++ b/crates/provider/src/openai/chat_completions.rs @@ -178,7 +178,9 @@ pub(super) struct OpenAIChatCompletionMessage { function_call: Option, #[serde(default, deserialize_with = "deserialize_null_vec")] tool_calls: Vec, - #[serde(default)] + /// DeepSeek / vLLM use `reasoning_content`; Ollama's OpenAI-compat layer + /// currently emits the same payload under `reasoning`. + #[serde(default, alias = "reasoning")] reasoning_content: Option, } @@ -1480,6 +1482,37 @@ mod tests { } } + #[test] + fn parse_response_reads_ollama_reasoning_field_alias() { + let response = parse_response( + json!({ + "id": "chatcmpl-ollama", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello!", + "reasoning": "plan via ollama" + }, + "finish_reason": "stop" + } + ] + }), + &DsmlToolCallHealer::for_model("qwen3"), + ) + .expect("parse response"); + + assert_eq!( + response.content, + vec![ResponseContent::Text("Hello!".to_string())] + ); + assert!(response.metadata.extras.iter().any(|extra| matches!( + extra, + ResponseExtra::ReasoningText { text } if text == "plan via ollama" + ))); + } + #[test] fn parse_response_heals_deepseek_v4_dsml_text_tool_calls() { let response = parse_response( diff --git a/crates/provider/src/openai/chat_completions/stream.rs b/crates/provider/src/openai/chat_completions/stream.rs index f55597b0..2a0a9adb 100644 --- a/crates/provider/src/openai/chat_completions/stream.rs +++ b/crates/provider/src/openai/chat_completions/stream.rs @@ -830,7 +830,9 @@ struct ChatCompletionStreamDelta { role: Option, #[serde(default)] content: Option, - #[serde(default)] + /// DeepSeek / vLLM use `reasoning_content`; Ollama's OpenAI-compat layer + /// currently emits the same payload under `reasoning`. + #[serde(default, alias = "reasoning")] reasoning_content: Option, #[serde(default)] refusal: Option, @@ -1309,6 +1311,51 @@ mod tests { ); } + #[test] + fn ollama_reasoning_field_alias_emits_reasoning_events() { + let mut state = ChatCompletionStreamState::default(); + + let events = state.apply_chunk(parse_chunk(json!({ + "id": "chatcmpl-ollama", + "choices": [ + { + "delta": { + "reasoning": "plan via ollama", + "content": "answer" + }, + "finish_reason": "stop" + } + ] + }))); + + assert_eq!( + events, + vec![ + StreamEvent::ReasoningStart { index: 1 }, + StreamEvent::ReasoningDelta { + index: 1, + text: "plan via ollama".to_string(), + }, + StreamEvent::TextStart { index: 0 }, + StreamEvent::TextDelta { + index: 0, + text: "answer".to_string(), + }, + StreamEvent::ReasoningDone { index: 1 }, + ] + ); + + let response = state.into_response(); + assert!(response.metadata.extras.iter().any(|extra| matches!( + extra, + ResponseExtra::ReasoningText { text } if text == "plan via ollama" + ))); + assert_eq!( + response.content, + vec![ResponseContent::Text("answer".to_string())] + ); + } + #[test] fn finish_pending_content_closes_tagged_reasoning_at_stream_end() { let mut state = ChatCompletionStreamState::default(); diff --git a/crates/server/AGENTS.md b/crates/server/AGENTS.md index a5610ffc..d65aed04 100644 --- a/crates/server/AGENTS.md +++ b/crates/server/AGENTS.md @@ -5,39 +5,38 @@ The server runtime uses **one session actor per session**. Durable session state ### Ownership and actor boundaries - **Mutate durable session state only through `SessionHandle` → `SessionCommand`.** Do not reach into `SessionActorState` from handlers or turn tasks except inside the actor loop or via explicit snapshot/command APIs. -- **`ActiveTurnRegistry` is the single source for in-flight turn execution handles** (cancel tokens, abort handles, connection routing, spawn snapshots, active stream state). Register on turn start. Use `clear_active_turn_interrupt_handles` during in-actor finalization so stream/spawn mirrors stay available until final state merges; use `clear_active_turn_runtime_handles` for full teardown. +- **Actor mailbox commands must be short.** No unbounded I/O (`query()`, tool waits, client reverse-RPC) inside the actor task. See `L2-DES-SERVER-002`. +- **Turns execute on a spawned task** with a checked-out `TurnWorkingSet`. Checkout / `MergeTurn` are the only turn↔actor crossings for conversation ownership. +- **`ActiveTurnRegistry` is the single source for in-flight turn execution handles** (cancel tokens, abort handles, connection routing, spawn snapshots, active stream state). Register on turn start. Use `clear_active_turn_interrupt_handles` during finalization so stream/spawn mirrors stay available until merge; use `clear_active_turn_runtime_handles` for full teardown. - **Use `turn_lifecycle` helpers** (`register_active_turn_execution`, `spawn_active_turn_task`, `signal_active_turn_interrupt`) instead of touching `ActiveTurnRegistry` fields ad hoc from handlers. -- **Turns execute in-actor** via `SessionCommand::ExecuteTurn`. -- **Interactive waits (approval, `request_user_input`) live in `SessionInteractiveLanes`, not the session actor.** The actor must not block the mailbox waiting on client responses. -- **Post-turn scheduling runs outside the actor.** After `ExecuteTurn` replies, continuation (queued follow-ups, goal continuation) is spawned in a background task—never inline in the mailbox handler when interrupts may still be in flight. +- **Interactive waits (approval, `request_user_input`) live in `SessionInteractiveLanes`, not the session actor.** +- **Post-turn scheduling runs outside the turn task and actor.** After `MergeTurn`, continuation (queued follow-ups, goal continuation) is spawned via `spawn_post_turn_scheduling`—never inline in the mailbox handler. ### Lock usage - **Never hold `ServerRuntime.sessions` (or other runtime `Mutex` maps) across `.await`.** Look up the `SessionHandle`, drop the lock, then call handle methods. -- **Mutate `pending_turn_queue` only through actor commands** (`EnqueuePendingTurnInput`, `RemoveQueuedTurnInput`, `TakeQueuedTurnInputForSteer`, `PopQueuedTurnInput`). Handlers must not lock the queue directly while a session actor is running. +- **`state_change_gate` must not span unbounded I/O** (model calls, long disk waits). Hold it only for short admission / apply critical sections. +- **Mutate `pending_turn_queue` / `steer_input_queue` through shared mutexes** for mid-turn control (last-write-wins). Those Arcs are the control plane, not a blocked-mailbox workaround. - **`SessionStreamState` uses `Arc>`** and is shared with the turn event stream task. Prefer actor commands for durable merges; use the stream lock only for streaming-era fields (deferred assistant/reasoning, inline turn scratch state). -- **From turn event streams, use `try_send` on the session mailbox** for fire-and-forget updates (`SetActiveGoal`, `ApplyParentUsageSnapshot`, `TouchLastActivity`). Blocking `send().await` from a stream the actor is waiting on can deadlock. -- **Interrupt/cancel:** call `signal_active_turn_interrupt` before relying on mailbox round-trips—the actor may be blocked in permission wait. +- **From turn event streams, prefer `try_send` on the session mailbox** for fire-and-forget updates when the caller might still be awaited by actor-side work. +- **Interrupt/cancel:** `signal_active_turn_interrupt` cancels the token only. Hard `abort_task` is for orphan recovery after the terminal-status wait times out—aborting immediately would skip `MergeTurn`. ### Turn lifecycle - **Reservation:** use `TryBeginActiveTurn` (idle session + empty pending queue) or turn-reservation snapshots when starting turns from handlers. -- **Terminal status:** in-actor turns finalize via `finalize_executed_turn` when the cancel token fires. +- **Terminal status:** turn tasks finalize via `finalize_executed_turn`, then `MergeTurn` installs durable state; cancel-token interrupts record terminal status the same way. - **Always record terminal turn status** (`record_terminal_turn_status`) and clear runtime handles when a turn ends or is interrupted. - **Subagent usage:** only root sessions own a parent usage ledger; child turns publish into the parent's ledger. ### Queues -- **`pending_turn_queue`:** user-visible queued turns while a session is busy. Enqueue via `SessionHandle::enqueue_pending_turn_input`; pop/remove/steer via actor commands only. -- **`steer_input_queue`:** input for injection into an active turn. Active-turn - handlers mutate it through the reservation snapshot's shared mutex rather - than waiting on the actor mailbox; finalization either consumes it or - degrades unconsumed input to `pending_turn_queue`. +- **`pending_turn_queue`:** user-visible queued turns while a session is busy. Enqueue via shared mutex or `SessionHandle::enqueue_pending_turn_input`. +- **`steer_input_queue`:** input for injection into an active turn. Active-turn handlers mutate it through the reservation snapshot's shared mutex; finalization either consumes it or degrades unconsumed input to `pending_turn_queue`. - **After dequeuing,** broadcast queue updates and start the next turn from a spawned task (`chain_queued_followup_turn` / `spawn_next_turn_from_queue`). ### Tests -- **Runtime concurrency changes need integration coverage** in `crates/server/tests/`: interrupt mid-stream, queued follow-ups, goal lifecycle interrupts, and persistence/resume. +- **Runtime concurrency changes need integration coverage** in `crates/server/tests/`: interrupt mid-stream, queued follow-ups, goal lifecycle interrupts, persistence/resume, and mid-turn read RPCs (`session/list`, `session/items/list`, `workspace/changes/read`, `runtime/ping`). - **Prefer waiting on observable protocol outcomes** (notifications, terminal status) over sleeping or polling internal maps. - Follow existing test conventions: `pretty_assertions::assert_eq`, compare whole objects where possible, platform-aware paths when touching filesystem behavior. diff --git a/crates/server/src/runtime/active_turn.rs b/crates/server/src/runtime/active_turn.rs index 79cc78de..10610f9a 100644 --- a/crates/server/src/runtime/active_turn.rs +++ b/crates/server/src/runtime/active_turn.rs @@ -10,10 +10,10 @@ use crate::turn::TurnMetadata; use super::session_actor::state::{SessionStreamState, SpawnSnapshot}; -/// Per-session execution state for an in-flight turn. +/// Runtime coordination for an in-flight turn (cancel, abort, spawn/stream mirrors). /// -/// Durable session fields remain on `SessionActorState`; this struct holds only -/// runtime coordination needed while a turn blocks the actor or runs outside it. +/// Durable session fields remain on `SessionActorState`; turn I/O runs on a +/// spawned task with a `TurnWorkingSet` and merges back through `MergeTurn`. pub(crate) struct ActiveTurnExecution { pub turn: Option, pub cancel_token: Option, @@ -161,8 +161,8 @@ impl ActiveTurnRegistry { /// Clears cancellation, metadata, and connection routing while a turn ends. /// - /// Stream state and spawn snapshots remain registered until - /// `execute_turn_in_actor` unregisters them after inline finalization. + /// Stream state and spawn snapshots remain registered until the turn task + /// unregisters them after `MergeTurn`. pub(crate) async fn clear_interrupt_handles(&self, session_id: SessionId) { let mut turns = self.turns.lock().await; if let Some(execution) = turns.get_mut(&session_id) { diff --git a/crates/server/src/runtime/approval.rs b/crates/server/src/runtime/approval.rs index 7290e571..42a8b2f2 100644 --- a/crates/server/src/runtime/approval.rs +++ b/crates/server/src/runtime/approval.rs @@ -918,9 +918,9 @@ impl ServerRuntime { persisted: pending.persisted, tx: scope_tx, }; - // ExecuteTurn owns the session mailbox, so ApplyApprovalScope cannot - // run until the turn ends. Update live TurnInlineState here so the - // same turn's later tool calls see PathPrefix/Session grants. + // Apply durable scope via the mailbox, and update live + // TurnInlineState so the same turn's later tool calls see + // PathPrefix/Session grants without waiting on MergeTurn. self.apply_approval_scope_to_turn_inline( host_session_id, &scope, diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index 06b2fbb1..c6c56e94 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -4496,14 +4496,10 @@ mod tests { } } - /// Regression: during the first turn of an untitled session, final - /// title generation takes `state_change_gate` - /// (`maybe_generate_final_title` in runtime/items.rs) and then parks on - /// the session-actor mailbox (`update_title`) while the actor is busy - /// executing the turn, so the gate stays held for the rest of the turn. - /// A `session/queue/push` in that window must still answer `Queued` - /// immediately: the busy path no longer touches the gate - /// (runtime/handlers/turn.rs). + /// Regression: title generation used to hold `state_change_gate` across a + /// parked `update_title` mailbox wait while the actor ran the turn. + /// Title apply is now gate-free (persist-first), and the turn no longer + /// blocks the actor; queue push must still answer immediately. #[tokio::test] async fn queue_push_responds_immediately_while_title_generation_holds_gate() -> Result<()> { let data_root = TempDir::new()?; @@ -4561,36 +4557,9 @@ mod tests { "turn should be executing" ); - // Let the title model call finish: the title task now grabs - // `state_change_gate` and parks on the busy actor mailbox. + // Title apply no longer holds `state_change_gate`. Queue push must + // still answer `Queued` immediately while the turn stream is gated. completion_open.store(true, std::sync::atomic::Ordering::SeqCst); - let session_handle = runtime.session(session_id).await.expect("session"); - let mut gate_held = false; - for _ in 0..50 { - match tokio::time::timeout( - Duration::from_millis(100), - session_handle.lock_state_change(), - ) - .await - { - Ok(guard) => { - drop(guard); - tokio::time::sleep(Duration::from_millis(50)).await; - } - Err(_) => { - gate_held = true; - break; - } - } - } - assert!( - gate_held, - "title generation should be holding state_change_gate across the actor mailbox wait" - ); - - // Fixed behavior: the busy push no longer touches - // `state_change_gate`, so it answers `Queued` immediately even - // while title generation holds the gate. let push_runtime = Arc::clone(&runtime); let push = tokio::spawn(async move { push_runtime @@ -4610,7 +4579,7 @@ mod tests { }); let response = tokio::time::timeout(Duration::from_secs(5), push) .await - .context("busy push must respond immediately while the gate is held")?? + .context("busy push must respond immediately during an active turn")?? .expect("push response"); assert!(response.get("error").is_none(), "push: {response}"); let result: devo_protocol::native::rpc_turn::SessionQueuePushResult = @@ -4623,7 +4592,7 @@ mod tests { "busy push must queue: {response}" ); - // Cleanup: let the turn finish so the gate is released. + // Cleanup: let the turn finish. stream_open.store(true, std::sync::atomic::Ordering::SeqCst); Ok(()) } @@ -4679,29 +4648,6 @@ mod tests { "title generation should have requested its completion" ); completion_open.store(true, std::sync::atomic::Ordering::SeqCst); - let session_handle = runtime.session(session_id).await.expect("session"); - let mut gate_held = false; - for _ in 0..50 { - match tokio::time::timeout( - Duration::from_millis(100), - session_handle.lock_state_change(), - ) - .await - { - Ok(guard) => { - drop(guard); - tokio::time::sleep(Duration::from_millis(50)).await; - } - Err(_) => { - gate_held = true; - break; - } - } - } - assert!( - gate_held, - "title generation should be holding state_change_gate across the actor mailbox wait" - ); let start_runtime = Arc::clone(&runtime); let native_start = tokio::spawn(async move { @@ -4722,7 +4668,7 @@ mod tests { }); let response = tokio::time::timeout(Duration::from_secs(2), native_start) .await - .context("native turn/start must respond while the gate is held")?? + .context("native turn/start must respond during an active turn")?? .expect("turn/start response"); assert_eq!( response["error"]["code"].as_str(), @@ -4749,7 +4695,7 @@ mod tests { }); let response = tokio::time::timeout(Duration::from_secs(2), metadata_update) .await - .context("session/metadata/update must respond while the gate is held")?? + .context("session/metadata/update must respond during an active turn")?? .expect("metadata update response"); assert!( response.get("error").is_none(), @@ -5745,6 +5691,212 @@ mod tests { Ok(()) } + /// Trace: L2-DES-SERVER-002, L2-DES-CONV-002 + /// Verifies: mid-turn control/read RPCs return promptly while the model + /// stream is gated open (session actor mailbox must stay free). + #[tokio::test] + async fn mid_turn_list_items_workspace_and_ping_respond_promptly() -> Result<()> { + let data_root = TempDir::new()?; + let open = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let runtime = build_runtime_with_provider( + data_root.path(), + Arc::new(GatedProvider { + open: Arc::clone(&open), + started: Arc::clone(&started), + }), + ); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + let _turn_id = start_turn(&runtime, connection_id, session_id, "hold the turn").await?; + let wait_started = async { + while !started.load(std::sync::atomic::Ordering::SeqCst) { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }; + tokio::time::timeout(std::time::Duration::from_secs(10), wait_started) + .await + .expect("turn should start streaming"); + + let deadline = std::time::Duration::from_millis(500); + let list = tokio::time::timeout( + deadline, + history_request( + &runtime, + connection_id, + 20, + "session/list", + serde_json::json!({}), + ), + ) + .await + .expect("session/list must return mid-turn"); + assert!(list.get("result").is_some(), "session/list: {list}"); + + let items = tokio::time::timeout( + deadline, + history_request( + &runtime, + connection_id, + 21, + "session/items/list", + serde_json::json!({ "sessionId": session_id.to_string() }), + ), + ) + .await + .expect("session/items/list must return mid-turn"); + assert!( + items.get("result").is_some() || items.get("error").is_some(), + "session/items/list: {items}" + ); + + let workspace = tokio::time::timeout( + deadline, + history_request( + &runtime, + connection_id, + 22, + "workspace/changes/read", + serde_json::json!({ + "sessionId": session_id.to_string(), + "scopes": ["uncommitted"], + }), + ), + ) + .await + .expect("workspace/changes/read must return mid-turn"); + assert!( + workspace.get("result").is_some() || workspace.get("error").is_some(), + "workspace/changes/read: {workspace}" + ); + + let ping = tokio::time::timeout( + deadline, + history_request( + &runtime, + connection_id, + 23, + "runtime/ping", + serde_json::json!({}), + ), + ) + .await + .expect("runtime/ping must return mid-turn"); + assert!(ping.get("result").is_some(), "runtime/ping: {ping}"); + + open.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + + /// Trace: L2-DES-SERVER-002, L2-DES-APP-008 + /// Verifies: session/list and session/read overlay ActiveTurnRegistry so a + /// mid-turn session reports `active` with matching `activeTurnId`, then + /// returns to `idle` after the turn completes. + #[tokio::test] + async fn mid_turn_session_list_and_read_report_active_status() -> Result<()> { + use devo_protocol::native::session::SessionStatus; + use pretty_assertions::assert_eq; + + let data_root = TempDir::new()?; + let open = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let runtime = build_runtime_with_provider( + data_root.path(), + Arc::new(GatedProvider { + open: Arc::clone(&open), + started: Arc::clone(&started), + }), + ); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + + let turn_started = history_request( + &runtime, + connection_id, + 2, + "turn/start", + serde_json::json!({ + "sessionId": session_id.to_string(), + "input": [{ "type": "text", "text": "hold the turn" }], + "idempotencyKey": "list-live-status-turn", + }), + ) + .await; + let turn_started: devo_protocol::native::rpc_turn::TurnStartResult = + serde_json::from_value(turn_started["result"].clone()).expect("turn/start result"); + let expected_turn_id = turn_started.turn.id.clone(); + + let wait_started = async { + while !started.load(std::sync::atomic::Ordering::SeqCst) { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }; + tokio::time::timeout(std::time::Duration::from_secs(10), wait_started) + .await + .expect("turn should start streaming"); + + let listed = history_request( + &runtime, + connection_id, + 3, + "session/list", + serde_json::json!({}), + ) + .await; + let listed: devo_protocol::native::rpc_session::SessionListResult = + serde_json::from_value(listed["result"].clone()).expect("session/list result"); + let listed_session = listed + .data + .iter() + .find(|session| session.id.as_str() == session_id.to_string()) + .expect("listed session"); + assert_eq!(listed_session.status, SessionStatus::Active); + assert_eq!(listed_session.active_turn_id.as_ref(), Some(&expected_turn_id)); + + let read = history_request( + &runtime, + connection_id, + 4, + "session/read", + serde_json::json!({ "sessionId": session_id.to_string() }), + ) + .await; + let read: devo_protocol::native::rpc_session::SessionReadResult = + serde_json::from_value(read["result"].clone()).expect("session/read result"); + assert_eq!(read.session.status, SessionStatus::Active); + assert_eq!(read.session.active_turn_id.as_ref(), Some(&expected_turn_id)); + + open.store(true, std::sync::atomic::Ordering::SeqCst); + let wait_idle = async { + while runtime.runtime_active_turn_id(session_id).await.is_some() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }; + tokio::time::timeout(std::time::Duration::from_secs(10), wait_idle) + .await + .expect("turn should finish"); + + let listed_idle = history_request( + &runtime, + connection_id, + 5, + "session/list", + serde_json::json!({}), + ) + .await; + let listed_idle: devo_protocol::native::rpc_session::SessionListResult = + serde_json::from_value(listed_idle["result"].clone()) + .expect("session/list after turn"); + let listed_idle_session = listed_idle + .data + .iter() + .find(|session| session.id.as_str() == session_id.to_string()) + .expect("listed session after turn"); + assert_eq!(listed_idle_session.status, SessionStatus::Idle); + assert_eq!(listed_idle_session.active_turn_id, None); + Ok(()) + } + /// Trace: L2-DES-CONV-002, L2-DES-APP-008 /// Verifies: the native settings update returns while a turn is in /// flight (the persist-first path never waits on the session actor). @@ -5904,7 +6056,7 @@ mod tests { ), ) .await - .context("native turn/start must return before ExecuteTurn finishes")?; + .context("native turn/start must return before the turn finishes")?; let result: devo_protocol::native::rpc_turn::TurnStartResult = serde_json::from_value(started["result"].clone()).expect("native turn/start result"); assert_eq!(result.turn.session_id.as_str(), session_id.to_string()); @@ -5962,7 +6114,7 @@ mod tests { history_request(&runtime, connection_id, 7, "turn/start", params.clone()), ) .await - .context("native turn/start replay setup must return before ExecuteTurn finishes")?; + .context("native turn/start replay setup must return before the turn finishes")?; let first: devo_protocol::native::rpc_turn::TurnStartResult = serde_json::from_value(first["result"].clone()).expect("first result"); let replay = history_request(&runtime, connection_id, 8, "turn/start", params).await; diff --git a/crates/server/src/runtime/goal_continuation.rs b/crates/server/src/runtime/goal_continuation.rs index bcb192ee..438fe53d 100644 --- a/crates/server/src/runtime/goal_continuation.rs +++ b/crates/server/src/runtime/goal_continuation.rs @@ -449,9 +449,8 @@ impl ServerRuntime { .await .remove(&turn_id); - // Turns run inline on the session actor. Do not touch the actor mailbox - // while ExecuteTurn is in flight — cancel the turn token and wait for - // `finalize_executed_turn` to emit lifecycle events instead. + // Interrupt via the cancel token and wait for + // `finalize_executed_turn` / `MergeTurn` to emit lifecycle events. if self.runtime_active_turn_id(session_id).await != Some(turn_id) { let already_terminal = self.recent_terminal_turn_status(turn_id).await.is_some(); if already_terminal { diff --git a/crates/server/src/runtime/handlers/compaction.rs b/crates/server/src/runtime/handlers/compaction.rs index 5f3fa7da..5824b9dd 100644 --- a/crates/server/src/runtime/handlers/compaction.rs +++ b/crates/server/src/runtime/handlers/compaction.rs @@ -63,9 +63,9 @@ impl ServerRuntime { ); }; // `spawn_active_turn_task` has already registered runtime metadata. - // Compaction does not run `ExecuteTurn`, so the mailbox snapshot can - // miss the active turn (no spawn snapshot / no stream). Read the - // registry the same way native `turn/start` does. + // Compaction may not yet have a stream/spawn snapshot, so the mailbox + // reservation can miss the active turn. Read the registry the same + // way native `turn/start` does. let Some(metadata) = self .active_turns .active_turn_metadata(legacy_session_id) @@ -371,15 +371,20 @@ impl ServerRuntime { .await .unwrap_or_else(CancellationToken::new); - // Compaction computes a replacement from a history snapshot. Keep the - // session mutation gate for the whole summarize-and-apply operation so - // rollback, turn admission, and metadata edits cannot make that - // replacement stale while the model call is in flight. - let state_change_guard = session_handle.lock_state_change().await; - let result = { + // Snapshot under the gate, then release it before the model call so + // admission / queue / metadata RPCs stay responsive (L2-DES-SERVER-002). + let ( + items, + token_info, + model_slug, + request_model, + max_tokens, + provider_route, + budget, + ) = { + let _state_change_guard = session_handle.lock_state_change().await; let Some(runtime_session) = session_handle.export_runtime_session().await else { tracing::warn!(session_id = %session_id, "session compaction failed: session unavailable"); - drop(state_change_guard); self.finalize_manual_compaction_turn( &session_handle, session_id, @@ -420,58 +425,84 @@ impl ServerRuntime { .get(&model_slug) .and_then(|m| m.max_tokens.map(|t| t as usize)) .unwrap_or(4096); - - tracing::debug!( - session_id = %session_id, - turn_id = %turn.turn_id, - model = %model_slug, - request_model = %request_model, - item_count = items.len(), - input_tokens = token_info.input_tokens, - cached_input_tokens = token_info.cached_input_tokens, - output_tokens = token_info.output_tokens, - "starting compaction summarization" - ); - let provider = self.usage_ledger.instrumented_provider( - runtime_session - .runtime_context - .provider_for_route(turn_config.provider_route.clone()), - session_id, - Some(turn.turn_id), - devo_protocol::native::usage::UsagePurpose::Compaction, - ); - let summarizer = DefaultHistorySummarizer::with_models( - provider, + let budget = core_session.config.token_budget.clone(); + let provider_route = turn_config.provider_route.clone(); + drop(core_session); + drop(runtime_session); + ( + items, + token_info, model_slug, request_model, max_tokens, - ); - - let config = CompactionConfig { - budget: core_session.config.token_budget.clone(), - // Proactive: user-requested /compact; preserve latest user suffix. - // Example: [user1, asst1, user2, asst2, user3] -> [summary, user3]. - kind: CompactionKind::Proactive, - }; + provider_route, + budget, + ) + }; - // Drop the core_session lock before the long summarizer await. - drop(core_session); - drop(runtime_session); + tracing::debug!( + session_id = %session_id, + turn_id = %turn.turn_id, + model = %model_slug, + request_model = %request_model, + item_count = items.len(), + input_tokens = token_info.input_tokens, + cached_input_tokens = token_info.cached_input_tokens, + output_tokens = token_info.output_tokens, + "starting compaction summarization" + ); + let provider = self.usage_ledger.instrumented_provider( + { + // Resolve provider without holding the session gate. + let Some(runtime_session) = session_handle.export_runtime_session().await else { + self.finalize_manual_compaction_turn( + &session_handle, + session_id, + turn, + CompactionTurnOutcome::Failed { + message: "compaction failed: session unavailable".to_string(), + }, + ) + .await; + return; + }; + runtime_session + .runtime_context + .provider_for_route(provider_route) + }, + session_id, + Some(turn.turn_id), + devo_protocol::native::usage::UsagePurpose::Compaction, + ); + let summarizer = DefaultHistorySummarizer::with_models( + provider, + model_slug, + request_model, + max_tokens, + ); - compact_history( - &items, - &token_info, - &summarizer, - &config, - Some(&cancel_token), - ) - .await + let config = CompactionConfig { + budget, + // Proactive: user-requested /compact; preserve latest user suffix. + kind: CompactionKind::Proactive, }; + let result = compact_history( + &items, + &token_info, + &summarizer, + &config, + Some(&cancel_token), + ) + .await; + // Summarize is done: detach abort so interrupt cannot kill mid-terminalize. // Cancel token still works for any remaining cooperative checks. self.detach_active_turn_abort(session_id).await; + // Apply under the gate so replace_state cannot race admission/edit. + let state_change_guard = session_handle.lock_state_change().await; + match result { Err(devo_core::history::compaction::CompactionError::Canceled) => { drop(state_change_guard); diff --git a/crates/server/src/runtime/handlers/history.rs b/crates/server/src/runtime/handlers/history.rs index 1712eae5..3d62bf57 100644 --- a/crates/server/src/runtime/handlers/history.rs +++ b/crates/server/src/runtime/handlers/history.rs @@ -126,30 +126,34 @@ impl ServerRuntime { }) } - /// Finds the rollout file for a session, loaded or cold: a resumed/live - /// session knows its path; otherwise the SQLite index, then the - /// file-name scan. Ephemeral sessions have no persisted history and - /// resolve to `None` (reported as not found — they have no history to - /// page). + /// Finds the rollout file for a session, loaded or cold. Prefer durable + /// indexes (SQLite, file scan) so history reads never depend on a + /// mailbox round-trip. Fall back to the live actor record when the + /// session is loaded and the index has no path yet. pub(crate) async fn resolve_rollout_path( &self, session_id: &devo_protocol::native::ids::SessionId, ) -> Option { let legacy_id = SessionId::try_from(session_id.as_str()).ok()?; - if let Some(handle) = self.session(legacy_id).await - && let Some(record) = handle.record().await.flatten() - { - return Some(record.rollout_path); - } if let Ok(Some(index)) = self.deps.db.get_session_index(&legacy_id) && let Some(path) = index.rollout_path { return Some(path); } - self.rollout_store + if let Some(path) = self + .rollout_store .find_rollout_by_session_id(&legacy_id) .ok() .flatten() + { + return Some(path); + } + if let Some(handle) = self.session(legacy_id).await + && let Some(record) = handle.record().await.flatten() + { + return Some(record.rollout_path); + } + None } } diff --git a/crates/server/src/runtime/handlers/queue.rs b/crates/server/src/runtime/handlers/queue.rs index 0ffd6aa0..6f12221d 100644 --- a/crates/server/src/runtime/handlers/queue.rs +++ b/crates/server/src/runtime/handlers/queue.rs @@ -447,11 +447,8 @@ impl ServerRuntime { } }; let pending_id = PendingInputId::from(queue_item_uuid); - // Remove directly through the shared queue, not the actor mailbox: - // the actor loop is busy for the whole duration of a running turn - // (`ExecuteTurn` is inline in the actor), so a mailbox round-trip - // would block the RPC until the turn ends. The queue mutex is the - // per-session serialization point for queue ops (01 §4.3). + // Remove through the shared queue mutex (01 §4.3 last-write-wins), + // not an actor command: queue ops must stay zero-hop at decision points. let Some(reservation) = self .session_turn_reservation_snapshot(legacy_session_id) .await diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index 6c3b9696..65394a23 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -282,10 +282,15 @@ impl ServerRuntime { } }; - for handle in self.list_session_handles().await { - let Some(runtime_summary) = handle.summary().await else { - continue; - }; + // Parallel mailbox reads: the actor is short-command only, so this + // stays bounded even when a turn is active on some sessions. + let handles = self.list_session_handles().await; + let summaries = + futures::future::join_all(handles.into_iter().map(|handle| async move { + handle.summary().await + })) + .await; + for runtime_summary in summaries.into_iter().flatten() { if runtime_summary.ephemeral || runtime_summary.agent_path.is_some() { continue; } @@ -1212,6 +1217,32 @@ impl ServerRuntime { history.session.map(|session| *session) } + /// Overlays live runtime pointers onto a durable session snapshot. + /// + /// Rollout / index snapshots almost always report `Idle`; in-flight turns + /// live in `ActiveTurnRegistry`. List/read must project that truth so + /// clients (e.g. delete-refill) do not treat a working session as idle. + async fn apply_live_session_runtime_fields( + &self, + session_id: SessionId, + session: &mut devo_protocol::native::session::Session, + ) { + match self.runtime_active_turn_id(session_id).await { + Some(turn_id) => { + session.status = devo_protocol::native::session::SessionStatus::Active; + session.active_turn_id = Some( + devo_protocol::native::ids::TurnId::from_legacy_uuid(uuid::Uuid::from( + turn_id, + )), + ); + } + None => { + session.status = devo_protocol::native::session::SessionStatus::Idle; + session.active_turn_id = None; + } + } + } + /// Native `session/read` (L2-DES-APP-008): one session's /// rollout-backed canonical snapshot. pub(crate) async fn handle_native_session_read( @@ -1237,13 +1268,15 @@ impl ServerRuntime { "session id is not addressable by this server", ); }; - let Some(session) = self.native_session_snapshot(session_id).await else { + let Some(mut session) = self.native_session_snapshot(session_id).await else { return self.error_response( request_id, ProtocolErrorCode::SessionNotFound, "session does not exist", ); }; + self.apply_live_session_runtime_fields(session_id, &mut session) + .await; serde_json::to_value(SuccessResponse { id: request_id, result: devo_protocol::native::rpc_session::SessionReadResult { session }, @@ -1308,6 +1341,8 @@ impl ServerRuntime { .unwrap_or_else(|| { Self::native_session_from_index_metadata(&summary, summary.session_id) }); + self.apply_live_session_runtime_fields(summary.session_id, &mut session) + .await; let rollout_path = self .deps .db diff --git a/crates/server/src/runtime/handlers/turn.rs b/crates/server/src/runtime/handlers/turn.rs index a13286c7..1177db21 100644 --- a/crates/server/src/runtime/handlers/turn.rs +++ b/crates/server/src/runtime/handlers/turn.rs @@ -163,11 +163,8 @@ impl ServerRuntime { "session already has an active prompt turn", ); }; - // `spawn_active_turn_task` has already queued `ExecuteTurn`, so the - // actor mailbox is unresponsive until that turn ends. Read the - // runtime registry instead of `session_turn_reservation_snapshot` - // (mailbox) or the TUI's second `turn/start` times out while the - // turn continues in the background. + // Prefer runtime registry metadata over a mailbox reservation read: + // `spawn_active_turn_task` registers before the turn task checkouts. let Some(metadata) = self .active_turns .active_turn_metadata(legacy_session_id) @@ -217,8 +214,8 @@ impl ServerRuntime { ); }; // Registry presence is mailbox-free: `spawn_active_turn_task` - // records the turn before `ExecuteTurn` registers a stream. Native - // busy clients must reject here instead of waiting on the actor. + // records the turn before the stream is registered. Native busy + // clients must reject here instead of waiting on the actor. if queue_policy == TurnStartQueuePolicy::RejectActive && self .runtime_active_turn_id(params.session_id) @@ -232,10 +229,7 @@ impl ServerRuntime { ); } // A busy session needs no state-change gate to enqueue: the queue - // mutex is the serialization point for queue ops, and the gate can - // be held for the rest of a turn (final title generation parking - // on the busy actor mailbox) or across a compaction provider call, - // which would park every push behind it without responding. + // mutex is the serialization point for queue ops (01 §4.3). let Some(mut reservation) = self .session_turn_reservation_snapshot(params.session_id) .await @@ -398,11 +392,9 @@ impl ServerRuntime { now, ); let queued_input_id = item.id; - // Push into the shared queue directly instead of the actor - // mailbox: a busy actor does not service its mailbox until the - // turn finishes, and callers must see their entry synchronously - // (01 §4.3 last-write-wins). The actor reads the same shared - // queue at drain time. + // Push into the shared queue directly (01 §4.3 last-write-wins): + // callers must see their entry synchronously at decision points. + // The actor / turn drain reads the same shared queue. reservation .pending_turn_queue .lock() diff --git a/crates/server/src/runtime/handlers/turn_interrupt.rs b/crates/server/src/runtime/handlers/turn_interrupt.rs index ef784f92..9741efd6 100644 --- a/crates/server/src/runtime/handlers/turn_interrupt.rs +++ b/crates/server/src/runtime/handlers/turn_interrupt.rs @@ -38,11 +38,10 @@ impl ServerRuntime { ); }; - // Turns that run inline on the session actor finalize themselves when the - // cancel token fires (`finalize_executed_turn` records terminal status). - // Research (and similar) turns run on a spawned task outside the actor: - // aborting that task does not record a terminal status, so we must claim - // `active_turn` via the mailbox and finalize here. + // Turns that run on a spawned task finalize themselves when the cancel + // token fires (`finalize_executed_turn` + `MergeTurn`). Interrupt waits + // for that terminal status; claiming `active_turn` is only an orphan + // fallback after the wait times out. if self.runtime_active_turn_id(params.session_id).await != Some(params.turn_id) { if let Some(snapshot) = self.recent_terminal_turn_status(params.turn_id).await { return self.turn_interrupt_success(request_id, params.turn_id, snapshot.status); @@ -60,8 +59,10 @@ impl ServerRuntime { .await; return self.turn_interrupt_success(request_id, params.turn_id, snapshot.status); } - // Cancel before any session-actor mailbox round-trip: the actor may be blocked - // waiting for a permission response and cannot process commands until cancelled. + // Cancel before mailbox work. All turns run on a spawned task; the + // cancel token unblocks query, and abort covers stuck tasks. Do not + // claim `active_turn` until terminal wait times out — claiming while + // the turn task is still finalizing races with `MergeTurn`. // Cancel via a clone rather than `remove`: see the comment in // `interrupt_child_runtime_work` for why removing here races with // `run_turn_model_query` fetching the same token. @@ -98,51 +99,58 @@ impl ServerRuntime { .interrupt_all_child_agents(params.session_id) .await; - // Out-of-actor turns (research): actor is free, so we can claim active_turn. - // In-actor turns: finalize already cleared it; fall through to terminal wait. - if let Some(interrupted_turn) = session_handle.interrupt_active_turn().await.flatten() { - if interrupted_turn.turn_id != params.turn_id { - return self.error_response( - request_id, - ProtocolErrorCode::TurnNotFound, - "turn does not exist", - ); - } - return self - .finalize_claimed_interrupted_turn( - request_id, - &session_handle, - params.session_id, - interrupted_turn, - ) - .await; - } - let snapshot = match tokio::time::timeout(TURN_INTERRUPT_TERMINAL_TIMEOUT, terminal_rx).await { Ok(Ok(snapshot)) => snapshot, Ok(Err(_)) | Err(_) => { if let Some(snapshot) = self.recent_terminal_turn_status(params.turn_id).await { snapshot - } else if let Some(orphaned) = self - .recover_orphaned_manual_compaction_interrupt( - &session_handle, - params.session_id, - params.turn_id, - ) - .await - { - return self.turn_interrupt_success( - request_id, - params.turn_id, - orphaned.status, - ); } else { - return self.error_response( - request_id, - ProtocolErrorCode::TurnNotFound, - "turn is not active", - ); + // Cooperative cancel timed out: hard-abort, then claim + // or recover any leftover active_turn without MergeTurn. + self.active_turns.abort_task(params.session_id).await; + if let Some(snapshot) = + self.recent_terminal_turn_status(params.turn_id).await + { + snapshot + } else if let Some(interrupted_turn) = + session_handle.interrupt_active_turn().await.flatten() + { + if interrupted_turn.turn_id != params.turn_id { + return self.error_response( + request_id, + ProtocolErrorCode::TurnNotFound, + "turn does not exist", + ); + } + return self + .finalize_claimed_interrupted_turn( + request_id, + &session_handle, + params.session_id, + interrupted_turn, + ) + .await; + } else if let Some(orphaned) = self + .recover_orphaned_manual_compaction_interrupt( + &session_handle, + params.session_id, + params.turn_id, + ) + .await + { + return self.turn_interrupt_success( + request_id, + params.turn_id, + orphaned.status, + ); + } else { + return self.error_response( + request_id, + ProtocolErrorCode::TurnNotFound, + "turn is not active", + ); + } } } }; diff --git a/crates/server/src/runtime/handlers/workspace_changes.rs b/crates/server/src/runtime/handlers/workspace_changes.rs index e48f78a7..0adedb0c 100644 --- a/crates/server/src/runtime/handlers/workspace_changes.rs +++ b/crates/server/src/runtime/handlers/workspace_changes.rs @@ -106,7 +106,13 @@ impl ServerRuntime { "session does not exist".to_string(), )); }; - let Some(reservation) = session_handle.turn_reservation_snapshot().await else { + // Prefer the registry/spawn fast path when a turn is active so reads + // never wait on turn I/O; falls through to the mailbox when idle. + let Some(reservation) = self + .session_turn_reservation_snapshot(params.session_id) + .await + .or(session_handle.turn_reservation_snapshot().await) + else { return Err(( ProtocolErrorCode::SessionNotFound, "session does not exist".to_string(), diff --git a/crates/server/src/runtime/items.rs b/crates/server/src/runtime/items.rs index 8f500076..d4440d05 100644 --- a/crates/server/src/runtime/items.rs +++ b/crates/server/src/runtime/items.rs @@ -51,10 +51,10 @@ impl ServerRuntime { /// Spawns final (LLM) title generation in the background. /// - /// Safe to call at turn start: actor mailbox round-trips happen here, then - /// the model call runs on a detached task so it does not block `ExecuteTurn`. - /// Duplicate schedules for the same session are ignored while a generation - /// task is already in flight. + /// Safe to call at turn start: short mailbox round-trips happen here, then + /// the model call runs on a detached task (must not hold `state_change_gate` + /// across that await). Duplicate schedules for the same session are ignored + /// while a generation task is already in flight. pub(super) async fn maybe_schedule_final_title_generation( self: &Arc, session_id: SessionId, @@ -269,7 +269,13 @@ impl ServerRuntime { let Some(session_handle) = self.session(session_id).await else { return; }; - let state_change_guard = session_handle.lock_state_change().await; + // Persist-first title apply: do not hold `state_change_gate` across + // mailbox or disk waits. The actor command is short; concurrent + // user renames last-write-wins via the title_state Final check. + let previous_title = session_handle + .summary() + .await + .and_then(|summary| summary.title); let Some(updated_summary) = session_handle .update_title( generated_title.clone(), @@ -285,7 +291,7 @@ impl ServerRuntime { &record, generated_title.clone(), SessionTitleState::Final(SessionTitleFinalSource::ModelGenerated), - updated_summary.title.clone(), + previous_title, ) { tracing::warn!(session_id = %session_id, error = %error, "failed to persist title"); @@ -293,7 +299,6 @@ impl ServerRuntime { self.persist_session_summary_if_persistent(session_id, &updated_summary) .await; - drop(state_change_guard); self.broadcast_event(ServerEvent::SessionTitleUpdated(SessionEventPayload { session: updated_summary, diff --git a/crates/server/src/runtime/session_actor/actor_loop.rs b/crates/server/src/runtime/session_actor/actor_loop.rs index e6f6fc3d..916627ba 100644 --- a/crates/server/src/runtime/session_actor/actor_loop.rs +++ b/crates/server/src/runtime/session_actor/actor_loop.rs @@ -17,7 +17,6 @@ use super::snapshots::{ TitleGenerationContext, TurnPersistenceSnapshot, TurnReservationSnapshot, }; use super::state::SessionActorState; -use super::turn::execute_turn_in_actor; use crate::SessionRuntimeStatus; use crate::persistence::build_turn_record; use crate::runtime::protocol_preset_from_safety; @@ -30,49 +29,18 @@ pub(super) async fn run_session_actor( ) { while let Some(command) = mailbox.recv().await { match command { - SessionCommand::ExecuteTurn { - runtime: turn_runtime, - request, - reply, - } => { - let session_id = request.session_id; - execute_turn_in_actor(&mut state, turn_runtime.clone(), request).await; - // Interrupted turns must not auto-start continuation here: that would - // re-block the actor mailbox before the interrupting handler finishes - // (goal replace/clear/cancel). Failed turns still enter maybe_start so - // `pause_goal_continuation_after_failed_turn` can suppress looping. - // Explicit restarts go through goal handlers' maybe_start calls. - let should_auto_continue_goal = state.latest_turn.as_ref().is_some_and(|turn| { - matches!(turn.status, TurnStatus::Completed | TurnStatus::Failed) - }); + SessionCommand::CheckoutTurnWorkingSet { turn, reply } => { + let working = state.checkout_turn_working_set(&turn); + { + let mut stream = working.state.stream.lock().await; + stream.turn_inline = + Some(super::turn_inline::TurnInlineState::new(&working.state, &turn)); + } + let _ = reply.send(working); + } + SessionCommand::MergeTurn { working, reply } => { + state.merge_turn_working_set(*working); let _ = reply.send(()); - tokio::spawn(async move { - turn_runtime - .maybe_schedule_final_title_generation(session_id, None) - .await; - if turn_runtime.chain_queued_followup_turn(session_id).await { - return; - } - if turn_runtime.spawn_next_turn_from_queue(session_id).await { - return; - } - if turn_runtime - .child_parent_and_path(session_id) - .await - .is_some() - && turn_runtime.child_can_accept_next_turn(session_id).await - { - let _ = turn_runtime - .drain_child_mailbox_into_user_turns(session_id) - .await; - return; - } - if should_auto_continue_goal { - turn_runtime - .maybe_start_goal_continuation_turn(session_id) - .await; - } - }); } SessionCommand::GetSummary { reply } => { let _ = reply.send(state.summary.clone()); diff --git a/crates/server/src/runtime/session_actor/commands.rs b/crates/server/src/runtime/session_actor/commands.rs index 0709f39e..f240c16e 100644 --- a/crates/server/src/runtime/session_actor/commands.rs +++ b/crates/server/src/runtime/session_actor/commands.rs @@ -17,16 +17,22 @@ use super::state::{ApprovalCacheSnapshot, DeferredItems, SessionActorState, Spaw use crate::execution::PendingApproval; use crate::execution::PersistedTurnItem; use crate::runtime::subagent_usage::ParentUsageSnapshot; -use crate::runtime::turn_exec::ExecuteTurnRequest; use crate::session::SessionHistoryItem; use crate::session::SessionMetadata; use crate::turn::TurnMetadata; use devo_core::TurnConfig; +use super::turn_working::TurnWorkingSet; + pub(crate) enum SessionCommand { - ExecuteTurn { - runtime: Arc, - request: ExecuteTurnRequest, + /// Short: clone turn-owned state and install `TurnInlineState` on the shared stream. + CheckoutTurnWorkingSet { + turn: TurnMetadata, + reply: oneshot::Sender, + }, + /// Short: install turn-owned fields after the spawned turn task finishes. + MergeTurn { + working: Box, reply: oneshot::Sender<()>, }, GetSummary { diff --git a/crates/server/src/runtime/session_actor/handle.rs b/crates/server/src/runtime/session_actor/handle.rs index 554923ef..fbb1e207 100644 --- a/crates/server/src/runtime/session_actor/handle.rs +++ b/crates/server/src/runtime/session_actor/handle.rs @@ -19,6 +19,7 @@ use super::snapshots::{ TurnPersistenceSnapshot, TurnReservationSnapshot, }; use super::state::{ApprovalCacheSnapshot, DeferredItems, SessionActorState, SpawnSnapshot}; +use super::turn_working::TurnWorkingSet; use crate::execution::PendingApproval; use crate::execution::PersistedTurnItem; use crate::runtime::subagent_usage::ParentUsageSnapshot; @@ -82,22 +83,57 @@ impl SessionHandle { Arc::clone(&self.state_change_gate).lock_owned().await } - /// Non-blocking enqueue. Used by turn event streams so they never park on a - /// session actor that is itself waiting for that stream to finish. + /// Non-blocking enqueue for fire-and-forget updates from turn streams. + /// Prefer this over `send().await` when the caller is on a path the actor + /// might still be waiting on (legacy stream↔mailbox deadlock avoidance). fn try_send(&self, command: SessionCommand) -> bool { self.tx.try_send(command).is_ok() } + /// Checks out a turn working copy (short mailbox), runs the turn on this + /// task, then merges results. The actor mailbox stays free during query I/O. pub(crate) async fn execute_turn( &self, runtime: Arc, request: ExecuteTurnRequest, ) { + let session_id = request.session_id; + let Some(working) = self.checkout_turn_working_set(request.turn.clone()).await else { + return; + }; + let should_auto_continue_goal = + super::turn::execute_turn_task(working, Arc::clone(&runtime), request).await; + // Sync helper: keeps the spawn's Send check outside this async fn's + // opaque type so follow-up → execute_turn cannot form a rustc cycle. + crate::runtime::turn_exec::spawn_post_turn_scheduling( + runtime, + session_id, + should_auto_continue_goal, + ); + } + + pub(crate) async fn checkout_turn_working_set( + &self, + turn: TurnMetadata, + ) -> Option { let (reply_tx, reply_rx) = oneshot::channel(); if !self - .send(SessionCommand::ExecuteTurn { - runtime, - request, + .send(SessionCommand::CheckoutTurnWorkingSet { + turn, + reply: reply_tx, + }) + .await + { + return None; + } + reply_rx.await.ok() + } + + pub(crate) async fn merge_turn(&self, working: TurnWorkingSet) { + let (reply_tx, reply_rx) = oneshot::channel(); + if !self + .send(SessionCommand::MergeTurn { + working: Box::new(working), reply: reply_tx, }) .await @@ -564,9 +600,9 @@ impl SessionHandle { /// Best-effort permission-profile notification for the persist-first /// settings write path (L2-DES-CONV-002 Phase 2): the change is already - /// durable, so the actor must not be waited on (it may be running a turn). - /// Mailbox FIFO still guarantees the actor applies it before the next - /// `ExecuteTurn`, so the next turn always sees the new profile. + /// durable, so the actor must not be waited on. Mailbox FIFO still + /// guarantees the actor applies it before the next turn checkout, so the + /// next turn always sees the new profile. pub(crate) fn notify_permission_profile(&self, profile: devo_safety::RuntimePermissionProfile) { let (reply_tx, _reply_rx) = oneshot::channel(); let _ = self.try_send(SessionCommand::ApplyPermissionProfile { diff --git a/crates/server/src/runtime/session_actor/mod.rs b/crates/server/src/runtime/session_actor/mod.rs index 853dce01..3fa63067 100644 --- a/crates/server/src/runtime/session_actor/mod.rs +++ b/crates/server/src/runtime/session_actor/mod.rs @@ -1,8 +1,9 @@ // Per-session actor: single-writer for durable session state. // -// Long-running turns still execute inside or beside this actor today. While a -// turn is in flight, transient execution state lives in ActiveTurnRegistry and -// merges back through actor commands when the turn completes. +// Unbounded turn I/O (model streams, tools) runs on a spawned task with a +// [`turn_working::TurnWorkingSet`]. The actor mailbox stays short-command only; +// turn results re-enter through `MergeTurn`. Control-plane Arcs (queues, +// TurnInlineState, cancel tokens) let mid-turn RPCs avoid mailbox hops. mod actor_loop; pub(crate) mod approval_scope; @@ -13,6 +14,8 @@ pub(crate) mod snapshots; pub(crate) mod state; mod turn; mod turn_inline; +mod turn_working; pub(crate) use handle::SessionHandle; pub(crate) use state::SessionActorState; +pub(crate) use turn_working::TurnWorkingSet; diff --git a/crates/server/src/runtime/session_actor/registry.rs b/crates/server/src/runtime/session_actor/registry.rs index 6e6baf38..24945daf 100644 --- a/crates/server/src/runtime/session_actor/registry.rs +++ b/crates/server/src/runtime/session_actor/registry.rs @@ -89,21 +89,15 @@ impl ServerRuntime { summaries } - /// Reads turn reservation state, preferring runtime caches while the session - /// actor is blocked in `ExecuteTurn`. + /// Reads turn reservation state, preferring runtime caches while a turn + /// task holds the working copy (shared queue Arcs stay usable without a + /// mailbox hop). Callers may mutate `pending_turn_queue` and + /// `steer_input_queue` through the returned shared mutexes. /// - /// `execute_turn_in_actor` runs inline, so its actor does not poll mailbox - /// commands until the turn finishes. This is the only synchronous fast path - /// for work that must remain responsive during an active turn. Callers may - /// mutate `pending_turn_queue` and `steer_input_queue` through the returned - /// shared mutexes; those mutexes are the per-session serialization point. - /// Do not replace this with a mailbox round-trip for queue, steer, or other - /// active-turn control paths. - /// - /// Stream presence **or** runtime turn metadata means the actor is blocked - /// (or about to be). Finalization clears `runtime_active_turn_id` before - /// `ExecuteTurn` returns; falling through to the mailbox in that window - /// hangs the next `turn/start`. + /// Stream presence **or** runtime turn metadata means a turn is admitted. + /// Finalization clears `runtime_active_turn_id` before `MergeTurn` + /// returns; falling through to the mailbox in that window is safe because + /// the actor no longer runs unbounded turn I/O. pub(crate) async fn session_turn_reservation_snapshot( &self, session_id: SessionId, @@ -113,15 +107,10 @@ impl ServerRuntime { if stream_busy || runtime_turn.is_some() { let handle = self.session(session_id).await?; let Some(spawn) = self.active_spawn_snapshot_for_session(session_id).await else { - // Actor is blocked (`ExecuteTurn` in flight or stream still - // registered) but the spawn snapshot is not available yet. - // Falling through to the mailbox hangs until the turn ends. + // Turn is admitted but the spawn snapshot is not available yet. + // Prefer None over a stale mailbox read of pre-admission state. return None; }; - // Only live runtime metadata means a turn is still admitted. - // After finalize clears it, synthesizing a placeholder made the - // next `turn/start` queue instead of starting once the actor - // finished merging. return Some(super::snapshots::TurnReservationSnapshot { max_turns: handle.max_turns(), active_turn: runtime_turn, @@ -155,7 +144,7 @@ impl ServerRuntime { .await; } - /// Snapshot registered at turn start while the session actor is busy executing. + /// Snapshot registered at turn start for zero-hop control-plane access. pub(crate) async fn active_spawn_snapshot_for_session( &self, session_id: SessionId, diff --git a/crates/server/src/runtime/session_actor/state.rs b/crates/server/src/runtime/session_actor/state.rs index c9f5c92d..5ea97715 100644 --- a/crates/server/src/runtime/session_actor/state.rs +++ b/crates/server/src/runtime/session_actor/state.rs @@ -34,8 +34,8 @@ pub(crate) struct SpawnSnapshot { pub(crate) steer_input_queue: Arc>>, } -/// Approval caches cloned at turn start for permission checks while the actor -/// is busy executing a turn. +/// Approval caches cloned at turn start for permission checks while the turn +/// task owns the working copy. #[derive(Clone, Default)] pub(crate) struct ApprovalCacheSnapshot { pub(crate) session_approval_cache: crate::execution::ApprovalGrantCache, diff --git a/crates/server/src/runtime/session_actor/turn.rs b/crates/server/src/runtime/session_actor/turn.rs index 402cde96..3e14c0e1 100644 --- a/crates/server/src/runtime/session_actor/turn.rs +++ b/crates/server/src/runtime/session_actor/turn.rs @@ -3,24 +3,24 @@ use std::sync::Arc; use tokio::sync::mpsc; use crate::runtime::ServerRuntime; -use crate::runtime::session_actor::state::SessionActorState; +use crate::runtime::session_actor::TurnWorkingSet; use crate::runtime::subagent_usage::UsageTotals; use crate::runtime::turn_exec::{ ExecuteTurnRequest, FinalizeTurnParams, QUERY_EVENT_CHANNEL_CAPACITY, TurnModelQueryParams, spawn_turn_event_stream, }; +use devo_core::TurnStatus; -/// Executes a turn inline on the session actor. +/// Runs one turn on the caller's task using a checked-out [`TurnWorkingSet`]. /// -/// The actor does not poll its mailbox until this function returns. Code that -/// must remain responsive while a turn runs (for example queue operations, -/// steering, or future rollback preview) must not wait for an actor command. -/// It must use the runtime reservation fast path and its shared queues instead. -pub(super) async fn execute_turn_in_actor( - state: &mut SessionActorState, +/// Returns whether goal continuation should be considered after merge. +/// Post-turn scheduling is the caller's responsibility so this future does not +/// recursively type-check against queue/follow-up spawn paths. +pub(crate) async fn execute_turn_task( + mut working: TurnWorkingSet, runtime: Arc, request: ExecuteTurnRequest, -) { +) -> bool { let ExecuteTurnRequest { session_id, turn, @@ -32,22 +32,18 @@ pub(super) async fn execute_turn_in_actor( input_mode, } = request; - let spawn_snapshot = Arc::new(state.spawn_snapshot()); + let spawn_snapshot = Arc::new(working.state.spawn_snapshot()); runtime .register_turn_spawn_snapshot(session_id, turn.turn_id, Arc::clone(&spawn_snapshot)) .await; - { - let mut stream = state.stream.lock().await; - stream.turn_inline = Some(super::turn_inline::TurnInlineState::new(state, &turn)); - } runtime - .register_active_stream(session_id, Arc::clone(&state.stream)) + .register_active_stream(session_id, Arc::clone(&working.state.stream)) .await; runtime .prepare_turn_execution_for_actor( - state, + &mut working.state, &turn, &display_input, input_mode.emits_user_message(), @@ -55,25 +51,21 @@ pub(super) async fn execute_turn_in_actor( .await; let (event_tx, event_rx) = mpsc::channel(QUERY_EVENT_CHANNEL_CAPACITY); - let event_tool_registry = runtime.tool_registry_for_actor_state(state); - let usage_parent_session_id = state.parent_session_id(); + let event_tool_registry = runtime.tool_registry_for_actor_state(&working.state); + let usage_parent_session_id = working.state.parent_session_id(); let usage_context_window = Some(turn_config.model.context_window as u64); - // Only root sessions own a parent-turn usage ledger. Child turns publish - // through `publish_subagent_turn_usage` into their parent's ledger; starting - // a ledger keyed by the child session id is incorrect and can strand usage - // updates on the wrong session. if usage_parent_session_id.is_none() { runtime .begin_parent_usage_turn_with_base( session_id, turn.turn_id, - UsageTotals::from_session_summary(&state.summary), + UsageTotals::from_session_summary(&working.state.summary), usage_context_window, ) .await; } - let stream = Arc::clone(&state.stream); + let stream = Arc::clone(&working.state.stream); let event_task = spawn_turn_event_stream( Arc::clone(&runtime), stream, @@ -88,7 +80,7 @@ pub(super) async fn execute_turn_in_actor( let query_outcome = runtime .run_turn_model_query(TurnModelQueryParams { - state, + state: &mut working.state, turn_id: turn.turn_id, turn_config: &turn_config, input: &input, @@ -104,7 +96,7 @@ pub(super) async fn execute_turn_in_actor( let turn_id = turn.turn_id; runtime .finalize_executed_turn(FinalizeTurnParams { - state, + state: &mut working.state, session_id, turn, query_outcome, @@ -113,13 +105,29 @@ pub(super) async fn execute_turn_in_actor( }) .await; - runtime.clear_turn_spawn_snapshot(session_id, turn_id).await; - runtime.unregister_active_stream(session_id).await; + // Merge before clearing the runtime registry so admission (compact / + // turn/start) cannot see a free registry while the actor still holds + // `active_turn` from BeginActiveTurn. let inline = { - let mut stream = state.stream.lock().await; + let mut stream = working.state.stream.lock().await; stream.turn_inline.take() }; if let Some(inline) = inline { - inline.merge_into(state); + inline.merge_into(&mut working.state); + } + + let should_auto_continue_goal = working.state.latest_turn.as_ref().is_some_and(|turn| { + matches!(turn.status, TurnStatus::Completed | TurnStatus::Failed) + }); + + if let Some(handle) = runtime.session(session_id).await { + handle.merge_turn(working).await; } + + runtime.clear_turn_spawn_snapshot(session_id, turn_id).await; + runtime.unregister_active_stream(session_id).await; + runtime.clear_active_turn_interrupt_handles(session_id).await; + runtime.clear_active_turn_runtime_handles(session_id).await; + + should_auto_continue_goal } diff --git a/crates/server/src/runtime/session_actor/turn_inline.rs b/crates/server/src/runtime/session_actor/turn_inline.rs index 0b5d616e..12d70b9a 100644 --- a/crates/server/src/runtime/session_actor/turn_inline.rs +++ b/crates/server/src/runtime/session_actor/turn_inline.rs @@ -16,10 +16,11 @@ use crate::turn::TurnMetadata; use super::SessionActorState; use super::snapshots::HookContextSnapshot; -/// Mutable session fields updated during an in-actor turn without mailbox round-trips. +/// Mutable session fields updated during an active turn without mailbox round-trips. /// -/// Transient scratch state registered in `ActiveTurnRegistry` while the actor -/// mailbox is blocked. Merges into durable actor state when the turn completes. +/// Transient scratch state registered in `ActiveTurnRegistry` while the turn +/// task owns a [`super::TurnWorkingSet`]. Merges into durable actor state when +/// the turn completes via `MergeTurn`. pub(crate) struct TurnInlineState { pub(crate) turn_id: TurnId, pub(crate) turn_kind: TurnKind, diff --git a/crates/server/src/runtime/session_actor/turn_working.rs b/crates/server/src/runtime/session_actor/turn_working.rs new file mode 100644 index 00000000..4bb9a075 --- /dev/null +++ b/crates/server/src/runtime/session_actor/turn_working.rs @@ -0,0 +1,140 @@ +//! Turn working copy checked out of the session actor for unbounded I/O. +//! +//! The session actor remains free to drain short mailbox commands while the +//! turn task owns this copy. Durable conversation state returns only through +//! [`SessionActorState::merge_turn_working_set`]. + +use super::state::SessionActorState; +use crate::turn::TurnMetadata; + +/// Turn-owned session state for one in-flight execution. +/// +/// Shares `stream`, queue Arcs, and `file_read_ledger` with the actor so the +/// control plane and item stream stay coherent. Conversation mutations happen +/// only on the embedded `state` until merge. +pub(crate) struct TurnWorkingSet { + pub(crate) state: SessionActorState, +} + +impl SessionActorState { + /// Builds a working copy for turn execution without removing actor state. + /// + /// Installs `TurnInlineState` on the shared stream. Queue Arcs are shared so + /// pending/steer RPCs remain mailbox-free. + pub(crate) fn checkout_turn_working_set(&self, _turn: &TurnMetadata) -> TurnWorkingSet { + TurnWorkingSet { + state: SessionActorState { + runtime_context: std::sync::Arc::clone(&self.runtime_context), + record: self.record.clone(), + summary: self.summary.clone(), + config: self.config.clone(), + core: self.core.snapshot_for_export(), + stream: std::sync::Arc::clone(&self.stream), + active_turn: self.active_turn.clone(), + latest_turn: self.latest_turn.clone(), + loaded_item_count: self.loaded_item_count, + history_items: self.history_items.clone(), + persisted_turn_items: self.persisted_turn_items.clone(), + latest_compaction_snapshot: self.latest_compaction_snapshot.clone(), + turn_records_by_id: self.turn_records_by_id.clone(), + pending_turn_queue: std::sync::Arc::clone(&self.pending_turn_queue), + steer_input_queue: std::sync::Arc::clone(&self.steer_input_queue), + agent_tool_policy: self.agent_tool_policy, + max_turns: self.max_turns, + next_item_seq: self.next_item_seq, + first_user_input: self.first_user_input.clone(), + tool_registry: self.tool_registry.clone(), + file_read_ledger: std::sync::Arc::clone(&self.file_read_ledger), + session_approval_cache: self.session_approval_cache.clone(), + turn_approval_cache: self.turn_approval_cache.clone(), + session_context_recorded: self.session_context_recorded, + }, + } + } + + /// Installs turn-owned fields from a completed working copy. + /// + /// Session-plane config/settings that may have landed via persist-first + /// `notify_*` during the turn are preserved on the actor. + pub(crate) fn merge_turn_working_set(&mut self, working: TurnWorkingSet) { + let working = working.state; + + let session_config = self.config.clone(); + let session_core_config = self.core.config.clone(); + let session_permission_preset = self.summary.permission_preset; + let session_model = self.summary.model.clone(); + let session_model_binding_id = self.summary.model_binding_id.clone(); + let session_effort = self.summary.reasoning_effort_selection.clone(); + let session_effective_context_window = self.summary.effective_context_window; + let session_title = self.summary.title.clone(); + let session_title_state = self.summary.title_state.clone(); + let session_record = self.record.clone(); + + self.core = working.core; + self.core.config = session_core_config; + self.config = session_config; + + self.summary = working.summary; + if session_permission_preset.is_some() { + self.summary.permission_preset = session_permission_preset; + } + if session_model.is_some() { + self.summary.model = session_model; + } + if session_model_binding_id.is_some() { + self.summary.model_binding_id = session_model_binding_id; + } + if session_effort.is_some() { + self.summary.reasoning_effort_selection = session_effort; + } + if session_effective_context_window.is_some() { + self.summary.effective_context_window = session_effective_context_window; + } + if session_title.is_some() { + self.summary.title = session_title; + self.summary.title_state = session_title_state; + } + + self.active_turn = working.active_turn; + self.latest_turn = working.latest_turn; + self.history_items = working.history_items; + self.persisted_turn_items = working.persisted_turn_items; + self.next_item_seq = working.next_item_seq; + self.loaded_item_count = working.loaded_item_count; + self.session_approval_cache = working.session_approval_cache; + self.turn_approval_cache = working.turn_approval_cache; + if working.latest_compaction_snapshot.is_some() { + self.latest_compaction_snapshot = working.latest_compaction_snapshot; + } + self.session_context_recorded = working.session_context_recorded; + self.turn_records_by_id = working.turn_records_by_id; + self.first_user_input = working + .first_user_input + .or_else(|| self.first_user_input.clone()); + + match (session_record, working.record) { + (Some(actor_record), Some(mut turn_record)) => { + turn_record.title = actor_record.title.or(turn_record.title); + turn_record.title_state = actor_record.title_state; + if actor_record.permission_preset.is_some() { + turn_record.permission_preset = actor_record.permission_preset; + } + if actor_record.model.is_some() { + turn_record.model = actor_record.model; + } + if actor_record.model_binding_id.is_some() { + turn_record.model_binding_id = actor_record.model_binding_id; + } + if actor_record.reasoning_effort_selection.is_some() { + turn_record.reasoning_effort_selection = + actor_record.reasoning_effort_selection; + } + turn_record.updated_at = turn_record.updated_at.max(actor_record.updated_at); + self.record = Some(turn_record); + } + (actor_record, turn_record) => { + self.record = turn_record.or(actor_record); + } + } + } +} diff --git a/crates/server/src/runtime/subagent_usage.rs b/crates/server/src/runtime/subagent_usage.rs index b56e6fb3..20616f1f 100644 --- a/crates/server/src/runtime/subagent_usage.rs +++ b/crates/server/src/runtime/subagent_usage.rs @@ -537,12 +537,8 @@ impl ServerRuntime { } async fn apply_parent_usage_snapshot(&self, snapshot: ParentUsageSnapshot) { - // The turn event stream runs while the session actor is blocked inside - // `execute_turn_in_actor` awaiting that same stream. Any mailbox send to - // `snapshot.session_id` here can fill the actor mailbox and then block - // forever on `send().await`, which stops the event stream from `recv`ing, - // fills the event channel, and wedges the whole turn. Prefer the in-flight - // turn inline state whenever it is registered. + // Prefer the in-flight turn inline state whenever it is registered so + // usage lands without a mailbox hop from the event stream task. let applied_inline = if let Some(stream) = self.active_stream_state(snapshot.session_id).await { let mut stream = stream.lock().await; @@ -561,10 +557,7 @@ impl ServerRuntime { }; if !applied_inline { // Child agent event streams publish usage onto the parent session. - // The parent actor may be inside `execute_turn_in_actor` (or in the - // brief window after it unregisters its active stream but before it - // resumes polling). Blocking `send().await` here can fill the parent - // mailbox and deadlock the child stream, so only try-send. + // Use try-send so a full mailbox cannot deadlock the child stream. if let Some(session_handle) = self.session(snapshot.session_id).await { let _ = session_handle.try_apply_parent_usage_snapshot(snapshot); } diff --git a/crates/server/src/runtime/turn_exec/finalize.rs b/crates/server/src/runtime/turn_exec/finalize.rs index e3879021..b6b47712 100644 --- a/crates/server/src/runtime/turn_exec/finalize.rs +++ b/crates/server/src/runtime/turn_exec/finalize.rs @@ -104,7 +104,8 @@ impl ServerRuntime { snapshot.session_totals.cache_creation_input_tokens; session_total_cache_read_tokens = snapshot.session_totals.cache_read_input_tokens; } - self.clear_active_turn_interrupt_handles(session_id).await; + // Leave ActiveTurnRegistry turn metadata until after MergeTurn so + // admission cannot race a free registry against an unmerged working set. match &result { Ok(()) => { self.run_session_hook_for_actor_state( diff --git a/crates/server/src/runtime/turn_exec/mod.rs b/crates/server/src/runtime/turn_exec/mod.rs index 1bede30d..7c90b5f1 100644 --- a/crates/server/src/runtime/turn_exec/mod.rs +++ b/crates/server/src/runtime/turn_exec/mod.rs @@ -21,11 +21,45 @@ pub(crate) use types::ExecuteTurnRequest; use std::sync::Arc; use anyhow::Context; +use devo_core::SessionId; use super::*; +/// Schedules queue drain / goal continuation after a turn merges. +/// +/// Must stay a sync function so callers' async opaque types do not recursively +/// include this spawn's future (rustc Send-cycle with `execute_turn`). +pub(crate) fn spawn_post_turn_scheduling( + runtime: Arc, + session_id: SessionId, + should_auto_continue_goal: bool, +) { + tokio::spawn(async move { + runtime + .maybe_schedule_final_title_generation(session_id, None) + .await; + if runtime.chain_queued_followup_turn(session_id).await { + return; + } + if runtime.spawn_next_turn_from_queue(session_id).await { + return; + } + if runtime.child_parent_and_path(session_id).await.is_some() + && runtime.child_can_accept_next_turn(session_id).await + { + let _ = runtime.drain_child_mailbox_into_user_turns(session_id).await; + return; + } + if should_auto_continue_goal { + runtime + .maybe_start_goal_continuation_turn(session_id) + .await; + } + }); +} + impl ServerRuntime { - /// Execute one turn end-to-end via the session actor. + /// Execute one turn on a spawned working copy; the session actor stays free. pub(in crate::runtime) async fn execute_turn(self: Arc, request: ExecuteTurnRequest) { let Some(handle) = self.session(request.session_id).await else { return; diff --git a/crates/server/src/runtime/turn_exec/query.rs b/crates/server/src/runtime/turn_exec/query.rs index c71ec0bf..ff05a0be 100644 --- a/crates/server/src/runtime/turn_exec/query.rs +++ b/crates/server/src/runtime/turn_exec/query.rs @@ -188,19 +188,16 @@ impl ServerRuntime { ..ToolExecutionOptions::default() }, ); - // Turns execute inline on the session actor's own task rather than as a - // separately spawned task, so an external `JoinHandle::abort()` can no - // longer stop an in-flight query: it only cancels the caller waiting on - // the actor's reply, not the actor itself. Race the query against the - // turn's cancellation token so interrupting a turn actually unblocks the - // actor's mailbox instead of hanging it forever. + // Turn I/O runs on the spawned active-turn task (not the session actor). + // Race the query against the turn cancel token so interrupt unblocks + // promptly even when the join handle has not been aborted yet. // // The stream-loop's biased select! checks the cancel token before every // chunk. On interrupt it breaks immediately, the post-processing stage // commits partial assistant/reasoning text to session, then the cancel // guard before tool execution skips incomplete tool calls and ends the // turn cleanly. The tool-execution cancel guard already handles the - // "cancel during tools" case (line ~1827). + // "cancel during tools" case. let result = { let provider = self.usage_ledger.instrumented_provider( runtime_context.provider_for_route(turn_config.provider_route.clone()), diff --git a/crates/server/src/runtime/turn_lifecycle.rs b/crates/server/src/runtime/turn_lifecycle.rs index e200da5c..61fd6bf4 100644 --- a/crates/server/src/runtime/turn_lifecycle.rs +++ b/crates/server/src/runtime/turn_lifecycle.rs @@ -46,13 +46,17 @@ impl ServerRuntime { self.active_turns.remove_abort_handle(session_id).await; } - /// Cancels and aborts the active turn for `session_id` without clearing the - /// full runtime handle (used while waiting for terminal status). + /// Cancels the active turn for `session_id` without clearing the full + /// runtime handle (used while waiting for terminal status). + /// + /// Does not abort the join handle: the turn task must run + /// `finalize_executed_turn` + `MergeTurn` after seeing the cancel token. + /// Callers that need hard abort after a timed-out wait use + /// `ActiveTurnRegistry::abort_task` on the orphan path. pub(crate) async fn signal_active_turn_interrupt(&self, session_id: SessionId) { if let Some(cancel_token) = self.active_turns.cancel_token(session_id).await { cancel_token.cancel(); } - self.active_turns.abort_task(session_id).await; } pub(crate) async fn spawn_active_turn_task( diff --git a/specs/L2/conv/L2-DES-CONV-002-two-plane-session-settings.md b/specs/L2/conv/L2-DES-CONV-002-two-plane-session-settings.md index 507fb637..d49a1dd6 100644 --- a/specs/L2/conv/L2-DES-CONV-002-two-plane-session-settings.md +++ b/specs/L2/conv/L2-DES-CONV-002-two-plane-session-settings.md @@ -33,16 +33,20 @@ This document does **not** cover: ## Current State (Audit Summary) -Settings writes today flow through the session actor mailbox, which is blocked for the entire duration of an active turn (`crates/server/src/runtime/session_actor/actor_loop.rs`, `ExecuteTurn` awaits `execute_turn_in_actor` inline). Consequences: +**Historical (pre L2-DES-SERVER-002):** settings writes and many reads flowed through the session actor mailbox while `ExecuteTurn` ran unbounded model/tool I/O inline on that same task. Mid-turn RPCs that awaited the mailbox appeared to hang for the turn duration. + +**Current (after L2-DES-SERVER-002):** turns check out a `TurnWorkingSet` and run on a spawned task; the actor mailbox stays short-command only. Settings writes use the persist-first path below. Remaining risks are incomplete control-plane coverage and merge races—not a blocked mailbox. - `session/metadata/update` is the unified settings write. It persists first, notifies the actor best-effort, and returns without waiting for an active turn to finish. -- Persistence is record-level and actor-dependent: the handler waits for the actor, then appends a full-record `SessionMeta` rollout line (`crates/server/src/runtime/handlers/session.rs:391`). The crash-loss window equals the turn duration. -- The same setting has up to five independently captured copies with no synchronization discipline: actor `state.config` / `state.core.config`, `TurnInlineState.hook_context.config` (turn-start snapshot; updated by approval grants but not by preset changes), the by-value `permission_mode` captured in `build_permission_checker` (`crates/server/src/runtime/turn_exec/query.rs:98`), the by-value `TurnConfig` in the core query loop, and `ToolRuntimeContext.sandbox_profile` (consumed per tool call at `crates/core/src/tools/router.rs:277`). -- The implicit, undocumented promise for every setting is: *blocks until turn end; effective next turn; persisted after actor processing.* - -Already aligned with the target model: queue (session plane, durable) vs steer (turn plane, ephemeral channel); the two-level session/turn approval caches; mid-turn approval grants applied directly to `TurnInlineState` (`crates/server/src/runtime/approval.rs:512`); per-turn cancellation tokens. +- Persistence uses field-level `InternalRecordV2::SessionSettings` lines (plus + legacy dual-write where still present); crash loss is bounded by the sync + append, not the turn duration. +- Live mid-turn effect rides `TurnInlineState` overlays (`sandbox_profile_live`, + `live_turn_settings`) per DD-5/DD-6. +- Queue (session plane, durable) vs steer (turn plane, ephemeral channel); + two-level session/turn approval caches; per-turn cancellation tokens. ## Design Decisions @@ -68,7 +72,7 @@ Promises of the call: **Decision**: the handler, holding the per-session `state_change_gate`, performs: (1) synchronous append of field-level settings lines to the rollout store; (2) best-effort mailbox notification to the actor (epoch-tagged) to refresh its cached copies, update summary/record, clear caches, and broadcast; (3) when a turn is active, delivery of the override to the turn control plane (DD-5). Success is returned after step (1). -Why the mailbox notification can be best-effort: mailbox FIFO guarantees the notification is processed before the next `ExecuteTurn`, so the next turn's baseline snapshot always includes the change; a crash is covered by the recovery path (DD-4). The actor never writes the live override channel; handlers do. +Why the mailbox notification can be best-effort: after L2-DES-SERVER-002 the actor mailbox stays short-command only, so `notify_*` is processed before the next turn's `CheckoutTurnWorkingSet` baseline snapshot; a crash is covered by the recovery path (DD-4). The actor never writes the live override channel; handlers do. ### DD-4: Field-level append-only settings log with epochs diff --git a/specs/L2/server/L2-DES-SERVER-002-session-actor-turn-isolation.md b/specs/L2/server/L2-DES-SERVER-002-session-actor-turn-isolation.md new file mode 100644 index 00000000..0fa74d36 --- /dev/null +++ b/specs/L2/server/L2-DES-SERVER-002-session-actor-turn-isolation.md @@ -0,0 +1,96 @@ +--- +artifact_id: L2-DES-SERVER-002 +revision: 1 +status: Draft +active_baseline: no +supersedes: +superseded_by: +owner: Assistant +last_updated: 2026-08-29 +--- + +# L2-DES-SERVER-002 — Session Actor / Turn Execution Isolation + +## Purpose + +Define the concurrency boundary between the per-session actor mailbox and turn execution so mailbox commands remain short, RPC handlers stay responsive during active turns, and durable session state has a single writer. + +## Source Requirements + +- `L1-REQ-APP-001` — shared server-side agent capability must stay usable while work runs. +- `L1-REQ-CONV-006` / `L2-DES-CONV-002` — settings writes must not wait on turn completion; live overrides ride the turn control plane. +- `L1-REQ-AGENT-002` / `L2-DES-AGENT-002` — interrupt must cancel in-flight work without hanging the session control surface. +- `L2-DES-AGENT-001` — execution engine owns model/tool I/O. + +## Problem + +Historically, `SessionCommand::ExecuteTurn` ran `query()` inline on the session actor task. The mailbox stopped draining for the full model+tool duration. Handlers that still awaited mailbox round-trips (`summary()`, `record()`, reservation snapshots) appeared hung; desktop clients hit their 10s RPC timeout. Compaction already used the correct pattern (spawned task + short mailbox commands); regular turns did not. + +## Design Decisions + +### DD-1: Actor mailbox commands are short + +Every `SessionCommand` must complete without awaiting unbounded I/O (provider streams, tool processes, client reverse-RPC). Allowed: in-memory mutation, short synchronous disk appends, cloning Arcs. Forbidden: `query()`, waiting on approval/user-input oneshots from inside the actor task, holding `state_change_gate` across those awaits. + +### DD-2: Turns execute on a spawned task with a working copy + +Turn admission (`BeginActiveTurn` / `TryBeginActiveTurn`) remains an actor command. Execution: + +1. Handler registers runtime handles via `spawn_active_turn_task`. +2. The turn task performs a short `CheckoutTurnWorkingSet` mailbox round-trip (install `TurnInlineState`, clone turn-owned state, share queue/stream Arcs). +3. The task runs model query + finalization against the working copy. +4. The task sends a short `MergeTurn` command; the actor is the only writer that installs durable conversation state. + +`MergeTurn` is the sole turn→session crossing for conversation ownership. + +### DD-3: Two planes (aligned with L2-DES-CONV-002) + +| Plane | Owner | Mid-turn writes | +|---|---|---| +| Session-durable | Actor + persist-first handlers | Settings/title via disk then `notify_*`; never blocked on turn I/O | +| Turn-ephemeral | Turn task + shared control plane | Cancel token, steer queue, pending queue mutex, `TurnInlineState` live overlays | + +Control-plane Arcs exist so decision points read the latest value with zero mailbox hops—not as a workaround for a blocked actor. + +### DD-4: `state_change_gate` never spans unbounded I/O + +Admission, rollback, message-edit, and compaction apply take the gate only for short critical sections (snapshot / commit). Title generation and compaction summarization must not hold the gate while awaiting the model. + +### DD-5: Interrupt is one path + +Cancel the turn token and wait for terminal status recorded by finalization/`MergeTurn`. Hard-abort the spawned task and claim leftover `active_turn` via the mailbox only as orphan recovery when the task dies without merging—same shape for regular turns and manual compaction. + +### DD-6: Merge must not clobber session-plane updates + +During a turn, persist-first settings may update actor `config` / summary via `notify_*` while the working copy still holds turn-start conversation state. `MergeTurn` installs turn-owned fields (messages, tokens, history/items produced by the turn, terminal turn metadata) and preserves actor-side session-plane config/settings that landed mid-turn. + +## Ownership Matrix + +**Turn-owned (working copy → MergeTurn):** `SessionState` conversation (`messages`, turn bookkeeping, token counters), turn-produced history/persisted items (via inline merge), terminal `latest_turn` / cleared `active_turn` as finalized by the turn task. + +**Actor-owned always:** mailbox command processing, idle-session structural edits, responding to short reads (`GetSummary`, `GetRecord`, …). + +**Shared control plane (Arc):** `pending_turn_queue`, `steer_input_queue`, `SessionStreamState` / `TurnInlineState`, cancel token in `ActiveTurnRegistry`. + +## Non-Goals + +- Changing Native `turn/start` to block until the turn ends. +- Putting pending-queue ops back onto a pure mailbox serial path. +- Sharing one locked `SessionState` across threads instead of checkout/merge. + +## Implementation Anchors + +- `crates/server/src/runtime/session_actor/` — mailbox, `TurnWorkingSet`, `MergeTurn` +- `crates/server/src/runtime/turn_exec/` — query + finalize on working set +- `crates/server/AGENTS.md` — concurrency rules of record +- `specs/L2/conv/L2-DES-CONV-002-two-plane-session-settings.md` — live settings overlay + +## Verification + +- Mid-turn `session/list`, `session/items/list`, `workspace/changes/read`, `runtime/ping` return within a short client-facing bound (well under desktop 10s timeout). +- Existing mid-turn settings, queue push/steer, and interrupt suites remain green. +- Parent mailbox remains responsive while a child turn publishes usage. + +## Revision Notes + +- Rev 1: Initial draft capturing actor/turn isolation after diagnosing mailbox blocking under inline `ExecuteTurn`. diff --git a/specs/traceability/l1_to_l2.md b/specs/traceability/l1_to_l2.md index 67157b49..25e7411d 100644 --- a/specs/traceability/l1_to_l2.md +++ b/specs/traceability/l1_to_l2.md @@ -196,5 +196,7 @@ | L1-REQ-TUI-010 | specs/L1/L1-REQ-TUI-010-onboarding-ui.md | L2-DES-APP-002 | specs/L2/app/L2-DES-APP-002-configuration-precedence.md | related-to | The configuration precedence design defines the persistence target for successful TUI onboarding results. | | L1-REQ-TUI-010 | specs/L1/L1-REQ-TUI-010-onboarding-ui.md | L2-DES-APP-005 | specs/L2/app/L2-DES-APP-005-config-toml-schema.md | related-to | The config and auth schema defines the persisted fields and credentials produced by successful TUI onboarding. | | L1-REQ-CONV-006 | specs/L1/L1-REQ-CONV-006-live-session-settings-update.md | L2-DES-CONV-002 | specs/L2/conv/L2-DES-CONV-002-two-plane-session-settings.md | refined-by | The two-plane session settings design refines the live settings update requirement into an architecture, API contract, and per-setting promise matrix. | +| L1-REQ-CONV-006 | specs/L1/L1-REQ-CONV-006-live-session-settings-update.md | L2-DES-SERVER-002 | specs/L2/server/L2-DES-SERVER-002-session-actor-turn-isolation.md | related-to | Actor/turn isolation keeps the mailbox free so settings writes never wait on turn I/O. | +| L1-REQ-APP-001 | specs/L1/L1-REQ-APP-001-client-server-arch.md | L2-DES-SERVER-002 | specs/L2/server/L2-DES-SERVER-002-session-actor-turn-isolation.md | related-to | Session actor isolation keeps the shared runtime responsive to concurrent client RPCs during active turns. | | L1-REQ-APP-001 | specs/L1/L1-REQ-APP-001-client-server-arch.md | L2-DES-APP-008 | specs/L2/app/L2-DES-APP-008-protocol-unification.md | refined-by | Protocol unification refines the client-server architecture into a single canonical surface with edge adapters (ACP, future A2A). | | L1-REQ-APP-001 | specs/L1/L1-REQ-APP-001-client-server-arch.md | L2-DES-APP-009 | specs/L2/app/L2-DES-APP-009-event-stream-cutover.md | refined-by | The event stream cutover design refines the client-server architecture onto canonical typed events. | diff --git a/specs/traceability/verification.md b/specs/traceability/verification.md index 32d5859e..41c895f0 100644 --- a/specs/traceability/verification.md +++ b/specs/traceability/verification.md @@ -116,6 +116,7 @@ | runtime::connection::tests::canonical_metadata_update_persists_preset_and_returns_updated_session | Integration | crates/server/src/runtime/connection.rs | L2-DES-CONV-002 | 1 | L1-REQ-CONV-006 | Verifies canonical session/metadata/update persists a field-level line and returns the rollout-built session (persist-first). | | runtime::connection::tests::canonical_metadata_update_rejects_stale_expected_version | Integration | crates/server/src/runtime/connection.rs | L2-DES-CONV-002 | 1 | L1-REQ-CONV-006 | Verifies stale expectedVersion is rejected with WORKSPACE_VERSION_CONFLICT. | | runtime::connection::tests::canonical_metadata_update_returns_during_active_turn | Integration | crates/server/src/runtime/connection.rs | L2-DES-CONV-002 | 1 | L1-REQ-CONV-006 | Verifies the canonical settings update returns while a turn is in flight (no actor wait). | +| runtime::connection::tests::mid_turn_list_items_workspace_and_ping_respond_promptly | Integration | crates/server/src/runtime/connection.rs | L2-DES-SERVER-002 | 1 | L1-REQ-APP-001 | Verifies session/list, session/items/list, workspace/changes/read, and runtime/ping return within 500ms while a turn stream is gated open. | | conversation::history::tests::session_settings_lines_fold_into_canonical_session | Unit | crates/core/src/conversation/history.rs | L2-DES-CONV-002 | 1 | L1-REQ-CONV-006 | Verifies settings lines fold into the canonical session snapshot and bump its version. | | protocol_contract::canonical_session_metadata_update_params_roundtrip | Unit | crates/server/tests/protocol_contract.rs | L2-DES-APP-008 | 1 | L1-REQ-APP-001 | Pins the canonical session/metadata/update wire shape (SessionSettings patch + expectedVersion). | | runtime::connection::tests::canonical_metadata_update_applies_overlay_to_active_turn | Integration | crates/server/src/runtime/connection.rs | L2-DES-CONV-002 | 1 | L1-REQ-CONV-006 | Verifies a mid-turn settings update is delivered to the turn-inline override (profile + live sandbox handle) and reports appliedToActiveTurn (Phase 3). | From ff7f98514fe4374991e77e80b83564097a9425c9 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 29 Aug 2026 18:01:22 +0800 Subject: [PATCH 3/8] Replace the desktop stats button with a context occupancy ring. Show window fill and prompt-category shares from Native occupancy, matching TUI /status. Co-authored-by: Cursor --- .../src/v2/client-native-interactions.test.ts | 58 +++++++ .../packages/devo-ai-sdk/src/v2/client.ts | 75 ++++++++- .../renderer/atoms/actions/event-processor.ts | 11 ++ .../src/renderer/atoms/session-native.test.ts | 24 ++- .../src/renderer/atoms/session-native.ts | 2 + .../src/renderer/components/agent-detail.tsx | 4 +- .../components/context-usage-button.tsx | 154 ++++++++++++++++++ .../components/session-metrics-bar.test.ts | 32 +++- .../components/session-metrics-bar.tsx | 5 +- .../renderer/lib/context-occupancy.test.ts | 94 +++++++++++ .../src/renderer/lib/context-occupancy.ts | 90 ++++++++++ .../renderer/services/connection-manager.ts | 2 + 12 files changed, 539 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/renderer/components/context-usage-button.tsx create mode 100644 apps/desktop/src/renderer/lib/context-occupancy.test.ts create mode 100644 apps/desktop/src/renderer/lib/context-occupancy.ts diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts index 6ec9562e..e9efe1d5 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts @@ -46,6 +46,15 @@ class FakeNativeTransport implements DevoNativeTransport { return { views: [nativeWorkspaceView] } case "turn/start": return { turn: nativeTurnInProgress } + case "session/resume": + return { + session: nativeSession, + lastContextOccupancy: nativeOccupancy, + } + case "session/items/list": + return { data: [], nextCursor: null } + case "context/usage/read": + return { occupancy: nativeOccupancy } default: throw new Error(`unexpected request ${method}`) } @@ -145,6 +154,18 @@ const nativeTurnCompleted = { completedAt: "2026-08-24T00:00:08Z", } +const nativeOccupancy = { + totalTokens: 100_000, + contextWindowTokens: 200_000, + categories: [ + { id: "base", tokens: 10_000, shareBps: 1000 }, + { id: "skills", tokens: 5_000, shareBps: 500 }, + { id: "toolsBuiltin", tokens: 20_000, shareBps: 2000 }, + { id: "toolsMcp", tokens: 15_000, shareBps: 1500 }, + { id: "conversation", tokens: 50_000, shareBps: 5000 }, + ], +} + const approvalItem = { type: "approval", approvalId: "approval-1", @@ -648,4 +669,41 @@ describe("Native desktop SDK interactions", () => { expect((await client.session.status()).data["session-1"]).toEqual({ type: "busy" }) }) + + test("projects context occupancy from context/usageUpdated", async () => { + const transport = new FakeNativeTransport() + const client = createDevoClient({ directory: "/repo", transport }) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + + transport.emit({ + type: "notification", + method: "context/usageUpdated", + params: { sessionId: nativeSession.id, occupancy: nativeOccupancy }, + }) + + expect(await nextPayloadOfType(stream, "context.usage.updated")).toEqual({ + type: "context.usage.updated", + properties: { + sessionID: nativeSession.id, + occupancy: nativeOccupancy, + }, + }) + }) + + test("reads context occupancy through context/usage/read", async () => { + const transport = new FakeNativeTransport() + const client = createDevoClient({ directory: "/repo", transport }) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + + const result = await client.context.usage.read({ sessionID: nativeSession.id }) + expect(result.data).toEqual(nativeOccupancy) + expect(transport.requests.some((request) => request.method === "context/usage/read")).toBe(true) + expect(await nextPayloadOfType(stream, "context.usage.updated")).toEqual({ + type: "context.usage.updated", + properties: { + sessionID: nativeSession.id, + occupancy: nativeOccupancy, + }, + }) + }) }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index 87ec2f09..822c9a74 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -337,6 +337,36 @@ function numberFromProtocol(value: unknown): number { return 0 } +type ContextOccupancyWire = { + totalTokens: number + contextWindowTokens: number + categories: Array<{ id: string; tokens: number; shareBps: number }> +} + +function contextOccupancyFromProtocol(value: unknown): ContextOccupancyWire | null { + const occupancy = objectRecord(value) + if (!occupancy) return null + const rawCategories = Array.isArray(occupancy.categories) ? occupancy.categories : [] + return { + totalTokens: numberFromProtocol(occupancy.totalTokens ?? occupancy.total_tokens), + contextWindowTokens: numberFromProtocol( + occupancy.contextWindowTokens ?? occupancy.context_window_tokens, + ), + categories: rawCategories.flatMap((entry) => { + const category = objectRecord(entry) + const id = String(category?.id ?? "") + if (!id) return [] + return [ + { + id, + tokens: numberFromProtocol(category?.tokens), + shareBps: numberFromProtocol(category?.shareBps ?? category?.share_bps), + }, + ] + }), + } +} + function workspaceChangeStats(value: unknown): WorkspaceChangeStats { const stats = objectRecord(value) return { @@ -1208,6 +1238,18 @@ class NativeClient { }, } + context = { + usage: { + read: async (params: { sessionID: string }) => { + const result = (await this.requestCanonical("context/usage/read", { + sessionId: params.sessionID, + })) as { occupancy?: unknown } + this.emitContextUsage(params.sessionID, result.occupancy) + return { data: result.occupancy } + }, + }, + } + mcp = { list: async () => { const result = (await this.requestCanonical("mcp/list", {})) as { servers?: unknown[] } @@ -1383,8 +1425,12 @@ class NativeClient { if (!cwd) throw new Error(`session ${sessionId} not found`) const resumed = (await this.requestCanonical("session/resume", { sessionId, - })) as { session: Record } + })) as { session: Record; lastContextOccupancy?: unknown; last_context_occupancy?: unknown } this.rememberNativeSession(resumed.session) + await this.hydrateContextOccupancy( + sessionId, + resumed.lastContextOccupancy ?? resumed.last_context_occupancy, + ) await this.ensureSessionSubscription(sessionId) let cursor: string | undefined do { @@ -1794,6 +1840,11 @@ class NativeClient { } return true } + if (method === "context/usageUpdated") { + const sessionId = String(value.sessionId ?? "") + if (sessionId) this.emitContextUsage(sessionId, value.occupancy) + return true + } if (method === "turn/usage/updated" || method === "session/usage/updated") { const sessionId = String(value.sessionId ?? "") const usage = objectRecord(value.usage) ?? {} @@ -2936,6 +2987,28 @@ class NativeClient { return this.currentConfigOptions() } + private async hydrateContextOccupancy(sessionId: string, occupancy: unknown): Promise { + if (this.emitContextUsage(sessionId, occupancy)) return + try { + const result = (await this.requestCanonical("context/usage/read", { + sessionId, + })) as { occupancy?: unknown } + this.emitContextUsage(sessionId, result.occupancy) + } catch { + // Occupancy is optional chrome; live context/usageUpdated still hydrates later. + } + } + + private emitContextUsage(sessionId: string, occupancyValue: unknown): boolean { + const occupancy = contextOccupancyFromProtocol(occupancyValue) + if (!occupancy) return false + this.emit(this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd(), { + type: "context.usage.updated", + properties: { sessionID: sessionId, occupancy }, + }) + return true + } + private emit(directory: string, payload: Event): void { this.events.push({ directory, payload }) } diff --git a/apps/desktop/src/renderer/atoms/actions/event-processor.ts b/apps/desktop/src/renderer/atoms/actions/event-processor.ts index 8dccaccf..e670dee8 100644 --- a/apps/desktop/src/renderer/atoms/actions/event-processor.ts +++ b/apps/desktop/src/renderer/atoms/actions/event-processor.ts @@ -310,6 +310,17 @@ export function processEvent(event: Event): void { break } + case "context.usage.updated": { + const sessionID = event.properties.sessionID + if (!sessionID) break + const current = appStore.get(sessionNativeFamily(sessionID)) + set(sessionNativeFamily(sessionID), { + ...current, + occupancy: event.properties.occupancy, + }) + break + } + case "session.diff": { const { sessionID, diff } = event.properties as { sessionID: string diff --git a/apps/desktop/src/renderer/atoms/session-native.test.ts b/apps/desktop/src/renderer/atoms/session-native.test.ts index ea7f4d06..435ff2c3 100644 --- a/apps/desktop/src/renderer/atoms/session-native.test.ts +++ b/apps/desktop/src/renderer/atoms/session-native.test.ts @@ -29,7 +29,7 @@ describe("Native session renderer state", () => { expect(appStore.get(sessionFamily(sessionID))?.permissions).toEqual([event.properties]) }) - test("stores command, config, mode, and usage updates from events", () => { + test("stores command, config, mode, usage, and occupancy updates from events", () => { const sessionID = "session-native-state" processEvent({ @@ -62,6 +62,20 @@ describe("Native session renderer state", () => { cost: { amount: 1, currency: "USD" }, }, }) + processEvent({ + type: "context.usage.updated", + properties: { + sessionID, + occupancy: { + totalTokens: 48_000, + contextWindowTokens: 190_000, + categories: [ + { id: "base", tokens: 8_000, shareBps: 1667 }, + { id: "conversation", tokens: 40_000, shareBps: 8333 }, + ], + }, + }, + }) expect(appStore.get(sessionNativeFamily(sessionID))).toEqual({ commands: [{ name: "compact", description: "Compact session" }], @@ -72,6 +86,14 @@ describe("Native session renderer state", () => { size: 100, cost: { amount: 1, currency: "USD" }, }, + occupancy: { + totalTokens: 48_000, + contextWindowTokens: 190_000, + categories: [ + { id: "base", tokens: 8_000, shareBps: 1667 }, + { id: "conversation", tokens: 40_000, shareBps: 8333 }, + ], + }, }) }) diff --git a/apps/desktop/src/renderer/atoms/session-native.ts b/apps/desktop/src/renderer/atoms/session-native.ts index 6b0f9273..9c068e00 100644 --- a/apps/desktop/src/renderer/atoms/session-native.ts +++ b/apps/desktop/src/renderer/atoms/session-native.ts @@ -1,5 +1,6 @@ import { atom } from "jotai" import { atomFamily } from "jotai-family" +import type { ContextOccupancy } from "../lib/context-occupancy" export interface SessionNativeState { commands: unknown[] @@ -10,6 +11,7 @@ export interface SessionNativeState { size: unknown cost?: unknown } + occupancy?: ContextOccupancy } export const sessionNativeFamily = atomFamily((_sessionId: string) => diff --git a/apps/desktop/src/renderer/components/agent-detail.tsx b/apps/desktop/src/renderer/components/agent-detail.tsx index 579cf828..dc5c270e 100644 --- a/apps/desktop/src/renderer/components/agent-detail.tsx +++ b/apps/desktop/src/renderer/components/agent-detail.tsx @@ -44,9 +44,9 @@ import { setOpenInPreferred, } from "../services/backend" import { ChatView } from "./chat" +import { ContextUsageButton } from "./context-usage-button" import { BottomPanelIcon, RightPanelIcon } from "./panel-icons" import { ReviewPanel } from "./review/review-panel" -import { SessionMetricsOverviewButton } from "./session-metrics-bar" import { WorktreeActions } from "./worktree-actions" function useTurnWorkspaceChangeStats(sessionId: string): { @@ -416,7 +416,7 @@ function SessionPanelHeader({
- + = { + base: "bg-muted-foreground/45", + skills: "bg-chart-1", + toolsBuiltin: "bg-chart-2", + toolsMcp: "bg-chart-4", + conversation: "bg-chart-3", +} + +interface ContextUsageButtonProps { + sessionId: string + directory?: string +} + +export function ContextUsageButton({ sessionId, directory }: ContextUsageButtonProps) { + const native = useAtomValue(sessionNativeFamily(sessionId)) + const occupancy = native.occupancy + const used = occupancy?.totalTokens ?? Number(native.usage?.used ?? 0) + const windowTokens = occupancy?.contextWindowTokens ?? Number(native.usage?.size ?? 0) + const percent = windowFillPercent(used, windowTokens) + const rows = useMemo(() => occupancyCategoryRows(occupancy), [occupancy]) + const filledRows = rows.filter((row) => row.tokens > 0) + const strokeClass = + percent >= 90 ? "text-red-400" : percent >= 70 ? "text-yellow-400" : "text-muted-foreground" + + useEffect(() => { + if (occupancy) return + const client = (directory ? getProjectClient(directory) : null) ?? getBaseClient() + if (!client?.context?.usage?.read) return + void client.context.usage.read({ sessionID: sessionId }).catch(() => {}) + }, [directory, occupancy, sessionId]) + + const size = 14 + const strokeWidth = 2.5 + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + const offset = circumference - (Math.min(percent, 100) / 100) * circumference + + return ( + + + } + > + + + +
+
+

Context usage

+
+ + {formatTokens(used)} / {formatTokens(windowTokens)} + + {percent}% +
+
+ {filledRows.length > 0 + ? filledRows.map((row) => ( +
0 ? (row.tokens / windowTokens) * 100 : 0}%`, + }} + /> + )) + : percent > 0 && ( +
+ )} +
+
+ +
+

Prompt breakdown

+
+ {rows.map((row) => ( +
+ + + + {formatTokens(row.tokens)} + {row.sharePercent}% + +
+ ))} +
+
+
+ + + ) +} diff --git a/apps/desktop/src/renderer/components/session-metrics-bar.test.ts b/apps/desktop/src/renderer/components/session-metrics-bar.test.ts index b6266eb5..a3ee3e26 100644 --- a/apps/desktop/src/renderer/components/session-metrics-bar.test.ts +++ b/apps/desktop/src/renderer/components/session-metrics-bar.test.ts @@ -12,20 +12,20 @@ describe("SessionMetricsBar top timer wiring", () => { usesLatestTurnTimer: source.includes("computeLatestTurnTimerSplit(turns"), omitsCompletedSessionWorkTime: !source.includes("completedMs={metrics.completedWorkTimeMs}"), exportsOverviewButton: source.includes("export function SessionMetricsOverviewButton"), - headerUsesOverviewButton: agentDetailSource.includes(" { const openInIndex = agentDetailSource.indexOf(" { exposesTerminalButton: agentDetailSource.includes("function TerminalToggleButton"), rightControlOrder: openInIndex !== -1 && - overviewIndex !== -1 && + contextUsageIndex !== -1 && terminalIndex !== -1 && changesIndex !== -1 && - openInIndex < overviewIndex && - overviewIndex < terminalIndex && + openInIndex < contextUsageIndex && + contextUsageIndex < terminalIndex && terminalIndex < changesIndex, }).toEqual({ keepsOpenInButton: true, @@ -53,6 +53,26 @@ describe("SessionMetricsBar top timer wiring", () => { }) }) + test("session header context usage button shows occupancy breakdown", () => { + const contextUsageSource = readFileSync( + new URL("./context-usage-button.tsx", import.meta.url), + "utf8", + ) + expect({ + headerUsesContextUsageButton: agentDetailSource.includes(" { expect({ usesBottomPanelIcon: agentDetailSource.includes("BottomPanelIcon"), diff --git a/apps/desktop/src/renderer/components/session-metrics-bar.tsx b/apps/desktop/src/renderer/components/session-metrics-bar.tsx index 78101a92..77a01607 100644 --- a/apps/desktop/src/renderer/components/session-metrics-bar.tsx +++ b/apps/desktop/src/renderer/components/session-metrics-bar.tsx @@ -5,8 +5,9 @@ * Popover: full token breakdown, exchanges, model distribution, * tool calls, and cache efficiency. * - * Context window usage is displayed separately in the StatusBar below - * the chat input (see prompt-toolbar.tsx). + * Context window occupancy is the circular progress control in the session + * header (see context-usage-button.tsx). The composer status bar also shows + * a compact usage indicator (see prompt-toolbar.tsx). */ import { Popover, PopoverContent, PopoverTrigger } from "@devo/ui/components/popover" import { Tooltip, TooltipContent, TooltipTrigger } from "@devo/ui/components/tooltip" diff --git a/apps/desktop/src/renderer/lib/context-occupancy.test.ts b/apps/desktop/src/renderer/lib/context-occupancy.test.ts new file mode 100644 index 00000000..a73ade50 --- /dev/null +++ b/apps/desktop/src/renderer/lib/context-occupancy.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test" +import { + occupancyCategoryRows, + occupancyWindowPercent, + type ContextOccupancy, +} from "./context-occupancy" + +const sampleOccupancy: ContextOccupancy = { + totalTokens: 100_000, + contextWindowTokens: 200_000, + categories: [ + { id: "base", tokens: 10_000, shareBps: 1000 }, + { id: "skills", tokens: 5_000, shareBps: 500 }, + { id: "toolsBuiltin", tokens: 20_000, shareBps: 2000 }, + { id: "toolsMcp", tokens: 15_000, shareBps: 1500 }, + { id: "conversation", tokens: 50_000, shareBps: 5000 }, + ], +} + +describe("occupancyWindowPercent", () => { + test("returns rounded window fill and clamps missing data to zero", () => { + expect({ + halfFull: occupancyWindowPercent(sampleOccupancy), + empty: occupancyWindowPercent(null), + zeroWindow: occupancyWindowPercent({ + totalTokens: 10, + contextWindowTokens: 0, + categories: [], + }), + }).toEqual({ + halfFull: 50, + empty: 0, + zeroWindow: 0, + }) + }) +}) + +describe("occupancyCategoryRows", () => { + test("keeps TUI /status order and fills missing categories with zeros", () => { + expect( + occupancyCategoryRows({ + totalTokens: 10_000, + contextWindowTokens: 100_000, + categories: [{ id: "conversation", tokens: 10_000, shareBps: 10_000 }], + }), + ).toEqual([ + { id: "base", label: "Base", tokens: 0, shareBps: 0, sharePercent: 0 }, + { id: "skills", label: "Skills", tokens: 0, shareBps: 0, sharePercent: 0 }, + { + id: "toolsBuiltin", + label: "Tools (builtin)", + tokens: 0, + shareBps: 0, + sharePercent: 0, + }, + { id: "toolsMcp", label: "Tools (MCP)", tokens: 0, shareBps: 0, sharePercent: 0 }, + { + id: "conversation", + label: "Conversation", + tokens: 10_000, + shareBps: 10_000, + sharePercent: 100, + }, + ]) + }) + + test("maps populated occupancy shares the same way as TUI /status", () => { + expect(occupancyCategoryRows(sampleOccupancy)).toEqual([ + { id: "base", label: "Base", tokens: 10_000, shareBps: 1000, sharePercent: 10 }, + { id: "skills", label: "Skills", tokens: 5_000, shareBps: 500, sharePercent: 5 }, + { + id: "toolsBuiltin", + label: "Tools (builtin)", + tokens: 20_000, + shareBps: 2000, + sharePercent: 20, + }, + { + id: "toolsMcp", + label: "Tools (MCP)", + tokens: 15_000, + shareBps: 1500, + sharePercent: 15, + }, + { + id: "conversation", + label: "Conversation", + tokens: 50_000, + shareBps: 5000, + sharePercent: 50, + }, + ]) + }) +}) diff --git a/apps/desktop/src/renderer/lib/context-occupancy.ts b/apps/desktop/src/renderer/lib/context-occupancy.ts new file mode 100644 index 00000000..0f08d42b --- /dev/null +++ b/apps/desktop/src/renderer/lib/context-occupancy.ts @@ -0,0 +1,90 @@ +/** + * Context-window occupancy (what fills the model window), matching + * Native `ContextOccupancy` and the TUI `/status` category breakdown. + */ + +export const CONTEXT_CATEGORY_IDS = [ + "base", + "skills", + "toolsBuiltin", + "toolsMcp", + "conversation", +] as const + +export type ContextCategoryId = (typeof CONTEXT_CATEGORY_IDS)[number] + +export interface ContextCategoryUsage { + id: ContextCategoryId + tokens: number + /** Share of occupancy in basis points (0..=10_000). */ + shareBps: number +} + +export interface ContextOccupancy { + totalTokens: number + contextWindowTokens: number + categories: ContextCategoryUsage[] +} + +export interface ContextCategoryRow { + id: ContextCategoryId + label: string + tokens: number + shareBps: number + /** Occupancy share as a 0–100 integer, matching TUI `/status`. */ + sharePercent: number +} + +const CATEGORY_LABELS: Record = { + base: "Base", + skills: "Skills", + toolsBuiltin: "Tools (builtin)", + toolsMcp: "Tools (MCP)", + conversation: "Conversation", +} + +const CATEGORY_IDS = new Set(CONTEXT_CATEGORY_IDS) + +export function isContextCategoryId(value: string): value is ContextCategoryId { + return CATEGORY_IDS.has(value) +} + +export function contextCategoryLabel(id: ContextCategoryId): string { + return CATEGORY_LABELS[id] +} + +/** Window fill 0–100 from occupancy tokens vs effective window. */ +export function occupancyWindowPercent(occupancy: ContextOccupancy | null | undefined): number { + if (!occupancy) return 0 + return windowFillPercent(occupancy.totalTokens, occupancy.contextWindowTokens) +} + +export function windowFillPercent(used: number, window: number): number { + if (window <= 0) return 0 + return Math.max(0, Math.min(100, Math.round((used / window) * 100))) +} + +/** + * Stable TUI `/status` category order, filling missing buckets with zeros. + */ +export function occupancyCategoryRows( + occupancy: ContextOccupancy | null | undefined, +): ContextCategoryRow[] { + const byId = new Map() + for (const category of occupancy?.categories ?? []) { + if (!isContextCategoryId(category.id)) continue + byId.set(category.id, category) + } + return CONTEXT_CATEGORY_IDS.map((id) => { + const category = byId.get(id) + const tokens = category?.tokens ?? 0 + const shareBps = category?.shareBps ?? 0 + return { + id, + label: CATEGORY_LABELS[id], + tokens, + shareBps, + sharePercent: Math.floor(shareBps / 100), + } + }) +} diff --git a/apps/desktop/src/renderer/services/connection-manager.ts b/apps/desktop/src/renderer/services/connection-manager.ts index 7e440f57..864ef5ae 100644 --- a/apps/desktop/src/renderer/services/connection-manager.ts +++ b/apps/desktop/src/renderer/services/connection-manager.ts @@ -616,6 +616,8 @@ function coalescingKey(event: Event): string | undefined { return `part:${event.properties.messageID}:${event.properties.partID}` case "session.status": return `status:${event.properties.sessionID}` + case "context.usage.updated": + return `context-usage:${event.properties.sessionID}` default: return undefined } From 10d79bba1c05c60a2fd809096300bff9348cf7ab Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 29 Aug 2026 18:41:22 +0800 Subject: [PATCH 4/8] Polish desktop chat typography, sidebar resize, and process timeline rhythm. Wire Inter/IBM Plex into the theme, tighten markdown reading size and CJK-safe bold weights, add a drag-resizable sidebar, and fix uneven Thought/tool row spacing from collapsible margins. Co-authored-by: Cursor --- .../packages/devo-ai-sdk/src/v2/client.ts | 14 +- .../src/components/ai-elements/code-block.tsx | 9 +- .../ui/src/components/ai-elements/message.tsx | 8 +- .../packages/ui/src/styles/globals.css | 4 +- .../desktop/src/renderer/atoms/preferences.ts | 17 ++ .../src/renderer/components/agent-detail.tsx | 2 +- .../components/chat/chat-tool-call.test.ts | 2 + .../components/chat/chat-tool-call.tsx | 53 +++--- .../components/chat/chat-turn.test.ts | 16 ++ .../renderer/components/chat/chat-turn.tsx | 4 +- .../chat/message-response-style.test.ts | 38 ++++- .../components/chat/process-timeline-view.tsx | 5 +- .../renderer/components/chat/thought-row.tsx | 8 +- .../components/chat/transcript-disclosure.tsx | 24 ++- .../components/context-usage-button.tsx | 13 +- .../components/session-metrics-bar.test.ts | 4 +- .../components/sidebar-layout.test.ts | 19 +++ .../renderer/components/sidebar-layout.tsx | 34 +++- .../sidebar/sidebar-resize-handle.test.ts | 29 ++++ .../sidebar/sidebar-resize-handle.tsx | 111 +++++++++++++ apps/desktop/src/renderer/desktop-chrome.css | 6 + apps/desktop/src/renderer/index.css | 153 +++++++++++++++++- apps/desktop/src/renderer/index.html | 2 +- .../desktop/src/renderer/lib/sidebar-width.ts | 19 +++ crates/server/src/titles.rs | 4 +- 25 files changed, 521 insertions(+), 77 deletions(-) create mode 100644 apps/desktop/src/renderer/components/sidebar/sidebar-resize-handle.test.ts create mode 100644 apps/desktop/src/renderer/components/sidebar/sidebar-resize-handle.tsx create mode 100644 apps/desktop/src/renderer/lib/sidebar-width.ts diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index 822c9a74..f3acd727 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -1427,7 +1427,7 @@ class NativeClient { sessionId, })) as { session: Record; lastContextOccupancy?: unknown; last_context_occupancy?: unknown } this.rememberNativeSession(resumed.session) - await this.hydrateContextOccupancy( + this.emitContextUsage( sessionId, resumed.lastContextOccupancy ?? resumed.last_context_occupancy, ) @@ -2987,18 +2987,6 @@ class NativeClient { return this.currentConfigOptions() } - private async hydrateContextOccupancy(sessionId: string, occupancy: unknown): Promise { - if (this.emitContextUsage(sessionId, occupancy)) return - try { - const result = (await this.requestCanonical("context/usage/read", { - sessionId, - })) as { occupancy?: unknown } - this.emitContextUsage(sessionId, result.occupancy) - } catch { - // Occupancy is optional chrome; live context/usageUpdated still hydrates later. - } - } - private emitContextUsage(sessionId: string, occupancyValue: unknown): boolean { const occupancy = contextOccupancyFromProtocol(occupancyValue) if (!occupancy) return false diff --git a/apps/desktop/packages/ui/src/components/ai-elements/code-block.tsx b/apps/desktop/packages/ui/src/components/ai-elements/code-block.tsx index 2099f7e5..29bbabdf 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/code-block.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/code-block.tsx @@ -84,6 +84,10 @@ const LineSpan = ({ {keyedLine.tokens.length === 0 ? "\n" : keyedLine.tokens.map(({ token, key }) => )} + {/* Always terminate non-empty lines with a real newline. Relying only on + display:block inside an inline is fragile under
 and can
+		    collapse multi-line Read output into a single visual line. */}
+		{keyedLine.tokens.length > 0 ? "\n" : null}
 	
 )
 
@@ -275,7 +279,10 @@ const CodeBlockBody = memo(
 				style={preStyle}
 			>
 				
 					{keyedLines.map((keyedLine) => (
diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx
index 04a54161..49310f89 100644
--- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx
+++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx
@@ -40,8 +40,8 @@ export const MessageContent = ({ children, className, ...props }: MessageContent
 	
@@ -324,7 +324,7 @@ export const MessageResponse = memo( ({ className, ...props }: MessageResponseProps) => ( *:first-child]:mt-0 [&>*:last-child]:mb-0", + "devo-message-response size-full font-sans [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className, )} components={transcriptMarkdownComponents} diff --git a/apps/desktop/packages/ui/src/styles/globals.css b/apps/desktop/packages/ui/src/styles/globals.css index 27667105..9d52bd2b 100644 --- a/apps/desktop/packages/ui/src/styles/globals.css +++ b/apps/desktop/packages/ui/src/styles/globals.css @@ -48,8 +48,8 @@ --color-diff-addition-foreground: var(--diff-addition-foreground); --color-diff-deletion: var(--diff-deletion); --color-diff-deletion-foreground: var(--diff-deletion-foreground); - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - --font-mono: ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --font-sans: "Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; --text-xs: 0.8125rem; --text-xs--line-height: 1.125rem; --text-sm: 0.9375rem; diff --git a/apps/desktop/src/renderer/atoms/preferences.ts b/apps/desktop/src/renderer/atoms/preferences.ts index bd93dc6e..e3ebb8f4 100644 --- a/apps/desktop/src/renderer/atoms/preferences.ts +++ b/apps/desktop/src/renderer/atoms/preferences.ts @@ -3,6 +3,14 @@ import { atomWithStorage } from "jotai/utils" import type { DisplayMode, WindowChromeTier } from "../../preload/api" import { DEFAULT_APPEARANCE_SETTINGS } from "../../shared/app-settings" import type { ColorScheme } from "../lib/themes" +import { SIDEBAR_DEFAULT_WIDTH_PX } from "../lib/sidebar-width" + +export { + clampSidebarWidth, + SIDEBAR_DEFAULT_WIDTH_PX, + SIDEBAR_MAX_WIDTH_PX, + SIDEBAR_MIN_WIDTH_PX, +} from "../lib/sidebar-width" // ============================================================ // Types @@ -114,6 +122,15 @@ export const lastProjectDirectoryAtom = atomWithStorage( null, ) +/** + * User-resized app sidebar width in pixels. + * Applied as `--sidebar-width` on the sidebar wrapper. + */ +export const sidebarWidthAtom = atomWithStorage( + "devo:sidebarWidth", + SIDEBAR_DEFAULT_WIDTH_PX, +) + /** * Whether the user has dismissed the automations permissions info banner. * Once dismissed, the banner never reappears. diff --git a/apps/desktop/src/renderer/components/agent-detail.tsx b/apps/desktop/src/renderer/components/agent-detail.tsx index dc5c270e..5eb77980 100644 --- a/apps/desktop/src/renderer/components/agent-detail.tsx +++ b/apps/desktop/src/renderer/components/agent-detail.tsx @@ -416,7 +416,7 @@ function SessionPanelHeader({
- + { preRule: rendererCssSource.includes(".devo-read-output pre"), codeRule: rendererCssSource.includes(".devo-read-output code"), lineHeight: rendererCssSource.includes("line-height: 1.35"), + preservesWhitespace: rendererCssSource.includes("white-space: pre"), }).toEqual({ readClass: true, preRule: true, codeRule: true, lineHeight: true, + preservesWhitespace: true, }) }) }) diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx index 67c4e629..3608792c 100644 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx @@ -643,34 +643,40 @@ function ReadContent({ part }: { part: ToolPart }) { ) } -/** Search tools (glob/grep/list): shows pattern + results */ +/** Search tools (glob/grep/list): shows results; pattern stays in the row subtitle. */ function SearchContent({ part }: { part: ToolPart }) { const pattern = (part.state.input?.pattern as string) ?? undefined - const include = (part.state.input?.include as string) ?? undefined + const include = (part.state.input?.include as string) ?? (part.state.input?.glob as string) ?? undefined const path = (part.state.input?.path as string) ?? undefined const output = part.state.status === "completed" ? part.state.output : undefined + // Grep/Glob already put `pattern` in the tool-row subtitle ("Grep · …"). + const patternInSubtitle = part.tool === "grep" || part.tool === "glob" + const showPattern = Boolean(pattern) && !patternInSubtitle + const hasMeta = showPattern || Boolean(include) || Boolean(path) return (
-
- {pattern && ( - - pattern: {pattern} - - )} - {include && ( - - include: {include} - - )} - {path && ( - - in: {shortenPathForDisplay(path)} - - )} -
+ {hasMeta && ( +
+ {showPattern && ( + + pattern: {pattern} + + )} + {include && ( + + include: {include} + + )} + {path && ( + + in: {shortenPathForDisplay(path)} + + )} +
+ )} {output && ( -
+				
 					{truncateOutput(output)}
 				
)} @@ -1013,6 +1019,8 @@ interface ChatToolCallProps { onDelete?: (part: ToolPart) => void /** Project root used only for display-only path labels. */ projectRoot?: string | null + /** Tighter row rhythm for nested items inside a tool group. */ + compact?: boolean open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void @@ -1061,6 +1069,7 @@ export const ChatToolCall = memo( turnWorking = true, onDelete, projectRoot, + compact = false, open, defaultOpen: defaultOpenProp, onOpenChange, @@ -1195,7 +1204,7 @@ export const ChatToolCall = memo( const showViewDiff = editFilePath != null && status === "completed" return ( -
+
0 && !compact ? "space-y-1.5" : undefined}> @@ -1235,6 +1245,7 @@ export const ChatToolCall = memo( // open is controlled by the parent timeline (expandedRowIds); without this // comparison the memo blocks the re-render and the row can never expand. if (prev.open !== next.open) return false + if (prev.compact !== next.compact) return false if (prev.turnHasError !== next.turnHasError) return false if (prev.turnWorking !== next.turnWorking) return false if (prev.projectRoot !== next.projectRoot) return false diff --git a/apps/desktop/src/renderer/components/chat/chat-turn.test.ts b/apps/desktop/src/renderer/components/chat/chat-turn.test.ts index cedf294a..28b90437 100644 --- a/apps/desktop/src/renderer/components/chat/chat-turn.test.ts +++ b/apps/desktop/src/renderer/components/chat/chat-turn.test.ts @@ -235,6 +235,9 @@ describe("ChatTurnComponent transcript controls", () => { test("uses transcript disclosure rows for thoughts and tools", () => { expect({ definesThoughtRow: thoughtRowSource.includes("export const ThoughtRow"), + thoughtContentUsesRail: thoughtRowSource.includes( + "", + ), usesTranscriptDisclosureTrigger: transcriptDisclosureSource.includes( "export const TranscriptDisclosureTrigger", ), @@ -259,6 +262,15 @@ describe("ChatTurnComponent transcript controls", () => { timelineRendersSeparateThoughtRows: processTimelineViewSource.includes( 'item.kind === "thought"', ), + toolGroupRowsHaveReadableSpacing: processTimelineViewSource.includes( + 'rail className="space-y-0"', + ) && processTimelineViewSource.includes("compact"), + timelineUsesEvenRowGap: processTimelineViewSource.includes( + 'className="flex flex-col gap-0.5"', + ), + disclosureContentUsesPaddingNotMargin: + transcriptDisclosureSource.includes('"pt-1"') && + !transcriptDisclosureSource.includes("data-open:mt-"), disclosureDoesNotShiftLeft: !transcriptDisclosureSource.includes("-mx-1.5") && transcriptDisclosureSource.includes("px-0 py-0.5"), @@ -283,6 +295,7 @@ describe("ChatTurnComponent transcript controls", () => { ), }).toEqual({ definesThoughtRow: true, + thoughtContentUsesRail: true, usesTranscriptDisclosureTrigger: true, usesCollapsedThoughtChevron: true, removesBareReasoningTrigger: true, @@ -293,6 +306,9 @@ describe("ChatTurnComponent transcript controls", () => { toolsUseTranscriptDisclosure: true, toolsOmitDurationTrailing: true, timelineRendersSeparateThoughtRows: true, + toolGroupRowsHaveReadableSpacing: true, + timelineUsesEvenRowGap: true, + disclosureContentUsesPaddingNotMargin: true, disclosureDoesNotShiftLeft: true, thoughtHasNoLeadingSpacer: true, assistantColumnHasNoProcessIndent: true, diff --git a/apps/desktop/src/renderer/components/chat/chat-turn.tsx b/apps/desktop/src/renderer/components/chat/chat-turn.tsx index ec70cda2..70b5bd7e 100644 --- a/apps/desktop/src/renderer/components/chat/chat-turn.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-turn.tsx @@ -868,7 +868,7 @@ export const ChatTurnComponent = memo( {/* Interleaved thought/tool process timeline */} {processSectionVisible && ( -
+
( -
+
{ + test("wires Inter Variable and IBM Plex Mono into theme font tokens", () => { + expect({ + sansInter: uiStylesSource.includes('"Inter Variable"'), + monoPlex: uiStylesSource.includes('"IBM Plex Mono"'), + rendererImportsInter: rendererCssSource.includes("@fontsource-variable/inter"), + rendererImportsPlex: rendererCssSource.includes("@fontsource/ibm-plex-mono"), + markdownReadingSurface: rendererCssSource.includes( + "Transcript markdown — European minimal reading surface", + ), + }).toEqual({ + sansInter: true, + monoPlex: true, + rendererImportsInter: true, + rendererImportsPlex: true, + markdownReadingSurface: true, + }) + }) + test("uses desktop dark theme surfaces for streamdown markdown cells", () => { expect({ responseClass: messageSource.includes("devo-message-response"), @@ -26,6 +44,24 @@ describe("MessageResponse markdown surfaces", () => { }) }) + test("keeps transcript markdown size aligned with chrome and strong weight visible for CJK", () => { + expect({ + markdownBodySize: rendererCssSource.includes("font-size: 0.875rem;"), + strongWeight: rendererCssSource.includes( + ".devo-message-response [data-streamdown=\"strong\"]", + ), + strongUsesSemibold: /\[data-streamdown="strong"\]\s*\{[^}]*font-weight:\s*600/.test( + rendererCssSource, + ), + cjkWeightNote: rendererCssSource.includes("CJK fallbacks"), + }).toEqual({ + markdownBodySize: true, + strongWeight: true, + strongUsesSemibold: true, + cjkWeightNote: true, + }) + }) + test("keeps transcript markdown headings visually compact", () => { expect({ requirementComment: messageSource.includes( @@ -33,7 +69,7 @@ describe("MessageResponse markdown surfaces", () => { ), headingComponents: messageSource.includes("const transcriptMarkdownComponents"), headingStyle: messageSource.includes( - "my-2 border-0 pb-0 text-sm font-semibold leading-6 text-foreground", + "mt-3 mb-1 border-0 p-0 text-[14px] font-semibold leading-snug tracking-normal text-foreground first:mt-0", ), markdownRulesHidden: messageSource.includes("hr: TranscriptMarkdownRule"), markdownRulesRequirementComment: messageSource.includes( diff --git a/apps/desktop/src/renderer/components/chat/process-timeline-view.tsx b/apps/desktop/src/renderer/components/chat/process-timeline-view.tsx index f3a864d0..be77a22b 100644 --- a/apps/desktop/src/renderer/components/chat/process-timeline-view.tsx +++ b/apps/desktop/src/renderer/components/chat/process-timeline-view.tsx @@ -49,13 +49,14 @@ const TranscriptToolGroupRow = memo(function TranscriptToolGroupRow({ ) : undefined } /> - + {tools.map((tool) => ( ))} @@ -98,7 +99,7 @@ export const ProcessTimelineView = memo(function ProcessTimelineView({ ) return ( -
+
{items.map((item, index) => { const rowId = processTimelineRowId(item, index) diff --git a/apps/desktop/src/renderer/components/chat/thought-row.tsx b/apps/desktop/src/renderer/components/chat/thought-row.tsx index 15c1ea19..bb16a23f 100644 --- a/apps/desktop/src/renderer/components/chat/thought-row.tsx +++ b/apps/desktop/src/renderer/components/chat/thought-row.tsx @@ -26,7 +26,6 @@ export const ThoughtRow = memo(function ThoughtRow({ return ( - -
+ +
{text}
diff --git a/apps/desktop/src/renderer/components/chat/transcript-disclosure.tsx b/apps/desktop/src/renderer/components/chat/transcript-disclosure.tsx index 80cf7191..98b18a87 100644 --- a/apps/desktop/src/renderer/components/chat/transcript-disclosure.tsx +++ b/apps/desktop/src/renderer/components/chat/transcript-disclosure.tsx @@ -88,6 +88,7 @@ export const TranscriptDisclosure = memo(function TranscriptDisclosure({ ) }) +/** Shared process-row trigger: one line height for Thought / tools / groups. */ const triggerClassName = "group/row flex w-full max-w-full items-center gap-1.5 rounded-md border-0 bg-transparent px-0 py-0.5 m-0 text-left text-[13px] leading-5 text-muted-foreground transition-colors hover:text-foreground" @@ -157,10 +158,15 @@ export const TranscriptDisclosureTrigger = memo(function TranscriptDisclosureTri export interface TranscriptDisclosureContentProps { children: ReactNode className?: string - /** Indent content under a left guide line (aligned with the chevron) instead of a bordered box. */ + /** Indent content under a left guide line instead of a bordered box. */ rail?: boolean } +/** + * Expanded body for a transcript row. + * Spacing uses padding inside the panel (not margin) so Base UI's height + * collapse to 0 does not leave uneven gaps between process rows. + */ export const TranscriptDisclosureContent = memo(function TranscriptDisclosureContent({ children, className, @@ -168,14 +174,18 @@ export const TranscriptDisclosureContent = memo(function TranscriptDisclosureCon }: TranscriptDisclosureContentProps) { return ( - {children} +
+ {children} +
) }) diff --git a/apps/desktop/src/renderer/components/context-usage-button.tsx b/apps/desktop/src/renderer/components/context-usage-button.tsx index 3d0af5a9..59de0edf 100644 --- a/apps/desktop/src/renderer/components/context-usage-button.tsx +++ b/apps/desktop/src/renderer/components/context-usage-button.tsx @@ -7,7 +7,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@devo/ui/components/popover" import { cn } from "@devo/ui/lib/utils" import { useAtomValue } from "jotai" -import { useEffect, useMemo } from "react" +import { useMemo } from "react" import { sessionNativeFamily } from "../atoms/session-native" import { occupancyCategoryRows, @@ -15,7 +15,6 @@ import { type ContextCategoryId, } from "../lib/context-occupancy" import { formatTokens } from "../lib/session-metrics" -import { getBaseClient, getProjectClient } from "../services/connection-manager" const CATEGORY_COLORS: Record = { base: "bg-muted-foreground/45", @@ -27,10 +26,9 @@ const CATEGORY_COLORS: Record = { interface ContextUsageButtonProps { sessionId: string - directory?: string } -export function ContextUsageButton({ sessionId, directory }: ContextUsageButtonProps) { +export function ContextUsageButton({ sessionId }: ContextUsageButtonProps) { const native = useAtomValue(sessionNativeFamily(sessionId)) const occupancy = native.occupancy const used = occupancy?.totalTokens ?? Number(native.usage?.used ?? 0) @@ -41,13 +39,6 @@ export function ContextUsageButton({ sessionId, directory }: ContextUsageButtonP const strokeClass = percent >= 90 ? "text-red-400" : percent >= 70 ? "text-yellow-400" : "text-muted-foreground" - useEffect(() => { - if (occupancy) return - const client = (directory ? getProjectClient(directory) : null) ?? getBaseClient() - if (!client?.context?.usage?.read) return - void client.context.usage.read({ sessionID: sessionId }).catch(() => {}) - }, [directory, occupancy, sessionId]) - const size = 14 const strokeWidth = 2.5 const radius = (size - strokeWidth) / 2 diff --git a/apps/desktop/src/renderer/components/session-metrics-bar.test.ts b/apps/desktop/src/renderer/components/session-metrics-bar.test.ts index a3ee3e26..0fb3e606 100644 --- a/apps/desktop/src/renderer/components/session-metrics-bar.test.ts +++ b/apps/desktop/src/renderer/components/session-metrics-bar.test.ts @@ -63,13 +63,13 @@ describe("SessionMetricsBar top timer wiring", () => { replacesOverviewButtonInHeader: !agentDetailSource.includes(" { opensTerminal: true, }) }) + + test("sidebar width is drag-resizable and persisted", async () => { + const source = await readFile(sourcePath, "utf8") + expect({ + importsResizeHandle: source.includes( + 'import { SidebarResizeHandle } from "./sidebar/sidebar-resize-handle"', + ), + importsWidthAtom: source.includes("sidebarWidthAtom"), + appliesSidebarWidthVar: source.includes('"--sidebar-width"'), + marksResizing: source.includes('data-resizing={sidebarResizing ? "true" : undefined}'), + rendersHandle: source.includes(" { + setSidebarWidth(clampSidebarWidth(nextWidth, { windowWidth: window.innerWidth })) + }, + [setSidebarWidth], + ) + + const resolvedSidebarWidth = clampSidebarWidth(sidebarWidth, { + windowWidth: typeof window !== "undefined" ? window.innerWidth : undefined, + }) + return (
- + {/* Sidebar header -- reserves space to match the app bar height so @@ -605,6 +630,11 @@ export function SidebarLayout() { * When default sidebar is active, AppSidebarContent renders its own footer. */} {slotFooter !== false && slotFooter} + {!transcriptFillsTitlebar && } diff --git a/apps/desktop/src/renderer/components/sidebar/sidebar-resize-handle.test.ts b/apps/desktop/src/renderer/components/sidebar/sidebar-resize-handle.test.ts new file mode 100644 index 00000000..f5aa1b4e --- /dev/null +++ b/apps/desktop/src/renderer/components/sidebar/sidebar-resize-handle.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { + clampSidebarWidth, + SIDEBAR_DEFAULT_WIDTH_PX, + SIDEBAR_MAX_WIDTH_PX, + SIDEBAR_MIN_WIDTH_PX, +} from "../../lib/sidebar-width" + +describe("clampSidebarWidth", () => { + test("clamps to min and max defaults", () => { + expect({ + belowMin: clampSidebarWidth(100), + aboveMax: clampSidebarWidth(900), + defaultPassthrough: clampSidebarWidth(SIDEBAR_DEFAULT_WIDTH_PX), + }).toEqual({ + belowMin: SIDEBAR_MIN_WIDTH_PX, + aboveMax: SIDEBAR_MAX_WIDTH_PX, + defaultPassthrough: SIDEBAR_DEFAULT_WIDTH_PX, + }) + }) + + test("reserves room for the main content pane", () => { + expect(clampSidebarWidth(480, { windowWidth: 700, contentMinWidth: 360 })).toEqual(340) + }) + + test("falls back for non-finite values", () => { + expect(clampSidebarWidth(Number.NaN)).toEqual(SIDEBAR_DEFAULT_WIDTH_PX) + }) +}) diff --git a/apps/desktop/src/renderer/components/sidebar/sidebar-resize-handle.tsx b/apps/desktop/src/renderer/components/sidebar/sidebar-resize-handle.tsx new file mode 100644 index 00000000..d9fef517 --- /dev/null +++ b/apps/desktop/src/renderer/components/sidebar/sidebar-resize-handle.tsx @@ -0,0 +1,111 @@ +import { useSidebar } from "@devo/ui/components/sidebar" +import { cn } from "@devo/ui/lib/utils" +import { useCallback, useEffect, useRef, type PointerEvent as ReactPointerEvent } from "react" +import { + clampSidebarWidth, + SIDEBAR_DEFAULT_WIDTH_PX, + SIDEBAR_MAX_WIDTH_PX, + SIDEBAR_MIN_WIDTH_PX, +} from "../../lib/sidebar-width" + +type SidebarResizeHandleProps = { + width: number + onWidthChange: (width: number) => void + onResizingChange?: (resizing: boolean) => void + className?: string +} + +/** + * Drag handle on the sidebar / main content split. + * Double-click resets to the default width. + */ +export function SidebarResizeHandle({ + width, + onWidthChange, + onResizingChange, + className, +}: SidebarResizeHandleProps) { + const { open } = useSidebar() + const dragRef = useRef<{ startX: number; startWidth: number } | null>(null) + + const endDrag = useCallback(() => { + if (!dragRef.current) return + dragRef.current = null + onResizingChange?.(false) + document.body.style.removeProperty("cursor") + document.body.style.removeProperty("user-select") + }, [onResizingChange]) + + useEffect(() => { + if (!open) endDrag() + }, [endDrag, open]) + + useEffect(() => { + return () => endDrag() + }, [endDrag]) + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0 || !open) return + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + dragRef.current = { startX: event.clientX, startWidth: width } + onResizingChange?.(true) + document.body.style.cursor = "col-resize" + document.body.style.userSelect = "none" + }, + [onResizingChange, open, width], + ) + + const handlePointerMove = useCallback( + (event: ReactPointerEvent) => { + const drag = dragRef.current + if (!drag) return + const next = clampSidebarWidth(drag.startWidth + (event.clientX - drag.startX), { + windowWidth: window.innerWidth, + }) + onWidthChange(next) + }, + [onWidthChange], + ) + + const handlePointerUp = useCallback( + (event: ReactPointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + endDrag() + }, + [endDrag], + ) + + const handleDoubleClick = useCallback(() => { + onWidthChange( + clampSidebarWidth(SIDEBAR_DEFAULT_WIDTH_PX, { windowWidth: window.innerWidth }), + ) + }, [onWidthChange]) + + if (!open) return null + + return ( +
+ ) +} diff --git a/apps/desktop/src/renderer/desktop-chrome.css b/apps/desktop/src/renderer/desktop-chrome.css index eb123341..2f906cb9 100644 --- a/apps/desktop/src/renderer/desktop-chrome.css +++ b/apps/desktop/src/renderer/desktop-chrome.css @@ -42,6 +42,12 @@ width: 0; } +/* While dragging the sidebar split, snap width without the collapse animation. */ +[data-slot="sidebar-wrapper"][data-resizing="true"] [data-slot="sidebar"], +[data-slot="sidebar-wrapper"][data-resizing="true"] [data-slot="sidebar-container"] { + transition: none !important; +} + [data-slot="content-area"] { background: var(--devo-transcript-background); } diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index ed7a3817..b4fbd18c 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -17,10 +17,139 @@ body { background: color-mix(in srgb, var(--background) var(--glass-body), transparent) !important; } +/* Transcript markdown — European minimal reading surface. + * Chat body stays near chrome (13–14px), not a larger “document” size. + * Strong weight must stay at 600+: CJK fallbacks (e.g. YaHei) snap mid + * weights to Regular, so 500–550 often looks unbolded. */ +.devo-message-response { + --devo-markdown-surface: color-mix(in srgb, var(--muted) 55%, var(--background)); + --devo-markdown-surface-subtle: color-mix(in srgb, var(--muted) 70%, transparent); + --devo-markdown-code-bg: color-mix(in srgb, var(--muted) 40%, var(--background)); + --devo-markdown-inline-code-bg: color-mix(in srgb, var(--muted) 72%, transparent); + color: var(--foreground); + font-size: 0.875rem; + line-height: 1.6; + letter-spacing: -0.01em; + font-weight: 400; +} + :root.dark .devo-message-response { --devo-markdown-surface: color-mix(in srgb, var(--card) 92%, transparent); --devo-markdown-surface-subtle: color-mix(in srgb, var(--muted) 86%, transparent); --devo-markdown-code-bg: var(--background); + --devo-markdown-inline-code-bg: color-mix(in srgb, var(--muted) 78%, transparent); +} + +.devo-message-response p { + margin-block: 0.7em; +} + +.devo-message-response [data-streamdown="strong"] { + font-weight: 600; + letter-spacing: 0; + color: var(--foreground); +} + +.devo-message-response em { + font-style: italic; + font-synthesis: none; +} + +.devo-message-response [data-streamdown="link"] { + color: var(--primary); + font-weight: 500; + text-decoration-line: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; + text-decoration-color: color-mix(in srgb, var(--primary) 45%, transparent); + transition: text-decoration-color 120ms ease; +} + +.devo-message-response [data-streamdown="link"]:hover { + text-decoration-color: var(--primary); +} + +.devo-message-response [data-streamdown="unordered-list"], +.devo-message-response [data-streamdown="ordered-list"] { + margin-block: 0.65em; + padding-inline-start: 1.35em; + list-style-position: outside; + white-space: normal; +} + +.devo-message-response [data-streamdown="unordered-list"] { + list-style-type: disc; +} + +.devo-message-response [data-streamdown="ordered-list"] { + list-style-type: decimal; +} + +.devo-message-response [data-streamdown="list-item"] { + margin: 0; + padding-block: 0.18em; + padding-inline-start: 0.15em; +} + +.devo-message-response [data-streamdown="list-item"]::marker { + color: color-mix(in srgb, var(--muted-foreground) 75%, var(--foreground)); +} + +.devo-message-response [data-streamdown="list-item"] > p { + margin-block: 0.25em; +} + +.devo-message-response [data-streamdown="blockquote"] { + margin-block: 0.9em; + border-left: 2px solid color-mix(in srgb, var(--border) 55%, var(--muted-foreground)); + padding-block: 0.15em; + padding-inline-start: 1rem; + color: var(--muted-foreground); + font-style: normal; + letter-spacing: -0.008em; +} + +.devo-message-response [data-streamdown="blockquote"] p { + margin-block: 0.35em; +} + +.devo-message-response [data-streamdown="inline-code"] { + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: 0.3rem; + background: var(--devo-markdown-inline-code-bg); + padding: 0.1em 0.38em; + font-family: var(--font-mono); + font-size: 0.84em; + font-weight: 450; + letter-spacing: 0; + line-height: 1.35; + color: var(--foreground); +} + +.devo-message-response [data-streamdown="code-block"], +.devo-message-response [data-streamdown="mermaid-block"] { + margin-block: 0.9em; + border-radius: calc(var(--radius) + 2px); + border-color: var(--border); + background: var(--devo-markdown-surface); +} + +.devo-message-response [data-streamdown="code-block-body"] { + border-color: var(--border); + background: var(--devo-markdown-code-bg); + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.55; + letter-spacing: 0; +} + +.devo-message-response [data-streamdown="code-block-header"] { + padding-right: 5rem; + font-family: var(--font-sans); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: -0.01em; + color: var(--muted-foreground); } :root.dark .devo-message-response [data-streamdown="code-block"], @@ -38,8 +167,23 @@ body { position: relative; } -.devo-message-response [data-streamdown="code-block-header"] { - padding-right: 5rem; +.devo-message-response [data-streamdown="table-wrapper"], +.devo-message-response [data-streamdown="table"] { + margin-block: 0.9em; + border-color: var(--border); + border-radius: var(--radius); +} + +.devo-message-response [data-streamdown="table-header-cell"], +.devo-message-response [data-streamdown="table-cell"] { + padding: 0.55rem 0.85rem; + font-size: 0.875rem; + line-height: 1.45; + letter-spacing: -0.01em; +} + +.devo-message-response [data-streamdown="table-header-cell"] { + font-weight: 600; } /* Streamdown renders code actions as a sibling of the language header; keep @@ -90,6 +234,11 @@ body { padding: 0.75rem; } +.devo-read-output code { + display: block; + white-space: pre; +} + .devo-splash-brand { display: inline-flex; align-items: center; diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 66f59990..a3629442 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -40,7 +40,7 @@ align-items: center; gap: 10px; color: hsl(var(--foreground, 0 0% 93%)); - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-family: 'Inter Variable', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 32px; font-weight: 650; letter-spacing: 0; diff --git a/apps/desktop/src/renderer/lib/sidebar-width.ts b/apps/desktop/src/renderer/lib/sidebar-width.ts new file mode 100644 index 00000000..0cab855a --- /dev/null +++ b/apps/desktop/src/renderer/lib/sidebar-width.ts @@ -0,0 +1,19 @@ +/** Default expanded sidebar width in px (matches UI `SIDEBAR_WIDTH` 17.5rem at 16px root). */ +export const SIDEBAR_DEFAULT_WIDTH_PX = 280 +export const SIDEBAR_MIN_WIDTH_PX = 200 +export const SIDEBAR_MAX_WIDTH_PX = 480 + +/** Clamp a sidebar width to usable min/max bounds. */ +export function clampSidebarWidth( + width: number, + options?: { windowWidth?: number; contentMinWidth?: number }, +): number { + const windowWidth = options?.windowWidth ?? Number.POSITIVE_INFINITY + const contentMinWidth = options?.contentMinWidth ?? 360 + const maxForWindow = Number.isFinite(windowWidth) + ? Math.max(SIDEBAR_MIN_WIDTH_PX, windowWidth - contentMinWidth) + : SIDEBAR_MAX_WIDTH_PX + const max = Math.min(SIDEBAR_MAX_WIDTH_PX, maxForWindow) + if (!Number.isFinite(width)) return SIDEBAR_DEFAULT_WIDTH_PX + return Math.min(max, Math.max(SIDEBAR_MIN_WIDTH_PX, Math.round(width))) +} diff --git a/crates/server/src/titles.rs b/crates/server/src/titles.rs index e250196c..433d63ac 100644 --- a/crates/server/src/titles.rs +++ b/crates/server/src/titles.rs @@ -60,13 +60,13 @@ pub(crate) fn build_title_generation_request( model_slug: devo_protocol::ModelProfileKey::CatalogSlug(model_slug), model, system: Some( - "Generate a short session title. Respond with only the title in sentence case. Use 3 to 8 words. No markdown, no quotes, no trailing punctuation unless required by a proper noun.".to_string(), + "Generate a short session title. Respond with only the title. Match the language of the first user message exactly — do not translate. Prefer 3 to 8 words (or a similarly short phrase in that language). Use sentence case when the language has case. No markdown, no quotes, no trailing punctuation unless required by a proper noun.".to_string(), ), messages: vec![RequestMessage { role: "user".to_string(), content: vec![RequestContent::Text { text: format!( - "First user message:\n{user_input}\n\nReturn only the best concise title." + "First user message:\n{user_input}\n\nReturn only the best concise title in the same language as the message above." ), }], }], From e0aa99fb81f5eec1172c4ce187f0af5cbf352f7b Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Sat, 29 Aug 2026 22:16:06 +0800 Subject: [PATCH 5/8] Make Desktop reasoning effort options follow the selected model. Preferences now advertise per-model availableEfforts so the effort menu updates on model switch; also simplify session header and model trigger chrome. Co-authored-by: Cursor --- apps/desktop/bun.lock | 9 +- apps/desktop/package.json | 1 + .../src/v2/client-config-options.test.ts | 39 +++++- .../packages/devo-ai-sdk/src/v2/client.ts | 26 +++- .../src/v2/native-client-support.ts | 65 ++++++--- .../provider-data-from-config-options.test.ts | 86 ++++++++++++ .../src/v2/reference-search-session.test.ts | 37 ++++++ .../src/v2/reference-search-session.ts | 38 +++++- .../ui/src/components/ai-elements/message.tsx | 2 +- .../packages/ui/src/styles/globals.css | 2 +- .../src/renderer/components/agent-detail.tsx | 12 +- .../chat/composer-popover-styles.ts | 31 +++++ .../components/chat/mention-popover.test.ts | 107 ++++++++++++++- .../components/chat/mention-popover.tsx | 124 +++++++++--------- .../chat/message-response-style.test.ts | 24 ++-- .../components/chat/prompt-toolbar.tsx | 11 +- .../chat/slash-command-popover.test.ts | 16 ++- .../components/chat/slash-command-popover.tsx | 42 +++--- .../src/renderer/hooks/use-devo-data.test.ts | 29 ++++ apps/desktop/src/renderer/index.css | 13 +- apps/desktop/src/renderer/index.html | 2 +- .../renderer/lib/model-config-options.test.ts | 10 ++ .../src/renderer/lib/model-config-options.ts | 7 +- crates/protocol/src/native/rpc_admin.rs | 5 + crates/server/src/runtime/connection.rs | 31 +++++ crates/server/src/runtime/model_api.rs | 34 ++++- crates/server/src/runtime/reference_search.rs | 100 +++++++++++++- crates/server/tests/model_config_e2e.rs | 42 ++++++ .../server/tests/support/acp_session_setup.rs | 7 +- ...L2-DES-APP-008-legacy-canonical-mapping.md | 2 +- 30 files changed, 792 insertions(+), 162 deletions(-) create mode 100644 apps/desktop/packages/devo-ai-sdk/src/v2/provider-data-from-config-options.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/composer-popover-styles.ts diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index 9e4f48a2..ec2a3c6d 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -10,6 +10,7 @@ "@devo/configconv": "file:./packages/configconv", "@devo/ui": "file:./packages/ui", "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/noto-sans-sc": "^5", "@fontsource/ibm-plex-mono": "^5.2.7", "@libsql/client": "^0.17.0", "@pierre/diffs": "^1.0.10", @@ -243,6 +244,8 @@ "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "https://registry.npmmirror.com/@fontsource-variable/inter/-/inter-5.2.8.tgz", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + "@fontsource-variable/noto-sans-sc": ["@fontsource-variable/noto-sans-sc@5.3.0", "https://registry.npmmirror.com/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.3.0.tgz", {}, "sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w=="], + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "https://registry.npmmirror.com/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="], "@hono/node-server": ["@hono/node-server@1.19.14", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], @@ -869,7 +872,7 @@ "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "cross-spawn-windows-exe": ["cross-spawn-windows-exe@1.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^1.1.0", "is-wsl": "^2.2.0", "which": "^2.0.2" } }, "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw=="], + "cross-spawn-windows-exe": ["cross-spawn-windows-exe@1.2.0", "https://registry.npmmirror.com/cross-spawn-windows-exe/-/cross-spawn-windows-exe-1.2.0.tgz", { "dependencies": { "@malept/cross-spawn-promise": "^1.1.0", "is-wsl": "^2.2.0", "which": "^2.0.2" } }, "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw=="], "cssesc": ["cssesc@3.0.0", "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], @@ -1767,7 +1770,7 @@ "raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "rcedit": ["rcedit@5.0.2", "", { "dependencies": { "cross-spawn-windows-exe": "^1.1.0" } }, "sha512-dgysxaeXZ4snLpPjn8aVtHvZDCx+aRcvZbaWBgl1poU6OPustMvOkj9a9ZqASQ6i5Y5szJ13LSvglEOwrmgUxA=="], + "rcedit": ["rcedit@5.0.2", "https://registry.npmmirror.com/rcedit/-/rcedit-5.0.2.tgz", { "dependencies": { "cross-spawn-windows-exe": "^1.1.0" } }, "sha512-dgysxaeXZ4snLpPjn8aVtHvZDCx+aRcvZbaWBgl1poU6OPustMvOkj9a9ZqASQ6i5Y5szJ13LSvglEOwrmgUxA=="], "react": ["react@19.2.7", "https://registry.npmmirror.com/react/-/react-19.2.7.tgz", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], @@ -2255,7 +2258,7 @@ "cross-spawn/which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "cross-spawn-windows-exe/@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="], + "cross-spawn-windows-exe/@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="], "cross-spawn-windows-exe/which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 87cf7631..0af00f9b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -61,6 +61,7 @@ "@devo/configconv": "file:./packages/configconv", "@devo/ui": "file:./packages/ui", "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/noto-sans-sc": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.2.7", "@libsql/client": "^0.17.0", "@pierre/diffs": "^1.0.10", diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts index 1b01509a..ac977323 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts @@ -48,10 +48,32 @@ const initializeResult = { const modelPreferences = { model: "test-openai", availableModels: [ - { value: "test-openai", label: "Test OpenAI", description: "OpenAI: test-model" }, - { value: "alt-openai", label: "Alt OpenAI", description: "OpenAI: alt-model" }, + { + value: "test-openai", + label: "Test OpenAI", + description: "OpenAI: test-model", + availableEfforts: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + ], + }, + { + value: "alt-openai", + label: "Alt OpenAI", + description: "OpenAI: alt-model", + availableEfforts: [ + { value: "high", label: "High" }, + { value: "max", label: "Max" }, + ], + }, + ], + availableEfforts: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, ], - availableEfforts: [], + reasoningEffort: "medium", } const configOptions = [ @@ -131,6 +153,17 @@ describe("Native desktop SDK config option cache", () => { const config = await client.config.get() expect(providers.data.default).toEqual({ session: "test-openai" }) + expect(Object.keys(providers.data.providers[0].models["test-openai"].variants)).toEqual([ + "low", + "medium", + "high", + ]) + expect(Object.keys(providers.data.providers[0].models["alt-openai"].variants)).toEqual([ + "high", + "max", + ]) + expect(providers.data.providers[0].models["test-openai"].currentVariant).toBe("medium") + expect(providers.data.providers[0].models["alt-openai"].currentVariant).toBeUndefined() expect(config.data).toEqual({ model: "session/test-openai" }) expect(transport.requests.map((request) => request.method)).toEqual([ "initialize", diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index f3acd727..1fd58689 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -454,20 +454,38 @@ function legacyModelBindingFromCanonical(binding: Record): Prov } } -/** Canonical `model/preferences` wire shape (ratified #12). */type ModelPreferencesWire = { +/** Canonical `model/preferences` wire shape (ratified #12). */ +type PreferencesOptionWire = { + value: string + label: string + description?: string + /** Present on `availableModels` entries: that model's effort choices. */ + availableEfforts?: PreferencesOptionWire[] +} + +type ModelPreferencesWire = { model?: string reasoningEffort?: string - availableModels?: Array<{ value: string; label: string; description?: string }> - availableEfforts?: Array<{ value: string; label: string; description?: string }> + availableModels?: PreferencesOptionWire[] + availableEfforts?: PreferencesOptionWire[] } /** Canonical model preferences → the select options the config UI renders. */ function sessionConfigOptionsFromModelPreferences(preferences: ModelPreferencesWire): SessionConfigOption[] { - const toSelectOptions = (entries?: Array<{ value: string; label: string; description?: string }>) => + const toSelectOptions = (entries?: PreferencesOptionWire[]) => (entries ?? []).map((entry) => ({ value: entry.value, name: entry.label, ...(entry.description !== undefined ? { description: entry.description } : {}), + ...(entry.availableEfforts?.length + ? { + availableEfforts: entry.availableEfforts.map((effort) => ({ + value: effort.value, + name: effort.label, + ...(effort.description !== undefined ? { description: effort.description } : {}), + })), + } + : {}), })) const options: SessionConfigOption[] = [] if (preferences.model !== undefined || (preferences.availableModels?.length ?? 0) > 0) { diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts index e59ba6e0..11ca6c20 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts @@ -81,12 +81,15 @@ export function providerDataFromConfigOptions(configOptions: SessionConfigOption if (!modelOption) return { default: {}, providers: [] } const currentValue = typeof modelOption.currentValue === "string" ? modelOption.currentValue : undefined const reasoningOption = configOptions.find((option) => option.id === "thought_level") - const reasoningVariants = variantsFromConfigOption(reasoningOption) + const fallbackReasoningVariants = variantsFromConfigOption(reasoningOption) const currentVariant = typeof reasoningOption?.currentValue === "string" ? reasoningOption.currentValue : undefined - const hasReasoningVariants = Object.keys(reasoningVariants).length > 0 const models = Object.fromEntries( flattenSelectOptions(modelOption.options).map((option) => { + const perModelVariants = variantsFromAvailableEfforts(option.availableEfforts) + const reasoningVariants = + Object.keys(perModelVariants).length > 0 ? perModelVariants : fallbackReasoningVariants + const hasReasoningVariants = Object.keys(reasoningVariants).length > 0 const model = { name: option.name, description: option.description, @@ -96,16 +99,20 @@ export function providerDataFromConfigOptions(configOptions: SessionConfigOption attachment: false, }, } + if (!hasReasoningVariants) return [option.value, model] + const isCurrentModel = option.value === currentValue + const variantOnCurrent = + isCurrentModel && currentVariant && currentVariant in reasoningVariants + ? currentVariant + : undefined return [ option.value, - hasReasoningVariants - ? { - ...model, - variants: reasoningVariants, - currentVariant, - allowDefaultVariant: false, - } - : model, + { + ...model, + variants: reasoningVariants, + ...(variantOnCurrent !== undefined ? { currentVariant: variantOnCurrent } : {}), + allowDefaultVariant: false, + }, ] }), ) @@ -441,21 +448,28 @@ function flattenSelectOptions(options: unknown): Array<{ value: string name: string description?: string + availableEfforts?: unknown }> { if (!Array.isArray(options)) return [] - const result: Array<{ value: string; name: string; description?: string }> = [] + const result: Array<{ + value: string + name: string + description?: string + availableEfforts?: unknown + }> = [] for (const option of options) { if (!option || typeof option !== "object") continue - const value = (option as Record).value - const nestedOptions = (option as Record).options + const record = option as Record + const value = record.value + const nestedOptions = record.options if (typeof value === "string") { result.push({ value, - name: String((option as Record).name ?? value), - description: - typeof (option as Record).description === "string" - ? String((option as Record).description) - : undefined, + name: String(record.name ?? value), + description: typeof record.description === "string" ? String(record.description) : undefined, + ...(record.availableEfforts !== undefined + ? { availableEfforts: record.availableEfforts } + : {}), }) continue } @@ -464,6 +478,21 @@ function flattenSelectOptions(options: unknown): Array<{ return result } +function variantsFromAvailableEfforts( + availableEfforts: unknown, +): Record { + if (!Array.isArray(availableEfforts)) return {} + return Object.fromEntries( + flattenSelectOptions(availableEfforts).map((selectOption) => [ + selectOption.value, + { + name: selectOption.name, + description: selectOption.description, + }, + ]), + ) +} + function variantsFromConfigOption(option?: SessionConfigOption): Record { if (!option) return {} return Object.fromEntries( diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/provider-data-from-config-options.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/provider-data-from-config-options.test.ts new file mode 100644 index 00000000..271a1248 --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/provider-data-from-config-options.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test" +import { providerDataFromConfigOptions, type SessionConfigOption } from "./native-client-support" + +describe("providerDataFromConfigOptions per-model efforts", () => { + test("projects each model's availableEfforts into its own variants", () => { + const configOptions = [ + { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: "model-a", + options: [ + { + value: "model-a", + name: "Model A", + availableEfforts: [ + { value: "r1", name: "R1" }, + { value: "r2", name: "R2" }, + ], + }, + { + value: "model-b", + name: "Model B", + availableEfforts: [ + { value: "t1", name: "T1" }, + { value: "t2", name: "T2" }, + { value: "t3", name: "T3" }, + ], + }, + ], + }, + { + type: "select", + id: "thought_level", + name: "Reasoning Effort", + category: "thought_level", + currentValue: "r1", + options: [ + { value: "r1", name: "R1" }, + { value: "r2", name: "R2" }, + ], + }, + ] satisfies SessionConfigOption[] + + const data = providerDataFromConfigOptions(configOptions) + const models = data.providers[0]?.models as Record + + expect(Object.keys(models["model-a"].variants)).toEqual(["r1", "r2"]) + expect(Object.keys(models["model-b"].variants)).toEqual(["t1", "t2", "t3"]) + expect(models["model-a"].currentVariant).toBe("r1") + expect(models["model-b"].currentVariant).toBeUndefined() + expect(models["model-a"].allowDefaultVariant).toBe(false) + expect(models["model-b"].allowDefaultVariant).toBe(false) + }) + + test("falls back to global thought_level when a model has no availableEfforts", () => { + const configOptions = [ + { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: "legacy-model", + options: [{ value: "legacy-model", name: "Legacy" }], + }, + { + type: "select", + id: "thought_level", + name: "Reasoning Effort", + category: "thought_level", + currentValue: "high", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }, + ] satisfies SessionConfigOption[] + + const data = providerDataFromConfigOptions(configOptions) + const models = data.providers[0]?.models as Record + + expect(Object.keys(models["legacy-model"].variants)).toEqual(["low", "high"]) + expect(models["legacy-model"].currentVariant).toBe("high") + }) +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/reference-search-session.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/reference-search-session.test.ts index db0d9452..42c12329 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/reference-search-session.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/reference-search-session.test.ts @@ -101,4 +101,41 @@ describe("ReferenceSearchSession", () => { expect(session.filePaths()).toEqual(["src/lib.rs"]) }) + + it("normalizes camelCase wire disabled MCP flags", async () => { + const request = vi.fn(async () => ({ + snapshot: { + searchId: "search-1", + query: "docs", + results: [ + { + kind: "mcp", + displayName: "Docs", + insertText: "@mcp:docs", + mentionPath: "mcp://server/docs", + isDisabled: true, + disabledReason: "Server is disconnected", + }, + ], + totalFileMatchCount: 0, + scannedFileCount: 0, + fileSearchComplete: true, + }, + })) + const session = new ReferenceSearchSession(request, "/workspace") + const snapshot = await session.startOrUpdate("docs") + + expect(snapshot.results).toEqual([ + { + kind: "mcp", + display_name: "Docs", + insert_text: "@mcp:docs", + description: undefined, + mention_path: "mcp://server/docs", + file_path: undefined, + is_disabled: true, + disabled_reason: "Server is disconnected", + }, + ]) + }) }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/reference-search-session.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/reference-search-session.ts index 23822169..c8a5b12a 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/reference-search-session.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/reference-search-session.ts @@ -46,6 +46,37 @@ type ReferenceSearchState = { error: string | null } +function parseReferenceResult(value: unknown): ReferenceSearchResult | null { + if (!value || typeof value !== "object") return null + const raw = value as Record + const kind = raw.kind + if (kind !== "skill" && kind !== "mcp" && kind !== "file") return null + if (typeof raw.display_name !== "string" && typeof raw.displayName !== "string") return null + if (typeof raw.insert_text !== "string" && typeof raw.insertText !== "string") return null + + const isDisabled = raw.is_disabled ?? raw.isDisabled + const disabledReason = raw.disabled_reason ?? raw.disabledReason + return { + kind, + display_name: (raw.display_name ?? raw.displayName) as string, + insert_text: (raw.insert_text ?? raw.insertText) as string, + description: + typeof raw.description === "string" + ? raw.description + : undefined, + mention_path: + typeof (raw.mention_path ?? raw.mentionPath) === "string" + ? ((raw.mention_path ?? raw.mentionPath) as string) + : undefined, + file_path: + typeof (raw.file_path ?? raw.filePath) === "string" + ? ((raw.file_path ?? raw.filePath) as string) + : undefined, + is_disabled: isDisabled === true, + disabled_reason: typeof disabledReason === "string" ? disabledReason : undefined, + } +} + function parseSnapshot(payload: unknown): ReferenceSearchSnapshot | null { if (!payload || typeof payload !== "object") return null if ("snapshot" in payload) { @@ -60,10 +91,15 @@ function parseSnapshot(payload: unknown): ReferenceSearchSnapshot | null { if (typeof searchId !== "string" || typeof candidate.query !== "string") { return null } + const results = Array.isArray(candidate.results) + ? candidate.results + .map(parseReferenceResult) + .filter((result): result is ReferenceSearchResult => result != null) + : [] return { search_id: searchId, query: candidate.query, - results: Array.isArray(candidate.results) ? (candidate.results as ReferenceSearchResult[]) : [], + results, total_file_match_count: typeof totalFileMatchCount === "number" ? totalFileMatchCount : 0, scanned_file_count: typeof scannedFileCount === "number" ? scannedFileCount : 0, file_search_complete: typeof fileSearchComplete === "boolean" ? fileSearchComplete : false, diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx index 49310f89..4aac0b6f 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx @@ -293,7 +293,7 @@ function TranscriptMarkdownHeading({

diff --git a/apps/desktop/packages/ui/src/styles/globals.css b/apps/desktop/packages/ui/src/styles/globals.css index 9d52bd2b..eae9d129 100644 --- a/apps/desktop/packages/ui/src/styles/globals.css +++ b/apps/desktop/packages/ui/src/styles/globals.css @@ -48,7 +48,7 @@ --color-diff-addition-foreground: var(--diff-addition-foreground); --color-diff-deletion: var(--diff-deletion); --color-diff-deletion-foreground: var(--diff-deletion-foreground); - --font-sans: "Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-sans: "Inter Variable", "Noto Sans SC Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; --font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; --text-xs: 0.8125rem; --text-xs--line-height: 1.125rem; diff --git a/apps/desktop/src/renderer/components/agent-detail.tsx b/apps/desktop/src/renderer/components/agent-detail.tsx index 5eb77980..8589a8ae 100644 --- a/apps/desktop/src/renderer/components/agent-detail.tsx +++ b/apps/desktop/src/renderer/components/agent-detail.tsx @@ -353,20 +353,10 @@ function SessionPanelHeader({ data-slot="session-panel-header" className="flex h-[44px] w-full min-w-0 shrink-0 items-center gap-2.5 border-b border-border/40 px-5" > - {/* Breadcrumb: project / [branch badge] / session name */} + {/* Session title (+ optional worktree branch badge) */}

- {/* Project name */} - - {agent.project} - - - {/* Worktree branch badge */} {agent.worktreeBranch && } - - / - - {/* Session name — click to edit */} {isEditingTitle ? (
diff --git a/apps/desktop/src/renderer/components/chat/composer-popover-styles.ts b/apps/desktop/src/renderer/components/chat/composer-popover-styles.ts new file mode 100644 index 00000000..e81fc67c --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/composer-popover-styles.ts @@ -0,0 +1,31 @@ +import { cn } from "@devo/ui/lib/utils" + +/** Shared shell for composer `@` / `/` suggestion popovers. */ +export const composerPopoverShellClass = + "absolute inset-x-0 bottom-full z-50 mb-1.5 origin-bottom-left overflow-hidden rounded-lg border border-border/70 bg-popover shadow-sm" + +export const composerPopoverScrollClass = "max-h-64 overflow-y-auto overscroll-contain" + +export const composerPopoverListClass = "flex flex-col gap-0.5 p-1" + +export const composerPopoverHeaderClass = + "flex items-center gap-2 border-b border-border/50 px-2.5 py-2" + +export const composerPopoverGroupLabelClass = + "sticky top-0 z-10 bg-popover px-2.5 py-1.5 text-[11px] font-medium tracking-normal text-muted-foreground/70" + +export const composerPopoverEmptyClass = + "px-2.5 py-6 text-center text-[13px] leading-5 text-muted-foreground/70" + +export const composerPopoverIconClass = + "size-3.5 shrink-0 stroke-[1.5] text-muted-foreground" + +export function composerPopoverItemClass(isActive: boolean, disabled = false): string { + return cn( + "flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[13px] leading-5 transition-colors", + isActive + ? "bg-muted/80 text-foreground" + : "text-foreground hover:bg-black/[0.04] dark:hover:bg-white/[0.06]", + disabled && "cursor-not-allowed opacity-45 hover:bg-transparent dark:hover:bg-transparent", + ) +} diff --git a/apps/desktop/src/renderer/components/chat/mention-popover.test.ts b/apps/desktop/src/renderer/components/chat/mention-popover.test.ts index aaeef220..1626c77c 100644 --- a/apps/desktop/src/renderer/components/chat/mention-popover.test.ts +++ b/apps/desktop/src/renderer/components/chat/mention-popover.test.ts @@ -1,8 +1,15 @@ import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" import type { ReferenceSearchResult } from "@devo-ai/sdk/v2/client" -import { isMentionOptionDisabled, mapReferenceSearchResults } from "./mention-popover" +import { isMentionOptionDisabled, isMentionOptionVisible, mapReferenceSearchResults } from "./mention-popover" import { createMentionFromOption, insertMentionIntoText } from "./prompt-mentions" +const mentionPopoverSource = readFileSync(new URL("./mention-popover.tsx", import.meta.url), "utf8") +const popoverStylesSource = readFileSync( + new URL("./composer-popover-styles.ts", import.meta.url), + "utf8", +) + describe("mention popover reference results", () => { test("preserves skill, MCP, and file results from the server", () => { const results: ReferenceSearchResult[] = [ @@ -112,4 +119,102 @@ describe("mention popover reference results", () => { selectable: false, }) }) + + test("hides disabled MCP servers from the popover list", () => { + const options = mapReferenceSearchResults([ + { + kind: "mcp", + display_name: "Connected", + insert_text: "@mcp:connected", + }, + { + kind: "mcp", + display_name: "Disconnected", + insert_text: "@mcp:disconnected", + is_disabled: true, + disabled_reason: "Server is disconnected", + }, + { + kind: "skill", + display_name: "docs", + insert_text: "@docs", + description: "Lookup docs", + }, + ]).filter(isMentionOptionVisible) + + expect(options.map((option) => option.display)).toEqual(["Connected", "docs"]) + }) + + test("treats camelCase wire disabled flags as disabled MCP", () => { + const options = mapReferenceSearchResults([ + { + kind: "mcp", + display_name: "Wire Disabled", + insert_text: "@mcp:wire", + isDisabled: true, + disabledReason: "Server is disconnected", + } as ReferenceSearchResult & { + isDisabled: boolean + disabledReason: string + }, + ]).filter(isMentionOptionVisible) + + expect(options).toEqual([]) + }) + + test("uses a single outer scroll container without nested ScrollArea", () => { + expect({ + noScrollAreaImport: !mentionPopoverSource.includes("@devo/ui/components/scroll-area"), + outerOverflowYAuto: + popoverStylesSource.includes("overflow-y-auto") && + !popoverStylesSource.includes("scroll-area-viewport"), + usesSharedScrollClass: mentionPopoverSource.includes("composerPopoverScrollClass"), + }).toEqual({ + noScrollAreaImport: true, + outerOverflowYAuto: true, + usesSharedScrollClass: true, + }) + }) + + test("renders skill and MCP rows as a single compact line", () => { + expect({ + skillMcpSingleLine: mentionPopoverSource.includes( + '{option.display}', + ), + noStackedSkillBody: !mentionPopoverSource.includes( + 'className="min-w-0 flex-1"', + ), + filtersDisabledMcp: mentionPopoverSource.includes("isMentionOptionVisible"), + }).toEqual({ + skillMcpSingleLine: true, + noStackedSkillBody: true, + filtersDisabledMcp: true, + }) + }) + + test("matches the shared minimal composer popover surface", () => { + expect({ + usesSharedShell: mentionPopoverSource.includes("composerPopoverShellClass"), + usesSharedItems: mentionPopoverSource.includes("composerPopoverItemClass"), + usesMutedIcons: mentionPopoverSource.includes("composerPopoverIconClass"), + omitsAccentIconColors: + !mentionPopoverSource.includes("text-blue-400") && + !mentionPopoverSource.includes("text-cyan-500") && + !mentionPopoverSource.includes("text-fuchsia-500"), + shellIsQuiet: + popoverStylesSource.includes("shadow-sm") && + popoverStylesSource.includes("border-border/70") && + !popoverStylesSource.includes("shadow-md"), + activeUsesMuted: + popoverStylesSource.includes("bg-muted/80") && + !popoverStylesSource.includes("bg-accent"), + }).toEqual({ + usesSharedShell: true, + usesSharedItems: true, + usesMutedIcons: true, + omitsAccentIconColors: true, + shellIsQuiet: true, + activeUsesMuted: true, + }) + }) }) diff --git a/apps/desktop/src/renderer/components/chat/mention-popover.tsx b/apps/desktop/src/renderer/components/chat/mention-popover.tsx index d01f13b9..927aa9fe 100644 --- a/apps/desktop/src/renderer/components/chat/mention-popover.tsx +++ b/apps/desktop/src/renderer/components/chat/mention-popover.tsx @@ -4,8 +4,6 @@ * Preserves server-ranked references and combines them with local agents. */ -import { ScrollArea } from "@devo/ui/components/scroll-area" -import { cn } from "@devo/ui/lib/utils" import type { ReferenceSearchResult } from "@devo-ai/sdk/v2/client" import fuzzysort from "fuzzysort" import { @@ -28,6 +26,16 @@ import { } from "react" import { useReferenceSearch } from "../../hooks/use-reference-search" import type { SdkAgent } from "../../hooks/use-devo-data" +import { + composerPopoverEmptyClass, + composerPopoverGroupLabelClass, + composerPopoverHeaderClass, + composerPopoverIconClass, + composerPopoverItemClass, + composerPopoverListClass, + composerPopoverScrollClass, + composerPopoverShellClass, +} from "./composer-popover-styles" // ============================================================ // Types @@ -97,9 +105,20 @@ export function isMentionOptionDisabled(option: MentionOption): boolean { return option.type !== "agent" && option.disabled } +/** Disabled MCPs are omitted from the popover entirely (not shown greyed-out). */ +export function isMentionOptionVisible(option: MentionOption): boolean { + return !(option.type === "mcp" && option.disabled) +} + export function mapReferenceSearchResults(results: ReferenceSearchResult[]): MentionOption[] { return results.map((result) => { - const disabled = result.is_disabled === true || result.disabled_reason != null + const wire = result as ReferenceSearchResult & { + isDisabled?: boolean + disabledReason?: string + } + const disabledReason = wire.disabled_reason ?? wire.disabledReason + const disabled = + wire.is_disabled === true || wire.isDisabled === true || disabledReason != null if (result.kind === "file") { return { type: "file", @@ -107,7 +126,7 @@ export function mapReferenceSearchResults(results: ReferenceSearchResult[]): Men display: result.display_name, insertText: result.insert_text, disabled, - disabledReason: result.disabled_reason, + disabledReason, } } return { @@ -118,7 +137,7 @@ export function mapReferenceSearchResults(results: ReferenceSearchResult[]): Men insertText: result.insert_text, mentionPath: result.mention_path, disabled, - disabledReason: result.disabled_reason, + disabledReason, } }) } @@ -146,7 +165,10 @@ export const MentionPopover = memo( // --- Data: server-ranked Skill, MCP, and File references --- const { results, isLoading, error } = useReferenceSearch(directory, query, open) - const referenceOptions = useMemo(() => mapReferenceSearchResults(results), [results]) + const referenceOptions = useMemo( + () => mapReferenceSearchResults(results).filter(isMentionOptionVisible), + [results], + ) // --- Merge and filter --- const allOptions = useMemo(() => { @@ -238,25 +260,24 @@ export const MentionPopover = memo( return (
e.preventDefault()} > - {/* Search header */} -
- - - {query ? `Searching for "${query}"` : "Mention references or agents"} +
+
- {/* Results */} - -
+ {/* Single outer scroll only — avoid nested ScrollArea scrollbars. */} +
+
{!hasResults && ( -
+
{showLoading ? query - ? `Searching for "${query}"…` + ? `Searching for “${query}”…` : "Searching references and agents…" : showError ? error @@ -266,12 +287,9 @@ export const MentionPopover = memo(
)} - {/* Agent group */} {agentItems.length > 0 && (
-
- Agents -
+
Agents
{agentItems.map((option) => { const idx = selectableIndex(option) return ( @@ -309,12 +327,9 @@ export const MentionPopover = memo( /> )} - {/* File group */} {fileItems.length > 0 && (
-
- Files -
+
Files
{fileItems.map((option) => { const idx = selectableIndex(option) const path = option.type === "file" ? option.path : "" @@ -333,7 +348,7 @@ export const MentionPopover = memo(
)}
- +
) }), @@ -356,9 +371,7 @@ const MentionGroup = memo(function MentionGroup({ }) { return (
-
- {label} -
+
{label}
{options.map((option) => { const idx = selectableIndex(option) return ( @@ -397,15 +410,12 @@ const MentionItem = memo(function MentionItem({ ) } @@ -413,34 +423,22 @@ const MentionItem = memo(function MentionItem({ if (option.type !== "file") { const disabled = option.disabled const Icon = option.type === "skill" ? SparklesIcon : PlugIcon + const detail = option.disabledReason ?? option.description return ( ) } @@ -456,22 +454,18 @@ const MentionItem = memo(function MentionItem({ data-active={isActive} disabled={option.disabled} title={option.disabled ? option.disabledReason : path} - className={cn( - "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors", - isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted", - option.disabled && "cursor-not-allowed opacity-50 hover:bg-transparent", - )} + className={composerPopoverItemClass(isActive, option.disabled)} onClick={onSelect} onMouseEnter={onHover} > {isDir ? ( - +