diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 1a80284d7f..2692fe406d 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -22,9 +22,9 @@ - `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、workspace search concrete owner、remote SSH/SFTP/PTY owner、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO。 - `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy 和 provider-entry materialization;core 只保留 Computer Use 旧 public path re-export / compatibility shim 与产品执行入口。 - `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、process manager / host adapter 调用、read-state adapter、remote shell/FS 调用和旧工具入口。 -- `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、session/config/summary facts、persisted session state sidecar / processing-state sanitization、session state facts / event-label projection、dialog-turn id / stats facts、side-question runtime-only tracking、thread-goal facts、context profile / model capability policy、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation gate / wait channel state、user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping、DeepResearch citation renumber 与 report post-process gate,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。SDK facade 已支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 +- `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、session/config/summary facts、persisted session state sidecar / processing-state sanitization、session state facts / event-label projection、session state manager / event emission owner、dialog-turn id / stats facts、side-question runtime-only tracking、thread-goal facts、context profile / model capability policy、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation gate / wait channel state、user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping、DeepResearch citation renumber 与 report post-process gate,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。SDK facade 已支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 - `harness` 已建立 descriptor、route plan 和 legacy provider registry。 -- `product-domains` 已承接 MiniApp state/workflow planning、compile / permission adaptation、import lifecycle、AI / Agent permission、rate-limit、model/message/session/workspace/turn-text bridge rules、AI / Agent 请求计划、stream / runtime event payload、worker restart / draft key / workspace input 规则、function-agent prompt/parser/response policy 和部分 Git snapshot/fallback 逻辑。 +- `product-domains` 已承接 MiniApp state/workflow planning、built-in seed orchestration / host adapter contract、compile / permission adaptation、import lifecycle、AI / Agent permission、rate-limit、model/message/session/workspace/turn-text bridge rules、AI / Agent 请求计划、stream / runtime event payload、worker restart / draft key / workspace input 规则、function-agent prompt/parser/response policy 和部分 Git snapshot/fallback 逻辑。 - `bitfun-core` 的 function-agent AI concrete acquisition 已从旧 `runtime_services` 路径收拢到明确的 core port adapter;Git / AI compatibility re-export 仍保留旧 public path。 - Product Assembly 已承接 `DeliveryProfile`、当前交付形态入口矩阵、`CapabilitySet`、feature group matrix、profile-scoped capability plan、product-full provider plan、service availability report、profile-scoped harness registry 入口与 legacy-route 行为保护,以及 `ProductAssembler` 对 explicit profile input、runtime services、harness registry 和 service requirement 的验证;core 只保留兼容 re-export。ProductFull / Desktop / CLI / ACP 保留完整能力;Server / Remote / Web / MobileWeb 不再 materialize product-full capability packs、feature groups、runtime services、tool groups 或 harness routes。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 991fdd2206..682b6da87b 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -27,37 +27,12 @@ - Custom agent / mode / skill、Agent lifecycle、tool side-effect、Computer Use、file tool、MiniApp、DeepReview、DeepResearch、remote-connect、workspace search、remote SSH/SFTP/PTY 等多批 provider-neutral 或 concrete owner 已迁出。 - Root boundary scripts 已覆盖核心 owner 防回流、six-layer path 解析、facade-only 文件、custom agent owner / custom subagent wrapper 保护和重点 feature gate。 - Agent Runtime session workspace resolution、Cron / SessionControl / SessionMessage / SessionHistory 的 target session/workspace owner routing、`/goal` tool management runtime-port routing、session/config/context/lifecycle fact owner 收口,以及 `services-integrations` workspace search preview/result conversion 已纳入已完成摘要;后续计划只保留仍需迁移的 feature/kernel、security/control-plane、execution、extension 和 cross-platform adapter 主体工作。 +- MiniApp built-in seed orchestration 已进入 `product-domains`,core 只保留 concrete host adapter;session state manager 已进入 `agent-runtime`,core 只保留兼容 re-export。 -## 3. 后续大块 PR 节奏 +## 3. 大块 PR 节奏 后续不再按旧 H/M 标签判断完成度。每个 PR 必须包含实质迁移或旧路径显著简化,并在提交前做独立第三方视角的功能边界、依赖关系、不同产品形态和操作系统影响复审。 -### PR-A:Product Feature 与 Agent Kernel 分界收口 - -目标: - -- 建立 feature bundle / capability pack 的判定口径,把 DeepReview、MiniApp、input command、settings、UI panel 等产品特性从 Agent Kernel 能力中分离;`/goal` 已先完成 model tool 到 AgentRuntime thread-goal management port 的 owner routing。 -- 识别仍留在 `bitfun-core` 中的 provider-neutral feature mapping、command-to-runtime request、UI-facing DTO 和 long-running task 相关旧路径。 -- 迁移或显著简化至少一组实际 feature 主体路径;`/goal` 之后优先选择 DeepReview / MiniApp / input command 的 feature assembly 路径。 - -保护: - -- 保持 long-running task、goal metadata、post-turn、continuation、background delivery、UI state 和 command response wire shape 等价。 -- 补 feature pack / capability matrix focused tests,覆盖 Desktop / CLI / SDK-safe fallback。 - -### PR-B:Agent Kernel API 与安全控制面收口 - -目标: - -- 继续收敛 Kernel API 的 builder / runner / event stream / permission / session / workspace / memory / DFX contract;session/config/summary facts、persisted session state sidecar、dialog-turn facts、side-question runtime tracking、context profile policy 和 round-level tool confirmation gate 已完成 owner 收口。 -- 把安全控制面所需的 capability/effect/security decision facts 下沉为稳定 contract 或 kernel facts,而不是散落在 UI、tool、MCP、hook 和 plugin 路径。 -- 迁移或显著简化仍在 core 中的 provider-neutral scheduler、permission coordination、event routing、memory/context 或 long-running task 事实路径。 - -保护: - -- session / turn / cancellation / event sequence / permission source / audit facts 不漂移。 -- `cargo test -p bitfun-agent-runtime`、permission / event focused tests、no-default-features check 和 boundary check 必跑。 - ### PR-C:Execution 层深迁移 目标: diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index da13a1662f..49ff7f8a7e 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -1335,6 +1335,31 @@ export const forbiddenContentRules = [ }, ], }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/state_manager.rs', + patterns: [ + { + regex: /\bpub\s+struct\s+SessionStateManager\b/, + message: + 'core session state manager path must remain a compatibility facade; use bitfun-agent-runtime session_state_manager', + }, + { + regex: /\bDashMap\b/, + message: + 'core session state manager path must not own session state storage; use bitfun-agent-runtime session_state_manager', + }, + { + regex: /\bAgenticEvent::SessionStateChanged\b/, + message: + 'core session state manager path must not emit session-state events directly; use bitfun-agent-runtime session_state_manager', + }, + { + regex: /\bimpl\s+SessionStateManager\b/, + message: + 'core session state manager path must not reimplement session state transitions; use bitfun-agent-runtime session_state_manager', + }, + ], + }, { path: 'src/crates/assembly/core/src/agentic/coordination/scheduler.rs', patterns: [ diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 82e90a4365..f9a6649d12 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -2378,6 +2378,56 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/execution/agent-runtime/src/session_state_manager.rs', + reason: + 'agent-runtime owns provider-neutral session state storage, transition helpers, and SessionStateChanged event projection', + patterns: [ + { + regex: /\bpub struct SessionStateManager\b/, + message: 'missing agent-runtime session state manager owner', + }, + { + regex: /\bDashMap/, + message: 'missing session state storage owner', + }, + { + regex: /\bEventQueue\b/, + message: 'missing runtime event queue integration', + }, + { + regex: /\bAgenticEvent::SessionStateChanged\b/, + message: 'missing SessionStateChanged event projection', + }, + { + regex: /\bsession_state_label_for_state\b/, + message: 'missing stable session-state label projection', + }, + { + regex: /\bcan_start_new_turn\b/, + message: 'missing turn-start guard owner', + }, + { + regex: /\bsession_state_manager_emits_compatible_state_change_events\b/, + message: 'missing session state event compatibility test', + }, + { + regex: /\bsession_state_manager_keeps_turn_start_guard_semantics\b/, + message: 'missing session state guard compatibility test', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/state_manager.rs', + reason: + 'core session state manager path must preserve legacy imports while agent-runtime owns the implementation', + patterns: [ + { + regex: /pub use bitfun_agent_runtime::session_state_manager::SessionStateManager;/, + message: 'missing SessionStateManager compatibility re-export', + }, + ], + }, { path: 'src/crates/assembly/core/src/agentic/agents/prompt_builder/user_context.rs', reason: @@ -7142,27 +7192,23 @@ export const requiredContentRules = [ { path: 'src/crates/assembly/core/src/miniapp/builtin/mod.rs', reason: - 'core must coordinate built-in MiniApp seed decisions and recompilation while services-integrations owns seed filesystem IO', + 'core must adapt built-in MiniApp seed host operations while product-domains owns seed orchestration and services-integrations owns seed filesystem IO', patterns: [ { regex: /\bBUILTIN_APPS\b/, message: 'missing product-domain built-in MiniApp bundle re-export/use', }, { - regex: /\bbuiltin_content_hash\b/, - message: 'missing product-domain built-in MiniApp content hash use', - }, - { - regex: /\bshould_seed_builtin_app\b/, - message: 'missing product-domain built-in MiniApp seed decision use', + regex: /\bseed_builtin_miniapps_with_host\b/, + message: 'missing product-domain built-in MiniApp seed orchestrator use', }, { - regex: /\bresolve_builtin_seed_check\b/, - message: 'missing product-domain built-in MiniApp seed check use', + regex: /\bimpl BuiltinMiniAppSeedHost for CoreBuiltinMiniAppSeedHost\b/, + message: 'missing core built-in MiniApp seed host adapter', }, { - regex: /\bresolve_builtin_seed_action\b/, - message: 'missing product-domain built-in MiniApp seed action use', + regex: /\bmark_builtin_update_available\b/, + message: 'missing built-in MiniApp local-override update-record host delegation', }, { regex: /\bminiapp_builtin_io::prepare_builtin_seed_bundle_files\b/, @@ -8154,7 +8200,7 @@ export const requiredContentRules = [ { path: 'src/crates/contracts/product-domains/src/miniapp/builtin.rs', reason: - 'product-domains owns built-in MiniApp bundle assets, marker, hash, and seed-decision contracts while core keeps asset seeding IO and recompilation', + 'product-domains owns built-in MiniApp bundle assets, marker, hash, seed orchestration, and host adapter contract while core keeps concrete IO and recompilation', patterns: [ { regex: /id: "builtin-pr-review"/, @@ -8196,6 +8242,22 @@ export const requiredContentRules = [ regex: /\bpub enum BuiltinSeedAction\b/, message: 'missing built-in MiniApp seed action contract', }, + { + regex: /\bpub trait BuiltinMiniAppSeedHost\b/, + message: 'missing built-in MiniApp seed host adapter contract', + }, + { + regex: /\bpub async fn seed_builtin_miniapps_with_host\b/, + message: 'missing built-in MiniApp seed orchestrator', + }, + { + regex: /\bpub async fn seed_builtin_miniapp_with_host\b/, + message: 'missing built-in MiniApp single-bundle seed orchestrator', + }, + { + regex: /\bpub enum BuiltinMiniAppSeedOutcome\b/, + message: 'missing built-in MiniApp seed outcome contract', + }, { regex: /\bpub fn resolve_builtin_seed_check\b/, message: 'missing built-in MiniApp seed check helper', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index c38051f312..8c015bd18c 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -445,6 +445,17 @@ export function runManifestParserSelfTest({ throw new Error(`core scheduler boundary rule must forbid contract: ${contract}`); } } + const coreSessionStateManagerRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/agentic/coordination/state_manager.rs', + ); + if (!coreSessionStateManagerRuleText) { + throw new Error('missing core session state manager boundary rule'); + } + for (const contract of ['SessionStateManager', 'DashMap', 'AgenticEvent::SessionStateChanged']) { + if (!coreSessionStateManagerRuleText.includes(contract)) { + throw new Error(`core session state manager boundary rule must forbid contract: ${contract}`); + } + } const coreRoundPreemptRuleText = forbiddenRuleTextForPath( 'src/crates/assembly/core/src/agentic/round_preempt.rs', ); @@ -1485,6 +1496,23 @@ export function runManifestParserSelfTest({ path: 'src/crates/execution/agent-runtime/src/event_queue.rs', contracts: ['EventQueue', 'impl StreamEventSink for EventQueue', 'clear_session'], }, + { + path: 'src/crates/execution/agent-runtime/src/session_state_manager.rs', + contracts: [ + 'pub struct SessionStateManager', + 'DashMap', + 'EventQueue', + 'AgenticEvent::SessionStateChanged', + 'session_state_label_for_state', + 'can_start_new_turn', + 'session_state_manager_emits_compatible_state_change_events', + 'session_state_manager_keeps_turn_start_guard_semantics', + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/coordination/state_manager.rs', + contracts: ['pub use bitfun_agent_runtime::session_state_manager::SessionStateManager'], + }, { path: 'src/crates/execution/agent-runtime/src/event_router.rs', contracts: ['EventSubscriber', 'EventRouter', 'route_batch'], @@ -2734,10 +2762,10 @@ export function runManifestParserSelfTest({ path: 'src/crates/assembly/core/src/miniapp/builtin/mod.rs', contracts: [ 'BUILTIN_APPS', - 'builtin_content_hash', - 'should_seed_builtin_app', - 'resolve_builtin_seed_check', - 'resolve_builtin_seed_action', + 'seed_builtin_miniapps_with_host', + 'BuiltinMiniAppSeedHost', + 'CoreBuiltinMiniAppSeedHost', + 'mark_builtin_update_available', 'miniapp_builtin_io::prepare_builtin_seed_bundle_files', 'read_builtin_install_marker', 'miniapp_builtin_io::read_builtin_install_marker', @@ -2776,6 +2804,10 @@ export function runManifestParserSelfTest({ 'BuiltinSeedArtifacts', 'BuiltinSeedCheck', 'BuiltinSeedAction', + 'BuiltinMiniAppSeedHost', + 'seed_builtin_miniapps_with_host', + 'seed_builtin_miniapp_with_host', + 'BuiltinMiniAppSeedOutcome', 'resolve_builtin_seed_check', 'resolve_builtin_seed_action', 'serialize_builtin_install_marker', diff --git a/src/crates/assembly/core/src/agentic/coordination/state_manager.rs b/src/crates/assembly/core/src/agentic/coordination/state_manager.rs index 08938c5221..e40111bcd4 100644 --- a/src/crates/assembly/core/src/agentic/coordination/state_manager.rs +++ b/src/crates/assembly/core/src/agentic/coordination/state_manager.rs @@ -1,135 +1,3 @@ -//! Session state manager -//! -//! Provides centralized management and synchronization of session state +//! Compatibility facade for the Agent Runtime session state manager. -use crate::agentic::core::{ProcessingPhase, SessionState}; -use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue}; -use dashmap::DashMap; -use log::debug; -use std::sync::Arc; - -/// Session state manager -pub struct SessionStateManager { - /// Session states (by session ID) - states: Arc>, - - /// Event queue - event_queue: Arc, -} - -impl SessionStateManager { - pub fn new(event_queue: Arc) -> Self { - Self { - states: Arc::new(DashMap::new()), - event_queue, - } - } - - /// Initialize session state - pub async fn initialize(&self, session_id: &str) { - self.states - .insert(session_id.to_string(), SessionState::Idle); - } - - /// Get session state - pub fn get_state(&self, session_id: &str) -> Option { - self.states.get(session_id).map(|s| s.clone()) - } - - /// Update session state - pub async fn update_state(&self, session_id: &str, new_state: SessionState) { - // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. - let should_emit = if let Some(mut state) = self.states.get_mut(session_id) { - *state = new_state.clone(); - true - } else { - false - }; - // RefMut guard released here -- DashMap shard lock is free. - - if should_emit { - self.emit_state_change_event(session_id, new_state).await; - } - } - - /// Set processing phase - pub async fn set_processing_phase( - &self, - session_id: &str, - current_turn_id: String, - phase: ProcessingPhase, - ) { - self.update_state( - session_id, - SessionState::Processing { - current_turn_id, - phase, - }, - ) - .await; - } - - /// Set to idle - pub async fn set_idle(&self, session_id: &str) { - self.update_state(session_id, SessionState::Idle).await; - } - - /// Set to error - pub async fn set_error(&self, session_id: &str, error: String, recoverable: bool) { - self.update_state(session_id, SessionState::Error { error, recoverable }) - .await; - } - - /// Check if new dialog turn can be started - /// Allows Idle state or recoverable error state (e.g., after cancellation) - pub fn can_start_new_turn(&self, session_id: &str) -> bool { - if let Some(state) = self.get_state(session_id) { - matches!( - state, - SessionState::Idle - | SessionState::Error { - recoverable: true, - .. - } - ) - } else { - false - } - } - - /// Check if currently processing - pub fn is_processing(&self, session_id: &str) -> bool { - if let Some(state) = self.get_state(session_id) { - matches!(state, SessionState::Processing { .. }) - } else { - false - } - } - - /// Remove session state - pub fn remove(&self, session_id: &str) { - self.states.remove(session_id); - debug!("Removed session state: session_id={}", session_id); - } - - /// Emit state change event - async fn emit_state_change_event(&self, session_id: &str, state: SessionState) { - let event = AgenticEvent::SessionStateChanged { - session_id: session_id.to_string(), - new_state: crate::agentic::events::types::session_state_to_string(&state), - }; - - let _ = self - .event_queue - .enqueue(event, Some(EventPriority::High)) - .await; - } - - /// Get all session states - pub fn get_all_states(&self) -> Vec<(String, SessionState)> { - self.states - .iter() - .map(|entry| (entry.key().clone(), entry.value().clone())) - .collect() - } -} +pub use bitfun_agent_runtime::session_state_manager::SessionStateManager; diff --git a/src/crates/assembly/core/src/miniapp/builtin/mod.rs b/src/crates/assembly/core/src/miniapp/builtin/mod.rs index e961bc48cc..b26141a0d8 100644 --- a/src/crates/assembly/core/src/miniapp/builtin/mod.rs +++ b/src/crates/assembly/core/src/miniapp/builtin/mod.rs @@ -8,12 +8,16 @@ use crate::miniapp::manager::MiniAppManager; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_product_domains::miniapp::builtin::{ - resolve_builtin_seed_action, resolve_builtin_seed_check, BuiltinInstallMarker, - BuiltinSeedAction, BuiltinSeedCheck, BUILTIN_INSTALL_MARKER, + seed_builtin_miniapps_with_host, BuiltinInstallMarker, BuiltinMiniAppSeedBundleRequest, + BuiltinMiniAppSeedHost, BuiltinMiniAppSeedOutcome, BuiltinMiniAppSeedReport, + BuiltinSeedArtifacts, BUILTIN_INSTALL_MARKER, }; pub use bitfun_product_domains::miniapp::builtin::{ BuiltinMiniAppBundle as BuiltinApp, BUILTIN_APPS, }; +use bitfun_product_domains::miniapp::ports::{ + MiniAppPortError, MiniAppPortErrorKind, MiniAppPortFuture, +}; use bitfun_services_integrations::miniapp::builtin_io as miniapp_builtin_io; use chrono::Utc; use std::path::Path; @@ -24,91 +28,154 @@ use std::sync::Arc; /// is preserved across reseeds; source files & meta.json (without timestamps) are /// overwritten. pub async fn seed_builtin_miniapps(manager: &Arc) -> BitFunResult<()> { - for app in BUILTIN_APPS { - if let Err(e) = seed_one(manager, app).await { - log::warn!("seed builtin miniapp '{}' failed: {}", app.id, e); - } + let host = CoreBuiltinMiniAppSeedHost { + manager: Arc::clone(manager), + }; + for report in seed_builtin_miniapps_with_host(&host).await { + log_builtin_seed_report(report); } Ok(()) } -async fn seed_one(manager: &Arc, app: &BuiltinApp) -> BitFunResult<()> { - let app_dir = manager.path_manager().miniapp_dir(app.id); - let marker_path = app_dir.join(BUILTIN_INSTALL_MARKER); - let installed_marker = read_builtin_install_marker(&marker_path).await?; - let seed_artifacts = match resolve_builtin_seed_check(app, installed_marker.as_ref()) { - BuiltinSeedCheck::Skip => return Ok(()), - BuiltinSeedCheck::NeedsSeed(artifacts) => artifacts, - }; +struct CoreBuiltinMiniAppSeedHost { + manager: Arc, +} + +impl BuiltinMiniAppSeedHost for CoreBuiltinMiniAppSeedHost { + fn now_ms(&self) -> i64 { + Utc::now().timestamp_millis() + } + + fn installed_marker( + &self, + app_id: &'static str, + ) -> MiniAppPortFuture<'_, Option> { + Box::pin(async move { + let marker_path = self + .manager + .path_manager() + .miniapp_dir(app_id) + .join(BUILTIN_INSTALL_MARKER); + read_builtin_install_marker(&marker_path) + .await + .map_err(map_bitfun_error_to_miniapp_port_error) + }) + } + + fn has_local_override(&self, app_id: &'static str) -> MiniAppPortFuture<'_, bool> { + Box::pin(async move { + match self.manager.load_customization_metadata(app_id).await { + Ok(Some(metadata)) => Ok(metadata.local_override), + Ok(None) => Ok(false), + Err(e) => { + log::warn!( + "read customization metadata for builtin miniapp '{}' failed: {}", + app_id, + e + ); + Ok(false) + } + } + }) + } + + fn record_available_update( + &self, + app_id: &'static str, + version: u32, + content_hash: String, + now_ms: i64, + ) -> MiniAppPortFuture<'_, bool> { + Box::pin(async move { + self.manager + .mark_builtin_update_available(app_id, version, &content_hash, now_ms) + .await + .map_err(map_bitfun_error_to_miniapp_port_error) + }) + } + + fn seed_bundle(&self, request: BuiltinMiniAppSeedBundleRequest) -> MiniAppPortFuture<'_, ()> { + Box::pin(async move { + prepare_builtin_seed_bundle(&self.manager, request) + .await + .map_err(map_bitfun_error_to_miniapp_port_error) + }) + } + + fn write_seed_markers( + &self, + app_id: &'static str, + artifacts: BuiltinSeedArtifacts, + ) -> MiniAppPortFuture<'_, ()> { + Box::pin(async move { + let app_dir = self.manager.path_manager().miniapp_dir(app_id); + write_builtin_install_marker(&app_dir.join(BUILTIN_INSTALL_MARKER), &artifacts.marker) + .await + .map_err(map_bitfun_error_to_miniapp_port_error)?; + write_legacy_builtin_version_marker(&app_dir, &artifacts.legacy_version) + .await + .map_err(map_bitfun_error_to_miniapp_port_error) + }) + } +} + +async fn prepare_builtin_seed_bundle( + manager: &Arc, + request: BuiltinMiniAppSeedBundleRequest, +) -> BitFunResult<()> { + let app_dir = manager.path_manager().miniapp_dir(request.app.id); + miniapp_builtin_io::prepare_builtin_seed_bundle_files( + &app_dir, + request.app, + request.seeded_at_ms, + ) + .await + .map_err(map_builtin_io_error)?; + + // Recompile to assemble the final compiled.html with bridge + theme + import map. + manager.recompile(request.app.id, "dark", None).await?; + Ok(()) +} - let now = Utc::now().timestamp_millis(); - let has_local_override = match manager.load_customization_metadata(app.id).await { - Ok(Some(metadata)) => metadata.local_override, - Ok(None) => false, - Err(e) => { - log::warn!( - "read customization metadata for builtin miniapp '{}' failed: {}", - app.id, - e +fn log_builtin_seed_report(report: BuiltinMiniAppSeedReport) { + match report.outcome { + Ok(BuiltinMiniAppSeedOutcome::Skipped) => {} + Ok(BuiltinMiniAppSeedOutcome::Seeded { + version, + content_hash, + }) => { + log::info!( + "seeded builtin miniapp '{}' (v{}, {})", + report.app_id, + version, + content_hash ); - false } - }; - - match resolve_builtin_seed_action(seed_artifacts, has_local_override) { - BuiltinSeedAction::PreserveLocalOverride(artifacts) => { - let recorded = manager - .mark_builtin_update_available(app.id, app.version, &artifacts.content_hash, now) - .await?; - write_builtin_install_marker(&marker_path, &artifacts.marker).await?; - write_legacy_builtin_version_marker(&app_dir, &artifacts.legacy_version).await?; - if recorded { + Ok(BuiltinMiniAppSeedOutcome::PreservedLocalOverride { + version, + recorded_update, + .. + }) => { + if recorded_update { log::info!( "preserved customized builtin miniapp '{}' and recorded bundled update v{}", - app.id, - app.version + report.app_id, + version ); } else { log::info!( "preserved customized builtin miniapp '{}' and skipped previously declined bundled update v{}", - app.id, - app.version + report.app_id, + version ); } - return Ok(()); } - BuiltinSeedAction::SeedBundle(artifacts) => { - seed_builtin_bundle(manager, app, artifacts, now).await + Err(error) => { + log::warn!("seed builtin miniapp '{}' failed: {}", report.app_id, error); } } } -async fn seed_builtin_bundle( - manager: &Arc, - app: &BuiltinApp, - artifacts: bitfun_product_domains::miniapp::builtin::BuiltinSeedArtifacts, - now: i64, -) -> BitFunResult<()> { - let app_dir = manager.path_manager().miniapp_dir(app.id); - miniapp_builtin_io::prepare_builtin_seed_bundle_files(&app_dir, app, now) - .await - .map_err(map_builtin_io_error)?; - - // Recompile to assemble the final compiled.html with bridge + theme + import map. - manager.recompile(app.id, "dark", None).await?; - - let marker_path = app_dir.join(BUILTIN_INSTALL_MARKER); - write_builtin_install_marker(&marker_path, &artifacts.marker).await?; - write_legacy_builtin_version_marker(&app_dir, &artifacts.legacy_version).await?; - log::info!( - "seeded builtin miniapp '{}' (v{}, {})", - app.id, - app.version, - artifacts.marker.hash - ); - Ok(()) -} - async fn read_builtin_install_marker(path: &Path) -> BitFunResult> { miniapp_builtin_io::read_builtin_install_marker(path) .await @@ -146,6 +213,25 @@ fn map_builtin_io_error(err: miniapp_builtin_io::MiniAppBuiltinIoError) -> BitFu } } +fn map_bitfun_error_to_miniapp_port_error(error: BitFunError) -> MiniAppPortError { + let kind = match &error { + BitFunError::NotFound(_) => MiniAppPortErrorKind::NotFound, + BitFunError::Validation(_) => MiniAppPortErrorKind::InvalidInput, + BitFunError::Deserialization(_) | BitFunError::Serialization(_) => { + MiniAppPortErrorKind::Deserialization + } + BitFunError::Io(io_error) if io_error.kind() == std::io::ErrorKind::PermissionDenied => { + MiniAppPortErrorKind::PermissionDenied + } + BitFunError::Io(_) => MiniAppPortErrorKind::Io, + BitFunError::ProcessError(_) | BitFunError::Timeout(_) => { + MiniAppPortErrorKind::RuntimeUnavailable + } + _ => MiniAppPortErrorKind::Backend, + }; + MiniAppPortError::new(kind, error.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/crates/assembly/core/src/service/mcp/adapter/tool.rs b/src/crates/assembly/core/src/service/mcp/adapter/tool.rs index 3b8c25841f..83540ca10c 100644 --- a/src/crates/assembly/core/src/service/mcp/adapter/tool.rs +++ b/src/crates/assembly/core/src/service/mcp/adapter/tool.rs @@ -315,25 +315,3 @@ impl Default for MCPToolAdapter { Self::new() } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::service::mcp::protocol::MCPToolResultContent; - - #[test] - fn mcp_tool_result_rendering_does_not_pretruncate_before_storage_policy() { - let text = "x".repeat(12_001); - let result = MCPToolResult { - content: Some(vec![MCPToolResultContent::Text { text: text.clone() }]), - is_error: false, - structured_content: None, - meta: None, - }; - - let rendered = MCPToolWrapper::render_mcp_result_for_assistant("large_output", &result); - - assert_eq!(rendered, text); - assert!(!rendered.contains("[Result truncated:")); - } -} diff --git a/src/crates/contracts/product-domains/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index aa69741f9f..126c99a642 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -22,3 +22,6 @@ default = [] miniapp = ["dirs", "sha2", "which"] function-agents = ["log"] product-full = ["miniapp", "function-agents"] + +[dev-dependencies] +tokio = { workspace = true } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index 5fb66389fd..d13f990b9d 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs @@ -4,6 +4,7 @@ //! that is at least the bundled version. Do not hardcode bundle version numbers in //! tests — bumping a MiniApp version should not require shotgun edits across tests. +use crate::miniapp::ports::{MiniAppPortFuture, MiniAppPortResult}; use crate::miniapp::storage::{ build_package_json, ESM_DEPS_JSON, INDEX_HTML, STYLE_CSS, UI_JS, WORKER_JS, }; @@ -41,6 +42,54 @@ pub enum BuiltinSeedAction { SeedBundle(BuiltinSeedArtifacts), } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BuiltinMiniAppSeedBundleRequest { + pub app: &'static BuiltinMiniAppBundle, + pub seeded_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BuiltinMiniAppSeedOutcome { + Skipped, + Seeded { + version: u32, + content_hash: String, + }, + PreservedLocalOverride { + version: u32, + content_hash: String, + recorded_update: bool, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BuiltinMiniAppSeedReport { + pub app_id: &'static str, + pub outcome: MiniAppPortResult, +} + +pub trait BuiltinMiniAppSeedHost: Send + Sync { + fn now_ms(&self) -> i64; + fn installed_marker( + &self, + app_id: &'static str, + ) -> MiniAppPortFuture<'_, Option>; + fn has_local_override(&self, app_id: &'static str) -> MiniAppPortFuture<'_, bool>; + fn record_available_update( + &self, + app_id: &'static str, + version: u32, + content_hash: String, + now_ms: i64, + ) -> MiniAppPortFuture<'_, bool>; + fn seed_bundle(&self, request: BuiltinMiniAppSeedBundleRequest) -> MiniAppPortFuture<'_, ()>; + fn write_seed_markers( + &self, + app_id: &'static str, + artifacts: BuiltinSeedArtifacts, + ) -> MiniAppPortFuture<'_, ()>; +} + /// Pure built-in MiniApp asset bundle shape. The owning runtime still decides /// how bundles are seeded, compiled, and persisted. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -112,7 +161,7 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[ }, BuiltinMiniAppBundle { id: "builtin-ppt-live", - version: 184, + version: 189, meta_json: include_str!("builtin/assets/ppt-live/meta.json"), html: include_str!("builtin/assets/ppt-live/index.html"), css: include_str!("builtin/assets/ppt-live/style.css"), @@ -201,6 +250,56 @@ pub fn resolve_builtin_seed_action( } } +pub async fn seed_builtin_miniapps_with_host( + host: &dyn BuiltinMiniAppSeedHost, +) -> Vec { + let mut reports = Vec::with_capacity(BUILTIN_APPS.len()); + for app in BUILTIN_APPS { + reports.push(BuiltinMiniAppSeedReport { + app_id: app.id, + outcome: seed_builtin_miniapp_with_host(host, app).await, + }); + } + reports +} + +pub async fn seed_builtin_miniapp_with_host( + host: &dyn BuiltinMiniAppSeedHost, + app: &'static BuiltinMiniAppBundle, +) -> MiniAppPortResult { + let installed = host.installed_marker(app.id).await?; + let artifacts = match resolve_builtin_seed_check(app, installed.as_ref()) { + BuiltinSeedCheck::Skip => return Ok(BuiltinMiniAppSeedOutcome::Skipped), + BuiltinSeedCheck::NeedsSeed(artifacts) => artifacts, + }; + + let now_ms = host.now_ms(); + if host.has_local_override(app.id).await? { + let content_hash = artifacts.content_hash.clone(); + let recorded_update = host + .record_available_update(app.id, app.version, content_hash.clone(), now_ms) + .await?; + host.write_seed_markers(app.id, artifacts).await?; + return Ok(BuiltinMiniAppSeedOutcome::PreservedLocalOverride { + version: app.version, + content_hash, + recorded_update, + }); + } + + let content_hash = artifacts.content_hash.clone(); + host.seed_bundle(BuiltinMiniAppSeedBundleRequest { + app, + seeded_at_ms: now_ms, + }) + .await?; + host.write_seed_markers(app.id, artifacts).await?; + Ok(BuiltinMiniAppSeedOutcome::Seeded { + version: app.version, + content_hash, + }) +} + pub fn serialize_builtin_install_marker( marker: &BuiltinInstallMarker, ) -> serde_json::Result { @@ -259,7 +358,13 @@ mod tests { // Do not assert hardcoded BUILTIN_APPS[i].version or meta["version"] values here. // Version bumps should only touch bundle registration and seed runtime, not tests. - use super::{builtin_content_hash, BUILTIN_APPS}; + use super::{ + build_builtin_seed_artifacts, builtin_content_hash, seed_builtin_miniapp_with_host, + BuiltinInstallMarker, BuiltinMiniAppSeedBundleRequest, BuiltinMiniAppSeedHost, + BuiltinMiniAppSeedOutcome, BuiltinSeedArtifacts, BUILTIN_APPS, + }; + use crate::miniapp::ports::{MiniAppPortFuture, MiniAppPortResult}; + use std::sync::{Arc, Mutex}; #[test] fn builtin_miniapp_bundles_keep_product_domain_asset_owner_contract() { @@ -287,6 +392,159 @@ mod tests { } } + #[derive(Default)] + struct FakeSeedHost { + now_ms: i64, + installed_marker: Mutex>, + has_override: Mutex, + recorded_updates: Mutex>, + seeded_bundles: Mutex>, + written_markers: Mutex>, + } + + impl FakeSeedHost { + fn new() -> Arc { + Arc::new(Self { + now_ms: 12345, + ..Self::default() + }) + } + } + + impl BuiltinMiniAppSeedHost for FakeSeedHost { + fn now_ms(&self) -> i64 { + self.now_ms + } + + fn installed_marker( + &self, + _app_id: &'static str, + ) -> MiniAppPortFuture<'_, Option> { + Box::pin(async move { Ok(self.installed_marker.lock().unwrap().clone()) }) + } + + fn has_local_override(&self, _app_id: &'static str) -> MiniAppPortFuture<'_, bool> { + Box::pin(async move { Ok(*self.has_override.lock().unwrap()) }) + } + + fn record_available_update( + &self, + app_id: &'static str, + version: u32, + content_hash: String, + now_ms: i64, + ) -> MiniAppPortFuture<'_, bool> { + Box::pin(async move { + self.recorded_updates + .lock() + .unwrap() + .push((app_id, version, content_hash, now_ms)); + Ok(true) + }) + } + + fn seed_bundle( + &self, + request: BuiltinMiniAppSeedBundleRequest, + ) -> MiniAppPortFuture<'_, ()> { + Box::pin(async move { + self.seeded_bundles + .lock() + .unwrap() + .push((request.app.id, request.seeded_at_ms)); + Ok(()) + }) + } + + fn write_seed_markers( + &self, + app_id: &'static str, + artifacts: BuiltinSeedArtifacts, + ) -> MiniAppPortFuture<'_, ()> { + Box::pin(async move { + self.written_markers + .lock() + .unwrap() + .push((app_id, artifacts)); + Ok(()) + }) + } + } + + fn port_ok(result: MiniAppPortResult) -> T { + result.expect("seed host should succeed") + } + + #[tokio::test] + async fn builtin_seed_host_orchestrator_skips_current_bundle() { + let app = &BUILTIN_APPS[0]; + let host = FakeSeedHost::new(); + let artifacts = build_builtin_seed_artifacts(app); + *host.installed_marker.lock().unwrap() = Some(artifacts.marker.clone()); + + let outcome = port_ok(seed_builtin_miniapp_with_host(host.as_ref(), app).await); + + assert_eq!(outcome, BuiltinMiniAppSeedOutcome::Skipped); + assert!(host.seeded_bundles.lock().unwrap().is_empty()); + assert!(host.written_markers.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn builtin_seed_host_orchestrator_preserves_local_override() { + let app = &BUILTIN_APPS[0]; + let host = FakeSeedHost::new(); + *host.installed_marker.lock().unwrap() = Some(BuiltinInstallMarker { + version: 0, + hash: "sha256:old".to_string(), + }); + *host.has_override.lock().unwrap() = true; + + let outcome = port_ok(seed_builtin_miniapp_with_host(host.as_ref(), app).await); + + let BuiltinMiniAppSeedOutcome::PreservedLocalOverride { + version, + content_hash, + recorded_update, + } = outcome + else { + panic!("expected preserved local override"); + }; + assert_eq!(version, app.version); + assert!(content_hash.starts_with("sha256:")); + assert!(recorded_update); + assert_eq!(host.recorded_updates.lock().unwrap().len(), 1); + assert!(host.seeded_bundles.lock().unwrap().is_empty()); + assert_eq!(host.written_markers.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn builtin_seed_host_orchestrator_seeds_bundle_without_override() { + let app = &BUILTIN_APPS[0]; + let host = FakeSeedHost::new(); + *host.installed_marker.lock().unwrap() = Some(BuiltinInstallMarker { + version: 0, + hash: "sha256:old".to_string(), + }); + + let outcome = port_ok(seed_builtin_miniapp_with_host(host.as_ref(), app).await); + + let BuiltinMiniAppSeedOutcome::Seeded { + version, + content_hash, + } = outcome + else { + panic!("expected seeded bundle"); + }; + assert_eq!(version, app.version); + assert!(content_hash.starts_with("sha256:")); + assert_eq!( + host.seeded_bundles.lock().unwrap().as_slice(), + &[(app.id, host.now_ms)] + ); + assert!(host.recorded_updates.lock().unwrap().is_empty()); + assert_eq!(host.written_markers.lock().unwrap().len(), 1); + } + #[test] fn ppt_live_bundle_uses_bitfun_host_capabilities() { let app = BUILTIN_APPS diff --git a/src/crates/execution/agent-runtime/src/lib.rs b/src/crates/execution/agent-runtime/src/lib.rs index 0b4c7a0791..0f51e26cc8 100644 --- a/src/crates/execution/agent-runtime/src/lib.rs +++ b/src/crates/execution/agent-runtime/src/lib.rs @@ -29,6 +29,7 @@ pub mod sdk; pub mod session; pub mod session_control; pub mod session_state; +pub mod session_state_manager; pub mod side_question; pub mod skill_agent_snapshot; pub mod skills; diff --git a/src/crates/execution/agent-runtime/src/session_state_manager.rs b/src/crates/execution/agent-runtime/src/session_state_manager.rs new file mode 100644 index 0000000000..130394d993 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/session_state_manager.rs @@ -0,0 +1,187 @@ +//! Provider-neutral session state manager. +//! +//! This manager owns runtime session state transitions and emits the stable +//! product event projection without depending on `bitfun-core`. + +use crate::event_queue::EventQueue; +use crate::session_state::{session_state_label_for_state, ProcessingPhase, SessionState}; +use bitfun_events::{AgenticEvent, AgenticEventPriority as EventPriority}; +use dashmap::DashMap; +use log::debug; +use std::sync::Arc; + +pub struct SessionStateManager { + states: Arc>, + event_queue: Arc, +} + +impl SessionStateManager { + pub fn new(event_queue: Arc) -> Self { + Self { + states: Arc::new(DashMap::new()), + event_queue, + } + } + + pub async fn initialize(&self, session_id: &str) { + self.states + .insert(session_id.to_string(), SessionState::Idle); + } + + pub fn get_state(&self, session_id: &str) -> Option { + self.states.get(session_id).map(|state| state.clone()) + } + + pub async fn update_state(&self, session_id: &str, new_state: SessionState) { + let should_emit = if let Some(mut state) = self.states.get_mut(session_id) { + *state = new_state.clone(); + true + } else { + false + }; + + if should_emit { + self.emit_state_change_event(session_id, new_state).await; + } + } + + pub async fn set_processing_phase( + &self, + session_id: &str, + current_turn_id: String, + phase: ProcessingPhase, + ) { + self.update_state( + session_id, + SessionState::Processing { + current_turn_id, + phase, + }, + ) + .await; + } + + pub async fn set_idle(&self, session_id: &str) { + self.update_state(session_id, SessionState::Idle).await; + } + + pub async fn set_error(&self, session_id: &str, error: String, recoverable: bool) { + self.update_state(session_id, SessionState::Error { error, recoverable }) + .await; + } + + pub fn can_start_new_turn(&self, session_id: &str) -> bool { + matches!( + self.get_state(session_id), + Some(SessionState::Idle) + | Some(SessionState::Error { + recoverable: true, + .. + }) + ) + } + + pub fn is_processing(&self, session_id: &str) -> bool { + matches!( + self.get_state(session_id), + Some(SessionState::Processing { .. }) + ) + } + + pub fn remove(&self, session_id: &str) { + self.states.remove(session_id); + debug!("Removed session state: session_id={}", session_id); + } + + pub fn get_all_states(&self) -> Vec<(String, SessionState)> { + self.states + .iter() + .map(|entry| (entry.key().clone(), entry.value().clone())) + .collect() + } + + async fn emit_state_change_event(&self, session_id: &str, state: SessionState) { + let event = AgenticEvent::SessionStateChanged { + session_id: session_id.to_string(), + new_state: session_state_label_for_state(&state).to_string(), + }; + + let _ = self + .event_queue + .enqueue(event, Some(EventPriority::High)) + .await; + } +} + +#[cfg(test)] +mod tests { + use super::SessionStateManager; + use crate::event_queue::{EventQueue, EventQueueConfig}; + use crate::session_state::{ProcessingPhase, SessionState}; + use bitfun_events::{AgenticEvent, AgenticEventPriority as EventPriority}; + use std::sync::Arc; + + fn test_manager() -> (Arc, SessionStateManager) { + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let manager = SessionStateManager::new(Arc::clone(&queue)); + (queue, manager) + } + + #[tokio::test] + async fn session_state_manager_emits_compatible_state_change_events() { + let (queue, manager) = test_manager(); + + manager.initialize("session-1").await; + manager + .set_processing_phase("session-1", "turn-1".to_string(), ProcessingPhase::Thinking) + .await; + + let batch = queue.dequeue_batch(10).await; + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].priority, EventPriority::High); + match &batch[0].event { + AgenticEvent::SessionStateChanged { + session_id, + new_state, + } => { + assert_eq!(session_id, "session-1"); + assert_eq!(new_state, "processing"); + } + _ => panic!("expected session state event"), + } + } + + #[tokio::test] + async fn session_state_manager_keeps_turn_start_guard_semantics() { + let (_queue, manager) = test_manager(); + + assert!(!manager.can_start_new_turn("session-1")); + manager.initialize("session-1").await; + assert!(manager.can_start_new_turn("session-1")); + + manager + .set_processing_phase( + "session-1", + "turn-1".to_string(), + ProcessingPhase::ToolCalling, + ) + .await; + assert!(manager.is_processing("session-1")); + assert!(!manager.can_start_new_turn("session-1")); + + manager + .set_error("session-1", "cancelled".to_string(), true) + .await; + assert!(manager.can_start_new_turn("session-1")); + assert_eq!( + manager.get_state("session-1"), + Some(SessionState::Error { + error: "cancelled".to_string(), + recoverable: true, + }) + ); + + manager.remove("session-1"); + assert!(manager.get_all_states().is_empty()); + } +} diff --git a/src/crates/services/services-integrations/src/mcp/adapter/tool.rs b/src/crates/services/services-integrations/src/mcp/adapter/tool.rs index 0a36c1cfc3..9b6b308da3 100644 --- a/src/crates/services/services-integrations/src/mcp/adapter/tool.rs +++ b/src/crates/services/services-integrations/src/mcp/adapter/tool.rs @@ -173,3 +173,24 @@ pub fn render_mcp_tool_result_for_assistant( "MCP tool execution completed".to_string() } + +#[cfg(test)] +mod tests { + use super::{render_mcp_tool_result_for_assistant, MCPToolResult, MCPToolResultContent}; + + #[test] + fn mcp_tool_result_rendering_does_not_pretruncate_before_storage_policy() { + let text = "x".repeat(12_001); + let result = MCPToolResult { + content: Some(vec![MCPToolResultContent::Text { text: text.clone() }]), + is_error: false, + structured_content: None, + meta: None, + }; + + let rendered = render_mcp_tool_result_for_assistant("large_output", &result, usize::MAX); + + assert_eq!(rendered, text); + assert!(!rendered.contains("[Result truncated:")); + } +}