From cdbde37238b1bc7516178c2cd2387544fa5bf939 Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 30 Jun 2026 21:40:56 +0800 Subject: [PATCH] refactor: centralize agentic frontend event projection --- .../agent-runtime-services-design.md | 4 + docs/architecture/core-decomposition.md | 3 + docs/plans/core-decomposition-completed.md | 2 +- docs/plans/core-decomposition-plan.md | 1 + .../rules/source/forbidden-rules.mjs | 20 + .../rules/source/required-rules.mjs | 65 ++ scripts/core-boundaries/self-test.mjs | 23 + .../adapters/transport/src/adapters/tauri.rs | 479 +------------- .../transport/src/adapters/websocket.rs | 339 +++------- .../events/src/frontend_projection.rs | 602 ++++++++++++++++++ src/crates/contracts/events/src/lib.rs | 2 + 11 files changed, 814 insertions(+), 726 deletions(-) create mode 100644 src/crates/contracts/events/src/frontend_projection.rs diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index ee13ad2464..3f6c32312f 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -79,6 +79,10 @@ Product API 不应被定义为单一 Rust 后端 API。目标 API 分为三类 OpenCode adapter、ACP bridge 和未来 plugin runtime 必须把外部 API 映射为上述三类 API,再由 Product Assembly 统一注册。它们不能直接写 Agent Kernel 权威状态,也不能绕过 permission、sandbox、audit 或 UI host 的渲染边界。 +Agentic frontend event projection 属于稳定 event contract:Agent Kernel 产生 provider-neutral `AgenticEvent`, +契约层给出 event name、event type 和 payload;Tauri、WebSocket、OpenCode adapter 或 UI extension host +只选择 delivery/profile,不重新定义字段映射。 + Extension registration contract 属于稳定扩展契约,不属于 Product Assembly 的具体实现。Extension Host 和 OpenCode adapter 只产出可注册的 descriptor、provider 和 contribution;Product Assembly 消费这些 contract 并完成 产品形态内的注册,避免 Extension 层反向依赖 assembly crate。 diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index cf0ed0e211..2d651e542f 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -366,6 +366,8 @@ provider-neutral contract。 应该放这里: - 可序列化 DTO、identity、event、port trait、artifact ref、typed error。 +- 跨框架 frontend event projection:把内核事件映射为稳定 event name / event type / payload,不包含 Tauri、 + React、WebSocket delivery 或 OpenCode adapter 实现。 - capability/effect、permission、sandbox、execution domain、audit facts。 - UI extension descriptor contract 和跨产品领域的纯规则。 @@ -397,6 +399,7 @@ Platform adapter 只实现 ports 并调用边界外资源。 | `ExtensionHost` / OpenCode adapter | Extension | extension host / adapter | Product Assembly | Product Assembly、UI host、Execution provider registry | 产出 descriptor/provider,不写权威状态 | | `RuntimeServicesBuilder` / platform providers | Cross-platform Adapter | services/adapters/app provider | Product Assembly | Kernel/Execution/Harness 通过 port handle | 实现边界外 I/O,不读取 product profile | | `SecurityDecisionPort` / `CapabilityEffectPolicy` | Stable Contracts and Security Control Plane | 注入式策略 owner | Product Assembly | Kernel、Execution、Extension、UI projection | 决策可审计,模型/插件不能直接授权 | +| `AgenticFrontendEvent` / frontend event projection | Stable Contracts and Security Control Plane | `bitfun-events` | 无运行时注册 | Tauri/WebSocket/UI extension/OpenCode adapter | 只定义 event name/type/payload;delivery 由 adapter 执行 | 典型调用链: diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 0a74d915c1..5aa5ae8a37 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -18,7 +18,7 @@ - `services-core` 已承接 session layout、metadata store CRUD / index rebuild、metadata pagination、metadata construction / mutation、lineage / branch shaping、JSON file store、filesystem primitives、managed runtime command resolution / PATH merge、LSP plugin registry / extension matching / command-target mapping、diagnostic redaction、session usage/token usage 基础服务。 - `services-core` 已承接 workspace-runtime legacy session-store merge、metadata 冲突选择、index rebuild 和 legacy path copy/move fallback;core workspace-runtime 只保留路径计算、runtime layout ensure 和错误兼容映射。 - `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation、无副作用 capability marker ports 和 backend event delivery;core backend event system 只保留兼容 re-export。 -- `bitfun-events` 已承接 backend event DTO、agentic event DTO 和 platform-neutral `EventEmitter` trait。 +- `bitfun-events` 已承接 backend event DTO、agentic event DTO、framework-neutral Agentic frontend event projection 和 platform-neutral `EventEmitter` trait;Tauri/WebSocket transport 只负责 delivery。 - `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、Remote SSH disabled runtime surface、Remote SSH workspace/session identity helper、remote workspace-search disabled surface、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 调用和旧工具入口。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 167aec79b4..cc23cc1c71 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -28,6 +28,7 @@ - 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。 +- `bitfun-events` 已承接 Agentic frontend event projection;Tauri/WebSocket transport 不再内联事件字段映射,后续 OpenCode/UI extension adapter 应复用同一稳定投影。 ## 3. 大块 PR 节奏 diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 8401355c47..08be416422 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -1,6 +1,26 @@ // Boundary rules for source ownership, facades, and required owner content. export const forbiddenContentRules = [ + { + path: 'src/crates/adapters/transport/src/adapters/tauri.rs', + patterns: [ + { + regex: /\bAgenticEvent::[A-Z]/, + message: + 'Tauri transport adapter must not match agentic event variants directly; use bitfun-events frontend projection', + }, + ], + }, + { + path: 'src/crates/adapters/transport/src/adapters/websocket.rs', + patterns: [ + { + regex: /\bAgenticEvent::[A-Z]/, + message: + 'WebSocket transport adapter must not match agentic event variants directly; use bitfun-events frontend projection', + }, + ], + }, { path: 'src/crates/execution/agent-runtime/tests/sdk_smoke.rs', patterns: [ diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 977ab2d7da..7cf4c97fb0 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -1,6 +1,71 @@ // Boundary rules for source ownership, facades, and required owner content. export const requiredContentRules = [ + { + path: 'src/crates/contracts/events/src/frontend_projection.rs', + reason: + 'events contract must own framework-neutral agentic frontend event projection for Tauri, WebSocket, and future extension hosts', + patterns: [ + { + regex: /\bpub struct AgenticFrontendEvent\b/, + message: 'missing framework-neutral frontend event projection DTO', + }, + { + regex: /\bpub fn project_agentic_frontend_event\b/, + message: 'missing shared agentic frontend event projection function', + }, + { + regex: /\bpub fn legacy_flat_message\b/, + message: 'missing legacy flat payload projection helper', + }, + { + regex: /\bdeep_review_queue_projection_preserves_camel_case_contract\b/, + message: 'missing camelCase queue projection regression test', + }, + { + regex: /\blegacy_flat_message_keeps_projection_type_authoritative\b/, + message: 'missing WebSocket type precedence regression test', + }, + { + regex: /\blegacy_flat_dialog_turn_started_preserves_existing_shape\b/, + message: 'missing WebSocket dialog-turn-started shape regression test', + }, + ], + }, + { + path: 'src/crates/adapters/transport/src/adapters/tauri.rs', + reason: + 'Tauri transport adapter must only deliver projected events and must not own agentic event field mapping', + patterns: [ + { + regex: /\bproject_agentic_frontend_event\b/, + message: 'missing shared frontend projection usage in Tauri transport', + }, + { + regex: /\.emit\(projected\.event_name\.as_str\(\), projected\.payload\)/, + message: 'Tauri transport must emit projected event name and payload', + }, + ], + }, + { + path: 'src/crates/adapters/transport/src/adapters/websocket.rs', + reason: + 'WebSocket transport adapter must consume shared event projection while preserving the legacy WebSocket event allowlist', + patterns: [ + { + regex: /\bproject_agentic_frontend_event\b/, + message: 'missing shared frontend projection usage in WebSocket transport', + }, + { + regex: /\bis_legacy_websocket_agentic_event_type\b/, + message: 'missing legacy WebSocket agentic event allowlist', + }, + { + regex: /\bwebsocket_keeps_legacy_agentic_event_allowlist\b/, + message: 'missing WebSocket legacy event allowlist regression', + }, + ], + }, { path: 'src/crates/contracts/core-types/src/lsp.rs', reason: diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 4d68eaa15f..7674c261ff 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1027,6 +1027,29 @@ export function runManifestParserSelfTest({ 'backend_event_names_remain_stable', ], }, + { + path: 'src/crates/contracts/events/src/frontend_projection.rs', + contracts: [ + 'AgenticFrontendEvent', + 'project_agentic_frontend_event', + 'legacy_flat_message', + 'deep_review_queue_projection_preserves_camel_case_contract', + 'legacy_flat_message_keeps_projection_type_authoritative', + 'legacy_flat_dialog_turn_started_preserves_existing_shape', + ], + }, + { + path: 'src/crates/adapters/transport/src/adapters/tauri.rs', + contracts: ['project_agentic_frontend_event', 'projected.event_name.as_str()'], + }, + { + path: 'src/crates/adapters/transport/src/adapters/websocket.rs', + contracts: [ + 'project_agentic_frontend_event', + 'is_legacy_websocket_agentic_event_type', + 'websocket_keeps_legacy_agentic_event_allowlist', + ], + }, { path: 'src/crates/execution/runtime-services/tests/runtime_services_contracts.rs', contracts: [ diff --git a/src/crates/adapters/transport/src/adapters/tauri.rs b/src/crates/adapters/transport/src/adapters/tauri.rs index f6291cad83..657e62b372 100644 --- a/src/crates/adapters/transport/src/adapters/tauri.rs +++ b/src/crates/adapters/transport/src/adapters/tauri.rs @@ -1,12 +1,12 @@ -//! Tauri transport adapter +//! Tauri transport adapter. //! -//! Uses Tauri's app.emit() system to send events to frontend -//! Maintains compatibility with current implementation +//! This adapter owns only Tauri delivery. Agentic event names and payload +//! shapes are projected by `bitfun-events`. #[cfg(feature = "tauri-adapter")] use crate::traits::{TextChunk, ToolEventPayload, TransportAdapter}; use async_trait::async_trait; -use bitfun_events::AgenticEvent; +use bitfun_events::{project_agentic_frontend_event, AgenticEvent}; use log::warn; use serde_json::json; use std::fmt; @@ -14,7 +14,6 @@ use std::fmt; #[cfg(feature = "tauri-adapter")] use tauri::{AppHandle, Emitter}; -/// Tauri transport adapter #[cfg(feature = "tauri-adapter")] pub struct TauriTransportAdapter { app_handle: AppHandle, @@ -40,470 +39,12 @@ impl fmt::Debug for TauriTransportAdapter { #[async_trait] impl TransportAdapter for TauriTransportAdapter { async fn emit_event(&self, _session_id: &str, event: AgenticEvent) -> anyhow::Result<()> { - match event { - AgenticEvent::SessionCreated { - session_id, - session_name, - agent_type, - workspace_path, - remote_connection_id, - remote_ssh_host, - } => { - self.app_handle.emit( - "agentic://session-created", - json!({ - "sessionId": session_id, - "sessionName": session_name, - "agentType": agent_type, - "workspacePath": workspace_path, - "remoteConnectionId": remote_connection_id, - "remoteSshHost": remote_ssh_host, - }), - )?; - } - AgenticEvent::SessionDeleted { session_id } => { - self.app_handle.emit( - "agentic://session-deleted", - json!({ - "sessionId": session_id, - }), - )?; - } - AgenticEvent::ImageAnalysisStarted { - session_id, - image_count, - user_input, - image_metadata, - } => { - self.app_handle.emit( - "agentic://image-analysis-started", - json!({ - "sessionId": session_id, - "imageCount": image_count, - "userInput": user_input, - "imageMetadata": image_metadata, - }), - )?; - } - AgenticEvent::ImageAnalysisCompleted { - session_id, - success, - duration_ms, - } => { - self.app_handle.emit( - "agentic://image-analysis-completed", - json!({ - "sessionId": session_id, - "success": success, - "durationMs": duration_ms, - }), - )?; - } - AgenticEvent::DialogTurnStarted { - session_id, - turn_id, - turn_index, - user_input, - original_user_input, - user_message_metadata, - } => { - self.app_handle.emit( - "agentic://dialog-turn-started", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "turnIndex": turn_index, - "userInput": user_input, - "originalUserInput": original_user_input, - "userMessageMetadata": user_message_metadata, - }), - )?; - } - AgenticEvent::SubagentSessionLinked { - session_id, - parent_session_id, - parent_dialog_turn_id, - parent_tool_call_id, - agent_type, - } => { - self.app_handle.emit( - "agentic://subagent-session-linked", - json!({ - "sessionId": session_id, - "parentSessionId": parent_session_id, - "parentDialogTurnId": parent_dialog_turn_id, - "parentToolCallId": parent_tool_call_id, - "agentType": agent_type, - }), - )?; - } - AgenticEvent::ModelRoundStarted { - session_id, - turn_id, - round_id, - round_group_id, - round_index, - model_id, - } => { - self.app_handle.emit( - "agentic://model-round-started", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "roundGroupId": round_group_id, - "roundIndex": round_index, - "modelId": model_id, - }), - )?; - } - AgenticEvent::TextChunk { - session_id, - turn_id, - round_id, - attempt_id, - attempt_index, - text, - } => { - self.app_handle.emit( - "agentic://text-chunk", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "attemptId": attempt_id, - "attemptIndex": attempt_index, - "text": text, - }), - )?; - } - AgenticEvent::ThinkingChunk { - session_id, - turn_id, - round_id, - attempt_id, - attempt_index, - content, - is_end, - } => { - self.app_handle.emit( - "agentic://text-chunk", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "attemptId": attempt_id, - "attemptIndex": attempt_index, - "text": content, - "contentType": "thinking", - "isThinkingEnd": is_end, - }), - )?; - } - AgenticEvent::ToolEvent { - session_id, - turn_id, - round_id, - attempt_id, - attempt_index, - tool_event, - } => { - self.app_handle.emit( - "agentic://tool-event", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "attemptId": attempt_id, - "attemptIndex": attempt_index, - "toolEvent": tool_event, - }), - )?; - } - AgenticEvent::DialogTurnCompleted { - session_id, - turn_id, - partial_recovery_reason, - success, - finish_reason, - has_final_response, - .. - } => { - self.app_handle.emit( - "agentic://dialog-turn-completed", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "partialRecoveryReason": partial_recovery_reason, - "success": success, - "finishReason": finish_reason, - "hasFinalResponse": has_final_response, - }), - )?; - } - AgenticEvent::SessionTitleGenerated { - session_id, - title, - method, - } => { - self.app_handle.emit( - "session_title_generated", - json!({ - "sessionId": session_id, - "title": title, - "method": method, - "timestamp": chrono::Utc::now().timestamp_millis(), - }), - )?; - } - AgenticEvent::DialogTurnCancelled { - session_id, - turn_id, - } => { - self.app_handle.emit( - "agentic://dialog-turn-cancelled", - json!({ - "sessionId": session_id, - "turnId": turn_id, - }), - )?; - } - AgenticEvent::DialogTurnFailed { - session_id, - turn_id, - error, - error_category, - error_detail, - } => { - self.app_handle.emit( - "agentic://dialog-turn-failed", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "error": error, - "errorCategory": error_category, - "errorDetail": error_detail, - }), - )?; - } - AgenticEvent::TokenUsageUpdated { - session_id, - turn_id, - model_id, - input_tokens, - output_tokens, - total_tokens, - max_context_tokens, - is_subagent, - cached_tokens, - token_details, - } => { - self.app_handle.emit( - "agentic://token-usage-updated", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "modelId": model_id, - "inputTokens": input_tokens, - "outputTokens": output_tokens, - "totalTokens": total_tokens, - "maxContextTokens": max_context_tokens, - "isSubagent": is_subagent, - "cachedTokens": cached_tokens, - "tokenDetails": token_details, - }), - )?; - } - AgenticEvent::ContextCompressionStarted { - session_id, - turn_id, - compression_id, - trigger, - tokens_before, - context_window, - threshold, - } => { - self.app_handle.emit( - "agentic://context-compression-started", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "compressionId": compression_id, - "trigger": trigger, - "tokensBefore": tokens_before, - "contextWindow": context_window, - "threshold": threshold, - }), - )?; - } - AgenticEvent::ContextCompressionCompleted { - session_id, - turn_id, - compression_id, - compression_count, - tokens_before, - tokens_after, - compression_ratio, - duration_ms, - has_summary, - summary_source, - } => { - self.app_handle.emit( - "agentic://context-compression-completed", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "compressionId": compression_id, - "compressionCount": compression_count, - "tokensBefore": tokens_before, - "tokensAfter": tokens_after, - "compressionRatio": compression_ratio, - "durationMs": duration_ms, - "hasSummary": has_summary, - "summarySource": summary_source, - }), - )?; - } - AgenticEvent::ContextCompressionFailed { - session_id, - turn_id, - compression_id, - error, - } => { - self.app_handle.emit( - "agentic://context-compression-failed", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "compressionId": compression_id, - "error": error, - }), - )?; - } - AgenticEvent::ThreadGoalUpdated { session_id, goal } => { - self.app_handle.emit( - "agentic://thread-goal-updated", - json!({ - "sessionId": session_id, - "goal": goal, - }), - )?; - } - AgenticEvent::SessionStateChanged { - session_id, - new_state, - } => { - self.app_handle.emit( - "agentic://session-state-changed", - json!({ - "sessionId": session_id, - "newState": new_state, - }), - )?; - } - AgenticEvent::SessionModelAutoMigrated { - session_id, - previous_model_id, - new_model_id, - reason, - } => { - self.app_handle.emit( - "agentic://session-model-auto-migrated", - json!({ - "sessionId": session_id, - "previousModelId": previous_model_id, - "newModelId": new_model_id, - "reason": reason, - }), - )?; - } - AgenticEvent::DeepReviewQueueStateChanged { - session_id, - turn_id, - queue_state, - } => { - self.app_handle.emit( - "agentic://deep-review-queue-state-changed", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "queueState": { - "toolId": queue_state.tool_id, - "subagentType": queue_state.subagent_type, - "status": queue_state.status, - "reason": queue_state.reason, - "queuedReviewerCount": queue_state.queued_reviewer_count, - "activeReviewerCount": queue_state.active_reviewer_count, - "effectiveParallelInstances": queue_state.effective_parallel_instances, - "optionalReviewerCount": queue_state.optional_reviewer_count, - "queueElapsedMs": queue_state.queue_elapsed_ms, - "runElapsedMs": queue_state.run_elapsed_ms, - "maxQueueWaitSeconds": queue_state.max_queue_wait_seconds, - "sessionConcurrencyHigh": queue_state.session_concurrency_high, - }, - }), - )?; - } - AgenticEvent::ModelRoundCompleted { - session_id, - turn_id, - round_id, - has_tool_calls, - duration_ms, - provider_id, - model_id, - model_alias, - first_chunk_ms, - first_visible_output_ms, - stream_duration_ms, - attempt_count, - failure_category, - token_details, - } => { - self.app_handle.emit( - "agentic://model-round-completed", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "hasToolCalls": has_tool_calls, - "durationMs": duration_ms, - "providerId": provider_id, - "modelId": model_id, - "modelAlias": model_alias, - "firstChunkMs": first_chunk_ms, - "firstVisibleOutputMs": first_visible_output_ms, - "streamDurationMs": stream_duration_ms, - "attemptCount": attempt_count, - "failureCategory": failure_category, - "tokenDetails": token_details, - }), - )?; - } - AgenticEvent::UserSteeringInjected { - session_id, - turn_id, - round_index, - steering_id, - content, - display_content, - } => { - self.app_handle.emit( - "agentic://user-steering-injected", - json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundIndex": round_index, - "steeringId": steering_id, - "content": content, - "displayContent": display_content, - }), - )?; - } - _ => { - warn!("Unhandled AgenticEvent type in TauriAdapter"); - } - } + let Some(projected) = project_agentic_frontend_event(event) else { + warn!("Unhandled AgenticEvent type in TauriAdapter"); + return Ok(()); + }; + self.app_handle + .emit(projected.event_name.as_str(), projected.payload)?; Ok(()) } diff --git a/src/crates/adapters/transport/src/adapters/websocket.rs b/src/crates/adapters/transport/src/adapters/websocket.rs index a6697ee927..a82a032ee9 100644 --- a/src/crates/adapters/transport/src/adapters/websocket.rs +++ b/src/crates/adapters/transport/src/adapters/websocket.rs @@ -1,14 +1,15 @@ -/// WebSocket transport adapter +/// WebSocket transport adapter. /// -/// Used for Web Server version, pushes events to browser via WebSocket +/// Used for Web Server delivery. Agentic event payload projection is owned by +/// `bitfun-events`; this adapter only serializes the allowed legacy WebSocket +/// event set to text messages. use crate::traits::{TextChunk, ToolEventPayload, TransportAdapter}; use async_trait::async_trait; -use bitfun_events::AgenticEvent; +use bitfun_events::{project_agentic_frontend_event, AgenticEvent}; use serde_json::json; use std::fmt; use tokio::sync::mpsc; -/// WebSocket message type #[derive(Debug, Clone)] pub enum WsMessage { Text(String), @@ -16,19 +17,16 @@ pub enum WsMessage { Close, } -/// WebSocket transport adapter #[derive(Clone)] pub struct WebSocketTransportAdapter { tx: mpsc::UnboundedSender, } impl WebSocketTransportAdapter { - /// Create a new WebSocket adapter pub fn new(tx: mpsc::UnboundedSender) -> Self { Self { tx } } - /// Send JSON message fn send_json(&self, value: serde_json::Value) -> anyhow::Result<()> { let json_str = serde_json::to_string(&value)?; self.tx @@ -46,259 +44,34 @@ impl fmt::Debug for WebSocketTransportAdapter { } } +fn is_legacy_websocket_agentic_event_type(event_type: &str) -> bool { + matches!( + event_type, + "image-analysis-started" + | "image-analysis-completed" + | "dialog-turn-started" + | "subagent-session-linked" + | "model-round-started" + | "text-chunk" + | "tool-event" + | "token-usage-updated" + | "model-round-completed" + | "dialog-turn-completed" + | "deep-review-queue-state-changed" + | "thread-goal-updated" + ) +} + #[async_trait] impl TransportAdapter for WebSocketTransportAdapter { async fn emit_event(&self, _session_id: &str, event: AgenticEvent) -> anyhow::Result<()> { - let message = match event { - AgenticEvent::ImageAnalysisStarted { - session_id, - image_count, - user_input, - image_metadata, - } => { - json!({ - "type": "image-analysis-started", - "sessionId": session_id, - "imageCount": image_count, - "userInput": user_input, - "imageMetadata": image_metadata, - }) - } - AgenticEvent::ImageAnalysisCompleted { - session_id, - success, - duration_ms, - } => { - json!({ - "type": "image-analysis-completed", - "sessionId": session_id, - "success": success, - "durationMs": duration_ms, - }) - } - AgenticEvent::DialogTurnStarted { - session_id, - turn_id, - turn_index, - original_user_input, - user_message_metadata, - .. - } => { - json!({ - "type": "dialog-turn-started", - "sessionId": session_id, - "turnId": turn_id, - "turnIndex": turn_index, - "originalUserInput": original_user_input, - "userMessageMetadata": user_message_metadata, - }) - } - AgenticEvent::SubagentSessionLinked { - session_id, - parent_session_id, - parent_dialog_turn_id, - parent_tool_call_id, - agent_type, - } => { - json!({ - "type": "subagent-session-linked", - "sessionId": session_id, - "parentSessionId": parent_session_id, - "parentDialogTurnId": parent_dialog_turn_id, - "parentToolCallId": parent_tool_call_id, - "agentType": agent_type, - }) - } - AgenticEvent::ModelRoundStarted { - session_id, - turn_id, - round_id, - round_group_id, - round_index, - model_id, - } => { - json!({ - "type": "model-round-started", - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "roundGroupId": round_group_id, - "roundIndex": round_index, - "modelId": model_id, - }) - } - AgenticEvent::TextChunk { - session_id, - turn_id, - round_id, - attempt_id, - attempt_index, - text, - } => { - json!({ - "type": "text-chunk", - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "attemptId": attempt_id, - "attemptIndex": attempt_index, - "text": text, - }) - } - AgenticEvent::ThinkingChunk { - session_id, - turn_id, - round_id, - attempt_id, - attempt_index, - content, - is_end, - } => { - json!({ - "type": "text-chunk", - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "attemptId": attempt_id, - "attemptIndex": attempt_index, - "text": content, - "contentType": "thinking", - "isThinkingEnd": is_end, - }) - } - AgenticEvent::ToolEvent { - session_id, - turn_id, - round_id, - attempt_id, - attempt_index, - tool_event, - } => { - json!({ - "type": "tool-event", - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "attemptId": attempt_id, - "attemptIndex": attempt_index, - "toolEvent": tool_event, - }) - } - AgenticEvent::TokenUsageUpdated { - session_id, - turn_id, - model_id, - input_tokens, - output_tokens, - total_tokens, - max_context_tokens, - is_subagent, - cached_tokens, - token_details, - } => { - json!({ - "type": "token-usage-updated", - "sessionId": session_id, - "turnId": turn_id, - "modelId": model_id, - "inputTokens": input_tokens, - "outputTokens": output_tokens, - "totalTokens": total_tokens, - "maxContextTokens": max_context_tokens, - "isSubagent": is_subagent, - "cachedTokens": cached_tokens, - "tokenDetails": token_details, - }) - } - AgenticEvent::ModelRoundCompleted { - session_id, - turn_id, - round_id, - has_tool_calls, - duration_ms, - provider_id, - model_id, - model_alias, - first_chunk_ms, - first_visible_output_ms, - stream_duration_ms, - attempt_count, - failure_category, - token_details, - } => { - json!({ - "type": "model-round-completed", - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "hasToolCalls": has_tool_calls, - "durationMs": duration_ms, - "providerId": provider_id, - "modelId": model_id, - "modelAlias": model_alias, - "firstChunkMs": first_chunk_ms, - "firstVisibleOutputMs": first_visible_output_ms, - "streamDurationMs": stream_duration_ms, - "attemptCount": attempt_count, - "failureCategory": failure_category, - "tokenDetails": token_details, - }) - } - AgenticEvent::DialogTurnCompleted { - session_id, - turn_id, - partial_recovery_reason, - success, - finish_reason, - has_final_response, - .. - } => { - json!({ - "type": "dialog-turn-completed", - "sessionId": session_id, - "turnId": turn_id, - "partialRecoveryReason": partial_recovery_reason, - "success": success, - "finishReason": finish_reason, - "hasFinalResponse": has_final_response, - }) - } - AgenticEvent::DeepReviewQueueStateChanged { - session_id, - turn_id, - queue_state, - } => { - json!({ - "type": "deep-review-queue-state-changed", - "sessionId": session_id, - "turnId": turn_id, - "queueState": { - "toolId": queue_state.tool_id, - "subagentType": queue_state.subagent_type, - "status": queue_state.status, - "reason": queue_state.reason, - "queuedReviewerCount": queue_state.queued_reviewer_count, - "activeReviewerCount": queue_state.active_reviewer_count, - "effectiveParallelInstances": queue_state.effective_parallel_instances, - "optionalReviewerCount": queue_state.optional_reviewer_count, - "queueElapsedMs": queue_state.queue_elapsed_ms, - "runElapsedMs": queue_state.run_elapsed_ms, - "maxQueueWaitSeconds": queue_state.max_queue_wait_seconds, - "sessionConcurrencyHigh": queue_state.session_concurrency_high, - }, - }) - } - AgenticEvent::ThreadGoalUpdated { session_id, goal } => { - json!({ - "type": "thread-goal-updated", - "sessionId": session_id, - "goal": goal, - }) - } - _ => return Ok(()), + let Some(projected) = project_agentic_frontend_event(event) else { + return Ok(()); }; - - self.send_json(message)?; + if !is_legacy_websocket_agentic_event_type(&projected.event_type) { + return Ok(()); + } + self.send_json(projected.legacy_flat_message())?; Ok(()) } @@ -382,3 +155,57 @@ impl TransportAdapter for WebSocketTransportAdapter { "websocket" } } + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_events::AgenticEvent as Event; + + #[tokio::test] + async fn websocket_uses_shared_agentic_frontend_projection() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let adapter = WebSocketTransportAdapter::new(tx); + + adapter + .emit_event( + "session-1", + Event::TextChunk { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: Some("attempt-1".to_string()), + attempt_index: Some(1), + text: "hello".to_string(), + }, + ) + .await + .expect("emit"); + + let WsMessage::Text(message) = rx.recv().await.expect("message") else { + panic!("expected text message"); + }; + let value: serde_json::Value = serde_json::from_str(&message).expect("json"); + assert_eq!(value["type"], "text-chunk"); + assert_eq!(value["sessionId"], "session-1"); + assert_eq!(value["attemptId"], "attempt-1"); + assert_eq!(value["text"], "hello"); + } + + #[tokio::test] + async fn websocket_keeps_legacy_agentic_event_allowlist() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let adapter = WebSocketTransportAdapter::new(tx); + + adapter + .emit_event( + "session-1", + Event::SessionDeleted { + session_id: "session-1".to_string(), + }, + ) + .await + .expect("emit"); + + assert!(rx.try_recv().is_err()); + } +} diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs new file mode 100644 index 0000000000..a2bf514fb3 --- /dev/null +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -0,0 +1,602 @@ +//! Framework-neutral projection for product-facing agentic events. +//! +//! This module owns the stable event name and payload shape consumed by host +//! transports. Concrete delivery adapters should only emit the projected +//! envelope. + +use crate::AgenticEvent; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgenticFrontendEvent { + pub event_name: String, + pub event_type: String, + pub payload: Value, +} + +impl AgenticFrontendEvent { + pub fn new( + event_name: impl Into, + event_type: impl Into, + payload: Value, + ) -> Self { + Self { + event_name: event_name.into(), + event_type: event_type.into(), + payload, + } + } + + pub fn legacy_flat_message(&self) -> Value { + let mut message = Map::new(); + match &self.payload { + Value::Object(payload) => { + for (key, value) in payload { + message.insert(key.clone(), value.clone()); + } + } + payload => { + message.insert("payload".to_string(), payload.clone()); + } + } + if self.event_type == "dialog-turn-started" { + message.remove("userInput"); + } + message.insert("type".to_string(), Value::String(self.event_type.clone())); + Value::Object(message) + } +} + +pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option { + match event { + AgenticEvent::SessionCreated { + session_id, + session_name, + agent_type, + workspace_path, + remote_connection_id, + remote_ssh_host, + } => Some(AgenticFrontendEvent::new( + "agentic://session-created", + "session-created", + json!({ + "sessionId": session_id, + "sessionName": session_name, + "agentType": agent_type, + "workspacePath": workspace_path, + "remoteConnectionId": remote_connection_id, + "remoteSshHost": remote_ssh_host, + }), + )), + AgenticEvent::SessionDeleted { session_id } => Some(AgenticFrontendEvent::new( + "agentic://session-deleted", + "session-deleted", + json!({ "sessionId": session_id }), + )), + AgenticEvent::ImageAnalysisStarted { + session_id, + image_count, + user_input, + image_metadata, + } => Some(AgenticFrontendEvent::new( + "agentic://image-analysis-started", + "image-analysis-started", + json!({ + "sessionId": session_id, + "imageCount": image_count, + "userInput": user_input, + "imageMetadata": image_metadata, + }), + )), + AgenticEvent::ImageAnalysisCompleted { + session_id, + success, + duration_ms, + } => Some(AgenticFrontendEvent::new( + "agentic://image-analysis-completed", + "image-analysis-completed", + json!({ + "sessionId": session_id, + "success": success, + "durationMs": duration_ms, + }), + )), + AgenticEvent::DialogTurnStarted { + session_id, + turn_id, + turn_index, + user_input, + original_user_input, + user_message_metadata, + } => Some(AgenticFrontendEvent::new( + "agentic://dialog-turn-started", + "dialog-turn-started", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "turnIndex": turn_index, + "userInput": user_input, + "originalUserInput": original_user_input, + "userMessageMetadata": user_message_metadata, + }), + )), + AgenticEvent::SubagentSessionLinked { + session_id, + parent_session_id, + parent_dialog_turn_id, + parent_tool_call_id, + agent_type, + } => Some(AgenticFrontendEvent::new( + "agentic://subagent-session-linked", + "subagent-session-linked", + json!({ + "sessionId": session_id, + "parentSessionId": parent_session_id, + "parentDialogTurnId": parent_dialog_turn_id, + "parentToolCallId": parent_tool_call_id, + "agentType": agent_type, + }), + )), + AgenticEvent::ModelRoundStarted { + session_id, + turn_id, + round_id, + round_group_id, + round_index, + model_id, + } => Some(AgenticFrontendEvent::new( + "agentic://model-round-started", + "model-round-started", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "roundGroupId": round_group_id, + "roundIndex": round_index, + "modelId": model_id, + }), + )), + AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + text, + } => Some(AgenticFrontendEvent::new( + "agentic://text-chunk", + "text-chunk", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "attemptId": attempt_id, + "attemptIndex": attempt_index, + "text": text, + }), + )), + AgenticEvent::ThinkingChunk { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + content, + is_end, + } => Some(AgenticFrontendEvent::new( + "agentic://text-chunk", + "text-chunk", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "attemptId": attempt_id, + "attemptIndex": attempt_index, + "text": content, + "contentType": "thinking", + "isThinkingEnd": is_end, + }), + )), + AgenticEvent::ToolEvent { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + tool_event, + } => Some(AgenticFrontendEvent::new( + "agentic://tool-event", + "tool-event", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "attemptId": attempt_id, + "attemptIndex": attempt_index, + "toolEvent": tool_event, + }), + )), + AgenticEvent::DialogTurnCompleted { + session_id, + turn_id, + partial_recovery_reason, + success, + finish_reason, + has_final_response, + .. + } => Some(AgenticFrontendEvent::new( + "agentic://dialog-turn-completed", + "dialog-turn-completed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "partialRecoveryReason": partial_recovery_reason, + "success": success, + "finishReason": finish_reason, + "hasFinalResponse": has_final_response, + }), + )), + AgenticEvent::SessionTitleGenerated { + session_id, + title, + method, + } => Some(AgenticFrontendEvent::new( + "session_title_generated", + "session_title_generated", + json!({ + "sessionId": session_id, + "title": title, + "method": method, + "timestamp": chrono::Utc::now().timestamp_millis(), + }), + )), + AgenticEvent::DialogTurnCancelled { + session_id, + turn_id, + } => Some(AgenticFrontendEvent::new( + "agentic://dialog-turn-cancelled", + "dialog-turn-cancelled", + json!({ + "sessionId": session_id, + "turnId": turn_id, + }), + )), + AgenticEvent::DialogTurnFailed { + session_id, + turn_id, + error, + error_category, + error_detail, + } => Some(AgenticFrontendEvent::new( + "agentic://dialog-turn-failed", + "dialog-turn-failed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "error": error, + "errorCategory": error_category, + "errorDetail": error_detail, + }), + )), + AgenticEvent::TokenUsageUpdated { + session_id, + turn_id, + model_id, + input_tokens, + output_tokens, + total_tokens, + max_context_tokens, + is_subagent, + cached_tokens, + token_details, + } => Some(AgenticFrontendEvent::new( + "agentic://token-usage-updated", + "token-usage-updated", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "modelId": model_id, + "inputTokens": input_tokens, + "outputTokens": output_tokens, + "totalTokens": total_tokens, + "maxContextTokens": max_context_tokens, + "isSubagent": is_subagent, + "cachedTokens": cached_tokens, + "tokenDetails": token_details, + }), + )), + AgenticEvent::ContextCompressionStarted { + session_id, + turn_id, + compression_id, + trigger, + tokens_before, + context_window, + threshold, + } => Some(AgenticFrontendEvent::new( + "agentic://context-compression-started", + "context-compression-started", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "compressionId": compression_id, + "trigger": trigger, + "tokensBefore": tokens_before, + "contextWindow": context_window, + "threshold": threshold, + }), + )), + AgenticEvent::ContextCompressionCompleted { + session_id, + turn_id, + compression_id, + compression_count, + tokens_before, + tokens_after, + compression_ratio, + duration_ms, + has_summary, + summary_source, + } => Some(AgenticFrontendEvent::new( + "agentic://context-compression-completed", + "context-compression-completed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "compressionId": compression_id, + "compressionCount": compression_count, + "tokensBefore": tokens_before, + "tokensAfter": tokens_after, + "compressionRatio": compression_ratio, + "durationMs": duration_ms, + "hasSummary": has_summary, + "summarySource": summary_source, + }), + )), + AgenticEvent::ContextCompressionFailed { + session_id, + turn_id, + compression_id, + error, + } => Some(AgenticFrontendEvent::new( + "agentic://context-compression-failed", + "context-compression-failed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "compressionId": compression_id, + "error": error, + }), + )), + AgenticEvent::ThreadGoalUpdated { session_id, goal } => Some(AgenticFrontendEvent::new( + "agentic://thread-goal-updated", + "thread-goal-updated", + json!({ + "sessionId": session_id, + "goal": goal, + }), + )), + AgenticEvent::SessionStateChanged { + session_id, + new_state, + } => Some(AgenticFrontendEvent::new( + "agentic://session-state-changed", + "session-state-changed", + json!({ + "sessionId": session_id, + "newState": new_state, + }), + )), + AgenticEvent::SessionModelAutoMigrated { + session_id, + previous_model_id, + new_model_id, + reason, + } => Some(AgenticFrontendEvent::new( + "agentic://session-model-auto-migrated", + "session-model-auto-migrated", + json!({ + "sessionId": session_id, + "previousModelId": previous_model_id, + "newModelId": new_model_id, + "reason": reason, + }), + )), + AgenticEvent::DeepReviewQueueStateChanged { + session_id, + turn_id, + queue_state, + } => Some(AgenticFrontendEvent::new( + "agentic://deep-review-queue-state-changed", + "deep-review-queue-state-changed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "queueState": { + "toolId": queue_state.tool_id, + "subagentType": queue_state.subagent_type, + "status": queue_state.status, + "reason": queue_state.reason, + "queuedReviewerCount": queue_state.queued_reviewer_count, + "activeReviewerCount": queue_state.active_reviewer_count, + "effectiveParallelInstances": queue_state.effective_parallel_instances, + "optionalReviewerCount": queue_state.optional_reviewer_count, + "queueElapsedMs": queue_state.queue_elapsed_ms, + "runElapsedMs": queue_state.run_elapsed_ms, + "maxQueueWaitSeconds": queue_state.max_queue_wait_seconds, + "sessionConcurrencyHigh": queue_state.session_concurrency_high, + }, + }), + )), + AgenticEvent::ModelRoundCompleted { + session_id, + turn_id, + round_id, + has_tool_calls, + duration_ms, + provider_id, + model_id, + model_alias, + first_chunk_ms, + first_visible_output_ms, + stream_duration_ms, + attempt_count, + failure_category, + token_details, + } => Some(AgenticFrontendEvent::new( + "agentic://model-round-completed", + "model-round-completed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "hasToolCalls": has_tool_calls, + "durationMs": duration_ms, + "providerId": provider_id, + "modelId": model_id, + "modelAlias": model_alias, + "firstChunkMs": first_chunk_ms, + "firstVisibleOutputMs": first_visible_output_ms, + "streamDurationMs": stream_duration_ms, + "attemptCount": attempt_count, + "failureCategory": failure_category, + "tokenDetails": token_details, + }), + )), + AgenticEvent::UserSteeringInjected { + session_id, + turn_id, + round_index, + steering_id, + content, + display_content, + } => Some(AgenticFrontendEvent::new( + "agentic://user-steering-injected", + "user-steering-injected", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundIndex": round_index, + "steeringId": steering_id, + "content": content, + "displayContent": display_content, + }), + )), + AgenticEvent::SystemError { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DeepReviewQueueReason, DeepReviewQueueState, DeepReviewQueueStatus}; + + #[test] + fn thinking_chunk_projects_to_legacy_text_chunk_event() { + let projected = project_agentic_frontend_event(AgenticEvent::ThinkingChunk { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: Some("attempt-1".to_string()), + attempt_index: Some(2), + content: "thinking".to_string(), + is_end: true, + }) + .expect("projected"); + + assert_eq!(projected.event_name, "agentic://text-chunk"); + assert_eq!(projected.event_type, "text-chunk"); + assert_eq!(projected.payload["contentType"], "thinking"); + assert_eq!(projected.payload["isThinkingEnd"], true); + assert_eq!(projected.legacy_flat_message()["type"], "text-chunk"); + } + + #[test] + fn legacy_flat_dialog_turn_started_preserves_existing_shape() { + let projected = project_agentic_frontend_event(AgenticEvent::DialogTurnStarted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + turn_index: 1, + user_input: "raw input".to_string(), + original_user_input: Some("original".to_string()), + user_message_metadata: None, + }) + .expect("projected"); + + let message = projected.legacy_flat_message(); + + assert_eq!(message["type"], "dialog-turn-started"); + assert_eq!(message["sessionId"], "session-1"); + assert!(message.get("userInput").is_none()); + assert_eq!(message["originalUserInput"], "original"); + } + + #[test] + fn deep_review_queue_projection_preserves_camel_case_contract() { + let projected = project_agentic_frontend_event(AgenticEvent::DeepReviewQueueStateChanged { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + queue_state: DeepReviewQueueState { + tool_id: "tool-1".to_string(), + subagent_type: "ReviewSecurity".to_string(), + status: DeepReviewQueueStatus::QueuedForCapacity, + reason: Some(DeepReviewQueueReason::ProviderConcurrencyLimit), + queued_reviewer_count: 2, + active_reviewer_count: Some(1), + effective_parallel_instances: Some(2), + optional_reviewer_count: Some(1), + queue_elapsed_ms: Some(1200), + run_elapsed_ms: None, + max_queue_wait_seconds: Some(60), + session_concurrency_high: true, + }, + }) + .expect("projected"); + + assert_eq!( + projected.event_name, + "agentic://deep-review-queue-state-changed" + ); + assert_eq!(projected.payload["queueState"]["toolId"], "tool-1"); + assert_eq!( + projected.payload["queueState"]["reason"], + json!("provider_concurrency_limit") + ); + assert_eq!( + projected.payload["queueState"]["sessionConcurrencyHigh"], + true + ); + } + + #[test] + fn session_title_projection_preserves_legacy_event_name_and_timestamp() { + let projected = project_agentic_frontend_event(AgenticEvent::SessionTitleGenerated { + session_id: "session-1".to_string(), + title: "Title".to_string(), + method: "model".to_string(), + }) + .expect("projected"); + + assert_eq!(projected.event_name, "session_title_generated"); + assert_eq!(projected.payload["sessionId"], "session-1"); + assert!(projected.payload["timestamp"].as_i64().is_some()); + } + + #[test] + fn legacy_flat_message_keeps_projection_type_authoritative() { + let event = AgenticFrontendEvent::new( + "agentic://custom", + "projected-type", + json!({ + "type": "payload-type", + "sessionId": "session-1", + }), + ); + + let message = event.legacy_flat_message(); + + assert_eq!(message["type"], "projected-type"); + assert_eq!(message["sessionId"], "session-1"); + } +} diff --git a/src/crates/contracts/events/src/lib.rs b/src/crates/contracts/events/src/lib.rs index b2ba920c67..c650f0f44e 100644 --- a/src/crates/contracts/events/src/lib.rs +++ b/src/crates/contracts/events/src/lib.rs @@ -7,6 +7,7 @@ pub mod agentic; /// - Event abstraction independent of platforms pub mod backend; pub mod emitter; +pub mod frontend_projection; pub mod types; pub use agentic::{ @@ -18,4 +19,5 @@ pub use backend::{ ToolExecutionProgressInfo, ToolExecutionStartedInfo, ToolTerminalReadyInfo, }; pub use emitter::EventEmitter; +pub use frontend_projection::{project_agentic_frontend_event, AgenticFrontendEvent}; pub use types::*;