From c1a928e1200877323e86862b2ed2de3e1892be35 Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 16 Jul 2026 21:49:42 +0800 Subject: [PATCH] refactor(cli): route session restore through runtime SDK --- .../agent-runtime-services-design.md | 9 + docs/architecture/cli-product-line-design.md | 5 + docs/architecture/product-architecture.md | 37 ++- src/apps/cli/src/agent/core_adapter.rs | 41 ++- src/apps/cli/src/chat_state.rs | 213 +++++++++++---- src/apps/cli/src/modes/chat.rs | 26 +- src/apps/cli/src/root_handlers.rs | 69 ++--- .../src/agentic/coordination/coordinator.rs | 92 ++++++- .../assembly/core/src/product_runtime.rs | 18 +- .../core/src/service_agent_runtime.rs | 33 ++- src/crates/contracts/runtime-ports/src/lib.rs | 61 ++++- .../execution/agent-runtime/src/runtime.rs | 256 +++++++++++++++++- src/crates/execution/agent-runtime/src/sdk.rs | 35 ++- 13 files changed, 745 insertions(+), 150 deletions(-) diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index e0488a24f1..0083876222 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -749,6 +749,9 @@ ping 路由。未接入入口的 profile、枚举分支和单元测试仍不能 或 `if cli` 这样的产品分支。 - Tauri 句柄、窗口、命令宏和桌面 app 状态只能存在于 Desktop 提供方或 传输/接口适配器;运行时部件只接收类型化服务端口、DTO、事件事实和能力可用性。 +- 宿主通信的抽取门槛、Tauri 薄适配职责和逐能力迁移顺序以 + [`product-architecture.md`](product-architecture.md#22-宿主通信契约与-tauri-薄适配) 为准;不得用通用 API 转发层 + 包装所有 Runtime SDK 方法。 - 插件运行时客户端只能作为内核可调用的类型化边界注入;智能体内核、工具运行时和工作流不直接加载 OpenCode 插件代码。 - feature group 是构建时能力边界;能力计划和能力可用性是产品运行时能力边界;两者必须在 @@ -771,6 +774,12 @@ ping 路由。未接入入口的 profile、枚举分支和单元测试仍不能 | ACP | ACP 协议、客户端生命周期、远端探测 | 外部智能体/工具能力、环境事实、权限桥接 | | Web UI / mobile web | UI 状态、hydration、配对、会话展示、插件状态视图 | 接口/传输 DTO、运行时事件事实、能力服务读模型 | +当前 Runtime SDK 已提供会话创建、列出、删除、恢复和类型化转录读取。`AgentSessionRestoreRequest/Result` 与 +`AgentSessionRestorePort` 归 Agent Runtime SDK,以继续复用 Runtime owner 的完整 `SessionState`;类型化 +`SessionTranscript` 归 `runtime-ports`。两者都由 `assembly/core` 注入真实 persistence owner,CLI/TUI 是当前恢复与 +转录消费方。`CoreAgentRuntimeCompatibility` 仍承载未迁移的持久化、分支、用量、快照和交互操作;不能据此把整个 +兼容门面一次性删除,也不能把这些操作提前声明为跨宿主稳定接口。 + ### 4.3 Product Capability 设计 Product Capability 是产品能力的静态声明,由 `assembly/product-capabilities` 归属。当前实现已经声明能力集合、 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index b1f95037c1..f9922697cb 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -255,6 +255,11 @@ TUI renderer、实验性接口和完整外部 Server 协议按总矩阵明确降 | 脚本执行服务 | 物理进程健康、资源预算、进程树与句柄、类型化脚本请求和回收 | 决定工具权限、业务结果、TUI 或品牌资源 | | 生态配置适配器 | 解析受支持外部格式并生成导入候选/诊断 | 直接写运行时配置、读取密钥、决定最终权限 | +CLI/TUI 的会话创建、列出、删除、恢复和历史转录读取通过 Runtime SDK 的类型化端口完成;TUI 只把 +`SessionTranscript` 投影为本地渲染状态,不再消费 Core `Message`。Peer Host、账户同步、会话分支、用量、快照和 +工具交互仍使用经过审查的 Core compatibility 方法,直到各自具备明确 owner、稳定 DTO、远程语义和行为等价测试。 +这是一条垂直链路迁移,不是删除整个兼容门面或新建 CLI 专用服务层。 + Runtime Configuration Service 当前由 `bitfun-core/service/config` 负责。在经评审的 port/provider 迁移完成前,CLI 和生态适配器不得另建写入器;adapter 只做 discover/parse/normalize,配置服务才能 预览/应用、记录来源,并通过远程工作区 provider 写目标层。产品定义、品牌资源、界面布局选择 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 21b07c5fc3..9afdb2d9af 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -101,7 +101,42 @@ handler,不构成生产消费闭环。 client 或未来 CLI/HarmonyOS 计划,不能证明同名 Rust transport adapter 已接入;未接入实现应删除,待端到端 调用链确定后再按宿主边界实现。 -### 2.2 入口形态接口规则 +### 2.2 宿主通信契约与 Tauri 薄适配 + +前后端契约按能力语义归属,不按 Tauri command 名称归属。稳定的请求、响应、状态事实和类型化错误放在对应 +`contracts/*`、Runtime SDK 或能力 owner;Tauri、HTTP/WebSocket、CLI/TUI 与未来平台宿主只负责把各自协议映射到 +这些类型。该规则降低框架耦合,但不要求把每个 Desktop DTO 都搬进共享 crate。 + +| 层 | 允许 | 禁止 | +|---|---|---| +| 能力 owner / Runtime SDK | 类型化请求/响应、状态事实、权限/取消语义、与框架无关的用例方法 | `tauri::State`、`AppHandle`、窗口/菜单对象、command 宏、HTTP/WebSocket envelope | +| Desktop Tauri adapter | 解包宿主状态、构造稳定请求、调用 owner/SDK、把类型化错误映射为 Desktop 协议、投递桌面事件 | 复制业务校验、持有第二份权威状态、把 Tauri 类型传入下层 | +| Server / Remote adapter | 路由鉴权、协议 envelope、连接生命周期、背压与取消映射 | 为同一能力另建语义不同的 DTO 或 handler | +| GUI / TUI 消费方 | 依赖入口侧 API interface、稳定读模型或 Runtime SDK;各自保留渲染状态 | UI 组件直接持有平台句柄,或让 React/TUI 状态成为后端契约 | + +Rust 与 TypeScript 的字段一致性以能力所有者的 DTO 为事实源,不以 Tauri command 参数为事实源。单宿主阶段由 +前端基础设施层维护对应接口,并用序列化契约测试锁定字段命名、可选字段和错误形状;达到独立版本化门槛后,才使用 +不依赖 Tauri 的 JSON Schema 或类型生成任务输出只读 TypeScript 类型。生成结果只同步数据形状,不承载权限、重试或 +业务分支。本阶段不为此新增生成器或框架依赖。 + +抽取共享契约需要满足以下任一条件:至少两个当前生产宿主复用同一语义,或存在独立版本化的外部消费者。只有一个 +Desktop command 使用的序列化对象继续留在 `src/apps/desktop`;即使它不含 Tauri 类型,也不因“未来可能复用”而 +提升为公共 DTO。共享的框架中立用例 handler 也遵循同一门槛:它必须拥有真实的编排、权限、取消或错误语义,不能 +只是通用转发层。 + +单条能力按垂直切片迁移: + +1. 先确认权威 owner、当前生产消费方、远程/多产品形态语义和现有行为基线。 +2. 把稳定事实与请求/响应放到能力所有者的契约模块,并以序列化、错误、取消和行为等价测试锁定。 +3. 让非 Desktop 消费方或第二宿主先通过 Runtime SDK / owner 接口形成真实调用链。 +4. 将 Tauri command 收敛为薄 adapter;前端基础设施层负责 `invoke` 映射,UI 组件不直接依赖 Tauri API。 +5. 删除重复 DTO、旧 handler 或兼容方法;无法证明等价时保留已标注的兼容边界,不做批量迁移。 + +因此仓库不恢复一个通用 `api-layer` 作为默认中转层。只有达到上述复用门槛且现有 owner 无法合理承载时,才评审 +窄范围共享 API 模块。HarmonyOS GUI/TUI 可复用稳定能力契约,但仍需各自的平台宿主、生命周期和交付验证;契约 +抽取只是前置条件,不代表 HarmonyOS 已受支持。 + +### 2.3 入口形态接口规则 入口形态接口只描述宿主可消费的声明,不描述具体渲染实现。TUI 与 GUI 的能力边界不同,不能因为存在一个界面插件就自动扩展为全入口稳定接口。 diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index dbdfab9cfc..870c06f6c5 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -11,10 +11,10 @@ use tokio::sync::Mutex; use super::Agent; use bitfun_agent_runtime::sdk::{ AgentDialogTurnRequest, AgentRuntime, AgentSessionCreateRequest, AgentSessionDeleteRequest, - AgentSessionListRequest, AgentTurnCancellationRequest, + AgentSessionListRequest, AgentSessionRestoreRequest, AgentTurnCancellationRequest, + SessionTranscript, SessionTranscriptRequest, }; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; -use bitfun_core::agentic::core::Message; use bitfun_core::agentic::persistence::session_branch::SessionBranchResult; use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::service::session::DialogTurnData; @@ -119,11 +119,18 @@ impl CoreAgentAdapter { let sessions = self .list_sessions_in_workspace(&effective_workspace) .await?; - let summary = validated_session_summary(&sessions, session_id, &effective_workspace)?; + validated_session_summary(&sessions, session_id, &effective_workspace)?; - self.compatibility - .restore_session(&effective_workspace, session_id) - .await?; + let restored = self + .runtime + .restore_session(AgentSessionRestoreRequest { + workspace_path: effective_workspace.to_string_lossy().to_string(), + session_id: session_id.to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; let mut session_id_guard = self.session_id.lock().await; let mut turn_id_guard = self.current_turn_id.lock().await; @@ -135,7 +142,7 @@ impl CoreAgentAdapter { *session_id_guard = Some(session_id.to_string()); *turn_id_guard = None; - Ok((summary, effective_workspace)) + Ok((restored.session, effective_workspace)) } pub(crate) async fn delete_session(&self, session_id: &str) -> Result<()> { @@ -150,11 +157,14 @@ impl CoreAgentAdapter { .map_err(|error| anyhow::anyhow!(error.to_string())) } - pub(crate) async fn get_messages(&self, session_id: &str) -> Result> { - self.compatibility - .get_messages(session_id) + pub(crate) async fn get_transcript(&self, session_id: &str) -> Result { + self.runtime + .read_session_transcript(SessionTranscriptRequest { + session_id: session_id.to_string(), + turn_id: None, + }) .await - .map_err(Into::into) + .map_err(|error| anyhow::anyhow!(error.to_string())) } pub(crate) async fn update_session_model( @@ -269,8 +279,13 @@ impl CoreAgentAdapter { return Ok(()); } match self - .compatibility - .restore_session(&workspace, session_id) + .runtime + .restore_session(AgentSessionRestoreRequest { + workspace_path: workspace.to_string_lossy().to_string(), + session_id: session_id.to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) .await { Ok(_) => { diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index 485c75021d..a14b5cd0b6 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -4,13 +4,11 @@ use std::collections::HashMap; /// Pure UI rendering state for the chat interface. /// All session lifecycle and persistence is handled by bitfun-core. /// This module only maintains transient state needed for TUI rendering. -use std::time::SystemTime; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use bitfun_agent_runtime::prompt_markup::strip_prompt_markup; +use bitfun_agent_runtime::sdk::{SessionTranscript, TranscriptContent, TranscriptMessage}; use bitfun_agent_tools::effective_tool_invocation; -use bitfun_core::agentic::core::message::{ - Message as CoreMessage, MessageContent, MessageRole as CoreMessageRole, -}; -use bitfun_core::agentic::core::strip_prompt_markup; use bitfun_events::ToolEventData; use crate::ui::permission::PermissionPrompt; @@ -64,13 +62,48 @@ pub(crate) enum MessageRole { Tool, } -impl From<&CoreMessageRole> for MessageRole { - fn from(role: &CoreMessageRole) -> Self { +impl From<&str> for MessageRole { + fn from(role: &str) -> Self { match role { - CoreMessageRole::User => MessageRole::User, - CoreMessageRole::Assistant => MessageRole::Assistant, - CoreMessageRole::System => MessageRole::System, - CoreMessageRole::Tool => MessageRole::Tool, + "user" => MessageRole::User, + "assistant" => MessageRole::Assistant, + "tool" => MessageRole::Tool, + _ => MessageRole::System, + } + } +} + +pub(crate) fn transcript_role_label(role: &str) -> &'static str { + match role { + "user" => "User", + "assistant" => "Assistant", + "tool" => "Tool", + "system" => "System", + _ => "Unknown", + } +} + +pub(crate) fn transcript_message_preview(message: &TranscriptMessage) -> String { + match &message.content { + TranscriptContent::Text(text) => text.lines().next().unwrap_or("").to_string(), + TranscriptContent::Multimodal { text, image_count } => { + if text.is_empty() { + format!("[{image_count} images]") + } else { + text.lines().next().unwrap_or("").to_string() + } + } + TranscriptContent::Mixed { + text, tool_calls, .. + } => { + if text.is_empty() { + format!("[{} tool calls]", tool_calls.len()) + } else { + text.lines().next().unwrap_or("").to_string() + } + } + TranscriptContent::ToolResult { tool_name, .. } => { + format!("[Tool result: {tool_name}]") } } } @@ -137,13 +170,13 @@ pub(crate) struct ChatMessage { } impl ChatMessage { - /// Convert a core Message to a UI ChatMessage - pub(crate) fn from_core_message(msg: &CoreMessage) -> Self { - let role = MessageRole::from(&msg.role); + /// Convert a portable session transcript message to UI state. + fn from_transcript_message(msg: &TranscriptMessage, index: usize) -> Self { + let role = MessageRole::from(msg.role.as_str()); let mut flow_items = Vec::new(); match &msg.content { - MessageContent::Text(text) => { + TranscriptContent::Text(text) => { if !text.is_empty() { flow_items.push(FlowItem::Text { content: display_text_for_role(&role, text), @@ -151,7 +184,7 @@ impl ChatMessage { }); } } - MessageContent::Mixed { + TranscriptContent::Mixed { reasoning_content, text, tool_calls, @@ -192,7 +225,7 @@ impl ChatMessage { }); } } - MessageContent::Multimodal { text, .. } => { + TranscriptContent::Multimodal { text, .. } => { if !text.is_empty() { flow_items.push(FlowItem::Text { content: display_text_for_role(&role, text), @@ -200,13 +233,12 @@ impl ChatMessage { }); } } - MessageContent::ToolResult { + TranscriptContent::ToolResult { tool_id, tool_name, effective_tool_name, result, is_error, - .. } => { let result_str = extract_fallback_summary(result); flow_items.push(FlowItem::Tool { @@ -233,9 +265,14 @@ impl ChatMessage { } Self { - id: msg.id.clone(), + id: msg + .id + .clone() + .unwrap_or_else(|| format!("transcript-message-{index}")), role, - timestamp: msg.timestamp, + timestamp: UNIX_EPOCH + .checked_add(Duration::from_millis(msg.timestamp_ms.unwrap_or_default())) + .unwrap_or(UNIX_EPOCH), flow_items, is_streaming: false, version: 0, @@ -321,22 +358,22 @@ impl ChatState { } } - /// Load historical messages from core and create ChatState. + /// Load historical messages from the portable runtime transcript. /// /// Tool results (ToolResult messages) are merged back into the corresponding /// tool calls (in Mixed messages) so that tool cards render with full result data. - pub(crate) fn from_core_messages( + pub(crate) fn from_session_transcript( core_session_id: String, session_name: String, agent_type: String, workspace: Option, - core_messages: &[CoreMessage], + transcript: &SessionTranscript, ) -> Self { // Step 1: Build tool_id -> (result_summary, metadata, is_error) lookup from ToolResult messages let mut tool_results: HashMap, bool)> = HashMap::new(); - for msg in core_messages { - if let MessageContent::ToolResult { + for msg in &transcript.messages { + if let TranscriptContent::ToolResult { tool_id, result, is_error, @@ -352,16 +389,18 @@ impl ChatState { } // Step 2: Convert messages, merging tool results into tool call display states - let messages: Vec = core_messages + let messages: Vec = transcript + .messages .iter() + .enumerate() .filter(|msg| { // Skip tool result messages (merged into tool cards above) - !matches!(msg.role, CoreMessageRole::Tool) + msg.1.role != "tool" // Skip system messages (internal) - && !matches!(msg.role, CoreMessageRole::System) + && msg.1.role != "system" }) - .map(|msg| { - let mut chat_msg = ChatMessage::from_core_message(msg); + .map(|(index, msg)| { + let mut chat_msg = ChatMessage::from_transcript_message(msg, index); // Merge tool results into corresponding tool display states for item in &mut chat_msg.flow_items { if let FlowItem::Tool { tool_state } = item { @@ -1091,8 +1130,10 @@ fn truncate_string(s: &str, max_len: usize) -> String { #[cfg(test)] mod tests { - use super::{ChatState, FlowItem}; - use bitfun_core::agentic::core::message::{Message, ToolCall}; + use super::{ChatState, FlowItem, ToolDisplayStatus}; + use bitfun_agent_runtime::sdk::{ + SessionTranscript, TranscriptContent, TranscriptMessage, TranscriptToolCall, + }; use bitfun_events::{ToolEventData, ToolEventIdentity}; use serde_json::json; @@ -1151,36 +1192,104 @@ mod tests { #[test] fn deferred_history_projects_effective_view_without_mutating_wire_message() { let wire_input = deferred_input(); - let messages = vec![Message::assistant_with_tools( - String::new(), - vec![ToolCall { - tool_id: "tool-1".to_string(), - tool_name: bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME.to_string(), - arguments: wire_input.clone(), - raw_arguments: None, - is_error: false, - recovered_from_truncation: false, + let transcript = SessionTranscript { + session_id: "session-1".to_string(), + messages: vec![TranscriptMessage { + id: Some("message-1".to_string()), + role: "assistant".to_string(), + turn_id: Some("turn-1".to_string()), + timestamp_ms: Some(1234), + content: TranscriptContent::Mixed { + reasoning_content: None, + text: String::new(), + tool_calls: vec![TranscriptToolCall { + tool_id: "tool-1".to_string(), + tool_name: bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME.to_string(), + arguments: wire_input.clone(), + }], + }, }], - )]; + }; - let state = ChatState::from_core_messages( + let state = ChatState::from_session_transcript( "session-1".to_string(), "Session".to_string(), "agentic".to_string(), None, - &messages, + &transcript, ); assert_create_plan_item(&state.messages[0].flow_items[0]); - let bitfun_core::agentic::core::message::MessageContent::Mixed { tool_calls, .. } = - &messages[0].content - else { - panic!("expected mixed message"); - }; assert_eq!( - tool_calls[0].tool_name, + match &transcript.messages[0].content { + TranscriptContent::Mixed { tool_calls, .. } => tool_calls[0].tool_name.as_str(), + _ => panic!("expected mixed transcript content"), + }, bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME ); - assert_eq!(tool_calls[0].arguments, wire_input); + assert_eq!( + match &transcript.messages[0].content { + TranscriptContent::Mixed { tool_calls, .. } => &tool_calls[0].arguments, + _ => panic!("expected mixed transcript content"), + }, + &wire_input + ); + } + + #[test] + fn transcript_history_merges_tool_results_into_the_rendered_tool_card() { + let transcript = SessionTranscript { + session_id: "session-1".to_string(), + messages: vec![ + TranscriptMessage { + id: Some("assistant-1".to_string()), + role: "assistant".to_string(), + turn_id: Some("turn-1".to_string()), + timestamp_ms: Some(1234), + content: TranscriptContent::Mixed { + reasoning_content: None, + text: String::new(), + tool_calls: vec![TranscriptToolCall { + tool_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + arguments: json!({ "file_path": "README.md" }), + }], + }, + }, + TranscriptMessage { + id: Some("tool-result-1".to_string()), + role: "tool".to_string(), + turn_id: Some("turn-1".to_string()), + timestamp_ms: Some(1300), + content: TranscriptContent::ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + effective_tool_name: None, + result: json!({ "display_summary": "README contents" }), + is_error: true, + }, + }, + ], + }; + + let state = ChatState::from_session_transcript( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + None, + &transcript, + ); + + assert_eq!(state.messages.len(), 1); + assert_eq!(state.messages[0].id, "assistant-1"); + let FlowItem::Tool { tool_state } = &state.messages[0].flow_items[0] else { + panic!("expected tool item"); + }; + assert_eq!(tool_state.status, ToolDisplayStatus::Failed); + assert_eq!(tool_state.result.as_deref(), Some("README contents")); + assert_eq!( + tool_state.metadata, + Some(json!({ "display_summary": "README contents" })) + ); } } diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 54adab76d7..b8e8ab61ce 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -490,20 +490,25 @@ impl ChatMode { Some(effective_workspace_path.to_string_lossy().to_string()); // Load historical messages for UI display - let messages = agent.get_messages(&rid).await.unwrap_or_default(); + let transcript = agent.get_transcript(&rid).await.unwrap_or_else(|_| { + bitfun_agent_runtime::sdk::SessionTranscript { + session_id: rid.clone(), + messages: Vec::new(), + } + }); - let state = ChatState::from_core_messages( + let state = ChatState::from_session_transcript( rid.clone(), summary.session_name, summary.agent_type, effective_workspace, - &messages, + &transcript, ); tracing::info!( "Session restored: {}, {} messages loaded", rid, - messages.len() + transcript.messages.len() ); Ok::<_, anyhow::Error>((rid, state)) @@ -2941,15 +2946,20 @@ impl ChatMode { let effective_workspace = Some(effective_workspace_path.to_string_lossy().to_string()); - // Load historical messages from core. - let messages = agent.get_messages(&sid).await.unwrap_or_default(); + // Load historical messages through the runtime transcript contract. + let transcript = agent.get_transcript(&sid).await.unwrap_or_else(|_| { + bitfun_agent_runtime::sdk::SessionTranscript { + session_id: sid.clone(), + messages: Vec::new(), + } + }); - let state = ChatState::from_core_messages( + let state = ChatState::from_session_transcript( sid.clone(), session_summary.session_name, restored_agent_type.clone(), effective_workspace, - &messages, + &transcript, ); Ok::<_, anyhow::Error>((state, restored_agent_type)) diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 26478ef054..773425656d 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -3,7 +3,10 @@ use anyhow::{Context, Result}; use std::io::IsTerminal; use std::path::Path; +use bitfun_agent_runtime::sdk::{AgentSessionRestoreRequest, SessionTranscriptRequest}; + use crate::{ + chat_state::{transcript_message_preview, transcript_role_label}, config::CliConfig, diagnostics::{emit_exit_diagnostic, ExitContext, ExitKind}, modes::exec::{ @@ -223,55 +226,37 @@ pub(crate) async fn handle_session_action( let session_id = resolve_cli_session_id(runtime.agent_runtime(), &workspace_path, &id).await?; - let session = runtime - .compatibility() - .restore_session(&workspace_path, &session_id) + let restored = runtime + .agent_runtime() + .restore_session(AgentSessionRestoreRequest { + workspace_path: workspace_path.to_string_lossy().to_string(), + session_id: session_id.clone(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await?; + let transcript = runtime + .agent_runtime() + .read_session_transcript(SessionTranscriptRequest { + session_id: session_id.clone(), + turn_id: None, + }) .await?; - let messages = runtime.compatibility().get_messages(&session_id).await?; println!("Session Details\n"); - println!("Name: {}", session.session_name); - println!("ID: {}", session.session_id); - println!("Agent: {}", session.agent_type); - println!("State: {:?}", session.state); - println!("Messages: {}", messages.len()); + println!("Name: {}", restored.session.session_name); + println!("ID: {}", restored.session.session_id); + println!("Agent: {}", restored.session.agent_type); + println!("State: {:?}", restored.state); + println!("Messages: {}", transcript.messages.len()); println!(); - if !messages.is_empty() { + if !transcript.messages.is_empty() { println!("Recent messages:"); - let recent: Vec<_> = messages.iter().rev().take(5).collect(); + let recent: Vec<_> = transcript.messages.iter().rev().take(5).collect(); for msg in recent.iter().rev() { - let role = format!("{:?}", msg.role); - let content_preview = match &msg.content { - bitfun_core::agentic::core::message::MessageContent::Text(text) => { - text.lines().next().unwrap_or("").to_string() - } - bitfun_core::agentic::core::message::MessageContent::Multimodal { - text, - images, - } => { - if text.is_empty() { - format!("[{} images]", images.len()) - } else { - text.lines().next().unwrap_or("").to_string() - } - } - bitfun_core::agentic::core::message::MessageContent::Mixed { - text, - tool_calls, - .. - } => { - if text.is_empty() { - format!("[{} tool calls]", tool_calls.len()) - } else { - text.lines().next().unwrap_or("").to_string() - } - } - bitfun_core::agentic::core::message::MessageContent::ToolResult { - tool_name, - .. - } => format!("[Tool result: {}]", tool_name), - }; + let role = transcript_role_label(&msg.role); + let content_preview = transcript_message_preview(msg); let preview = if content_preview.len() > 80 { truncate_str(&content_preview, 77) } else { diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index ae49241e77..8e86f22bb7 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -7732,6 +7732,45 @@ impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinato } } +#[async_trait::async_trait] +impl bitfun_agent_runtime::sdk::AgentSessionRestorePort for ConversationCoordinator { + async fn restore_session( + &self, + request: bitfun_agent_runtime::sdk::AgentSessionRestoreRequest, + ) -> bitfun_runtime_ports::PortResult + { + bitfun_core_types::validate_session_id(&request.session_id).map_err(|message| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::InvalidRequest, + message, + ) + })?; + let session = self + .restore_session_for_workspace( + SessionStoragePathRequest { + workspace_path: PathBuf::from(request.workspace_path), + remote_connection_id: request.remote_connection_id, + remote_ssh_host: request.remote_ssh_host, + }, + &request.session_id, + ) + .await + .map_err(runtime_port_error_from_bitfun)?; + + Ok(bitfun_agent_runtime::sdk::AgentSessionRestoreResult { + session: bitfun_runtime_ports::AgentSessionSummary { + session_id: session.session_id, + session_name: session.session_name, + agent_type: session.agent_type, + turn_count: session.dialog_turn_ids.len(), + created_at_ms: runtime_session_time_ms(session.created_at), + last_active_at_ms: runtime_session_time_ms(session.last_activity_at), + }, + state: session.state, + }) + } +} + #[async_trait::async_trait] impl bitfun_runtime_ports::AgentThreadGoalManagementPort for ConversationCoordinator { async fn get_thread_goal( @@ -7875,12 +7914,7 @@ impl bitfun_runtime_ports::SessionTranscriptReader for ConversationCoordinator { let messages = self .get_messages(&request.session_id) .await - .map_err(|error| { - bitfun_runtime_ports::PortError::new( - bitfun_runtime_ports::PortErrorKind::Backend, - error.to_string(), - ) - })?; + .map_err(runtime_port_error_from_bitfun)?; let messages = messages .into_iter() @@ -7897,10 +7931,54 @@ impl bitfun_runtime_ports::SessionTranscriptReader for ConversationCoordinator { } .to_string(); + let content = match message.content { + MessageContent::Text(text) => { + bitfun_runtime_ports::TranscriptContent::Text(text) + } + MessageContent::Multimodal { text, images } => { + bitfun_runtime_ports::TranscriptContent::Multimodal { + text, + image_count: images.len(), + } + } + MessageContent::ToolResult { + tool_id, + tool_name, + effective_tool_name, + result, + is_error, + .. + } => bitfun_runtime_ports::TranscriptContent::ToolResult { + tool_id, + tool_name, + effective_tool_name, + result, + is_error, + }, + MessageContent::Mixed { + reasoning_content, + text, + tool_calls, + } => bitfun_runtime_ports::TranscriptContent::Mixed { + reasoning_content, + text, + tool_calls: tool_calls + .into_iter() + .map(|tool_call| bitfun_runtime_ports::TranscriptToolCall { + tool_id: tool_call.tool_id, + tool_name: tool_call.tool_name, + arguments: tool_call.arguments, + }) + .collect(), + }, + }; + bitfun_runtime_ports::TranscriptMessage { + id: Some(message.id), role, turn_id: message.metadata.turn_id, - content: serde_json::to_value(message.content).unwrap_or_default(), + timestamp_ms: Some(runtime_session_time_ms(message.timestamp)), + content, } }) .collect(); diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 2f85af27b6..e3bdb3d53d 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -17,7 +17,7 @@ use bitfun_runtime_services::RuntimeServices; use crate::agentic::coordination::{ ConversationCoordinator, DialogScheduler, SessionMaintenancePermit, }; -use crate::agentic::core::{Message, Session, SessionConfig, SessionState}; +use crate::agentic::core::{Session, SessionConfig, SessionState}; use crate::agentic::keyed_lock::KeyedAsyncLockGuard; use crate::agentic::persistence::session_branch::{SessionBranchRequest, SessionBranchResult}; use crate::agentic::persistence::{PersistenceManager, SessionMetadataPage}; @@ -242,16 +242,6 @@ impl CoreAgentRuntimeCompatibility { } } - pub async fn restore_session( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult { - self.coordinator - .restore_session(workspace_path, session_id) - .await - } - pub async fn is_session_loaded( &self, workspace_path: &Path, @@ -263,10 +253,6 @@ impl CoreAgentRuntimeCompatibility { .await } - pub async fn get_messages(&self, session_id: &str) -> BitFunResult> { - self.coordinator.get_messages(session_id).await - } - pub async fn update_session_model(&self, session_id: &str, model_id: &str) -> BitFunResult<()> { self.coordinator .update_session_model(session_id, model_id) @@ -698,8 +684,6 @@ mod tests { let _ = build; let _ = CoreAgentRuntimeCompatibility::create_session_with_id; - let _ = CoreAgentRuntimeCompatibility::restore_session; - let _ = CoreAgentRuntimeCompatibility::get_messages; let _ = CoreAgentRuntimeCompatibility::branch_session_at_latest_turn; let _ = CoreAgentRuntimeCompatibility::generate_session_usage_report; let _ = CoreAgentRuntimeCompatibility::list_persisted_sessions; diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 347b08ceca..100ca2be6b 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -5,7 +5,9 @@ //! session restore, terminal pre-warm, remote image conversion, and runtime-port //! implementations until a reviewed port/provider migration proves equivalence. -use bitfun_agent_runtime::sdk::{AgentRuntime, AgentRuntimeBuilder, RuntimeError}; +use bitfun_agent_runtime::sdk::{ + AgentRuntime, AgentRuntimeBuilder, AgentSessionRestorePort, RuntimeError, +}; use bitfun_runtime_ports::{ AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, AgentSessionCreateRequest, AgentSessionManagementPort, AgentSubmissionPort, @@ -377,6 +379,8 @@ fn agent_input_attachment_from_image_context(context: ImageContextData) -> Agent fn core_agent_runtime_builder( submission: Arc, session_management: Arc, + session_restore: Arc, + transcript_reader: Arc, thread_goal_management: Arc, cancellation: Arc, ) -> AgentRuntimeBuilder { @@ -385,6 +389,8 @@ fn core_agent_runtime_builder( AgentRuntimeBuilder::new() .with_submission_port(submission) .with_session_management_port(session_management) + .with_session_restore_port(session_restore) + .with_session_transcript_reader(transcript_reader) .with_thread_goal_management_port(thread_goal_management) .with_cancellation_port(cancellation) .with_agent_registry(agent_registry) @@ -760,11 +766,16 @@ impl CoreServiceAgentRuntime { ) -> Result { let submission: Arc = coordinator.clone(); let session_management: Arc = coordinator.clone(); + let session_restore: Arc = coordinator.clone(); + let transcript_reader: Arc = + coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator; core_agent_runtime_builder( submission, session_management, + session_restore, + transcript_reader, thread_goal_management, cancellation, ) @@ -779,6 +790,9 @@ impl CoreServiceAgentRuntime { let submission: Arc = coordinator.clone(); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); + let session_restore: Arc = coordinator.clone(); + let transcript_reader: Arc = + coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator; let dialog_turn: Arc = scheduler.clone(); @@ -786,6 +800,8 @@ impl CoreServiceAgentRuntime { core_agent_runtime_builder( submission, session_management, + session_restore, + transcript_reader, thread_goal_management, cancellation, ) @@ -802,12 +818,17 @@ impl CoreServiceAgentRuntime { let submission: Arc = coordinator.clone(); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); + let session_restore: Arc = coordinator.clone(); + let transcript_reader: Arc = + coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator; let lifecycle_delivery: Arc = scheduler; core_agent_runtime_builder( submission, session_management, + session_restore, + transcript_reader, thread_goal_management, cancellation, ) @@ -823,6 +844,9 @@ impl CoreServiceAgentRuntime { let submission: Arc = coordinator.clone(); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); + let session_restore: Arc = coordinator.clone(); + let transcript_reader: Arc = + coordinator.clone(); let thread_goal_management: Arc = coordinator; let cancellation: Arc = scheduler.clone(); let dialog_turn: Arc = scheduler.clone(); @@ -830,6 +854,8 @@ impl CoreServiceAgentRuntime { core_agent_runtime_builder( submission, session_management, + session_restore, + transcript_reader, thread_goal_management, cancellation, ) @@ -848,6 +874,9 @@ impl CoreServiceAgentRuntime { let submission: Arc = coordinator.clone(); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); + let session_restore: Arc = coordinator.clone(); + let transcript_reader: Arc = + coordinator.clone(); let thread_goal_management: Arc = coordinator; let cancellation: Arc = scheduler.clone(); let dialog_turn: Arc = scheduler.clone(); @@ -856,6 +885,8 @@ impl CoreServiceAgentRuntime { core_agent_runtime_builder( submission, session_management, + session_restore, + transcript_reader, thread_goal_management, cancellation, ) diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 57ddb1f942..052adcc681 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -1828,14 +1828,54 @@ pub struct SessionTranscript { pub messages: Vec, } +/// Read-only transcript content shared by runtime consumers. +/// +/// This projection preserves portable history facts without exposing the Core persistence +/// message type. Multimodal entries report attachment counts rather than transporting image +/// payloads; callers that need attachment content require a separate, authorized capability. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum TranscriptContent { + Text(String), + Multimodal { + text: String, + image_count: usize, + }, + ToolResult { + tool_id: String, + tool_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + effective_tool_name: Option, + result: serde_json::Value, + is_error: bool, + }, + Mixed { + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_content: Option, + text: String, + #[serde(default)] + tool_calls: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TranscriptToolCall { + pub tool_id: String, + pub tool_name: String, + #[serde(default)] + pub arguments: serde_json::Value, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TranscriptMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, pub role: String, #[serde(skip_serializing_if = "Option::is_none")] pub turn_id: Option, - #[serde(default)] - pub content: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp_ms: Option, + pub content: TranscriptContent, } #[async_trait::async_trait] @@ -2671,6 +2711,23 @@ mod tests { assert!(json.get("fromTurnId").is_none()); } + #[test] + fn transcript_contract_keeps_portable_message_identity_and_content() { + let message = TranscriptMessage { + id: Some("message_1".to_string()), + role: "assistant".to_string(), + turn_id: Some("turn_1".to_string()), + timestamp_ms: Some(3000), + content: TranscriptContent::Text("done".to_string()), + }; + + let message_json = serde_json::to_value(message).expect("serialize transcript message"); + + assert_eq!(message_json["id"], "message_1"); + assert_eq!(message_json["timestampMs"], 3000); + assert_eq!(message_json["content"]["Text"], "done"); + } + #[test] fn dynamic_tool_descriptor_serializes_current_wire_shape() { let descriptor = DynamicToolDescriptor { diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 32a0639d6b..4d429f6342 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -18,8 +18,9 @@ use bitfun_runtime_ports::{ AgentSubmissionResult, AgentSubmissionSource, AgentThreadGoalCreateRequest, AgentThreadGoalDeliveryRequest, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, - AgentTurnCancellationResult, DialogSubmitOutcome, PluginRuntimeBinding, PortError, - RuntimeEventEnvelope, ThreadGoal, + AgentTurnCancellationResult, DialogSubmitOutcome, PluginRuntimeBinding, PortError, PortResult, + RuntimeEventEnvelope, SessionTranscript, SessionTranscriptReader, SessionTranscriptRequest, + ThreadGoal, }; use bitfun_runtime_services::RuntimeServices; @@ -43,6 +44,10 @@ pub enum RuntimeError { MissingCancellationPort, #[error("agent session management port is not registered")] MissingSessionManagementPort, + #[error("agent session restore port is not registered")] + MissingSessionRestorePort, + #[error("session transcript reader is not registered")] + MissingSessionTranscriptReader, #[error("agent thread goal management port is not registered")] MissingThreadGoalManagementPort, #[error("runtime event sink is not registered")] @@ -51,6 +56,32 @@ pub enum RuntimeError { Port(#[from] PortError), } +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionRestoreRequest { + pub workspace_path: String, + pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionRestoreResult { + pub session: AgentSessionSummary, + pub state: crate::session_state::SessionState, +} + +#[async_trait::async_trait] +pub trait AgentSessionRestorePort: Send + Sync { + async fn restore_session( + &self, + request: AgentSessionRestoreRequest, + ) -> PortResult; +} + #[derive(Clone, Default)] pub struct AgentEventStream { events: Arc>>, @@ -103,6 +134,8 @@ pub trait RuntimeAgentRegistry: Send + Sync { pub struct AgentRuntime { submission: Arc, session_management: Option>, + session_restore: Option>, + session_transcript_reader: Option>, thread_goal_management: Option>, dialog_turn: Option>, lifecycle_delivery: Option>, @@ -127,6 +160,20 @@ impl std::fmt::Debug for AgentRuntime { .as_ref() .map(|_| ""), ) + .field( + "session_restore", + &self + .session_restore + .as_ref() + .map(|_| ""), + ) + .field( + "session_transcript_reader", + &self + .session_transcript_reader + .as_ref() + .map(|_| ""), + ) .field( "thread_goal_management", &self @@ -201,6 +248,8 @@ where pub struct AgentRuntimeBuilder { submission: Option>, session_management: Option>, + session_restore: Option>, + session_transcript_reader: Option>, thread_goal_management: Option>, dialog_turn: Option>, lifecycle_delivery: Option>, @@ -232,6 +281,19 @@ impl AgentRuntimeBuilder { self } + pub fn with_session_restore_port(mut self, port: Arc) -> Self { + self.session_restore = Some(port); + self + } + + pub fn with_session_transcript_reader( + mut self, + reader: Arc, + ) -> Self { + self.session_transcript_reader = Some(reader); + self + } + pub fn with_thread_goal_management_port( mut self, port: Arc, @@ -297,6 +359,8 @@ impl AgentRuntimeBuilder { let Self { submission, session_management, + session_restore, + session_transcript_reader, thread_goal_management, dialog_turn, lifecycle_delivery, @@ -317,6 +381,8 @@ impl AgentRuntimeBuilder { Ok(AgentRuntime { submission: submission.ok_or(RuntimeBuildError::MissingSubmissionPort)?, session_management, + session_restore, + session_transcript_reader, thread_goal_management, dialog_turn, lifecycle_delivery, @@ -500,6 +566,34 @@ impl AgentRuntime { .map_err(RuntimeError::from) } + pub async fn restore_session( + &self, + request: AgentSessionRestoreRequest, + ) -> Result { + let session_restore = self + .session_restore + .as_ref() + .ok_or(RuntimeError::MissingSessionRestorePort)?; + session_restore + .restore_session(request) + .await + .map_err(RuntimeError::from) + } + + pub async fn read_session_transcript( + &self, + request: SessionTranscriptRequest, + ) -> Result { + let reader = self + .session_transcript_reader + .as_ref() + .ok_or(RuntimeError::MissingSessionTranscriptReader)?; + reader + .read_session_transcript(request) + .await + .map_err(RuntimeError::from) + } + pub async fn resolve_session_workspace_binding( &self, request: AgentSessionWorkspaceRequest, @@ -701,6 +795,7 @@ impl AgentRuntime { #[cfg(test)] mod tests { use super::*; + use crate::session_state::SessionState; use bitfun_runtime_ports::{ AgentBackgroundResultRequest, AgentDialogTurnRequest, AgentLifecycleDeliveryPort, AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionListRequest, @@ -710,8 +805,9 @@ mod tests { DialogSubmissionPolicy, DialogSubmitOutcome, FileSystemPort, PermissionPort, PluginDispatchEnvelope, PluginResponseEnvelope, PluginRuntimeAvailability, PluginRuntimeClient, PluginRuntimeUnavailableReason, PortErrorKind, PortResult, - RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, SessionStorePort, ThreadGoal, - ThreadGoalStatus, WorkspacePort, + RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, SessionStorePort, + SessionTranscript, SessionTranscriptReader, SessionTranscriptRequest, ThreadGoal, + ThreadGoalStatus, TranscriptContent, TranscriptMessage, WorkspacePort, }; use bitfun_runtime_services::{test_support::FakeRuntimePort, RuntimeServicesBuilder}; @@ -722,6 +818,8 @@ mod tests { cancelled_turns: Mutex>, listed_sessions: Mutex>, deleted_sessions: Mutex>, + restored_sessions: Mutex>, + transcript_requests: Mutex>, workspace_binding_requests: Mutex>, thread_goal_gets: Mutex>, thread_goal_creates: Mutex>, @@ -843,6 +941,50 @@ mod tests { } } + #[async_trait::async_trait] + impl AgentSessionRestorePort for FakeAgentRuntimePorts { + async fn restore_session( + &self, + request: AgentSessionRestoreRequest, + ) -> PortResult { + self.restored_sessions.lock().unwrap().push(request); + Ok(AgentSessionRestoreResult { + session: AgentSessionSummary { + session_id: "session_1".to_string(), + session_name: "Main".to_string(), + agent_type: "agentic".to_string(), + turn_count: 3, + created_at_ms: 1000, + last_active_at_ms: 2000, + }, + state: SessionState::Idle, + }) + } + } + + #[async_trait::async_trait] + impl SessionTranscriptReader for FakeAgentRuntimePorts { + async fn read_session_transcript( + &self, + request: SessionTranscriptRequest, + ) -> PortResult { + self.transcript_requests + .lock() + .unwrap() + .push(request.clone()); + Ok(SessionTranscript { + session_id: request.session_id, + messages: vec![TranscriptMessage { + id: Some("message_1".to_string()), + role: "assistant".to_string(), + turn_id: request.turn_id, + timestamp_ms: Some(1000), + content: TranscriptContent::Text("done".to_string()), + }], + }) + } + } + #[async_trait::async_trait] impl AgentSubmissionPort for FakeAgentRuntimePorts { async fn create_session( @@ -1300,6 +1442,112 @@ mod tests { ); } + #[tokio::test] + async fn session_restore_and_transcript_read_delegate_to_registered_ports() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_session_management_port(ports.clone()) + .with_session_restore_port(ports.clone()) + .with_session_transcript_reader(ports.clone()) + .build() + .expect("runtime"); + + let restored = runtime + .restore_session(AgentSessionRestoreRequest { + workspace_path: "/workspace/project".to_string(), + session_id: "session_1".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .expect("restore session"); + let transcript = runtime + .read_session_transcript(SessionTranscriptRequest { + session_id: "session_1".to_string(), + turn_id: None, + }) + .await + .expect("read transcript"); + + assert_eq!(restored.session.session_id, "session_1"); + assert_eq!(transcript.messages[0].id.as_deref(), Some("message_1")); + assert_eq!(ports.restored_sessions.lock().unwrap().len(), 1); + assert_eq!(ports.transcript_requests.lock().unwrap().len(), 1); + } + + #[test] + fn session_restore_contract_serializes_runtime_owned_state() { + let request = AgentSessionRestoreRequest { + workspace_path: "/workspace/project".to_string(), + session_id: "session_1".to_string(), + remote_connection_id: Some("conn-1".to_string()), + remote_ssh_host: Some("host-1".to_string()), + }; + let result = AgentSessionRestoreResult { + session: AgentSessionSummary { + session_id: "session_1".to_string(), + session_name: "Main".to_string(), + agent_type: "agentic".to_string(), + turn_count: 3, + created_at_ms: 1000, + last_active_at_ms: 2000, + }, + state: SessionState::Error { + error: "recoverable failure".to_string(), + recoverable: true, + }, + }; + + let request_json = serde_json::to_value(request).expect("serialize restore request"); + let result_json = serde_json::to_value(result).expect("serialize restore result"); + + assert_eq!(request_json["workspacePath"], "/workspace/project"); + assert_eq!(request_json["remoteConnectionId"], "conn-1"); + assert_eq!(request_json["remoteSshHost"], "host-1"); + assert_eq!(result_json["state"]["Error"]["recoverable"], true); + } + + #[tokio::test] + async fn session_restore_requires_registered_port() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .restore_session(AgentSessionRestoreRequest { + workspace_path: "/workspace/project".to_string(), + session_id: "session_1".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .unwrap_err(); + + assert_eq!(error, RuntimeError::MissingSessionRestorePort); + } + + #[tokio::test] + async fn transcript_read_requires_registered_reader() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .read_session_transcript(SessionTranscriptRequest { + session_id: "session_1".to_string(), + turn_id: None, + }) + .await + .unwrap_err(); + + assert_eq!(error, RuntimeError::MissingSessionTranscriptReader); + } + #[tokio::test] async fn submit_dialog_turn_requires_registered_dialog_turn_port() { let ports = Arc::new(FakeAgentRuntimePorts::default()); diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index 1496ce5f05..d8557eab16 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -40,7 +40,8 @@ pub use crate::post_call_hooks::{ RuntimeHookRegistryBuildError, }; pub use crate::runtime::{ - AgentEventStream, AgentRunHandle, AgentRunRequest, RuntimeAgentRegistry, + AgentEventStream, AgentRunHandle, AgentRunRequest, AgentSessionRestorePort, + AgentSessionRestoreRequest, AgentSessionRestoreResult, RuntimeAgentRegistry, RuntimeAgentRegistryQuery, RuntimeBuildError, RuntimeError, RuntimeToolRegistry, SessionSelector, }; @@ -66,8 +67,9 @@ pub use bitfun_runtime_ports::{ RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, RemoteWorkspacePort, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, RuntimeEventEnvelope, RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, RuntimeServicePort, SessionStorageKind, - SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, TerminalPort, - ThreadGoal, ThreadGoalStatus, WorkspacePort, + SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, SessionTranscript, + SessionTranscriptReader, SessionTranscriptRequest, TerminalPort, ThreadGoal, ThreadGoalStatus, + TranscriptContent, TranscriptMessage, TranscriptToolCall, WorkspacePort, }; pub use bitfun_runtime_services::{ CapabilityAvailability, RuntimeServices, RuntimeServicesBuilder, RuntimeServicesError, @@ -110,6 +112,19 @@ impl AgentRuntimeBuilder { self } + pub fn with_session_restore_port(mut self, port: Arc) -> Self { + self.inner = self.inner.with_session_restore_port(port); + self + } + + pub fn with_session_transcript_reader( + mut self, + reader: Arc, + ) -> Self { + self.inner = self.inner.with_session_transcript_reader(reader); + self + } + pub fn with_thread_goal_management_port( mut self, port: Arc, @@ -213,6 +228,20 @@ impl AgentRuntime { self.inner.delete_session(request).await } + pub async fn restore_session( + &self, + request: AgentSessionRestoreRequest, + ) -> Result { + self.inner.restore_session(request).await + } + + pub async fn read_session_transcript( + &self, + request: SessionTranscriptRequest, + ) -> Result { + self.inner.read_session_transcript(request).await + } + pub async fn resolve_session_workspace_binding( &self, request: AgentSessionWorkspaceRequest,