From 2811904cf4fcbf0e26bb2df47276699bee1826cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=9D=E5=8D=87=E7=9A=84=E5=A4=AA=E9=98=B3?= Date: Fri, 29 May 2026 10:54:10 +0800 Subject: [PATCH 1/8] feat(ai-adapters): log effective reasoning_effort value at info level Add an info-level log in apply_openai_compatible_reasoning_fields that prints the raw configured value, the normalized value, model name, and reasoning mode. This makes it easy to observe what reasoning_effort actually takes effect, solving the problem of users being unable to tell which value was sent to the API after normalization. --- src/crates/ai-adapters/src/client/quirks.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/crates/ai-adapters/src/client/quirks.rs b/src/crates/ai-adapters/src/client/quirks.rs index d6042fe79..d7fa6d9de 100644 --- a/src/crates/ai-adapters/src/client/quirks.rs +++ b/src/crates/ai-adapters/src/client/quirks.rs @@ -100,7 +100,16 @@ pub(crate) fn apply_openai_compatible_reasoning_fields( return; } - if let Some(effort) = reasoning_effort.and_then(normalize_deepseek_reasoning_effort) { + let normalized_effort = reasoning_effort.and_then(normalize_deepseek_reasoning_effort); + if let Some(effort) = normalized_effort { request_body["reasoning_effort"] = serde_json::json!(effort); } + log::info!( + target: "ai::reasoning_effort", + "Effective reasoning_effort: raw={:?}, normalized={:?}, model={}, mode={:?}", + reasoning_effort, + normalized_effort, + model_name, + mode, + ); } From 22ed34c4074e2814d186cbef10b6caea1c92ec64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=9D=E5=8D=87=E7=9A=84=E5=A4=AA=E9=98=B3?= Date: Fri, 29 May 2026 11:53:05 +0800 Subject: [PATCH 2/8] feat(ai-adapters): add reasoning_effort log to Anthropic adapter path The Anthropic adapter path already normalizes reasoning_effort for DeepSeek models but had no observability. Add the same info-level log that was added to the OpenAI quirks path, covering all ReasoningMode variants. --- .../src/providers/anthropic/request.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/crates/ai-adapters/src/providers/anthropic/request.rs b/src/crates/ai-adapters/src/providers/anthropic/request.rs index 52940a075..3878c065c 100644 --- a/src/crates/ai-adapters/src/providers/anthropic/request.rs +++ b/src/crates/ai-adapters/src/providers/anthropic/request.rs @@ -8,6 +8,7 @@ use crate::client::{AIClient, StreamResponse}; use crate::providers::shared; use crate::stream::handle_anthropic_stream; use crate::types::ReasoningMode; +use log::info; use crate::types::{Message, ToolDefinition}; use anyhow::Result; use log::{debug, warn}; @@ -146,6 +147,20 @@ fn apply_reasoning_fields( model_name ); } + + let normalized_effort = if is_deepseek_reasoning_target { + reasoning_effort.and_then(normalize_deepseek_reasoning_effort) + } else { + None + }; + info!( + target: "ai::reasoning_effort", + "Effective reasoning_effort: raw={:?}, normalized={:?}, model={}, mode={:?}", + reasoning_effort, + normalized_effort, + model_name, + mode, + ); } pub(crate) fn build_request_body( From 7fe74cd0b8c4d58c7b739ad390b3f6e3d320b13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=9D=E5=8D=87=E7=9A=84=E5=A4=AA=E9=98=B3?= Date: Fri, 29 May 2026 13:33:48 +0800 Subject: [PATCH 3/8] feat(ai-adapters): add unified wire reasoning observability Add a format-agnostic extractor (wire_reasoning.rs) that reads thinking and effort values from the final provider request body, regardless of which adapter built it (OpenAI Chat, OpenAI Responses, Anthropic, Gemini). The extracted fields are carried through StreamResponse and logged in round_executor at ai::model_response target alongside other model metrics. This replaces the previous per-adapter logging approach with a single extraction point in sse.rs that covers all adapters uniformly. --- src/crates/ai-adapters/src/client.rs | 3 + src/crates/ai-adapters/src/client/quirks.rs | 11 +-- src/crates/ai-adapters/src/client/sse.rs | 3 + src/crates/ai-adapters/src/lib.rs | 2 + .../src/providers/anthropic/request.rs | 15 --- src/crates/ai-adapters/src/wire_reasoning.rs | 97 +++++++++++++++++++ .../src/agentic/execution/round_executor.rs | 25 ++++- src/crates/core/src/infrastructure/ai/mod.rs | 5 +- 8 files changed, 129 insertions(+), 32 deletions(-) create mode 100644 src/crates/ai-adapters/src/wire_reasoning.rs diff --git a/src/crates/ai-adapters/src/client.rs b/src/crates/ai-adapters/src/client.rs index 4a37438dc..a1fc22029 100644 --- a/src/crates/ai-adapters/src/client.rs +++ b/src/crates/ai-adapters/src/client.rs @@ -15,6 +15,7 @@ pub(crate) mod utils; use crate::providers::{anthropic, gemini, openai}; use crate::types::ProxyConfig; use crate::types::*; +use crate::WireReasoningFields; use anyhow::Result; use format::ApiFormat; use log::warn; @@ -31,6 +32,8 @@ pub struct StreamResponse { Box> + Send>, >, pub raw_sse_rx: Option>, + /// Thinking / effort values extracted from the final outbound request body. + pub wire_reasoning: WireReasoningFields, } /// Default time to wait for the first response headers / stream body to start. diff --git a/src/crates/ai-adapters/src/client/quirks.rs b/src/crates/ai-adapters/src/client/quirks.rs index d7fa6d9de..d6042fe79 100644 --- a/src/crates/ai-adapters/src/client/quirks.rs +++ b/src/crates/ai-adapters/src/client/quirks.rs @@ -100,16 +100,7 @@ pub(crate) fn apply_openai_compatible_reasoning_fields( return; } - let normalized_effort = reasoning_effort.and_then(normalize_deepseek_reasoning_effort); - if let Some(effort) = normalized_effort { + if let Some(effort) = reasoning_effort.and_then(normalize_deepseek_reasoning_effort) { request_body["reasoning_effort"] = serde_json::json!(effort); } - log::info!( - target: "ai::reasoning_effort", - "Effective reasoning_effort: raw={:?}, normalized={:?}, model={}, mode={:?}", - reasoning_effort, - normalized_effort, - model_name, - mode, - ); } diff --git a/src/crates/ai-adapters/src/client/sse.rs b/src/crates/ai-adapters/src/client/sse.rs index e920f6dba..9bfc7b854 100644 --- a/src/crates/ai-adapters/src/client/sse.rs +++ b/src/crates/ai-adapters/src/client/sse.rs @@ -1,6 +1,7 @@ use crate::client::utils::elapsed_ms_u64; use crate::client::StreamResponse; use crate::stream::UnifiedResponse; +use crate::extract_wire_reasoning_fields; use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; use log::{debug, error, warn}; @@ -108,6 +109,7 @@ where Option>, ), { + let wire_reasoning = extract_wire_reasoning_fields(request_body); let mut last_error = None; for attempt in 0..max_tries { let request_start_time = std::time::Instant::now(); @@ -229,6 +231,7 @@ where return Ok(StreamResponse { stream: Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(rx)), raw_sse_rx: Some(rx_raw), + wire_reasoning: wire_reasoning.clone(), }); } diff --git a/src/crates/ai-adapters/src/lib.rs b/src/crates/ai-adapters/src/lib.rs index e9a1146d9..b9229f6fe 100644 --- a/src/crates/ai-adapters/src/lib.rs +++ b/src/crates/ai-adapters/src/lib.rs @@ -6,6 +6,7 @@ pub mod providers; pub mod stream; pub mod tool_call_accumulator; pub mod types; +mod wire_reasoning; pub use client::{ AIClient, StreamOptions, StreamResponse, DEFAULT_STREAM_IDLE_TIMEOUT_SECS, @@ -17,3 +18,4 @@ pub use types::{ GeminiUsage, Message, ProxyConfig, ReasoningMode, RemoteModelInfo, ToolCall, ToolDefinition, ToolImageAttachment, }; +pub use wire_reasoning::{extract_wire_reasoning_fields, WireReasoningFields}; diff --git a/src/crates/ai-adapters/src/providers/anthropic/request.rs b/src/crates/ai-adapters/src/providers/anthropic/request.rs index 3878c065c..52940a075 100644 --- a/src/crates/ai-adapters/src/providers/anthropic/request.rs +++ b/src/crates/ai-adapters/src/providers/anthropic/request.rs @@ -8,7 +8,6 @@ use crate::client::{AIClient, StreamResponse}; use crate::providers::shared; use crate::stream::handle_anthropic_stream; use crate::types::ReasoningMode; -use log::info; use crate::types::{Message, ToolDefinition}; use anyhow::Result; use log::{debug, warn}; @@ -147,20 +146,6 @@ fn apply_reasoning_fields( model_name ); } - - let normalized_effort = if is_deepseek_reasoning_target { - reasoning_effort.and_then(normalize_deepseek_reasoning_effort) - } else { - None - }; - info!( - target: "ai::reasoning_effort", - "Effective reasoning_effort: raw={:?}, normalized={:?}, model={}, mode={:?}", - reasoning_effort, - normalized_effort, - model_name, - mode, - ); } pub(crate) fn build_request_body( diff --git a/src/crates/ai-adapters/src/wire_reasoning.rs b/src/crates/ai-adapters/src/wire_reasoning.rs new file mode 100644 index 000000000..00d6ee532 --- /dev/null +++ b/src/crates/ai-adapters/src/wire_reasoning.rs @@ -0,0 +1,97 @@ +//! Extract thinking / effort values from the final provider request body. + +use serde_json::Value; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WireReasoningFields { + pub thinking: Option, + pub effort: Option, +} + +pub fn extract_wire_reasoning_fields(body: &Value) -> WireReasoningFields { + WireReasoningFields { + thinking: extract_wire_thinking(body), + effort: extract_wire_effort(body), + } +} + +fn extract_wire_thinking(body: &Value) -> Option { + body.pointer("/thinking/type") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + body.get("enable_thinking") + .and_then(Value::as_bool) + .map(|enabled| enabled.to_string()) + }) + .or_else(|| { + body.pointer("/generationConfig/thinkingConfig/includeThoughts") + .and_then(Value::as_bool) + .map(|enabled| enabled.to_string()) + }) +} + +fn extract_wire_effort(body: &Value) -> Option { + body.get("reasoning_effort") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + body.pointer("/reasoning/effort") + .and_then(Value::as_str) + .map(str::to_string) + }) + .or_else(|| { + body.pointer("/output_config/effort") + .and_then(Value::as_str) + .map(str::to_string) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extracts_openai_chat_deepseek_fields() { + let body = json!({ + "model": "deepseek-v4-pro", + "thinking": { "type": "enabled" }, + "reasoning_effort": "max" + }); + let fields = extract_wire_reasoning_fields(&body); + assert_eq!(fields.thinking.as_deref(), Some("enabled")); + assert_eq!(fields.effort.as_deref(), Some("max")); + } + + #[test] + fn extracts_responses_effort_field() { + let body = json!({ + "model": "gpt-5", + "reasoning": { "effort": "high" } + }); + let fields = extract_wire_reasoning_fields(&body); + assert!(fields.thinking.is_none()); + assert_eq!(fields.effort.as_deref(), Some("high")); + } + + #[test] + fn extracts_anthropic_adaptive_fields() { + let body = json!({ + "model": "claude-sonnet-4-6", + "thinking": { "type": "adaptive" }, + "output_config": { "effort": "medium" } + }); + let fields = extract_wire_reasoning_fields(&body); + assert_eq!(fields.thinking.as_deref(), Some("adaptive")); + assert_eq!(fields.effort.as_deref(), Some("medium")); + } + + #[test] + fn returns_none_when_fields_absent() { + let body = json!({ "model": "gpt-4o", "messages": [] }); + let fields = extract_wire_reasoning_fields(&body); + assert!(fields.thinking.is_none()); + assert!(fields.effort.is_none()); + } +} diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 800fe16de..763c17129 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -124,7 +124,7 @@ impl RoundExecutor { let max_attempts = Self::MAX_STREAM_ATTEMPTS; let mut attempt_index = 0usize; - let (stream_result, send_to_stream_ms, stream_processing_ms) = loop { + let (stream_result, send_to_stream_ms, stream_processing_ms, wire_reasoning) = loop { // Check cancellation before opening a model stream. This catches // early cancellation registered before the first round starts. if cancel_token.is_cancelled() { @@ -193,6 +193,7 @@ impl RoundExecutor { }; // Destructure StreamResponse: get stream and raw SSE data receiver + let wire_reasoning = stream_response.wire_reasoning.clone(); let ai_stream = stream_response.stream; let raw_sse_rx = stream_response.raw_sse_rx; @@ -289,7 +290,7 @@ impl RoundExecutor { recovered .tool_calls .retain(|tool_call| tool_call.is_valid()); - break (recovered, send_to_stream_ms, stream_processing_ms); + break (recovered, send_to_stream_ms, stream_processing_ms, wire_reasoning); } self.emit_failed_partial_tool_calls( @@ -392,7 +393,7 @@ impl RoundExecutor { ); } - break (result, send_to_stream_ms, stream_processing_ms); + break (result, send_to_stream_ms, stream_processing_ms, wire_reasoning); } Err(stream_err) => { let err_msg = stream_err.error.to_string(); @@ -442,16 +443,30 @@ impl RoundExecutor { .iter() .map(|tc| tc.tool_name.as_str()) .collect(); + let output_tokens = stream_result + .usage + .as_ref() + .map(|usage| usage.candidates_token_count); + let tps = match (output_tokens, stream_processing_ms) { + (Some(tokens), ms) if ms > 0 && tokens > 0 => { + Some(tokens as f64 / (ms as f64 / 1000.0)) + } + _ => None, + }; debug!( target: "ai::model_response", - "Model response received: text_length={}, tool_calls={}, token_usage={:?}, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}", + "Model response received: text_length={}, tool_calls={}, token_usage={:?}, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}, output_tokens={:?}, tps={:?}, wire_thinking={:?}, wire_effort={:?}", stream_result.full_text.len(), if tool_names.is_empty() { "none".to_string() } else { tool_names.join(", ") }, stream_result.usage.as_ref().map(|u| format!("input={}, output={}, total={}", u.prompt_token_count, u.candidates_token_count, u.total_token_count)).unwrap_or_else(|| "none".to_string()), send_to_stream_ms, stream_processing_ms, stream_result.first_chunk_ms, - stream_result.first_visible_output_ms + stream_result.first_visible_output_ms, + output_tokens, + tps, + wire_reasoning.thinking.as_deref(), + wire_reasoning.effort.as_deref() ); // Check cancellation token again after stream processing completes diff --git a/src/crates/core/src/infrastructure/ai/mod.rs b/src/crates/core/src/infrastructure/ai/mod.rs index 3fba730da..94b09c302 100644 --- a/src/crates/core/src/infrastructure/ai/mod.rs +++ b/src/crates/core/src/infrastructure/ai/mod.rs @@ -11,8 +11,9 @@ pub use bitfun_ai_adapters::providers; pub use bitfun_ai_adapters::stream as ai_stream_handlers; pub use bitfun_ai_adapters::{ - AIClient, StreamOptions, StreamResponse, DEFAULT_STREAM_IDLE_TIMEOUT_SECS, - DEFAULT_STREAM_TTFT_TIMEOUT_SECS, REASONING_STREAM_TTFT_TIMEOUT_SECS, + AIClient, StreamOptions, StreamResponse, WireReasoningFields, + DEFAULT_STREAM_IDLE_TIMEOUT_SECS, DEFAULT_STREAM_TTFT_TIMEOUT_SECS, + REASONING_STREAM_TTFT_TIMEOUT_SECS, }; pub use client_factory::{ get_global_ai_client_factory, initialize_global_ai_client_factory, AIClientFactory, From ef580445be32ee7e587ebeba3320bb70372db9aa Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 29 May 2026 12:18:04 +0800 Subject: [PATCH 4/8] refactor(runtime-ports): move scheduler queue decisions --- docs/architecture/core-decomposition.md | 11 +- docs/plans/core-decomposition-plan.md | 11 +- scripts/check-core-boundaries.mjs | 63 +++++++ .../src/agentic/coordination/scheduler.rs | 127 +++++++------ src/crates/runtime-ports/src/lib.rs | 178 ++++++++++++++++++ 5 files changed, 329 insertions(+), 61 deletions(-) diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index f5a16b5aa..a06f1ad88 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -203,7 +203,9 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate Task 调用。迁移 agent scheduler、subagent runtime、session branch 或 prompt-cache owner 前, 必须保留 delegation policy、forked context seeding、prompt cache clone、已有上下文 dialog turn 持久化和递归 subagent 禁止语义。`DialogTriggerSource`、`DialogQueuePriority`、 - `DialogSubmissionPolicy` 与 `DialogSubmitOutcome` 已作为 runtime-port 契约,`DelegationPolicy` 与 `SubagentContextMode` 也已作为 + `DialogSubmissionPolicy`、`DialogSubmitOutcome`、submit queue routing decision、 + interactive preempt decision 与 agent-session cancel-reply suppression decision 已作为 + runtime-port 契约,`DelegationPolicy` 与 `SubagentContextMode` 也已作为 DTO/decision primitive 迁入 `bitfun-runtime-ports`,core 保留旧路径 re-export;当前 boundary check 已锁定 fork-aware Task 启动回执、child delegation policy 和 `` 结构化标记,防止后续 owner 迁移误删 @@ -340,9 +342,10 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate remote session/model selection 的 alias/config-reference 归一策略也迁入 `bitfun-services-integrations`;core 继续负责读取 `AIConfig` 并注入 model reference resolver。 - HR-C 已把 agent-session reply route、steering buffered outcome、round injection - kind / target / message / source traits、goal-mode DTO、prompt compression contract 与 - workspace related-path fact 等纯契约迁入 `bitfun-runtime-ports`,core 只保留兼容 + HR-C 已把 agent-session reply route、scheduler submit queue decision、interactive + preempt decision、agent-session cancel-reply suppression decision、steering buffered + outcome、round injection kind / target / message / source traits、goal-mode DTO、prompt + compression contract 与 workspace related-path fact 等纯契约迁入 `bitfun-runtime-ports`,core 只保留兼容 re-export;round injection buffer、scheduler 生命周期、remote-SSH runtime 与 terminal adapter 仍显式 core-owned。 最新 main 的 `/goal` 模式、goal verification events、request-context section policy、 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 22ea0fe20..b0a1ab7c1 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -255,7 +255,10 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts `start_dialog_turn_with_existing_context` 和递归 subagent 禁止语义。 - `DialogTriggerSource` 已复用 `AgentSubmissionSource` 的 `bitfun-runtime-ports` 契约;`DialogQueuePriority`、`DialogSubmissionPolicy` 与 `DialogSubmitOutcome` - 也已迁入 `bitfun-runtime-ports`。 + 也已迁入 `bitfun-runtime-ports`。本轮进一步把 scheduler submit queue routing、 + interactive preempt 与 agent-session cancel-reply suppression 的纯决策迁入 + `bitfun-runtime-ports`;core `DialogScheduler` 仍持有队列、active-turn map、round-yield + buffer、outcome loop 和 concrete coordinator submit,不改变执行生命周期。 `DelegationPolicy` 与 `SubagentContextMode` 已迁入 `bitfun-runtime-ports`,core `agentic::subagent_runtime` 只保留旧路径 re-export 和 core-owned `queue_timing`。这一步 只移动 portable DTO/decision primitive,不移动 scheduler、coordinator、session branch、 @@ -1806,6 +1809,12 @@ HR3:service / agent runtime deep owner migration 的主要风险和控制点 - 当前 HR-C 结论:portable contract closure 已完成,新增迁移范围限于 runtime-ports 可承载的 DTO/trait/fact;concrete remote/runtime 执行路径仍按上方 HR3 风险门禁作为后续可选深迁移主题处理。 +- 当前 service/agent 深迁移新增完成项:scheduler submit queue routing、 + interactive preempt 与 agent-session cancel-reply suppression 的纯决策已迁入 + `bitfun-runtime-ports`。core 继续持有 `DialogScheduler` 生命周期、queue storage、 + active-turn map、round-yield flags、outcome loop、coordinator submit 和 agent-session reply + delivery;这一步不改变 queue depth、preempt、cancel suppression 或 background reply + 行为。 - 风险:remote-SSH manager / remote FS / terminal 与 remote-connect workspace-root source、persistence/workspace service reads、`ImageContextData` concrete impl 都连接实际远端执行环境; 迁移不当会破坏 remote workspace guard、terminal pre-warm、response shape、 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 867643da1..453ffd585 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -2364,6 +2364,46 @@ const requiredContentRules = [ regex: /\bdialog_submit_outcome_preserves_started_and_queued_fields\b/, message: 'missing dialog submit outcome regression', }, + { + regex: /\bpub enum DialogSessionStateFact\b/, + message: 'missing dialog session state fact contract', + }, + { + regex: /\bpub struct DialogSubmitQueueFacts\b/, + message: 'missing dialog submit queue facts contract', + }, + { + regex: /\bpub enum DialogSubmitQueueAction\b/, + message: 'missing dialog submit queue action contract', + }, + { + regex: /\bpub const fn dialog_policy_may_preempt\b/, + message: 'missing dialog preempt policy contract', + }, + { + regex: /\bpub const fn resolve_dialog_submit_queue_action\b/, + message: 'missing dialog submit queue action resolver', + }, + { + regex: /\bdialog_submit_queue_action_preserves_current_scheduler_routing_policy\b/, + message: 'missing dialog submit queue action regression', + }, + { + regex: /\bpub fn should_suppress_agent_session_cancelled_reply\b/, + message: 'missing agent-session cancel suppression contract', + }, + { + regex: /\bpub enum DialogTurnOutcomeKind\b/, + message: 'missing dialog turn outcome kind contract', + }, + { + regex: /\bpub const fn should_skip_agent_session_reply\b/, + message: 'missing agent-session reply skip contract', + }, + { + regex: /\bagent_session_reply_decisions_preserve_cancel_suppression_boundary\b/, + message: 'missing agent-session reply decision regression', + }, { regex: /\bpub struct AgentSessionReplyRoute\b/, message: 'missing agent session reply route contract', @@ -2927,6 +2967,11 @@ const requiredContentRules = [ /pub use bitfun_runtime_ports::\{[\s\S]*AgentSessionReplyRoute[\s\S]*DialogQueuePriority[\s\S]*DialogSteerOutcome[\s\S]*DialogSubmissionPolicy[\s\S]*DialogSubmitOutcome[\s\S]*\};/, message: 'missing dialog submission policy compatibility re-export', }, + { + regex: + /use bitfun_runtime_ports::\{[\s\S]*DialogSessionStateFact[\s\S]*DialogSubmitQueueAction[\s\S]*DialogSubmitQueueFacts[\s\S]*DialogTurnOutcomeKind[\s\S]*resolve_dialog_submit_queue_action[\s\S]*should_skip_agent_session_reply_contract[\s\S]*should_suppress_agent_session_cancelled_reply_contract[\s\S]*\};/, + message: 'missing dialog scheduler decision contract import', + }, ], }, { @@ -6770,6 +6815,16 @@ function runManifestParserSelfTest() { 'dialog_submission_policy_preserves_current_surface_queue_defaults', 'DialogSubmitOutcome', 'dialog_submit_outcome_preserves_started_and_queued_fields', + 'DialogSessionStateFact', + 'DialogSubmitQueueFacts', + 'DialogSubmitQueueAction', + 'dialog_policy_may_preempt', + 'resolve_dialog_submit_queue_action', + 'dialog_submit_queue_action_preserves_current_scheduler_routing_policy', + 'should_suppress_agent_session_cancelled_reply', + 'DialogTurnOutcomeKind', + 'should_skip_agent_session_reply', + 'agent_session_reply_decisions_preserve_cancel_suppression_boundary', 'AgentSessionReplyRoute', 'agent_session_reply_route_keeps_requester_fields', 'DialogSteerOutcome', @@ -6967,9 +7022,17 @@ function runManifestParserSelfTest() { contracts: [ 'AgentSessionReplyRoute', 'DialogQueuePriority', + 'DialogSessionStateFact', 'DialogSteerOutcome', 'DialogSubmissionPolicy', 'DialogSubmitOutcome', + 'DialogSubmitQueueAction', + 'DialogSubmitQueueFacts', + 'DialogTurnOutcomeKind', + 'dialog_policy_may_preempt', + 'resolve_dialog_submit_queue_action', + 'should_skip_agent_session_reply_contract', + 'should_suppress_agent_session_cancelled_reply_contract', ], }, { diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index 146c1dff5..4355054fb 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -36,6 +36,12 @@ pub use bitfun_runtime_ports::{ AgentSessionReplyRoute, DialogQueuePriority, DialogSteerOutcome, DialogSubmissionPolicy, DialogSubmitOutcome, }; +use bitfun_runtime_ports::{ + DialogSessionStateFact, DialogSubmitQueueAction, DialogSubmitQueueFacts, DialogTurnOutcomeKind, + resolve_dialog_submit_queue_action, + should_skip_agent_session_reply as should_skip_agent_session_reply_contract, + should_suppress_agent_session_cancelled_reply as should_suppress_agent_session_cancelled_reply_contract, +}; #[derive(Debug, Clone)] struct ActiveTurn { @@ -70,11 +76,13 @@ impl ActiveTurn { } fn should_suppress_cancelled_reply_for_requester(&self, requester_session_id: &str) -> bool { - self.is_agent_session_request() - && self - .reply_route + should_suppress_agent_session_cancelled_reply_contract( + &self.policy, + self.reply_route .as_ref() - .is_some_and(|reply_route| reply_route.source_session_id == requester_session_id) + .map(|reply_route| reply_route.source_session_id.as_str()), + requester_session_id, + ) } } @@ -310,15 +318,21 @@ impl DialogScheduler { .await } - fn user_message_may_preempt(policy: &DialogSubmissionPolicy) -> bool { - matches!( - policy.trigger_source, - DialogTriggerSource::DesktopUi - | DialogTriggerSource::DesktopApi - | DialogTriggerSource::Cli - | DialogTriggerSource::RemoteRelay - | DialogTriggerSource::Bot - ) + fn session_state_fact(state: Option<&SessionState>) -> DialogSessionStateFact { + match state { + None => DialogSessionStateFact::Missing, + Some(SessionState::Idle) => DialogSessionStateFact::Idle, + Some(SessionState::Processing { .. }) => DialogSessionStateFact::Processing, + Some(SessionState::Error { .. }) => DialogSessionStateFact::Error, + } + } + + fn turn_outcome_kind(outcome: &TurnOutcome) -> DialogTurnOutcomeKind { + match outcome { + TurnOutcome::Completed { .. } => DialogTurnOutcomeKind::Completed, + TurnOutcome::Cancelled { .. } => DialogTurnOutcomeKind::Cancelled, + TurnOutcome::Failed { .. } => DialogTurnOutcomeKind::Failed, + } } /// Submit a user message for a session. @@ -401,8 +415,19 @@ impl DialogScheduler { .get_session(&session_id) .map(|s| s.state.clone()); - match state { - None => { + let queue_has_items = self + .queues + .get(&session_id) + .map(|q| !q.is_empty()) + .unwrap_or(false); + let action = resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: Self::session_state_fact(state.as_ref()), + queue_has_items, + policy: queued_turn.policy, + }); + + match action { + DialogSubmitQueueAction::StartImmediately => { let tid = self.start_turn(&session_id, &queued_turn).await?; self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) .await; @@ -412,7 +437,7 @@ impl DialogScheduler { }) } - Some(SessionState::Error { .. }) => { + DialogSubmitQueueAction::ClearQueueAndStartImmediately => { self.clear_queue(&session_id); let tid = self.start_turn(&session_id, &queued_turn).await?; self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) @@ -423,47 +448,30 @@ impl DialogScheduler { }) } - Some(SessionState::Idle) => { - let queue_non_empty = self - .queues - .get(&session_id) - .map(|q| !q.is_empty()) - .unwrap_or(false); - - if queue_non_empty { - self.enqueue(&session_id, queued_turn.clone())?; - self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) - .await; - let started_tid = self.try_start_next_queued(&session_id).await?; - let outcome = match started_tid { - Some(tid) if tid == resolved_turn_id => DialogSubmitOutcome::Started { - session_id: session_id.clone(), - turn_id: tid, - }, - _ => DialogSubmitOutcome::Queued { - session_id: session_id.clone(), - turn_id: resolved_turn_id, - }, - }; - Ok(outcome) - } else { - let tid = self.start_turn(&session_id, &queued_turn).await?; - self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) - .await; - Ok(DialogSubmitOutcome::Started { - session_id, + DialogSubmitQueueAction::EnqueueThenStartNext => { + self.enqueue(&session_id, queued_turn.clone())?; + self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) + .await; + let started_tid = self.try_start_next_queued(&session_id).await?; + let outcome = match started_tid { + Some(tid) if tid == resolved_turn_id => DialogSubmitOutcome::Started { + session_id: session_id.clone(), turn_id: tid, - }) - } + }, + _ => DialogSubmitOutcome::Queued { + session_id: session_id.clone(), + turn_id: resolved_turn_id, + }, + }; + Ok(outcome) } - Some(SessionState::Processing { .. }) => { - let may_preempt = Self::user_message_may_preempt(&queued_turn.policy); + DialogSubmitQueueAction::EnqueueForActiveTurn { request_yield } => { let accepted_agent_type = queued_turn.agent_type.clone(); self.enqueue(&session_id, queued_turn)?; self.record_last_submitted_agent_type(&session_id, &accepted_agent_type) .await; - if may_preempt { + if request_yield { self.round_yield_flags.request_yield(&session_id); } Ok(DialogSubmitOutcome::Queued { @@ -761,7 +769,10 @@ impl DialogScheduler { outcome: &TurnOutcome, suppressed_cancelled_reply: bool, ) -> bool { - matches!(outcome, TurnOutcome::Cancelled { .. }) && suppressed_cancelled_reply + should_skip_agent_session_reply_contract( + Self::turn_outcome_kind(outcome), + suppressed_cancelled_reply, + ) } fn format_agent_session_reply( @@ -987,8 +998,10 @@ mod tests { assert!( DialogScheduler::goal_verification_observation_text(&cancelled).contains("cancelled") ); - assert!(DialogScheduler::goal_verification_observation_text(&failed) - .contains("network offline")); + assert!( + DialogScheduler::goal_verification_observation_text(&failed) + .contains("network offline") + ); } #[test] @@ -996,16 +1009,18 @@ mod tests { let remote = DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay); assert_eq!(remote.queue_priority, DialogQueuePriority::Normal); assert!(remote.skip_tool_confirmation); - assert!(DialogScheduler::user_message_may_preempt(&remote)); + assert!(bitfun_runtime_ports::dialog_policy_may_preempt(&remote)); let bot = DialogSubmissionPolicy::for_source(DialogTriggerSource::Bot); assert_eq!(bot.queue_priority, DialogQueuePriority::Normal); assert!(bot.skip_tool_confirmation); - assert!(DialogScheduler::user_message_may_preempt(&bot)); + assert!(bitfun_runtime_ports::dialog_policy_may_preempt(&bot)); let agent_session = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); assert_eq!(agent_session.queue_priority, DialogQueuePriority::Low); assert!(agent_session.skip_tool_confirmation); - assert!(!DialogScheduler::user_message_may_preempt(&agent_session)); + assert!(!bitfun_runtime_ports::dialog_policy_may_preempt( + &agent_session + )); } } diff --git a/src/crates/runtime-ports/src/lib.rs b/src/crates/runtime-ports/src/lib.rs index 5fe539d28..6edd6ff80 100644 --- a/src/crates/runtime-ports/src/lib.rs +++ b/src/crates/runtime-ports/src/lib.rs @@ -151,6 +151,83 @@ pub enum DialogSubmitOutcome { Queued { session_id: String, turn_id: String }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DialogSessionStateFact { + Missing, + Idle, + Processing, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DialogSubmitQueueFacts { + pub session_state: DialogSessionStateFact, + pub queue_has_items: bool, + pub policy: DialogSubmissionPolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DialogSubmitQueueAction { + StartImmediately, + ClearQueueAndStartImmediately, + EnqueueThenStartNext, + EnqueueForActiveTurn { request_yield: bool }, +} + +pub const fn dialog_policy_may_preempt(policy: &DialogSubmissionPolicy) -> bool { + matches!( + policy.trigger_source, + DialogTriggerSource::DesktopUi + | DialogTriggerSource::DesktopApi + | DialogTriggerSource::Cli + | DialogTriggerSource::RemoteRelay + | DialogTriggerSource::Bot + ) +} + +pub const fn resolve_dialog_submit_queue_action( + facts: DialogSubmitQueueFacts, +) -> DialogSubmitQueueAction { + match facts.session_state { + DialogSessionStateFact::Missing => DialogSubmitQueueAction::StartImmediately, + DialogSessionStateFact::Error => DialogSubmitQueueAction::ClearQueueAndStartImmediately, + DialogSessionStateFact::Idle => { + if facts.queue_has_items { + DialogSubmitQueueAction::EnqueueThenStartNext + } else { + DialogSubmitQueueAction::StartImmediately + } + } + DialogSessionStateFact::Processing => DialogSubmitQueueAction::EnqueueForActiveTurn { + request_yield: dialog_policy_may_preempt(&facts.policy), + }, + } +} + +pub fn should_suppress_agent_session_cancelled_reply( + policy: &DialogSubmissionPolicy, + reply_source_session_id: Option<&str>, + requester_session_id: &str, +) -> bool { + policy.trigger_source == DialogTriggerSource::AgentSession + && reply_source_session_id.is_some_and(|source| source == requester_session_id) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DialogTurnOutcomeKind { + Completed, + Cancelled, + Failed, +} + +pub const fn should_skip_agent_session_reply( + outcome_kind: DialogTurnOutcomeKind, + suppressed_cancelled_reply: bool, +) -> bool { + matches!(outcome_kind, DialogTurnOutcomeKind::Cancelled) && suppressed_cancelled_reply +} + /// Source session route used when an agent-session request should reply to the /// requester after the target session finishes. #[derive(Debug, Clone, PartialEq, Eq)] @@ -775,6 +852,107 @@ mod tests { assert_ne!(started, queued); } + #[test] + fn dialog_submit_queue_action_preserves_current_scheduler_routing_policy() { + let remote = DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay); + assert!(dialog_policy_may_preempt(&remote)); + + let agent_session = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); + assert!(!dialog_policy_may_preempt(&agent_session)); + + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Missing, + queue_has_items: true, + policy: remote, + }), + DialogSubmitQueueAction::StartImmediately + ); + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Error, + queue_has_items: true, + policy: remote, + }), + DialogSubmitQueueAction::ClearQueueAndStartImmediately + ); + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Idle, + queue_has_items: false, + policy: remote, + }), + DialogSubmitQueueAction::StartImmediately + ); + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Idle, + queue_has_items: true, + policy: remote, + }), + DialogSubmitQueueAction::EnqueueThenStartNext + ); + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Processing, + queue_has_items: false, + policy: remote, + }), + DialogSubmitQueueAction::EnqueueForActiveTurn { + request_yield: true + } + ); + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Processing, + queue_has_items: false, + policy: agent_session, + }), + DialogSubmitQueueAction::EnqueueForActiveTurn { + request_yield: false + } + ); + } + + #[test] + fn agent_session_reply_decisions_preserve_cancel_suppression_boundary() { + let policy = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); + assert!(should_suppress_agent_session_cancelled_reply( + &policy, + Some("requester"), + "requester", + )); + assert!(!should_suppress_agent_session_cancelled_reply( + &policy, + Some("requester"), + "other", + )); + + let remote = DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay); + assert!(!should_suppress_agent_session_cancelled_reply( + &remote, + Some("requester"), + "requester", + )); + + assert!(should_skip_agent_session_reply( + DialogTurnOutcomeKind::Cancelled, + true, + )); + assert!(!should_skip_agent_session_reply( + DialogTurnOutcomeKind::Cancelled, + false, + )); + assert!(!should_skip_agent_session_reply( + DialogTurnOutcomeKind::Completed, + true, + )); + assert!(!should_skip_agent_session_reply( + DialogTurnOutcomeKind::Failed, + true, + )); + } + #[test] fn agent_session_reply_route_keeps_requester_fields() { let route = AgentSessionReplyRoute { From 9fa991f69b9fa739afac487cda37250128362e0f Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 29 May 2026 11:22:43 +0800 Subject: [PATCH 5/8] ci: strengthen contribution governance --- .github/ISSUE_TEMPLATE/bug_report.yml | 89 +++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/docs.yml | 36 +++++ .github/ISSUE_TEMPLATE/feature_request.yml | 71 +++++++++ .github/pull_request_template.md | 71 +++++++++ .github/workflows/ci.yml | 17 +++ AGENTS-CN.md | 16 +- AGENTS.md | 11 +- CONTRIBUTING.md | 18 ++- CONTRIBUTING_CN.md | 18 ++- package.json | 2 + scripts/check-github-config.mjs | 57 +++++++ scripts/check-repo-hygiene.mjs | 168 +++++++++++++++++++++ src/mobile-web/AGENTS.md | 33 ++++ src/mobile-web/package.json | 1 + 15 files changed, 596 insertions(+), 17 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/docs.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md create mode 100644 scripts/check-github-config.mjs create mode 100644 scripts/check-repo-hygiene.mjs create mode 100644 src/mobile-web/AGENTS.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..1c554900a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,89 @@ +name: Bug report +description: Report a reproducible bug or regression. +title: "[Bug]: " +body: + - type: markdown + attributes: + value: | + Thanks for helping improve BitFun. Please provide enough detail for maintainers to reproduce and route the issue. + - type: textarea + id: summary + attributes: + label: Summary + description: What went wrong? + placeholder: Briefly describe the bug. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - Desktop app + - Web UI + - Mobile web + - Agent runtime / core + - AI provider / model adapter + - Remote workspace / relay + - Installer / packaging + - Documentation + - Not sure + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: List exact steps from a clean state when possible. + placeholder: | + 1. Open ... + 2. Click ... + 3. Observe ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + placeholder: What should have happened? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + placeholder: What happened instead? + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Include the version, commit, OS, browser, desktop/mobile device, model/provider, or deployment mode if relevant. + placeholder: | + BitFun version/commit: + OS: + Browser/device: + Model/provider: + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs, screenshots, or recordings + description: Paste relevant logs or attach images/recordings. Remove tokens, keys, user IDs, and private data. + - type: dropdown + id: regression + attributes: + label: Regression? + options: + - "No" + - "Yes" + - "Not sure" + validations: + required: true + - type: textarea + id: extra + attributes: + label: Additional context + description: Anything else that may help triage the issue. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..26c74fd03 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/GCWing/BitFun/security/advisories/new + about: Please report security issues privately instead of opening a public issue. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 000000000..a121f76f7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,36 @@ +name: Documentation issue +description: Report missing, outdated, confusing, or incorrect documentation. +title: "[Docs]: " +body: + - type: textarea + id: location + attributes: + label: Documentation location + description: Link the page or file path if known. + placeholder: README.md, CONTRIBUTING.md, docs/..., website page, etc. + validations: + required: true + - type: textarea + id: problem + attributes: + label: Problem + description: What is missing, outdated, confusing, or incorrect? + validations: + required: true + - type: textarea + id: suggested + attributes: + label: Suggested update + description: Optional. Describe the wording or structure you expected. + - type: dropdown + id: audience + attributes: + label: Audience + options: + - New user + - Contributor + - Maintainer + - Developer integrating with BitFun + - Not sure + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..b9a810080 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,71 @@ +name: Feature or product proposal +description: Suggest a user-facing feature, workflow improvement, or product direction. +title: "[Feature]: " +body: + - type: markdown + attributes: + value: | + Use this for product ideas, UX improvements, and new workflows. For small bugs, use the bug report form. + - type: textarea + id: problem + attributes: + label: Problem / opportunity + description: What user problem, workflow gap, or product opportunity should this address? + placeholder: Describe the current pain point and who experiences it. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + description: What should BitFun do differently? + placeholder: Describe the desired behavior or workflow. + validations: + required: true + - type: dropdown + id: surface + attributes: + label: Product surface + options: + - Desktop app + - Web UI + - Mobile web + - Agent runtime / core + - AI provider / model adapter + - Remote workspace / relay + - Installer / packaging + - Documentation + - Cross-cutting + - Not sure + validations: + required: true + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: What must be true for this to feel complete? + placeholder: | + - Users can ... + - The UI shows ... + - It works when ... + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Optional. Mention other approaches or existing tools/workarounds. + - type: textarea + id: risks + attributes: + label: Risks or open questions + description: Optional. Include compatibility, privacy, performance, remote workspace, or UX concerns. + - type: checkboxes + id: contribution + attributes: + label: Contribution intent + options: + - label: I plan to open a PR for this. + required: false + - label: I can help test or provide feedback. + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..08fab9505 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,71 @@ +## Summary + + + +## Motivation / Product Context + + + +Fixes # + +## PR Type + +- [ ] Feature +- [ ] Bug fix +- [ ] Regression fix +- [ ] Refactor +- [ ] UI / UX +- [ ] Docs +- [ ] Tests +- [ ] Build / CI / release +- [ ] Dependency update +- [ ] AI-assisted prototype or iteration + +## Changed Areas + +- [ ] Rust core / agent runtime +- [ ] Desktop / Tauri integration +- [ ] Web UI +- [ ] Mobile web +- [ ] Server / relay / remote control +- [ ] AI adapters / model providers +- [ ] Installer / packaging +- [ ] Docs only + +## User Impact + + + +## Design / Architecture Notes + + + +## Verification + +Verification scope: + + + +Commands run and results: + + + +Manual verification: + + + +Skipped checks: + + + +## Risk and Rollback + + + +## Checklist + +- [ ] This PR is focused and does not bundle unrelated changes. +- [ ] No secrets, tokens, local absolute paths, temporary prompts, generated scratch files, or unrelated artifacts are committed. +- [ ] User-facing strings are localized where applicable. +- [ ] Docs are updated or intentionally not needed. +- [ ] AI-assisted work is disclosed with testing level: untested / lightly tested / fully tested. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 557f30b19..e78477554 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,8 @@ jobs: NODE_OPTIONS: --max-old-space-size=6144 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 2 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -112,15 +114,30 @@ jobs: node-version: 20 cache: pnpm + - name: Check repository hygiene + run: pnpm run check:repo-hygiene + - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Validate GitHub config + run: pnpm run check:github-config + - name: Lint web UI run: pnpm run lint:web + - name: Run web UI tests + run: pnpm --dir src/web-ui run test:run + - name: Build web UI run: pnpm run build:web + - name: Type-check mobile web + run: pnpm --dir src/mobile-web run type-check + + - name: Build mobile web + run: pnpm run build:mobile-web + - name: Upload frontend build artifacts uses: actions/upload-artifact@v4 with: diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 01ea442d1..067022222 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -2,7 +2,7 @@ # AGENTS-CN.md -BitFun 是一个由 Rust workspace 与共享 React 前端组成的项目。 +BitFun 是一个由 Rust workspace 与 React 前端组成的项目。 仓库核心原则:**先保持产品逻辑平台无关,再通过平台适配层对外暴露能力**。 @@ -19,7 +19,10 @@ BitFun 是一个由 Rust workspace 与共享 React 前端组成的项目。 |---|---|---| | Core(产品逻辑) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | | 已拆出的 core 支撑 crate | `src/crates/{core-types,agent-stream,runtime-ports,terminal,tool-runtime}` | (使用 core 指南) | -| Core owner crate | `src/crates/{services-core,services-integrations,agent-tools,tool-packs}` | (使用 core 指南 + 拆解护栏) | +| Service core owner crate | `src/crates/services-core` | [AGENTS.md](src/crates/services-core/AGENTS.md) | +| Service integrations owner crate | `src/crates/services-integrations` | [AGENTS.md](src/crates/services-integrations/AGENTS.md) | +| Agent tool contracts | `src/crates/agent-tools` | [AGENTS.md](src/crates/agent-tools/AGENTS.md) | +| Tool pack provider plan | `src/crates/tool-packs` | [AGENTS.md](src/crates/tool-packs/AGENTS.md) | | 产品领域 crate | `src/crates/product-domains` | [AGENTS.md](src/crates/product-domains/AGENTS.md) | | Transport 适配层 | `src/crates/transport` | (使用 core 指南) | | API layer | `src/crates/api-layer` | (使用 core 指南) | @@ -30,6 +33,7 @@ BitFun 是一个由 Rust workspace 与共享 React 前端组成的项目。 | CLI | `src/apps/cli` | (使用 core 指南) | | 中继服务器 | `src/apps/relay-server` | (使用 core 指南) | | 共享前端 | `src/web-ui` | [AGENTS.md](src/web-ui/AGENTS.md) | +| Mobile web | `src/mobile-web` | [AGENTS.md](src/mobile-web/AGENTS.md) | | 安装器 | `BitFun-Installer` | [AGENTS.md](BitFun-Installer/AGENTS.md) | | E2E 测试 | `tests/e2e` | [AGENTS.md](tests/e2e/AGENTS.md) | @@ -49,6 +53,9 @@ pnpm run cli:dev # CLI 运行时 pnpm run fmt:rs # 只格式化已改动 / 已暂存的 Rust 文件 pnpm run lint:web pnpm run type-check:web +pnpm --dir src/mobile-web run type-check +pnpm run check:repo-hygiene +pnpm run check:github-config cargo check --workspace # 测试 @@ -58,6 +65,7 @@ cargo test --workspace # 构建 cargo build -p bitfun-desktop pnpm run build:web +pnpm run build:mobile-web # 快速构建(开发 / CI 提速) pnpm run desktop:build:fast # debug 构建,不打包 @@ -164,6 +172,7 @@ SessionManager → Session → DialogTurn → ModelRound | 改动类型 | 最低验证要求 | |---|---| | 前端 UI、状态、适配层或多语言文案 | `pnpm run lint:web && pnpm run type-check:web && pnpm --dir src/web-ui run test:run` | +| Mobile web UI、状态、配对、断开或重连行为 | `pnpm --dir src/mobile-web run type-check && pnpm run build:mobile-web`;行为变化还需要在 PR 中说明手动配对 / 重连验证 | | Deep Review / 代码审核团队行为 | 运行上面的前端验证,再运行 `cargo test -p bitfun-core deep_review -- --nocapture`;如果触及后端或 Tauri API,还需要运行下方 Rust / 桌面端验证 | | `core`、`transport`、`api-layer` 或共享服务中的 Rust 逻辑 | `cargo check --workspace && cargo test --workspace` | | 桌面端集成、Tauri API、browser/computer-use 或桌面专属行为 | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | @@ -176,7 +185,8 @@ SessionManager → Session → DialogTurn → ModelRound | 功能 | 关键路径 | |---|---| | Agent mode | `src/crates/core/src/agentic/agents/`、`src/crates/core/src/agentic/agents/prompts/`、`src/web-ui/src/locales/*/scenes/agents.json` | -| Deep Review / 代码审核团队 | `src/crates/core/src/agentic/deep_review_policy.rs`、`src/crates/core/src/agentic/agents/deep_review_agent.rs`、`src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`、`src/web-ui/src/shared/services/reviewTeamService.ts`、`src/web-ui/src/flow_chat/services/DeepReviewService.ts`、`src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Deep Review / 代码审核团队 | `src/crates/core/src/agentic/deep_review/`、`src/crates/core/src/agentic/deep_review_policy.rs`、`src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs`、`src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`、`src/web-ui/src/shared/services/review-team/`、`src/web-ui/src/flow_chat/deep-review/`、`src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Mobile web 配对 / 远程控制 | `src/mobile-web/src/pages/PairingPage.tsx`、`src/mobile-web/src/pages/SessionListPage.tsx`、`src/mobile-web/src/pages/ChatPage.tsx`、`src/mobile-web/src/services/RemoteSessionManager.ts`、`src/mobile-web/src/services/RelayHttpClient.ts`、`src/mobile-web/src/services/store.ts` | | 会话用量报告(`/usage`) | `src/crates/core/src/service/session_usage/`、`src/web-ui/src/flow_chat/components/usage/`、`src/web-ui/src/locales/*/flow-chat.json` | | Tool | `src/crates/core/src/agentic/tools/implementations/`、`src/crates/core/src/agentic/tools/registry.rs` | | MCP / LSP / remote | `src/crates/core/src/service/mcp/`、`src/crates/core/src/service/lsp/`、`src/crates/core/src/service/remote_connect/`、`src/crates/core/src/service/remote_ssh/` | diff --git a/AGENTS.md b/AGENTS.md index 2e2343054..e8be05937 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ # AGENTS.md -BitFun is a Rust workspace plus a shared React frontend. +BitFun is a Rust workspace plus React frontends. Repository rule: **keep product logic platform-agnostic, then expose it through platform adapters**. @@ -33,6 +33,7 @@ Repository rule: **keep product logic platform-agnostic, then expose it through | CLI | `src/apps/cli` | (use core guide) | | Relay server | `src/apps/relay-server` | (use core guide) | | Shared frontend | `src/web-ui` | [AGENTS.md](src/web-ui/AGENTS.md) | +| Mobile web | `src/mobile-web` | [AGENTS.md](src/mobile-web/AGENTS.md) | | Installer | `BitFun-Installer` | [AGENTS.md](BitFun-Installer/AGENTS.md) | | E2E tests | `tests/e2e` | [AGENTS.md](tests/e2e/AGENTS.md) | @@ -52,6 +53,9 @@ pnpm run cli:dev # CLI runtime pnpm run fmt:rs # format only changed / staged Rust files pnpm run lint:web pnpm run type-check:web +pnpm --dir src/mobile-web run type-check +pnpm run check:repo-hygiene +pnpm run check:github-config cargo check --workspace # Test @@ -61,6 +65,7 @@ cargo test --workspace # Build cargo build -p bitfun-desktop pnpm run build:web +pnpm run build:mobile-web # Fast builds (for development / CI speed) pnpm run desktop:build:fast # debug build, no bundling @@ -171,6 +176,7 @@ Session data is stored under `.bitfun/sessions/{session_id}/`. | Change type | Minimum verification | |---|---| | Frontend UI, state, adapters, or locales | `pnpm run lint:web && pnpm run type-check:web && pnpm --dir src/web-ui run test:run` | +| Mobile web UI, state, pairing, disconnect, or reconnect behavior | `pnpm --dir src/mobile-web run type-check && pnpm run build:mobile-web`; include manual pairing / reconnect verification when behavior changes | | Deep Review / Code Review Team behavior | Web UI verification above, plus `cargo test -p bitfun-core deep_review -- --nocapture`; also run the Rust / desktop rows below when backend or Tauri APIs are touched | | Shared Rust logic in `core`, `transport`, `api-layer`, or services | `cargo check --workspace && cargo test --workspace` | | Desktop integration, Tauri APIs, browser/computer-use, or desktop-only behavior | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | @@ -183,7 +189,8 @@ Session data is stored under `.bitfun/sessions/{session_id}/`. | Feature | Key paths | |---|---| | Agent modes | `src/crates/core/src/agentic/agents/`, `src/crates/core/src/agentic/agents/prompts/`, `src/web-ui/src/locales/*/scenes/agents.json` | -| Deep Review / Code Review Team | `src/crates/core/src/agentic/deep_review/`, `src/crates/core/src/agentic/deep_review_policy.rs`, `src/crates/core/src/agentic/agents/deep_review_agent.rs`, `src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`, `src/web-ui/src/shared/services/review-team/`, `src/web-ui/src/flow_chat/deep-review/`, `src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Deep Review / Code Review Team | `src/crates/core/src/agentic/deep_review/`, `src/crates/core/src/agentic/deep_review_policy.rs`, `src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs`, `src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`, `src/web-ui/src/shared/services/review-team/`, `src/web-ui/src/flow_chat/deep-review/`, `src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Mobile web pairing / remote control | `src/mobile-web/src/pages/PairingPage.tsx`, `src/mobile-web/src/pages/SessionListPage.tsx`, `src/mobile-web/src/pages/ChatPage.tsx`, `src/mobile-web/src/services/RemoteSessionManager.ts`, `src/mobile-web/src/services/RelayHttpClient.ts`, `src/mobile-web/src/services/store.ts` | | Session usage report (`/usage`) | `src/crates/core/src/service/session_usage/`, `src/web-ui/src/flow_chat/components/usage/`, `src/web-ui/src/locales/*/flow-chat.json` | | Tools | `src/crates/core/src/agentic/tools/implementations/`, `src/crates/core/src/agentic/tools/registry.rs` | | MCP / LSP / remote | `src/crates/core/src/service/mcp/`, `src/crates/core/src/service/lsp/`, `src/crates/core/src/service/remote_connect/`, `src/crates/core/src/service/remote_ssh/` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3fe9ca064..85711ff9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,6 +139,7 @@ We welcome contributions beyond standard feature or bug-fix PRs. Examples includ - Open an issue to describe the problem or proposal, especially for larger changes, to avoid duplication and design conflicts - For new features or UI changes, discuss the design direction early to ensure it fits the product experience +- Use the issue and PR templates. If a field does not apply, write "N/A" instead of deleting the section. ### PR title and description @@ -155,6 +156,8 @@ UI changes should include before/after screenshots or a short recording for fast If your work is AI-assisted, please note it in the PR and indicate testing level (untested/lightly tested/fully tested) to help reviewers assess risk. +Do not commit transient AI prompts, local absolute paths, generated scratch files, pairing secrets, tokens, certificates, or unrelated artifacts. Keep the PR focused on the intended product or maintenance change. + ### Branch management **The `main` branch is the default collaboration branch and accepts feature PRs.** Since this repo encourages product managers and developers to use AI-generated code for rapid validation or idea submission, **please open all PRs targeting the `main` branch**. @@ -169,13 +172,16 @@ Run relevant tests for your change: For `/usage` UI copy changes, keep `en-US`, `zh-CN`, and `zh-TW` locale strings in sync. -```bash -# Rust -cargo test --workspace +| Change type | Recommended verification | +| --- | --- | +| Repository metadata, PR/issue templates, or GitHub workflows | `pnpm run check:repo-hygiene && pnpm run check:github-config && git diff --check` | +| Web UI, state, adapters, or locale changes | `pnpm run lint:web && pnpm run type-check:web && pnpm --dir src/web-ui run test:run` | +| Mobile web UI, pairing, reconnect, disconnect, or chat-flow changes | `pnpm --dir src/mobile-web run type-check && pnpm run build:mobile-web` | +| Rust core, transport, API layer, services, or shared runtime logic | `cargo check --workspace && cargo test --workspace` | +| Desktop integration, Tauri APIs, or desktop-only behavior | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | +| E2E-covered behavior | Build the nearest app target, then run the closest E2E spec or `pnpm run e2e:test:l0` | -# E2E -pnpm run e2e:test -``` +For mobile-web pairing, reconnect, disconnect, or chat-flow changes, include manual verification steps and screenshots or a short recording when the UI changes. If you cannot run tests, explain why in the PR and provide manual verification steps. diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index b84d654ec..9f47b5721 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -133,6 +133,7 @@ await api.invoke("your_command", { request: { /* ... */ } }); - 先开 Issue 说明问题或方案,尤其是较大改动,以避免重复与设计冲突 - 新功能或 UI 变更建议先讨论设计方向,确保符合产品体验 +- 使用 Issue 和 PR 模板;如果某个字段不适用,请写 `N/A`,不要直接删除该部分。 ### PR 标题与描述 @@ -149,6 +150,8 @@ UI 改动请附前后对比截图或短录屏,方便快速评审。 如为 AI 辅助产出,请在 PR 中注明并说明测试程度(未测/轻测/已测),便于评审风险。 +不要提交临时 AI prompt、本地绝对路径、生成的草稿文件、配对密钥、token、证书或无关产物。PR 应聚焦于本次产品或维护改动。 + ### 分支管理 **`main` 分支为默认协作分支,并接受特性 PR。** 本仓库欢迎产品经理、开发者使用 AI 生成代码进行快速验证或提交想法,因此 **所有 PR 请直接提交到 `main` 分支**。 @@ -163,13 +166,16 @@ UI 改动请附前后对比截图或短录屏,方便快速评审。 修改 `/usage` UI 文案时,请同步 `en-US`、`zh-CN`、`zh-TW` 多语言文本。 -```bash -# Rust -cargo test --workspace +| 改动类型 | 推荐验证 | +| --- | --- | +| 仓库元信息、PR/Issue 模板或 GitHub workflow | `pnpm run check:repo-hygiene && pnpm run check:github-config && git diff --check` | +| Web UI、状态、适配层或多语言文案 | `pnpm run lint:web && pnpm run type-check:web && pnpm --dir src/web-ui run test:run` | +| Mobile web UI、配对、重连、断开或聊天流程 | `pnpm --dir src/mobile-web run type-check && pnpm run build:mobile-web` | +| Rust core、transport、API layer、services 或共享运行时逻辑 | `cargo check --workspace && cargo test --workspace` | +| 桌面端集成、Tauri API 或桌面端专属行为 | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | +| E2E 覆盖的行为 | 先构建最近的 app target,再运行最接近的 E2E spec 或 `pnpm run e2e:test:l0` | -# E2E -pnpm run e2e:test -``` +Mobile web 配对、重连、断开或聊天流程改动,需要在 PR 中补充手动验证步骤;涉及 UI 变化时,请附截图或短录屏。 如暂时无法运行测试,请在 PR 描述中说明原因,并提供手动验证步骤。 diff --git a/package.json b/package.json index f3d534ce1..803e070c3 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "lint:web": "pnpm --dir src/web-ui run lint", "lint:web:fix": "pnpm --dir src/web-ui run lint:fix", "i18n:audit": "node scripts/i18n-audit.mjs", + "check:repo-hygiene": "node scripts/check-repo-hygiene.mjs", + "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs", "fmt:rs": "node scripts/format-changed-rust.mjs", "prebuild": "pnpm run prebuild:web", "prebuild:web": "pnpm run copy-assets --silent && pnpm run generate-all --silent", diff --git a/scripts/check-github-config.mjs b/scripts/check-github-config.mjs new file mode 100644 index 000000000..a98a57392 --- /dev/null +++ b/scripts/check-github-config.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const requireFromWebUi = createRequire(path.join(rootDir, 'src/web-ui/package.json')); +const yaml = requireFromWebUi('yaml'); + +const yamlFiles = []; + +function addYamlFiles(dir) { + const absoluteDir = path.join(rootDir, dir); + if (!existsSync(absoluteDir)) { + return; + } + + for (const entry of readdirSync(absoluteDir, { withFileTypes: true })) { + const relativePath = path.posix.join(dir.replace(/\\/g, '/'), entry.name); + const absolutePath = path.join(absoluteDir, entry.name); + + if (entry.isDirectory()) { + addYamlFiles(relativePath); + } else if (/\.(ya?ml)$/i.test(entry.name)) { + yamlFiles.push({ relativePath, absolutePath }); + } + } +} + +addYamlFiles('.github/workflows'); +addYamlFiles('.github/ISSUE_TEMPLATE'); + +const errors = []; + +for (const { relativePath, absolutePath } of yamlFiles) { + const document = yaml.parseDocument(readFileSync(absolutePath, 'utf8'), { + prettyErrors: true, + }); + + if (document.errors.length > 0) { + for (const error of document.errors) { + errors.push(`${relativePath}: ${error.message}`); + } + } +} + +if (errors.length > 0) { + console.error('GitHub YAML config check failed:'); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +} + +console.log(`GitHub YAML config check passed (${yamlFiles.length} files parsed).`); diff --git a/scripts/check-repo-hygiene.mjs b/scripts/check-repo-hygiene.mjs new file mode 100644 index 000000000..a69eddf6b --- /dev/null +++ b/scripts/check-repo-hygiene.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +function runGit(args) { + try { + return execFileSync('git', args, { encoding: 'utf8' }).split(/\r?\n/).filter(Boolean); + } catch { + return []; + } +} + +function uniqueFiles(files) { + return [...new Set(files.filter(Boolean))]; +} + +function hasCommit(ref) { + try { + execFileSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const trackedFiles = runGit(['ls-files']); +const untrackedFiles = runGit(['ls-files', '--others', '--exclude-standard']); +const repositoryFiles = uniqueFiles([...trackedFiles, ...untrackedFiles]); +const localChangedFiles = uniqueFiles([ + ...runGit(['diff', '--name-only', '--diff-filter=ACMRT', 'HEAD']), + ...untrackedFiles, +]); +const committedChangedFiles = hasCommit('HEAD^1') + ? runGit(['diff', '--name-only', '--diff-filter=ACMRT', 'HEAD^1', 'HEAD']) + : []; +const contentScanFiles = uniqueFiles( + localChangedFiles.length > 0 + ? localChangedFiles + : committedChangedFiles.length > 0 + ? committedChangedFiles + : trackedFiles, +); +const contentScanFileSet = new Set(contentScanFiles.map(normalizePath)); + +const textExtensions = new Set([ + '.cjs', + '.css', + '.html', + '.js', + '.json', + '.jsx', + '.md', + '.mjs', + '.rs', + '.scss', + '.toml', + '.ts', + '.tsx', + '.txt', + '.yaml', + '.yml', +]); + +const ignoredContentPaths = [ + /(^|\/)node_modules\//, + /(^|\/)dist\//, + /(^|\/)target\//, + /(^|\/)src\/web-ui\/public\/monaco-editor\//, + /(^|\/)src\/mobile-web\/dist\//, + /(^|\/).*package-lock\.json$/, + /(^|\/)pnpm-lock\.yaml$/, + /(^|\/)Cargo\.lock$/, +]; + +const testFilePattern = /(^|\/)(tests?|__tests__)\/|[._-](test|spec)\.[cm]?[jt]sx?$|_tests?\.rs$|\/tests\.rs$/; +const temporaryPromptNames = new Set([ + '_codex_review_prompt.txt', + 'codex_review_prompt.txt', + 'review_prompt.txt', +]); +const sensitiveFilenamePattern = + /(^|[._-])(id_rsa|id_dsa|id_ecdsa|id_ed25519)([._-]|$)|\.(pem|p12|pfx|mobileprovision)$/i; +const localAbsolutePathPattern = + /(^|[^A-Za-z])((?:[A-Za-z]:[\\/][^\s'"`)<\]]+)|(?:file:\/\/\/[A-Za-z]:\/[^\s'"`)<\]]+))/g; +const tokenPattern = + /\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,})\b/g; +const privateKeyPattern = /-----BEGIN (?:RSA |DSA |EC |OPENSSH |)?PRIVATE KEY-----/; + +const violations = []; + +function normalizePath(file) { + return file.replace(/\\/g, '/'); +} + +function shouldScanText(file) { + const normalized = normalizePath(file); + const ext = path.extname(normalized).toLowerCase(); + return textExtensions.has(ext) && !ignoredContentPaths.some((pattern) => pattern.test(normalized)); +} + +function addViolation(file, line, message) { + violations.push(line ? `${file}:${line} ${message}` : `${file} ${message}`); +} + +for (const file of repositoryFiles) { + const normalized = normalizePath(file); + const basename = path.posix.basename(normalized).toLowerCase(); + + if ( + temporaryPromptNames.has(basename) || + /(^|[-_])review[-_]?prompt\.(txt|md)$/i.test(basename) + ) { + addViolation(file, null, 'looks like a transient review prompt file.'); + } + + if (sensitiveFilenamePattern.test(basename)) { + addViolation(file, null, 'looks like a private key, certificate, or provisioning file.'); + } + + if (!contentScanFileSet.has(normalized) || !shouldScanText(file)) { + continue; + } + + let content; + try { + content = readFileSync(file, 'utf8'); + } catch { + continue; + } + + const isTestFile = testFilePattern.test(normalized); + const scanLocalPaths = !isTestFile; + const scanTokenLikeSecrets = !isTestFile; + const lines = content.split(/\r?\n/); + + for (const [index, line] of lines.entries()) { + const lineNumber = index + 1; + + if (privateKeyPattern.test(line)) { + addViolation(file, lineNumber, 'contains a private key marker.'); + } + + if (scanTokenLikeSecrets && tokenPattern.test(line)) { + addViolation(file, lineNumber, 'contains a token-like secret.'); + } + + if (scanLocalPaths && localAbsolutePathPattern.test(line)) { + addViolation(file, lineNumber, 'contains a local absolute path.'); + } + + localAbsolutePathPattern.lastIndex = 0; + tokenPattern.lastIndex = 0; + } +} + +if (violations.length > 0) { + console.error('Repository hygiene check failed:'); + for (const violation of violations) { + console.error(`- ${violation}`); + } + process.exit(1); +} + +console.log( + `Repository hygiene check passed (${contentScanFiles.length} content files scanned, ${repositoryFiles.length} filenames checked).`, +); diff --git a/src/mobile-web/AGENTS.md b/src/mobile-web/AGENTS.md new file mode 100644 index 000000000..0efa3aea4 --- /dev/null +++ b/src/mobile-web/AGENTS.md @@ -0,0 +1,33 @@ +# AGENTS.md + +Mobile web is the browser-based remote control client for BitFun desktop sessions. + +## Boundaries + +- Keep mobile-web logic inside `src/mobile-web`; do not import from `src/web-ui`. +- Treat pairing, reconnect, disconnect, session list, and chat state as one connected product flow. +- Keep connection state semantics consistent across persistent indicators, banners, dialogs, and disabled states. +- User-facing strings should use the mobile-web i18n message system when one is already present for the surface being changed. +- Do not commit local pairing URLs, user IDs, logs, screenshots with sensitive data, or temporary AI prompts. + +## Where to look first + +| Area | Paths | +|---|---| +| Pairing | `src/pages/PairingPage.tsx`, `src/services/RelayHttpClient.ts` | +| Session list | `src/pages/SessionListPage.tsx`, `src/services/store.ts` | +| Chat | `src/pages/ChatPage.tsx`, `src/services/RemoteSessionManager.ts` | +| Connection health / reconnect | `src/App.tsx`, `src/services/RemoteSessionManager.ts`, `src/services/store.ts` | +| Styles | `src/styles/`, `src/theme/` | +| Messages | `src/i18n/messages.ts` | + +## Verification + +Run the focused mobile-web checks after changes: + +```bash +pnpm --dir src/mobile-web run type-check +pnpm run build:mobile-web +``` + +For pairing, reconnect, disconnect, or chat behavior changes, also describe manual verification in the PR, including the browser/device used and the observed state transitions. diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 09e60b1bc..180f3e7e4 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "type-check": "tsc --noEmit", "build": "vite build", "preview": "vite preview" }, From f020e4b567c18682e0267275e658cb3b12bf2fb1 Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 29 May 2026 14:24:18 +0800 Subject: [PATCH 6/8] docs: simplify contribution templates --- .github/ISSUE_TEMPLATE/bug_report.yml | 52 ++---------------- .github/ISSUE_TEMPLATE/docs.yml | 21 +------ .github/ISSUE_TEMPLATE/feature_request.yml | 31 +---------- .github/pull_request_template.md | 64 +++++----------------- CONTRIBUTING.md | 4 +- CONTRIBUTING_CN.md | 4 +- 6 files changed, 28 insertions(+), 148 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 1c554900a..4ad6b595a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -2,16 +2,12 @@ name: Bug report description: Report a reproducible bug or regression. title: "[Bug]: " body: - - type: markdown - attributes: - value: | - Thanks for helping improve BitFun. Please provide enough detail for maintainers to reproduce and route the issue. - type: textarea id: summary attributes: label: Summary - description: What went wrong? - placeholder: Briefly describe the bug. + description: What went wrong, and what did you expect instead? + placeholder: Briefly describe the bug and the expected behavior. validations: required: true - type: dropdown @@ -31,59 +27,23 @@ body: validations: required: true - type: textarea - id: steps + id: reproduction attributes: - label: Steps to reproduce - description: List exact steps from a clean state when possible. + label: Reproduction or evidence + description: Steps, screenshots, logs, or a short recording. Remove tokens, keys, user IDs, and private data. placeholder: | 1. Open ... 2. Click ... 3. Observe ... validations: required: true - - type: textarea - id: expected - attributes: - label: Expected behavior - placeholder: What should have happened? - validations: - required: true - - type: textarea - id: actual - attributes: - label: Actual behavior - placeholder: What happened instead? - validations: - required: true - type: textarea id: environment attributes: - label: Environment + label: Environment, if relevant description: Include the version, commit, OS, browser, desktop/mobile device, model/provider, or deployment mode if relevant. placeholder: | BitFun version/commit: OS: Browser/device: Model/provider: - validations: - required: true - - type: textarea - id: logs - attributes: - label: Logs, screenshots, or recordings - description: Paste relevant logs or attach images/recordings. Remove tokens, keys, user IDs, and private data. - - type: dropdown - id: regression - attributes: - label: Regression? - options: - - "No" - - "Yes" - - "Not sure" - validations: - required: true - - type: textarea - id: extra - attributes: - label: Additional context - description: Anything else that may help triage the issue. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml index a121f76f7..f1a450aec 100644 --- a/.github/ISSUE_TEMPLATE/docs.yml +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -13,24 +13,7 @@ body: - type: textarea id: problem attributes: - label: Problem - description: What is missing, outdated, confusing, or incorrect? - validations: - required: true - - type: textarea - id: suggested - attributes: - label: Suggested update - description: Optional. Describe the wording or structure you expected. - - type: dropdown - id: audience - attributes: - label: Audience - options: - - New user - - Contributor - - Maintainer - - Developer integrating with BitFun - - Not sure + label: Problem or suggested update + description: What is missing, outdated, confusing, or incorrect? Include suggested wording if you have it. validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index b9a810080..819422e3f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -2,10 +2,6 @@ name: Feature or product proposal description: Suggest a user-facing feature, workflow improvement, or product direction. title: "[Feature]: " body: - - type: markdown - attributes: - value: | - Use this for product ideas, UX improvements, and new workflows. For small bugs, use the bug report form. - type: textarea id: problem attributes: @@ -40,32 +36,11 @@ body: validations: required: true - type: textarea - id: acceptance + id: details attributes: - label: Acceptance criteria - description: What must be true for this to feel complete? + label: Details, examples, or constraints + description: Optional. Include acceptance criteria, screenshots, alternatives, risks, or compatibility concerns. placeholder: | - Users can ... - The UI shows ... - It works when ... - validations: - required: true - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: Optional. Mention other approaches or existing tools/workarounds. - - type: textarea - id: risks - attributes: - label: Risks or open questions - description: Optional. Include compatibility, privacy, performance, remote workspace, or UX concerns. - - type: checkboxes - id: contribution - attributes: - label: Contribution intent - options: - - label: I plan to open a PR for this. - required: false - - label: I can help test or provide feedback. - required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 08fab9505..37f946e83 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,70 +2,32 @@ -## Motivation / Product Context - - - Fixes # -## PR Type - -- [ ] Feature -- [ ] Bug fix -- [ ] Regression fix -- [ ] Refactor -- [ ] UI / UX -- [ ] Docs -- [ ] Tests -- [ ] Build / CI / release -- [ ] Dependency update -- [ ] AI-assisted prototype or iteration +## Type and Areas -## Changed Areas +Type: -- [ ] Rust core / agent runtime -- [ ] Desktop / Tauri integration -- [ ] Web UI -- [ ] Mobile web -- [ ] Server / relay / remote control -- [ ] AI adapters / model providers -- [ ] Installer / packaging -- [ ] Docs only + -## User Impact +Areas: - + -## Design / Architecture Notes +## Motivation / Impact - + ## Verification -Verification scope: - - - -Commands run and results: - - - -Manual verification: - - - -Skipped checks: - - + -## Risk and Rollback +## Reviewer Notes - + ## Checklist -- [ ] This PR is focused and does not bundle unrelated changes. -- [ ] No secrets, tokens, local absolute paths, temporary prompts, generated scratch files, or unrelated artifacts are committed. -- [ ] User-facing strings are localized where applicable. -- [ ] Docs are updated or intentionally not needed. -- [ ] AI-assisted work is disclosed with testing level: untested / lightly tested / fully tested. +- [ ] This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts. +- [ ] Relevant verification is recorded above, or skipped checks are explained. +- [ ] User-facing strings, docs, and locales are updated where applicable. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85711ff9f..a33219acc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,7 +139,7 @@ We welcome contributions beyond standard feature or bug-fix PRs. Examples includ - Open an issue to describe the problem or proposal, especially for larger changes, to avoid duplication and design conflicts - For new features or UI changes, discuss the design direction early to ensure it fits the product experience -- Use the issue and PR templates. If a field does not apply, write "N/A" instead of deleting the section. +- Use the issue and PR templates as a guide. Keep the PR focused and explain any skipped verification when it matters. ### PR title and description @@ -168,7 +168,7 @@ Keep PRs small and focused. Avoid bundling unrelated changes. ## Testing and Verification -Run relevant tests for your change: +Run relevant tests for your change. You do not need to run every row below; choose the smallest set that matches the files and behavior you touched: For `/usage` UI copy changes, keep `en-US`, `zh-CN`, and `zh-TW` locale strings in sync. diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index 9f47b5721..9972d5aea 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -133,7 +133,7 @@ await api.invoke("your_command", { request: { /* ... */ } }); - 先开 Issue 说明问题或方案,尤其是较大改动,以避免重复与设计冲突 - 新功能或 UI 变更建议先讨论设计方向,确保符合产品体验 -- 使用 Issue 和 PR 模板;如果某个字段不适用,请写 `N/A`,不要直接删除该部分。 +- 将 Issue 和 PR 模板作为填写指引;保持 PR 聚焦,必要时说明跳过了哪些验证以及原因。 ### PR 标题与描述 @@ -162,7 +162,7 @@ UI 改动请附前后对比截图或短录屏,方便快速评审。 ## 测试与验证 -按改动范围运行相关测试: +按改动范围运行相关测试;不需要跑完下方所有命令,只选择与本次改动文件和行为匹配的最小集合: 修改 `/usage` UI 文案时,请同步 `en-US`、`zh-CN`、`zh-TW` 多语言文本。 From 1ba240fed444e7102bdc765f028d82c60979096f Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 29 May 2026 14:52:06 +0800 Subject: [PATCH 7/8] refactor(remote-connect): migrate command orchestration owner Move remote workspace, session, initial-sync, poll, and interaction command orchestration into services-integrations while keeping concrete runtime adapters in core. Update decomposition docs and boundary checks to match the new owner split. --- docs/architecture/core-decomposition.md | 8 +- docs/plans/core-decomposition-plan.md | 9 + scripts/check-core-boundaries.mjs | 80 +- .../service/remote_connect/remote_server.rs | 707 ++-------------- src/crates/core/src/service_agent_runtime.rs | 437 +++++++++- .../src/remote_connect.rs | 801 ++++++++++++++++++ 6 files changed, 1328 insertions(+), 714 deletions(-) diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index a06f1ad88..8eb021e49 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -69,7 +69,7 @@ Rust 编译和链接面。 | `bitfun-agent-tools` | 轻量 tool DTO / contract、portable tool context facts / provider、runtime restriction、host path normalization / runtime artifact URI / remote POSIX path pure contract、provider-neutral tool path resolution / absolute-path check / runtime artifact reference assembly、file guidance marker、file-read freshness comparison、oversized tool-result preview/rendering policy、tool execution result/error/invalid-call presentation policy、allowed-list / collapsed-tool execution gate policy、provider-neutral path policy root matching / denial message、pure manifest/exposure and GetToolSpec presentation/schema/static metadata/detail/result assembly / execution-plan contract、provider-backed tool catalog / GetToolSpec runtime facade、provider-backed GetToolSpec execution result helper / Tool-result vector adapter、generic contextual manifest resolver、generic catalog snapshot provider / GetToolSpec catalog provider、generic registry / static-provider / dynamic-provider / decorator-ref / snapshot-decorator adapter / runtime assembly container、generic readonly/enabled snapshot filter | partial:product registry snapshot access、`ToolUseContext` adapter、session file-read state storage、tool-result filesystem writes、`GetToolSpec` Tool impl 和 concrete tools 仍在 core,并由 core `tools/product_runtime.rs` 作为单一 product runtime owner 组装;core 当前从 `bitfun-tool-packs` provider plan 物化内置工具列表,static-provider 安装 assembly、decorator reference、generic snapshot decorator adapter、provider-backed catalog runtime facade、readonly/enabled 过滤规则、provider-neutral tool path resolution / runtime artifact reference assembly、file guidance/freshness policy、oversized result rendering、tool execution presentation 与 path policy 判定已委托给 `bitfun-agent-tools` | | `bitfun-tool-packs` | 由 feature group 隔离的工具 provider plan | partial:提供 basic / git / mcp / browser-web / computer-use / image-analysis / miniapp / agent-control feature-group 元数据和 product provider group plan;不得声明 concrete tools 已迁移 | | `bitfun-services-core` | Config、session、workspace、storage、filesystem、system services | partial:部分 pure helper 已迁出;通用本地 filesystem operations/tree/search/listing/service facade 已迁入;config/workspace/runtime/persistence 以及 filesystem 的 remote overlay、product runtime binding 仍在 core | -| `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle、tracker event reduction、dialog submission orchestration port/provider、file IO/path resolution helper、remote file command / response assembly、dialog/cancel/execution accepted response helper、workspace/session response assembly helper、remote model selection policy、remote chat history presentation assembly helper 与 image-context adapter contract;concrete scheduler/session restore/terminal adapter、workspace-root source、persistence/workspace service reads 与 product execution 仍在 core | +| `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle、tracker event reduction、dialog submission orchestration port/provider、workspace/session/initial-sync/poll/interaction command orchestration、file IO/path resolution helper、remote file command / response assembly、dialog/cancel/execution accepted response helper、workspace/session response assembly helper、remote model selection policy、remote chat history presentation assembly helper 与 image-context adapter contract;concrete scheduler/session restore/terminal adapter、workspace-root source、persistence/workspace service reads 与 product execution 仍在 core | | `bitfun-product-domains` | Miniapp 和 function-agent 产品子域 | partial:pure decision、port、MiniApp create/update/draft/apply/import state transition、storage/builtin contract、imported meta timestamp policy、seed meta timestamp policy、runtime detection concrete owner、worker/host/export 纯决策已迁入;filesystem IO、worker process、host dispatch execution、built-in asset seeding、export skeleton 与 Git/AI service runtime 仍在 core | | `terminal-core` | 已有 terminal package,移动到 workspace 顶层 `src/crates/terminal` 路径 | done:已在 workspace 顶层 | | `tool-runtime` | 已有 tool runtime,移动到 workspace 顶层路径 | done:已在 workspace 顶层 | @@ -102,6 +102,7 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate tracker state / registry lifecycle、remote tool preview slimming、remote model selection policy、legacy image context fallback / preference、restore target decision、cancel decision、cancel-task orchestration、 RemoteRelay/Bot dialog submission orchestration port/provider、dialog scheduler outcome assembly、 + workspace/session/initial-sync/poll/interaction command orchestration、 remote workspace path/MIME/full-read/chunk/info helper、 remote file command / response assembly、dialog/cancel/execution accepted response helper 与 remote file transfer size/chunk/name policy、workspace/session response assembly helper、remote chat history presentation assembly helper 可由 @@ -325,12 +326,13 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate `src/crates/core/src/product_domain_runtime.rs`,不改变实际执行路径。 - H3:remaining service/runtime owner。remote-connect 已把 dialog submission orchestration、cancel-task orchestration、terminal pre-warm decision、remote workspace file IO/path helper、 - workspace/session response assembly helper 与 image-context adapter contract 收敛到 owner crate port/provider; + workspace/session response assembly helper、workspace/session/initial-sync/poll/interaction + command orchestration 与 image-context adapter contract 收敛到 owner crate port/provider; concrete scheduler/session restore/terminal adapter、workspace-root source、 persistence/workspace service reads、remote-SSH runtime、agent registry/scheduler 等仍必须 另起 port/provider 设计和等价评审;HR3 进一步把这些仍 core-owned 的 service/agent runtime 绑定入口集中到 `src/crates/core/src/service_agent_runtime.rs`: - remote dialog/cancel/file/tracker host adapter、remote model catalog/session-model + remote dialog/cancel/file/workspace/session/poll/interaction/tracker host adapter、remote model catalog/session-model selection adapter、remote chat history persistence/projection adapter、 remote image-context conversion 和 coordinator runtime-port binding 均由该入口承载,不改变 remote-connect、 remote-SSH 或 scheduler 执行路径。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index b0a1ab7c1..764961877 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1806,6 +1806,11 @@ HR3:service / agent runtime deep owner migration 的主要风险和控制点 `bitfun-services-integrations`。core 只投影 concrete `DialogSubmitOutcome` 为 owner fact, scheduler submit、queue policy 映射和 terminal pre-warm 仍留在 core adapter,避免改变 restore -> prewarm -> submit 的执行顺序。 +- 当前 service/agent 深迁移新增完成项:remote workspace、session、initial-sync、poll + 与 tool/question interaction command orchestration 已迁入 `bitfun-services-integrations`。 + core 只通过 `CoreServiceAgentRuntime` 提供 workspace service、persistence/session restore、 + coordinator、tracker、model catalog 和 user-input host adapter,不改变 response shape、 + poll persistence dirty 判定、默认 reject/cancel reason 或 session/workspace 错误边界。 - 当前 HR-C 结论:portable contract closure 已完成,新增迁移范围限于 runtime-ports 可承载的 DTO/trait/fact;concrete remote/runtime 执行路径仍按上方 HR3 风险门禁作为后续可选深迁移主题处理。 @@ -2080,6 +2085,10 @@ owner 深迁移混入本里程碑。 - 已迁移的 remote model policy:remote session model id normalization、model selection alias handling 与 config-reference resolution policy 由 `bitfun-services-integrations` 提供; core adapter 只负责读取 `AIConfig` 并注入 resolver,保持原有 config service 缺失错误。 +- 已迁移的 remote command orchestration:workspace、session、initial-sync、poll 与 + interaction command handler 由 `bitfun-services-integrations` 拥有;core adapter 只注入 + workspace service、persistence/session restore、coordinator、tracker、model catalog 与 + user-input manager,并继续保留 concrete runtime 生命周期。 - concrete scheduler/session restore、workspace-root source、persistence/workspace service reads、 `ImageContextData` concrete impl、remote-SSH runtime、terminal adapter、agent registry/scheduler、 round injection buffer、goal-mode coordinator binding、request-context assembly 与 prompt compression runtime 继续 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 453ffd585..d4c4be7de 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -3674,56 +3674,28 @@ const requiredContentRules = [ message: 'missing remote cancel response assembly delegation', }, { - regex: /\bremote_interaction_accepted_response\b/, - message: 'missing remote interaction response assembly delegation', + regex: /\bhandle_remote_interaction_command\b/, + message: 'missing remote interaction command owner orchestration delegation', }, { - regex: /\bremote_answer_question_response\b/, - message: 'missing remote answer response assembly delegation', + regex: /\bgenerate_remote_initial_sync\b/, + message: 'missing remote initial-sync owner orchestration delegation', }, { - regex: /\bremote_workspace_info_response\b/, - message: 'missing remote workspace-info response assembly delegation', + regex: /\bhandle_remote_workspace_command\b/, + message: 'missing remote workspace command owner orchestration delegation', }, { - regex: /\bremote_recent_workspaces_response\b/, - message: 'missing remote recent-workspaces response assembly delegation', + regex: /\bhandle_remote_session_command\b/, + message: 'missing remote session command owner orchestration delegation', }, { - regex: /\bremote_assistant_list_response\b/, - message: 'missing remote assistant-list response assembly delegation', + regex: /\bhandle_remote_poll_command\b/, + message: 'missing remote poll command owner orchestration delegation', }, { - regex: /\bremote_workspace_updated_response\b/, - message: 'missing remote workspace-updated response assembly delegation', - }, - { - regex: /\bremote_assistant_updated_response\b/, - message: 'missing remote assistant-updated response assembly delegation', - }, - { - regex: /\bremote_session_list_response\b/, - message: 'missing remote session-list response assembly delegation', - }, - { - regex: /\bremote_initial_sync_response\b/, - message: 'missing remote initial-sync response assembly delegation', - }, - { - regex: /\bremote_session_created_response\b/, - message: 'missing remote session-created response assembly delegation', - }, - { - regex: /\bremote_session_model_updated_response\b/, - message: 'missing remote session-model response assembly delegation', - }, - { - regex: /\bremote_messages_response\b/, - message: 'missing remote messages response assembly delegation', - }, - { - regex: /\bremote_session_deleted_response\b/, - message: 'missing remote session-deleted response assembly delegation', + regex: /\bhandle_remote_interaction_command\b/, + message: 'missing remote interaction command owner orchestration delegation', }, { regex: /\bremote_image_context\b/, @@ -7099,6 +7071,10 @@ function runManifestParserSelfTest() { 'CoreRemoteDialogRuntimeHost', 'CoreRemoteCancelRuntimeHost', 'CoreRemoteWorkspaceFileRuntimeHost', + 'CoreRemoteWorkspaceRuntimeHost', + 'CoreRemoteSessionRuntimeHost', + 'CoreRemotePollRuntimeHost', + 'CoreRemoteInteractionRuntimeHost', 'CoreRemoteSessionTrackerHost', 'RemoteExecutionDispatcher', 'ImageContextData', @@ -7146,10 +7122,25 @@ function runManifestParserSelfTest() { 'remote_workspace_info_response', 'remote_recent_workspaces_response', 'remote_assistant_list_response', + 'RemoteWorkspaceRuntimeHost', + 'handle_remote_workspace_command', + 'remote_workspace_handler_preserves_response_shapes', + 'RemoteInitialSyncRuntimeHost', + 'generate_remote_initial_sync', 'remote_session_info', 'remote_session_list_response', 'remote_initial_sync_response', 'remote_messages_response', + 'RemoteSessionRuntimeHost', + 'handle_remote_session_command', + 'remote_session_handler_preserves_list_and_create_policy', + 'remote_session_handler_removes_tracker_after_delete_success', + 'RemotePollRuntimeHost', + 'handle_remote_poll_command', + 'remote_poll_handler_preserves_missing_workspace_error', + 'RemoteInteractionRuntimeHost', + 'handle_remote_interaction_command', + 'remote_interaction_handler_preserves_default_reject_reason', 'RemoteDefaultModelsConfig', 'RemoteModelConfig', 'RemoteModelCatalog', @@ -7203,10 +7194,11 @@ function runManifestParserSelfTest() { contracts: [ 'CoreServiceAgentRuntime', 'remote_image_context', - 'remote_workspace_info_response', - 'remote_session_list_response', - 'remote_initial_sync_response', - 'remote_messages_response', + 'handle_remote_workspace_command', + 'handle_remote_session_command', + 'generate_remote_initial_sync', + 'handle_remote_poll_command', + 'handle_remote_interaction_command', 'core_service_agent_runtime_owner_maps_remote_image_context', 'remote_execution_prefers_unified_image_contexts_over_legacy_images', 'remote_cancel_decision_preserves_current_turn_boundaries', diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 4e99cb6af..3e040aa6a 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -9,61 +9,30 @@ //! incremental updates (new messages + current active turn snapshot). use crate::service_agent_runtime::{CoreRemoteSessionTrackerHost, CoreServiceAgentRuntime}; -use anyhow::{anyhow, Result}; -use log::{debug, error, info}; +use anyhow::{Result, anyhow}; +use log::info; use serde_json::Value; use std::sync::{Arc, OnceLock}; use super::encryption; -use bitfun_services_integrations::remote_connect::{ - build_remote_image_contexts, cancel_remote_task, handle_remote_workspace_file_command, - remote_answer_question_response, remote_assistant_list_response, - remote_assistant_updated_response, remote_dialog_submit_response, remote_initial_sync_response, - remote_interaction_accepted_response, remote_messages_response, - remote_model_catalog_poll_delta, remote_no_change_poll_response, - remote_persisted_poll_response, remote_recent_workspaces_response, - remote_session_created_response, remote_session_deleted_response, remote_session_list_response, - remote_session_model_updated_response, remote_snapshot_poll_response, - remote_task_cancel_response, remote_workspace_info_response, remote_workspace_updated_response, - resolve_remote_execution_image_contexts, submit_remote_dialog, RemoteAssistantWorkspaceFacts, - RemoteCancelTaskRequest, RemoteConnectSubmissionSource, RemoteDialogSubmissionPolicy, - RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteImageContext, - RemoteRecentWorkspaceFacts, RemoteSessionMetadata, RemoteSessionTrackerRegistry, - RemoteWorkspaceFacts, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceUpdate, -}; pub use bitfun_services_integrations::remote_connect::{ ActiveTurnSnapshot, AssistantEntry, ChatImageAttachment, ChatMessage, ChatMessageItem, ImageAttachment, RecentWorkspaceEntry, RemoteCommand, RemoteDefaultModelsConfig, RemoteModelCatalog, RemoteModelConfig, RemoteResponse, RemoteSessionStateTracker, RemoteToolStatus, SessionInfo, TrackerEvent, }; - -fn remote_workspace_kind( - kind: crate::service::workspace::WorkspaceKind, -) -> RemoteConnectWorkspaceKind { - match kind { - crate::service::workspace::WorkspaceKind::Normal => RemoteConnectWorkspaceKind::Normal, - crate::service::workspace::WorkspaceKind::Assistant => { - RemoteConnectWorkspaceKind::Assistant - } - crate::service::workspace::WorkspaceKind::Remote => RemoteConnectWorkspaceKind::Remote, - } -} - -fn git_branch_for_workspace_path(path: &std::path::Path) -> Option { - git2::Repository::open(path).ok().and_then(|repo| { - repo.head() - .ok() - .and_then(|head| head.shorthand().map(String::from)) - }) -} +use bitfun_services_integrations::remote_connect::{ + RemoteCancelTaskRequest, RemoteConnectSubmissionSource, RemoteDialogSubmissionPolicy, + RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteImageContext, + RemoteSessionTrackerRegistry, build_remote_image_contexts, cancel_remote_task, + generate_remote_initial_sync, handle_remote_interaction_command, handle_remote_poll_command, + handle_remote_session_command, handle_remote_workspace_command, + handle_remote_workspace_file_command, remote_dialog_submit_response, + remote_task_cancel_response, resolve_remote_execution_image_contexts, submit_remote_dialog, +}; pub type EncryptedPayload = (String, String); -fn resolve_agent_type(mobile_type: Option<&str>) -> &'static str { - bitfun_services_integrations::remote_connect::resolve_remote_agent_type(mobile_type) -} - /// Convert legacy `ImageAttachment` to unified `ImageContextData`. pub fn images_to_contexts( images: Option<&Vec>, @@ -271,569 +240,42 @@ impl RemoteServer { } } - fn ensure_tracker(&self, session_id: &str) -> Arc { - get_or_init_global_dispatcher().ensure_tracker(session_id) - } - pub async fn generate_initial_sync( &self, authenticated_user_id: Option, ) -> RemoteResponse { - use crate::agentic::persistence::PersistenceManager; - use crate::infrastructure::PathManager; - - let (ws_path, workspace_facts) = - if let Some(ws_service) = crate::service::workspace::get_global_workspace_service() { - if let Some(ws) = ws_service.get_current_workspace().await { - let p = ws.root_path.clone(); - ( - Some(p.clone()), - Some(RemoteWorkspaceFacts { - path: p.to_string_lossy().to_string(), - name: ws.name.clone(), - git_branch: git_branch_for_workspace_path(&p), - kind: remote_workspace_kind(ws.workspace_kind), - assistant_id: ws.assistant_id.clone(), - }), - ) - } else { - (None, None) - } - } else { - (None, None) - }; - - let ws_name = ws_path - .as_ref() - .and_then(|wp| wp.file_name().map(|n| n.to_string_lossy().to_string())); - - let (sessions, has_more) = if let Some(ref wp) = ws_path { - if let Ok(pm) = PathManager::new() { - let pm = std::sync::Arc::new(pm); - if let Ok(store) = PersistenceManager::new(pm) { - if let Ok(all_meta) = store.list_session_metadata(wp).await { - let total = all_meta.len(); - let page_size = 100usize; - let has_more = total > page_size; - let sessions: Vec = all_meta - .into_iter() - .take(page_size) - .map(|s| RemoteSessionMetadata { - session_id: s.session_id, - name: s.session_name, - agent_type: s.agent_type, - created_at_ms: s.created_at, - last_active_at_ms: s.last_active_at, - turn_count: s.turn_count, - }) - .collect(); - (sessions, has_more) - } else { - (vec![], false) - } - } else { - (vec![], false) - } - } else { - (vec![], false) - } - } else { - (vec![], false) - }; - - remote_initial_sync_response( - workspace_facts, - sessions, - ws_name.as_deref(), - has_more, - authenticated_user_id, - ) + let host = CoreServiceAgentRuntime::remote_initial_sync_host(); + generate_remote_initial_sync(&host, authenticated_user_id).await } // ── Poll command handler ──────────────────────────────────────── async fn handle_poll_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - let RemoteCommand::PollSession { - session_id, - since_version, - known_msg_count, - known_model_catalog_version, - } = cmd - else { - return RemoteResponse::Error { - message: "expected poll_session".into(), - }; - }; - - let tracker = self.ensure_tracker(session_id); - let current_version = tracker.version(); - let current_model_catalog = - CoreServiceAgentRuntime::load_remote_model_catalog(Some(session_id)) - .await - .ok(); - let model_catalog_delta = - remote_model_catalog_poll_delta(current_model_catalog, *known_model_catalog_version); - - if *since_version == current_version && *since_version > 0 && !model_catalog_delta.changed { - return remote_no_change_poll_response(current_version); - } - - // Fast path: during active streaming, only the real-time snapshot - // changes — persisted messages stay the same. Skip the expensive - // disk read and return just the snapshot. - let needs_persistence = *since_version == 0 || tracker.is_persistence_dirty(); - - if !needs_persistence { - return remote_snapshot_poll_response( - &tracker, - current_version, - model_catalog_delta.catalog, - ); - } - - let Some(workspace_path) = - CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await - else { - return RemoteResponse::Error { - message: format!("Workspace path not available for session: {}", session_id), - }; - }; - let (all_chat_msgs, _) = - CoreServiceAgentRuntime::load_remote_chat_messages(&workspace_path, session_id).await; - let total_msg_count = all_chat_msgs.len(); - let skip = *known_msg_count; - let new_messages: Vec = all_chat_msgs.into_iter().skip(skip).collect(); - - remote_persisted_poll_response( - &tracker, - current_version, - new_messages, - total_msg_count, - model_catalog_delta.catalog, - ) + let dispatcher = get_or_init_global_dispatcher(); + let host = CoreServiceAgentRuntime::remote_poll_host(dispatcher.as_ref()); + handle_remote_poll_command(&host, cmd).await } // ── Workspace commands ────────────────────────────────────────── async fn handle_workspace_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - use crate::service::workspace::get_global_workspace_service; - - match cmd { - RemoteCommand::GetWorkspaceInfo => { - if let Some(ws_service) = get_global_workspace_service() { - if let Some(ws) = ws_service.get_current_workspace().await { - let p = ws.root_path.clone(); - return remote_workspace_info_response(Some(RemoteWorkspaceFacts { - path: p.to_string_lossy().to_string(), - name: ws.name.clone(), - git_branch: git_branch_for_workspace_path(&p), - kind: remote_workspace_kind(ws.workspace_kind), - assistant_id: ws.assistant_id.clone(), - })); - } - } - remote_workspace_info_response(None) - } - RemoteCommand::ListRecentWorkspaces => { - let ws_service = match get_global_workspace_service() { - Some(s) => s, - None => { - return remote_recent_workspaces_response(vec![]); - } - }; - let recent = ws_service.get_recent_workspaces().await; - let entries = recent - .into_iter() - .map(|w| RemoteRecentWorkspaceFacts { - path: w.root_path.to_string_lossy().to_string(), - name: w.name.clone(), - last_opened: w.last_accessed.to_rfc3339(), - kind: remote_workspace_kind(w.workspace_kind), - }) - .collect(); - remote_recent_workspaces_response(entries) - } - RemoteCommand::SetWorkspace { path } => { - let ws_service = match get_global_workspace_service() { - Some(s) => s, - None => { - return remote_workspace_updated_response(Err( - "Workspace service not available".to_string(), - )); - } - }; - let path_buf = std::path::PathBuf::from(path); - match ws_service.open_workspace(path_buf).await { - Ok(info) => { - if let Err(e) = - crate::service::snapshot::initialize_snapshot_manager_for_workspace( - info.root_path.clone(), - None, - ) - .await - { - error!("Failed to initialize snapshot after remote workspace set: {e}"); - } - remote_workspace_updated_response(Ok(RemoteWorkspaceUpdate { - path: info.root_path.to_string_lossy().to_string(), - name: info.name.clone(), - })) - } - Err(e) => remote_workspace_updated_response(Err(e.to_string())), - } - } - RemoteCommand::ListAssistants => { - let ws_service = match get_global_workspace_service() { - Some(s) => s, - None => { - return remote_assistant_list_response(vec![]); - } - }; - let assistants = ws_service.get_assistant_workspaces().await; - let entries = assistants - .into_iter() - .map(|w| RemoteAssistantWorkspaceFacts { - path: w.root_path.to_string_lossy().to_string(), - name: w.name.clone(), - assistant_id: w.assistant_id.clone(), - }) - .collect(); - remote_assistant_list_response(entries) - } - RemoteCommand::SetAssistant { path } => { - let ws_service = match get_global_workspace_service() { - Some(s) => s, - None => { - return remote_assistant_updated_response(Err( - "Workspace service not available".to_string(), - )); - } - }; - let path_buf = std::path::PathBuf::from(path); - match ws_service.open_workspace(path_buf).await { - Ok(info) => { - if let Err(e) = - crate::service::snapshot::initialize_snapshot_manager_for_workspace( - info.root_path.clone(), - None, - ) - .await - { - error!("Failed to initialize snapshot after remote assistant set: {e}"); - } - remote_assistant_updated_response(Ok(RemoteWorkspaceUpdate { - path: info.root_path.to_string_lossy().to_string(), - name: info.name.clone(), - })) - } - Err(e) => remote_assistant_updated_response(Err(e.to_string())), - } - } - _ => RemoteResponse::Error { - message: "Unknown workspace command".into(), - }, - } + let host = CoreServiceAgentRuntime::remote_workspace_host(); + handle_remote_workspace_command(&host, cmd).await } // ── Session commands ──────────────────────────────────────────── async fn handle_session_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - use crate::agentic::coordination::get_global_coordinator; - use bitfun_services_integrations::remote_connect::{ - build_remote_session_create_request, RemoteConnectSubmissionSource, - }; - - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => { - return RemoteResponse::Error { - message: "Desktop session system not ready".into(), - }; - } + let host = match CoreServiceAgentRuntime::remote_session_host() { + Ok(host) => host, + Err(message) => return RemoteResponse::Error { message }, }; - - match cmd { - RemoteCommand::ListSessions { - workspace_path, - limit, - offset, - query, - } => { - use crate::agentic::persistence::PersistenceManager; - use crate::infrastructure::PathManager; - - let page_size = limit.unwrap_or(30).min(100); - let page_offset = offset.unwrap_or(0); - - let Some(workspace_path) = workspace_path - .as_deref() - .filter(|path| !path.is_empty()) - .map(std::path::PathBuf::from) - else { - return RemoteResponse::Error { - message: "workspace_path is required for ListSessions".to_string(), - }; - }; - - let ws_str = workspace_path.to_string_lossy().to_string(); - let workspace_name = workspace_path - .file_name() - .map(|n| n.to_string_lossy().to_string()); - - if let Ok(pm) = PathManager::new() { - let pm = std::sync::Arc::new(pm); - match PersistenceManager::new(pm) { - Ok(store) => match store.list_session_metadata(&workspace_path).await { - Ok(all_meta) => { - let query = query - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_lowercase); - let sessions: Vec = all_meta - .into_iter() - .filter(|session| { - query.as_ref().map_or(true, |query| { - session.session_name.to_lowercase().contains(query) - }) - }) - .map(|s| RemoteSessionMetadata { - session_id: s.session_id, - name: s.session_name, - agent_type: s.agent_type, - created_at_ms: s.created_at, - last_active_at_ms: s.last_active_at, - turn_count: s.turn_count, - }) - .collect(); - remote_session_list_response( - sessions, - Some(ws_str.as_str()), - workspace_name.as_deref(), - page_size, - page_offset, - ) - } - Err(e) => { - debug!("Session list read failed for {ws_str}: {e}"); - RemoteResponse::Error { - message: format!("Failed to list sessions for workspace: {e}"), - } - } - }, - Err(e) => { - debug!("PersistenceManager init failed for {ws_str}: {e}"); - RemoteResponse::Error { - message: format!("Failed to initialize session storage: {e}"), - } - } - } - } else { - RemoteResponse::Error { - message: "Failed to initialize path manager".to_string(), - } - } - } - RemoteCommand::CreateSession { - agent_type, - session_name: custom_name, - workspace_path: requested_ws_path, - } => { - let agent = resolve_agent_type(agent_type.as_deref()); - let is_claw = agent == "Claw"; - - let session_name = - custom_name - .as_deref() - .filter(|n| !n.is_empty()) - .unwrap_or(match agent { - "Cowork" => "Remote Cowork Session", - "Claw" => "Remote Claw Session", - _ => "Remote Code Session", - }); - - let binding_ws_str = if is_claw { - // For Claw sessions, get or create default assistant workspace - use crate::service::workspace::get_global_workspace_service; - - let ws_service = match get_global_workspace_service() { - Some(s) => s, - None => { - return RemoteResponse::Error { - message: "Workspace service not available".to_string(), - }; - } - }; - - let workspaces = ws_service.get_assistant_workspaces().await; - if let Some(default_ws) = - workspaces.into_iter().find(|w| w.assistant_id.is_none()) - { - Some(default_ws.root_path.to_string_lossy().to_string()) - } else { - match ws_service.create_assistant_workspace(None).await { - Ok(ws_info) => Some(ws_info.root_path.to_string_lossy().to_string()), - Err(e) => { - return RemoteResponse::Error { - message: format!("Failed to create assistant workspace: {}", e), - }; - } - } - } - } else { - // For Code/Cowork sessions, use provided workspace - requested_ws_path - .as_deref() - .filter(|path| !path.is_empty()) - .map(ToOwned::to_owned) - }; - - debug!( - "Remote CreateSession: agent={}, requested_ws={:?}, binding_ws={:?}", - agent, requested_ws_path, binding_ws_str - ); - - let Some(binding_ws_str) = binding_ws_str else { - return RemoteResponse::Error { - message: if is_claw { - "Failed to get or create assistant workspace".to_string() - } else { - "workspace_path is required for CreateSession".to_string() - }, - }; - }; - - let request = build_remote_session_create_request( - session_name, - agent, - Some(binding_ws_str), - RemoteConnectSubmissionSource::Relay, - ); - let submission_port = - CoreServiceAgentRuntime::agent_submission_port(coordinator.as_ref()); - match submission_port.create_session(request).await { - Ok(session) => remote_session_created_response(session.session_id), - Err(e) => RemoteResponse::Error { message: e.message }, - } - } - RemoteCommand::GetModelCatalog { session_id } => { - match CoreServiceAgentRuntime::load_remote_model_catalog(session_id.as_deref()) - .await - { - Ok(catalog) => RemoteResponse::ModelCatalog { catalog }, - Err(message) => RemoteResponse::Error { message }, - } - } - RemoteCommand::SetSessionModel { - session_id, - model_id, - } => { - match CoreServiceAgentRuntime::update_remote_session_model( - coordinator.as_ref(), - session_id, - model_id, - ) - .await - { - Ok(normalized_model_id) => remote_session_model_updated_response( - session_id.clone(), - normalized_model_id, - ), - Err(message) => RemoteResponse::Error { message }, - } - } - RemoteCommand::UpdateSessionTitle { session_id, title } => { - if coordinator - .get_session_manager() - .get_session(session_id) - .is_none() - { - let Some(workspace_path) = - CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await - else { - return RemoteResponse::Error { - message: format!( - "Workspace path not available for session: {}", - session_id - ), - }; - }; - if let Err(e) = coordinator - .restore_session(&workspace_path, session_id) - .await - { - return RemoteResponse::Error { - message: format!("Failed to restore session: {e}"), - }; - } - } - - match coordinator.update_session_title(session_id, title).await { - Ok(normalized_title) => RemoteResponse::SessionTitleUpdated { - session_id: session_id.clone(), - title: normalized_title, - }, - Err(e) => RemoteResponse::Error { - message: e.to_string(), - }, - } - } - RemoteCommand::GetSessionMessages { - session_id, - limit: _, - before_message_id: _, - } => { - let Some(workspace_path) = - CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await - else { - return RemoteResponse::Error { - message: format!( - "Workspace path not available for session: {}", - session_id - ), - }; - }; - let (chat_msgs, has_more) = - CoreServiceAgentRuntime::load_remote_chat_messages(&workspace_path, session_id) - .await; - remote_messages_response(session_id.clone(), chat_msgs, has_more) - } - RemoteCommand::DeleteSession { session_id } => { - let Some(workspace_path) = - CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await - else { - return RemoteResponse::Error { - message: format!( - "Workspace path not available for session: {}", - session_id - ), - }; - }; - - match coordinator - .delete_session(&workspace_path, session_id) - .await - { - Ok(_) => { - get_or_init_global_dispatcher().remove_tracker(session_id); - remote_session_deleted_response(session_id.clone()) - } - Err(e) => RemoteResponse::Error { - message: e.to_string(), - }, - } - } - _ => RemoteResponse::Error { - message: "Unknown session command".into(), - }, - } + handle_remote_session_command(&host, cmd).await } // ── Execution commands ────────────────────────────────────────── async fn handle_execution_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - use crate::agentic::coordination::get_global_coordinator; - let dispatcher = get_or_init_global_dispatcher(); match cmd { @@ -881,76 +323,12 @@ impl RemoteServer { session_id.clone(), dispatcher.cancel_task(session_id, turn_id.as_deref()).await, ), - RemoteCommand::ConfirmTool { - tool_id, - updated_input, - } => { - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => { - return RemoteResponse::Error { - message: "Desktop session system not ready".into(), - }; - } - }; - remote_interaction_accepted_response( - "confirm_tool", - tool_id.clone(), - coordinator - .confirm_tool(tool_id, updated_input.clone()) - .await - .map(|_| ()) - .map_err(|e| e.to_string()), - ) - } - RemoteCommand::RejectTool { tool_id, reason } => { - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => { - return RemoteResponse::Error { - message: "Desktop session system not ready".into(), - }; - } - }; - let reject_reason = reason - .clone() - .unwrap_or_else(|| "User rejected".to_string()); - remote_interaction_accepted_response( - "reject_tool", - tool_id.clone(), - coordinator - .reject_tool(tool_id, reject_reason) - .await - .map(|_| ()) - .map_err(|e| e.to_string()), - ) - } - RemoteCommand::CancelTool { tool_id, reason } => { - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => { - return RemoteResponse::Error { - message: "Desktop session system not ready".into(), - }; - } - }; - let cancel_reason = reason - .clone() - .unwrap_or_else(|| "User cancelled".to_string()); - remote_interaction_accepted_response( - "cancel_tool", - tool_id.clone(), - coordinator - .cancel_tool(tool_id, cancel_reason) - .await - .map(|_| ()) - .map_err(|e| e.to_string()), - ) - } - RemoteCommand::AnswerQuestion { tool_id, answers } => { - use crate::agentic::tools::user_input_manager::get_user_input_manager; - let mgr = get_user_input_manager(); - remote_answer_question_response(mgr.send_answer(tool_id, answers.clone())) + RemoteCommand::ConfirmTool { .. } + | RemoteCommand::RejectTool { .. } + | RemoteCommand::CancelTool { .. } + | RemoteCommand::AnswerQuestion { .. } => { + let host = CoreServiceAgentRuntime::remote_interaction_host(); + handle_remote_interaction_command(&host, cmd).await } _ => RemoteResponse::Error { message: "Unknown execution command".into(), @@ -964,7 +342,7 @@ mod tests { use super::*; use crate::service::remote_connect::encryption::KeyPair; use bitfun_services_integrations::remote_connect::{ - remote_session_restore_target, resolve_remote_cancel_decision, RemoteCancelDecision, + RemoteCancelDecision, remote_session_restore_target, resolve_remote_cancel_decision, }; #[test] @@ -1014,6 +392,25 @@ mod tests { assert_eq!(value["_request_id"], "req_xyz"); } + #[tokio::test] + async fn remote_answer_question_preserves_user_input_manager_path() { + let (sender, receiver) = tokio::sync::oneshot::channel(); + crate::agentic::tools::user_input_manager::get_user_input_manager() + .register_channel("question-tool".to_string(), sender); + let bridge = RemoteServer::new([7; 32]); + let answers = serde_json::json!({ "choice": "yes" }); + + let response = bridge + .dispatch(&RemoteCommand::AnswerQuestion { + tool_id: "question-tool".to_string(), + answers: answers.clone(), + }) + .await; + + assert_eq!(response, RemoteResponse::AnswerAccepted); + assert_eq!(receiver.await.unwrap().answers, answers); + } + #[test] fn core_service_agent_runtime_owner_maps_remote_image_context() { let metadata = serde_json::json!({ "source": "relay" }); @@ -1041,7 +438,7 @@ mod tests { fn remote_execution_prefers_unified_image_contexts_over_legacy_images() { let explicit_context = crate::agentic::image_analysis::ImageContextData { id: "ctx-1".to_string(), - image_path: Some("D:/workspace/project/screenshot.png".to_string()), + image_path: Some("/workspace/project/screenshot.png".to_string()), data_url: None, mime_type: "image/png".to_string(), metadata: Some(serde_json::json!({ "source": "desktop" })), @@ -1113,11 +510,11 @@ mod tests { #[test] fn remote_restore_target_only_restores_cold_sessions_with_workspace_binding() { assert_eq!( - remote_session_restore_target(false, Some("D:/workspace/project")), - Some("D:/workspace/project") + remote_session_restore_target(false, Some("/workspace/project")), + Some("/workspace/project") ); assert_eq!( - remote_session_restore_target(true, Some("D:/workspace/project")), + remote_session_restore_target(true, Some("/workspace/project")), None ); assert_eq!(remote_session_restore_target(false, None), None); diff --git a/src/crates/core/src/service_agent_runtime.rs b/src/crates/core/src/service_agent_runtime.rs index 6a9a6eabf..e062dd1c8 100644 --- a/src/crates/core/src/service_agent_runtime.rs +++ b/src/crates/core/src/service_agent_runtime.rs @@ -6,27 +6,31 @@ //! implementations until a reviewed port/provider migration proves equivalence. use bitfun_runtime_ports::{ - AgentSubmissionPort, AgentSubmissionSource, AgentTurnCancellationPort, - AgentTurnCancellationRequest, RemoteControlStatePort, RemoteControlStateRequest, - RemoteControlStateSnapshot, + AgentSessionCreateRequest, AgentSubmissionPort, AgentSubmissionSource, + AgentTurnCancellationPort, AgentTurnCancellationRequest, RemoteControlStatePort, + RemoteControlStateRequest, RemoteControlStateSnapshot, }; use bitfun_services_integrations::remote_connect::{ - ChatImageAttachment, ChatMessage, RemoteCancelRuntimeHost, RemoteChatHistoryRound, - RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, - RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteConnectSubmissionSource, - RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, - RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSubmissionPolicy, - RemoteDialogSubmitOutcome, RemoteImageContext, RemoteImageContextAdapter, + ChatImageAttachment, ChatMessage, RemoteAssistantWorkspaceFacts, RemoteCancelRuntimeHost, + RemoteChatHistoryRound, RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, + RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, RemoteChatHistoryTurn, + RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, + RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, + RemoteDialogSubmissionPolicy, RemoteDialogSubmitOutcome, RemoteImageContext, + RemoteImageContextAdapter, RemoteInitialSyncRuntimeHost, RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelFacts, - RemoteReasoningModeFact, RemoteSessionStateTracker, RemoteSessionTrackerHost, - RemoteTerminalPrewarmRequest, RemoteWorkspaceFileRuntimeHost, build_remote_chat_messages, + RemotePollRuntimeHost, RemoteReasoningModeFact, RemoteRecentWorkspaceFacts, + RemoteSessionMetadata, RemoteSessionRuntimeHost, RemoteSessionStateTracker, + RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, + RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, + RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, build_remote_chat_messages, build_remote_model_catalog, normalize_remote_model_selection as normalize_remote_model_selection_contract, normalize_remote_session_model_id as normalize_remote_session_model_id_contract, remote_dialog_submit_outcome_from_scheduler, remote_model_selection_needs_config as remote_model_selection_needs_config_contract, }; -use log::{debug, info}; +use log::{debug, error, info}; use std::sync::Arc; use crate::agentic::coordination::{ @@ -47,6 +51,101 @@ fn current_workspace_path() -> Option { .and_then(|service| service.try_get_current_workspace_path()) } +fn remote_workspace_kind( + kind: crate::service::workspace::WorkspaceKind, +) -> RemoteConnectWorkspaceKind { + match kind { + crate::service::workspace::WorkspaceKind::Normal => RemoteConnectWorkspaceKind::Normal, + crate::service::workspace::WorkspaceKind::Assistant => { + RemoteConnectWorkspaceKind::Assistant + } + crate::service::workspace::WorkspaceKind::Remote => RemoteConnectWorkspaceKind::Remote, + } +} + +fn git_branch_for_workspace_path(path: &std::path::Path) -> Option { + git2::Repository::open(path).ok().and_then(|repo| { + repo.head() + .ok() + .and_then(|head| head.shorthand().map(String::from)) + }) +} + +async fn current_remote_workspace_facts() -> Option { + let workspace_service = crate::service::workspace::get_global_workspace_service()?; + workspace_service + .get_current_workspace() + .await + .map(|workspace| { + let root_path = workspace.root_path.clone(); + RemoteWorkspaceFacts { + path: root_path.to_string_lossy().to_string(), + name: workspace.name, + git_branch: git_branch_for_workspace_path(&root_path), + kind: remote_workspace_kind(workspace.workspace_kind), + assistant_id: workspace.assistant_id, + } + }) +} + +async fn open_workspace_with_snapshot( + path: &str, + snapshot_log_context: &str, +) -> Result { + let workspace_service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service not available".to_string())?; + let path_buf = std::path::PathBuf::from(path); + let info = workspace_service + .open_workspace(path_buf) + .await + .map_err(|error| error.to_string())?; + if let Err(error) = crate::service::snapshot::initialize_snapshot_manager_for_workspace( + info.root_path.clone(), + None, + ) + .await + { + error!("Failed to initialize snapshot after {snapshot_log_context}: {error}"); + } + Ok(RemoteWorkspaceUpdate { + path: info.root_path.to_string_lossy().to_string(), + name: info.name, + }) +} + +async fn load_remote_session_metadata_for_workspace( + workspace_path: &std::path::Path, +) -> Result, String> { + let workspace_path_display = workspace_path.to_string_lossy().to_string(); + let path_manager = crate::infrastructure::PathManager::new() + .map_err(|_| "Failed to initialize path manager".to_string())?; + let path_manager = std::sync::Arc::new(path_manager); + let store = + crate::agentic::persistence::PersistenceManager::new(path_manager).map_err(|error| { + debug!("PersistenceManager init failed for {workspace_path_display}: {error}"); + format!("Failed to initialize session storage: {error}") + })?; + let metadata = store + .list_session_metadata(workspace_path) + .await + .map_err(|error| { + debug!("Session list read failed for {workspace_path_display}: {error}"); + format!("Failed to list sessions for workspace: {error}") + })?; + + Ok(metadata + .into_iter() + .map(|session| RemoteSessionMetadata { + session_id: session.session_id, + name: session.session_name, + agent_type: session.agent_type, + created_at_ms: session.created_at, + last_active_at_ms: session.last_active_at, + turn_count: session.turn_count, + }) + .collect()) +} + fn normalize_remote_session_model_id(model_id: Option) -> Option { normalize_remote_session_model_id_contract(model_id.as_deref()) } @@ -354,6 +453,28 @@ impl CoreServiceAgentRuntime { CoreRemoteWorkspaceFileRuntimeHost::new() } + pub(crate) fn remote_workspace_host() -> CoreRemoteWorkspaceRuntimeHost { + CoreRemoteWorkspaceRuntimeHost::new() + } + + pub(crate) fn remote_initial_sync_host() -> CoreRemoteWorkspaceRuntimeHost { + CoreRemoteWorkspaceRuntimeHost::new() + } + + pub(crate) fn remote_session_host() -> Result { + CoreRemoteSessionRuntimeHost::new() + } + + pub(crate) fn remote_poll_host( + dispatcher: &RemoteExecutionDispatcher, + ) -> CoreRemotePollRuntimeHost<'_> { + CoreRemotePollRuntimeHost::new(dispatcher) + } + + pub(crate) fn remote_interaction_host() -> CoreRemoteInteractionRuntimeHost { + CoreRemoteInteractionRuntimeHost::new() + } + pub(crate) fn remote_image_context(context: RemoteImageContext) -> ImageContextData { ImageContextData::from_remote_image_context(context) } @@ -587,6 +708,54 @@ impl CoreRemoteWorkspaceFileRuntimeHost { } } +pub(crate) struct CoreRemoteWorkspaceRuntimeHost; + +impl CoreRemoteWorkspaceRuntimeHost { + pub(crate) fn new() -> Self { + Self + } +} + +pub(crate) struct CoreRemoteSessionRuntimeHost { + coordinator: Arc, +} + +impl CoreRemoteSessionRuntimeHost { + pub(crate) fn new() -> Result { + let coordinator = get_global_coordinator() + .ok_or_else(|| "Desktop session system not ready".to_string())?; + Ok(Self { coordinator }) + } +} + +pub(crate) struct CoreRemotePollRuntimeHost<'a> { + dispatcher: &'a RemoteExecutionDispatcher, +} + +impl<'a> CoreRemotePollRuntimeHost<'a> { + pub(crate) fn new(dispatcher: &'a RemoteExecutionDispatcher) -> Self { + Self { dispatcher } + } +} + +pub(crate) struct CoreRemoteInteractionRuntimeHost { + coordinator: Option>, +} + +impl CoreRemoteInteractionRuntimeHost { + pub(crate) fn new() -> Self { + Self { + coordinator: get_global_coordinator(), + } + } + + fn coordinator(&self) -> Result<&ConversationCoordinator, String> { + self.coordinator + .as_deref() + .ok_or_else(|| "Desktop session system not ready".to_string()) + } +} + #[async_trait::async_trait] impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { type ImageContext = ImageContextData; @@ -704,6 +873,250 @@ impl RemoteWorkspaceFileRuntimeHost for CoreRemoteWorkspaceFileRuntimeHost { } } +#[async_trait::async_trait] +impl RemoteWorkspaceRuntimeHost for CoreRemoteWorkspaceRuntimeHost { + async fn current_workspace(&self) -> Option { + current_remote_workspace_facts().await + } + + async fn recent_workspaces(&self) -> Vec { + let Some(workspace_service) = crate::service::workspace::get_global_workspace_service() + else { + return Vec::new(); + }; + workspace_service + .get_recent_workspaces() + .await + .into_iter() + .map(|workspace| RemoteRecentWorkspaceFacts { + path: workspace.root_path.to_string_lossy().to_string(), + name: workspace.name, + last_opened: workspace.last_accessed.to_rfc3339(), + kind: remote_workspace_kind(workspace.workspace_kind), + }) + .collect() + } + + async fn open_workspace(&self, path: &str) -> Result { + open_workspace_with_snapshot(path, "remote workspace set").await + } + + async fn assistant_workspaces(&self) -> Vec { + let Some(workspace_service) = crate::service::workspace::get_global_workspace_service() + else { + return Vec::new(); + }; + workspace_service + .get_assistant_workspaces() + .await + .into_iter() + .map(|workspace| RemoteAssistantWorkspaceFacts { + path: workspace.root_path.to_string_lossy().to_string(), + name: workspace.name, + assistant_id: workspace.assistant_id, + }) + .collect() + } + + async fn open_assistant_workspace(&self, path: &str) -> Result { + open_workspace_with_snapshot(path, "remote assistant set").await + } +} + +#[async_trait::async_trait] +impl RemoteInitialSyncRuntimeHost for CoreRemoteWorkspaceRuntimeHost { + async fn current_workspace(&self) -> Option { + current_remote_workspace_facts().await + } + + async fn list_session_metadata( + &self, + workspace_path: &std::path::Path, + ) -> Result, String> { + load_remote_session_metadata_for_workspace(workspace_path).await + } +} + +#[async_trait::async_trait] +impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { + async fn list_session_metadata( + &self, + workspace_path: &std::path::Path, + ) -> Result, String> { + load_remote_session_metadata_for_workspace(workspace_path).await + } + + async fn resolve_default_assistant_workspace_path(&self) -> Result { + let workspace_service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service not available".to_string())?; + let workspaces = workspace_service.get_assistant_workspaces().await; + if let Some(default_workspace) = workspaces + .into_iter() + .find(|workspace| workspace.assistant_id.is_none()) + { + return Ok(default_workspace.root_path.to_string_lossy().to_string()); + } + + workspace_service + .create_assistant_workspace(None) + .await + .map(|workspace| workspace.root_path.to_string_lossy().to_string()) + .map_err(|error| format!("Failed to create assistant workspace: {}", error)) + } + + async fn create_session(&self, request: AgentSessionCreateRequest) -> Result { + let submission_port = + CoreServiceAgentRuntime::agent_submission_port(self.coordinator.as_ref()); + submission_port + .create_session(request) + .await + .map(|session| session.session_id) + .map_err(|error| error.message) + } + + async fn load_model_catalog( + &self, + session_id: Option<&str>, + ) -> Result { + CoreServiceAgentRuntime::load_remote_model_catalog(session_id).await + } + + async fn update_session_model( + &self, + session_id: &str, + model_id: &str, + ) -> Result { + CoreServiceAgentRuntime::update_remote_session_model( + self.coordinator.as_ref(), + session_id, + model_id, + ) + .await + } + + async fn ensure_session_loaded(&self, session_id: &str) -> Result<(), String> { + if self + .coordinator + .get_session_manager() + .get_session(session_id) + .is_some() + { + return Ok(()); + } + + let Some(workspace_path) = + CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await + else { + return Err(format!( + "Workspace path not available for session: {}", + session_id + )); + }; + self.coordinator + .restore_session(&workspace_path, session_id) + .await + .map(|_| ()) + .map_err(|error| format!("Failed to restore session: {error}")) + } + + async fn update_session_title(&self, session_id: &str, title: &str) -> Result { + self.coordinator + .update_session_title(session_id, title) + .await + .map_err(|error| error.to_string()) + } + + async fn resolve_session_workspace_path(&self, session_id: &str) -> Option { + CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await + } + + async fn load_remote_chat_messages( + &self, + workspace_path: &std::path::Path, + session_id: &str, + ) -> (Vec, bool) { + CoreServiceAgentRuntime::load_remote_chat_messages(workspace_path, session_id).await + } + + async fn delete_session( + &self, + workspace_path: &std::path::Path, + session_id: &str, + ) -> Result<(), String> { + self.coordinator + .delete_session(workspace_path, session_id) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + fn remove_tracker(&self, session_id: &str) { + crate::service::remote_connect::remote_server::get_or_init_global_dispatcher() + .remove_tracker(session_id); + } +} + +#[async_trait::async_trait] +impl RemotePollRuntimeHost for CoreRemotePollRuntimeHost<'_> { + fn ensure_tracker(&self, session_id: &str) -> Arc { + self.dispatcher.ensure_tracker(session_id) + } + + async fn load_model_catalog(&self, session_id: &str) -> Option { + CoreServiceAgentRuntime::load_remote_model_catalog(Some(session_id)) + .await + .ok() + } + + async fn resolve_session_workspace_path(&self, session_id: &str) -> Option { + CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await + } + + async fn load_remote_chat_messages( + &self, + workspace_path: &std::path::Path, + session_id: &str, + ) -> (Vec, bool) { + CoreServiceAgentRuntime::load_remote_chat_messages(workspace_path, session_id).await + } +} + +#[async_trait::async_trait] +impl RemoteInteractionRuntimeHost for CoreRemoteInteractionRuntimeHost { + async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> Result<(), String> { + self.coordinator()? + .confirm_tool(tool_id, updated_input) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { + self.coordinator()? + .reject_tool(tool_id, reason) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { + self.coordinator()? + .cancel_tool(tool_id, reason) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + fn answer_question(&self, tool_id: &str, answers: serde_json::Value) -> Result<(), String> { + crate::agentic::tools::user_input_manager::get_user_input_manager() + .send_answer(tool_id, answers) + } +} + #[async_trait::async_trait] impl RemoteCancelRuntimeHost for CoreRemoteCancelRuntimeHost { async fn resolve_restore_workspace(&self, session_id: &str) -> Option { diff --git a/src/crates/services-integrations/src/remote_connect.rs b/src/crates/services-integrations/src/remote_connect.rs index c2ae765f7..c0f5d3921 100644 --- a/src/crates/services-integrations/src/remote_connect.rs +++ b/src/crates/services-integrations/src/remote_connect.rs @@ -971,6 +971,91 @@ pub fn remote_initial_sync_response( } } +#[async_trait::async_trait] +pub trait RemoteWorkspaceRuntimeHost: Send + Sync { + async fn current_workspace(&self) -> Option; + async fn recent_workspaces(&self) -> Vec; + async fn open_workspace(&self, path: &str) -> Result; + async fn assistant_workspaces(&self) -> Vec; + async fn open_assistant_workspace(&self, path: &str) -> Result; +} + +pub async fn handle_remote_workspace_command(host: &H, command: &RemoteCommand) -> RemoteResponse +where + H: RemoteWorkspaceRuntimeHost + ?Sized, +{ + match command { + RemoteCommand::GetWorkspaceInfo => { + remote_workspace_info_response(host.current_workspace().await) + } + RemoteCommand::ListRecentWorkspaces => { + remote_recent_workspaces_response(host.recent_workspaces().await) + } + RemoteCommand::SetWorkspace { path } => { + remote_workspace_updated_response(host.open_workspace(path).await) + } + RemoteCommand::ListAssistants => { + remote_assistant_list_response(host.assistant_workspaces().await) + } + RemoteCommand::SetAssistant { path } => { + remote_assistant_updated_response(host.open_assistant_workspace(path).await) + } + _ => RemoteResponse::Error { + message: "Unknown workspace command".into(), + }, + } +} + +#[async_trait::async_trait] +pub trait RemoteInitialSyncRuntimeHost: Send + Sync { + async fn current_workspace(&self) -> Option; + async fn list_session_metadata( + &self, + workspace_path: &Path, + ) -> Result, String>; +} + +pub async fn generate_remote_initial_sync( + host: &H, + authenticated_user_id: Option, +) -> RemoteResponse +where + H: RemoteInitialSyncRuntimeHost + ?Sized, +{ + let workspace = host.current_workspace().await; + let workspace_path = workspace + .as_ref() + .map(|workspace| PathBuf::from(&workspace.path)); + let workspace_name = workspace_path + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().to_string()); + + let (sessions, has_more) = if let Some(path) = workspace_path.as_deref() { + match host.list_session_metadata(path).await { + Ok(metadata) => { + let total = metadata.len(); + let page_size = 100usize; + ( + metadata.into_iter().take(page_size).collect(), + total > page_size, + ) + } + Err(_) => (Vec::new(), false), + } + } else { + (Vec::new(), false) + }; + + remote_initial_sync_response( + workspace, + sessions, + workspace_name.as_deref(), + has_more, + authenticated_user_id, + ) +} + pub fn remote_session_created_response(session_id: impl Into) -> RemoteResponse { RemoteResponse::SessionCreated { session_id: session_id.into(), @@ -1005,6 +1090,330 @@ pub fn remote_session_deleted_response(session_id: impl Into) -> RemoteR } } +#[async_trait::async_trait] +pub trait RemoteSessionRuntimeHost: Send + Sync { + async fn list_session_metadata( + &self, + workspace_path: &Path, + ) -> Result, String>; + async fn resolve_default_assistant_workspace_path(&self) -> Result; + async fn create_session(&self, request: AgentSessionCreateRequest) -> Result; + async fn load_model_catalog( + &self, + session_id: Option<&str>, + ) -> Result; + async fn update_session_model( + &self, + session_id: &str, + model_id: &str, + ) -> Result; + async fn ensure_session_loaded(&self, session_id: &str) -> Result<(), String>; + async fn update_session_title(&self, session_id: &str, title: &str) -> Result; + async fn resolve_session_workspace_path(&self, session_id: &str) -> Option; + async fn load_remote_chat_messages( + &self, + workspace_path: &Path, + session_id: &str, + ) -> (Vec, bool); + async fn delete_session(&self, workspace_path: &Path, session_id: &str) -> Result<(), String>; + fn remove_tracker(&self, session_id: &str); +} + +pub async fn handle_remote_session_command(host: &H, command: &RemoteCommand) -> RemoteResponse +where + H: RemoteSessionRuntimeHost + ?Sized, +{ + match command { + RemoteCommand::ListSessions { + workspace_path, + limit, + offset, + query, + } => { + let page_size = limit.unwrap_or(30).min(100); + let page_offset = offset.unwrap_or(0); + + let Some(workspace_path) = workspace_path + .as_deref() + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + else { + return RemoteResponse::Error { + message: "workspace_path is required for ListSessions".to_string(), + }; + }; + + let workspace_path_str = workspace_path.to_string_lossy().to_string(); + let workspace_name = workspace_path + .file_name() + .map(|name| name.to_string_lossy().to_string()); + + match host.list_session_metadata(&workspace_path).await { + Ok(metadata) => { + let query = query + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_lowercase); + let sessions = metadata + .into_iter() + .filter(|session| { + query + .as_ref() + .is_none_or(|query| session.name.to_lowercase().contains(query)) + }) + .collect(); + remote_session_list_response( + sessions, + Some(workspace_path_str.as_str()), + workspace_name.as_deref(), + page_size, + page_offset, + ) + } + Err(message) => RemoteResponse::Error { message }, + } + } + RemoteCommand::CreateSession { + agent_type, + session_name, + workspace_path, + } => { + let agent = resolve_remote_agent_type(agent_type.as_deref()); + let is_claw = agent == "Claw"; + let session_name = session_name + .as_deref() + .filter(|name| !name.is_empty()) + .unwrap_or(match agent { + "Cowork" => "Remote Cowork Session", + "Claw" => "Remote Claw Session", + _ => "Remote Code Session", + }); + + let binding_workspace = if is_claw { + match host.resolve_default_assistant_workspace_path().await { + Ok(path) => Some(path), + Err(message) => return RemoteResponse::Error { message }, + } + } else { + workspace_path + .as_deref() + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) + }; + + let Some(binding_workspace) = binding_workspace else { + return RemoteResponse::Error { + message: if is_claw { + "Failed to get or create assistant workspace".to_string() + } else { + "workspace_path is required for CreateSession".to_string() + }, + }; + }; + + let request = build_remote_session_create_request( + session_name, + agent, + Some(binding_workspace), + RemoteConnectSubmissionSource::Relay, + ); + match host.create_session(request).await { + Ok(session_id) => remote_session_created_response(session_id), + Err(message) => RemoteResponse::Error { message }, + } + } + RemoteCommand::GetModelCatalog { session_id } => { + match host.load_model_catalog(session_id.as_deref()).await { + Ok(catalog) => RemoteResponse::ModelCatalog { catalog }, + Err(message) => RemoteResponse::Error { message }, + } + } + RemoteCommand::SetSessionModel { + session_id, + model_id, + } => match host.update_session_model(session_id, model_id).await { + Ok(normalized_model_id) => { + remote_session_model_updated_response(session_id.clone(), normalized_model_id) + } + Err(message) => RemoteResponse::Error { message }, + }, + RemoteCommand::UpdateSessionTitle { session_id, title } => { + if let Err(message) = host.ensure_session_loaded(session_id).await { + return RemoteResponse::Error { message }; + } + + match host.update_session_title(session_id, title).await { + Ok(normalized_title) => RemoteResponse::SessionTitleUpdated { + session_id: session_id.clone(), + title: normalized_title, + }, + Err(message) => RemoteResponse::Error { message }, + } + } + RemoteCommand::GetSessionMessages { + session_id, + limit: _, + before_message_id: _, + } => { + let Some(workspace_path) = host.resolve_session_workspace_path(session_id).await else { + return RemoteResponse::Error { + message: format!("Workspace path not available for session: {}", session_id), + }; + }; + let (chat_messages, has_more) = host + .load_remote_chat_messages(&workspace_path, session_id) + .await; + remote_messages_response(session_id.clone(), chat_messages, has_more) + } + RemoteCommand::DeleteSession { session_id } => { + let Some(workspace_path) = host.resolve_session_workspace_path(session_id).await else { + return RemoteResponse::Error { + message: format!("Workspace path not available for session: {}", session_id), + }; + }; + + match host.delete_session(&workspace_path, session_id).await { + Ok(()) => { + host.remove_tracker(session_id); + remote_session_deleted_response(session_id.clone()) + } + Err(message) => RemoteResponse::Error { message }, + } + } + _ => RemoteResponse::Error { + message: "Unknown session command".into(), + }, + } +} + +#[async_trait::async_trait] +pub trait RemotePollRuntimeHost: Send + Sync { + fn ensure_tracker(&self, session_id: &str) -> Arc; + async fn load_model_catalog(&self, session_id: &str) -> Option; + async fn resolve_session_workspace_path(&self, session_id: &str) -> Option; + async fn load_remote_chat_messages( + &self, + workspace_path: &Path, + session_id: &str, + ) -> (Vec, bool); +} + +pub async fn handle_remote_poll_command(host: &H, command: &RemoteCommand) -> RemoteResponse +where + H: RemotePollRuntimeHost + ?Sized, +{ + let RemoteCommand::PollSession { + session_id, + since_version, + known_msg_count, + known_model_catalog_version, + } = command + else { + return RemoteResponse::Error { + message: "expected poll_session".into(), + }; + }; + + let tracker = host.ensure_tracker(session_id); + let current_version = tracker.version(); + let current_model_catalog = host.load_model_catalog(session_id).await; + let model_catalog_delta = + remote_model_catalog_poll_delta(current_model_catalog, *known_model_catalog_version); + + if *since_version == current_version && *since_version > 0 && !model_catalog_delta.changed { + return remote_no_change_poll_response(current_version); + } + + let needs_persistence = *since_version == 0 || tracker.is_persistence_dirty(); + if !needs_persistence { + return remote_snapshot_poll_response( + &tracker, + current_version, + model_catalog_delta.catalog, + ); + } + + let Some(workspace_path) = host.resolve_session_workspace_path(session_id).await else { + return RemoteResponse::Error { + message: format!("Workspace path not available for session: {}", session_id), + }; + }; + let (all_chat_messages, _) = host + .load_remote_chat_messages(&workspace_path, session_id) + .await; + let total_msg_count = all_chat_messages.len(); + let new_messages = all_chat_messages + .into_iter() + .skip(*known_msg_count) + .collect(); + + remote_persisted_poll_response( + &tracker, + current_version, + new_messages, + total_msg_count, + model_catalog_delta.catalog, + ) +} + +#[async_trait::async_trait] +pub trait RemoteInteractionRuntimeHost: Send + Sync { + async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> Result<(), String>; + async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; + async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; + fn answer_question(&self, tool_id: &str, answers: serde_json::Value) -> Result<(), String>; +} + +pub async fn handle_remote_interaction_command( + host: &H, + command: &RemoteCommand, +) -> RemoteResponse +where + H: RemoteInteractionRuntimeHost + ?Sized, +{ + match command { + RemoteCommand::ConfirmTool { + tool_id, + updated_input, + } => remote_interaction_accepted_response( + "confirm_tool", + tool_id.clone(), + host.confirm_tool(tool_id, updated_input.clone()).await, + ), + RemoteCommand::RejectTool { tool_id, reason } => { + let reject_reason = reason + .clone() + .unwrap_or_else(|| "User rejected".to_string()); + remote_interaction_accepted_response( + "reject_tool", + tool_id.clone(), + host.reject_tool(tool_id, reject_reason).await, + ) + } + RemoteCommand::CancelTool { tool_id, reason } => { + let cancel_reason = reason + .clone() + .unwrap_or_else(|| "User cancelled".to_string()); + remote_interaction_accepted_response( + "cancel_tool", + tool_id.clone(), + host.cancel_tool(tool_id, cancel_reason).await, + ) + } + RemoteCommand::AnswerQuestion { tool_id, answers } => { + remote_answer_question_response(host.answer_question(tool_id, answers.clone())) + } + _ => RemoteResponse::Error { + message: "Unknown execution command".into(), + }, + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RemoteDefaultModelsConfig { pub primary: Option, @@ -2620,3 +3029,395 @@ pub fn remote_persisted_poll_response( fn non_empty_title(title: String) -> Option { if title.is_empty() { None } else { Some(title) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + struct FakeWorkspaceHost; + + #[async_trait::async_trait] + impl RemoteWorkspaceRuntimeHost for FakeWorkspaceHost { + async fn current_workspace(&self) -> Option { + Some(RemoteWorkspaceFacts { + path: "/workspace/project".to_string(), + name: "project".to_string(), + git_branch: Some("main".to_string()), + kind: RemoteWorkspaceKind::Normal, + assistant_id: None, + }) + } + + async fn recent_workspaces(&self) -> Vec { + vec![RemoteRecentWorkspaceFacts { + path: "/workspace/project".to_string(), + name: "project".to_string(), + last_opened: "2026-05-29T00:00:00Z".to_string(), + kind: RemoteWorkspaceKind::Normal, + }] + } + + async fn open_workspace(&self, path: &str) -> Result { + Ok(RemoteWorkspaceUpdate { + path: path.to_string(), + name: "opened".to_string(), + }) + } + + async fn assistant_workspaces(&self) -> Vec { + vec![RemoteAssistantWorkspaceFacts { + path: "/workspace/assistant".to_string(), + name: "assistant".to_string(), + assistant_id: None, + }] + } + + async fn open_assistant_workspace( + &self, + path: &str, + ) -> Result { + Ok(RemoteWorkspaceUpdate { + path: path.to_string(), + name: "assistant".to_string(), + }) + } + } + + #[tokio::test] + async fn remote_workspace_handler_preserves_response_shapes() { + let host = FakeWorkspaceHost; + + assert_eq!( + handle_remote_workspace_command(&host, &RemoteCommand::GetWorkspaceInfo).await, + RemoteResponse::WorkspaceInfo { + has_workspace: true, + path: Some("/workspace/project".to_string()), + project_name: Some("project".to_string()), + git_branch: Some("main".to_string()), + workspace_kind: Some("normal".to_string()), + assistant_id: None, + } + ); + + assert_eq!( + handle_remote_workspace_command( + &host, + &RemoteCommand::SetWorkspace { + path: "/workspace/next".to_string(), + }, + ) + .await, + RemoteResponse::WorkspaceUpdated { + success: true, + path: Some("/workspace/next".to_string()), + project_name: Some("opened".to_string()), + error: None, + } + ); + } + + #[derive(Default)] + struct FakeSessionHost { + created_requests: Mutex>, + removed_trackers: Mutex>, + } + + #[async_trait::async_trait] + impl RemoteSessionRuntimeHost for FakeSessionHost { + async fn list_session_metadata( + &self, + _workspace_path: &Path, + ) -> Result, String> { + Ok(vec![ + RemoteSessionMetadata { + session_id: "session-a".to_string(), + name: "keep me".to_string(), + agent_type: "agentic".to_string(), + created_at_ms: 1_000, + last_active_at_ms: 2_000, + turn_count: 3, + }, + RemoteSessionMetadata { + session_id: "session-b".to_string(), + name: "other".to_string(), + agent_type: "agentic".to_string(), + created_at_ms: 1_000, + last_active_at_ms: 2_000, + turn_count: 1, + }, + ]) + } + + async fn resolve_default_assistant_workspace_path(&self) -> Result { + Ok("/workspace/assistant".to_string()) + } + + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> Result { + self.created_requests.lock().unwrap().push(request); + Ok("created-session".to_string()) + } + + async fn load_model_catalog( + &self, + _session_id: Option<&str>, + ) -> Result { + Ok(RemoteModelCatalog { + version: 1, + models: Vec::new(), + default_models: RemoteDefaultModelsConfig::default(), + session_model_id: None, + }) + } + + async fn update_session_model( + &self, + _session_id: &str, + model_id: &str, + ) -> Result { + Ok(model_id.to_string()) + } + + async fn ensure_session_loaded(&self, _session_id: &str) -> Result<(), String> { + Ok(()) + } + + async fn update_session_title( + &self, + _session_id: &str, + title: &str, + ) -> Result { + Ok(title.trim().to_string()) + } + + async fn resolve_session_workspace_path(&self, _session_id: &str) -> Option { + Some(PathBuf::from("/workspace/project")) + } + + async fn load_remote_chat_messages( + &self, + _workspace_path: &Path, + _session_id: &str, + ) -> (Vec, bool) { + ( + vec![ChatMessage { + id: "message-1".to_string(), + role: "user".to_string(), + content: "hello".to_string(), + timestamp: "1".to_string(), + metadata: None, + images: None, + thinking: None, + tools: None, + items: None, + }], + false, + ) + } + + async fn delete_session( + &self, + _workspace_path: &Path, + _session_id: &str, + ) -> Result<(), String> { + Ok(()) + } + + fn remove_tracker(&self, session_id: &str) { + self.removed_trackers + .lock() + .unwrap() + .push(session_id.to_string()); + } + } + + #[tokio::test] + async fn remote_session_handler_preserves_list_and_create_policy() { + let host = FakeSessionHost::default(); + + let list = handle_remote_session_command( + &host, + &RemoteCommand::ListSessions { + workspace_path: Some("/workspace/project".to_string()), + limit: Some(20), + offset: Some(0), + query: Some("keep".to_string()), + }, + ) + .await; + let RemoteResponse::SessionList { sessions, has_more } = list else { + panic!("expected session list"); + }; + assert!(!has_more); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, "session-a"); + assert_eq!( + sessions[0].workspace_path.as_deref(), + Some("/workspace/project") + ); + + let created = handle_remote_session_command( + &host, + &RemoteCommand::CreateSession { + agent_type: Some("Cowork".to_string()), + session_name: None, + workspace_path: Some("/workspace/project".to_string()), + }, + ) + .await; + assert_eq!( + created, + RemoteResponse::SessionCreated { + session_id: "created-session".to_string(), + } + ); + let created_requests = host.created_requests.lock().unwrap(); + assert_eq!(created_requests[0].session_name, "Remote Cowork Session"); + assert_eq!(created_requests[0].agent_type, "Cowork"); + assert_eq!( + created_requests[0].workspace_path.as_deref(), + Some("/workspace/project") + ); + } + + #[tokio::test] + async fn remote_session_handler_removes_tracker_after_delete_success() { + let host = FakeSessionHost::default(); + + let deleted = handle_remote_session_command( + &host, + &RemoteCommand::DeleteSession { + session_id: "session-a".to_string(), + }, + ) + .await; + + assert_eq!( + deleted, + RemoteResponse::SessionDeleted { + session_id: "session-a".to_string(), + } + ); + assert_eq!( + host.removed_trackers.lock().unwrap().as_slice(), + ["session-a"] + ); + } + + struct FakePollHost { + tracker: Arc, + } + + #[async_trait::async_trait] + impl RemotePollRuntimeHost for FakePollHost { + fn ensure_tracker(&self, _session_id: &str) -> Arc { + self.tracker.clone() + } + + async fn load_model_catalog(&self, _session_id: &str) -> Option { + None + } + + async fn resolve_session_workspace_path(&self, _session_id: &str) -> Option { + None + } + + async fn load_remote_chat_messages( + &self, + _workspace_path: &Path, + _session_id: &str, + ) -> (Vec, bool) { + (Vec::new(), false) + } + } + + #[tokio::test] + async fn remote_poll_handler_preserves_missing_workspace_error() { + let host = FakePollHost { + tracker: Arc::new(RemoteSessionStateTracker::new("session-a".to_string())), + }; + + let response = handle_remote_poll_command( + &host, + &RemoteCommand::PollSession { + session_id: "session-a".to_string(), + since_version: 0, + known_msg_count: 0, + known_model_catalog_version: None, + }, + ) + .await; + + assert_eq!( + response, + RemoteResponse::Error { + message: "Workspace path not available for session: session-a".to_string(), + } + ); + } + + #[derive(Default)] + struct FakeInteractionHost { + rejected: Mutex>, + } + + #[async_trait::async_trait] + impl RemoteInteractionRuntimeHost for FakeInteractionHost { + async fn confirm_tool( + &self, + _tool_id: &str, + _updated_input: Option, + ) -> Result<(), String> { + Ok(()) + } + + async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { + self.rejected + .lock() + .unwrap() + .push((tool_id.to_string(), reason)); + Ok(()) + } + + async fn cancel_tool(&self, _tool_id: &str, _reason: String) -> Result<(), String> { + Ok(()) + } + + fn answer_question( + &self, + _tool_id: &str, + _answers: serde_json::Value, + ) -> Result<(), String> { + Ok(()) + } + } + + #[tokio::test] + async fn remote_interaction_handler_preserves_default_reject_reason() { + let host = FakeInteractionHost::default(); + + let response = handle_remote_interaction_command( + &host, + &RemoteCommand::RejectTool { + tool_id: "tool-1".to_string(), + reason: None, + }, + ) + .await; + + assert_eq!( + response, + RemoteResponse::InteractionAccepted { + action: "reject_tool".to_string(), + target_id: "tool-1".to_string(), + } + ); + assert_eq!( + host.rejected.lock().unwrap().as_slice(), + [("tool-1".to_string(), "User rejected".to_string())] + ); + } +} From 19b53b1e064310f9646ec9894529a148675d5053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=9D=E5=8D=87=E7=9A=84=E5=A4=AA=E9=98=B3?= Date: Fri, 29 May 2026 16:43:29 +0800 Subject: [PATCH 8/8] refactor(agent): log reasoning_effort from config --- src/crates/ai-adapters/src/client.rs | 3 - src/crates/ai-adapters/src/client/sse.rs | 3 - src/crates/ai-adapters/src/lib.rs | 2 - src/crates/ai-adapters/src/wire_reasoning.rs | 97 ------------------- .../src/agentic/execution/round_executor.rs | 24 +---- src/crates/core/src/infrastructure/ai/mod.rs | 5 +- 6 files changed, 7 insertions(+), 127 deletions(-) delete mode 100644 src/crates/ai-adapters/src/wire_reasoning.rs diff --git a/src/crates/ai-adapters/src/client.rs b/src/crates/ai-adapters/src/client.rs index 47159bb61..ada8c9392 100644 --- a/src/crates/ai-adapters/src/client.rs +++ b/src/crates/ai-adapters/src/client.rs @@ -15,7 +15,6 @@ pub(crate) mod utils; use crate::providers::{anthropic, gemini, openai}; use crate::types::ProxyConfig; use crate::types::*; -use crate::WireReasoningFields; use anyhow::Result; use format::ApiFormat; use log::warn; @@ -32,8 +31,6 @@ pub struct StreamResponse { Box> + Send>, >, pub raw_sse_rx: Option>, - /// Thinking / effort values extracted from the final outbound request body. - pub wire_reasoning: WireReasoningFields, } /// Default time to wait for the first response headers / stream body to start. diff --git a/src/crates/ai-adapters/src/client/sse.rs b/src/crates/ai-adapters/src/client/sse.rs index 9bfc7b854..e920f6dba 100644 --- a/src/crates/ai-adapters/src/client/sse.rs +++ b/src/crates/ai-adapters/src/client/sse.rs @@ -1,7 +1,6 @@ use crate::client::utils::elapsed_ms_u64; use crate::client::StreamResponse; use crate::stream::UnifiedResponse; -use crate::extract_wire_reasoning_fields; use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; use log::{debug, error, warn}; @@ -109,7 +108,6 @@ where Option>, ), { - let wire_reasoning = extract_wire_reasoning_fields(request_body); let mut last_error = None; for attempt in 0..max_tries { let request_start_time = std::time::Instant::now(); @@ -231,7 +229,6 @@ where return Ok(StreamResponse { stream: Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(rx)), raw_sse_rx: Some(rx_raw), - wire_reasoning: wire_reasoning.clone(), }); } diff --git a/src/crates/ai-adapters/src/lib.rs b/src/crates/ai-adapters/src/lib.rs index b9229f6fe..e9a1146d9 100644 --- a/src/crates/ai-adapters/src/lib.rs +++ b/src/crates/ai-adapters/src/lib.rs @@ -6,7 +6,6 @@ pub mod providers; pub mod stream; pub mod tool_call_accumulator; pub mod types; -mod wire_reasoning; pub use client::{ AIClient, StreamOptions, StreamResponse, DEFAULT_STREAM_IDLE_TIMEOUT_SECS, @@ -18,4 +17,3 @@ pub use types::{ GeminiUsage, Message, ProxyConfig, ReasoningMode, RemoteModelInfo, ToolCall, ToolDefinition, ToolImageAttachment, }; -pub use wire_reasoning::{extract_wire_reasoning_fields, WireReasoningFields}; diff --git a/src/crates/ai-adapters/src/wire_reasoning.rs b/src/crates/ai-adapters/src/wire_reasoning.rs deleted file mode 100644 index 00d6ee532..000000000 --- a/src/crates/ai-adapters/src/wire_reasoning.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Extract thinking / effort values from the final provider request body. - -use serde_json::Value; - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct WireReasoningFields { - pub thinking: Option, - pub effort: Option, -} - -pub fn extract_wire_reasoning_fields(body: &Value) -> WireReasoningFields { - WireReasoningFields { - thinking: extract_wire_thinking(body), - effort: extract_wire_effort(body), - } -} - -fn extract_wire_thinking(body: &Value) -> Option { - body.pointer("/thinking/type") - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - body.get("enable_thinking") - .and_then(Value::as_bool) - .map(|enabled| enabled.to_string()) - }) - .or_else(|| { - body.pointer("/generationConfig/thinkingConfig/includeThoughts") - .and_then(Value::as_bool) - .map(|enabled| enabled.to_string()) - }) -} - -fn extract_wire_effort(body: &Value) -> Option { - body.get("reasoning_effort") - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - body.pointer("/reasoning/effort") - .and_then(Value::as_str) - .map(str::to_string) - }) - .or_else(|| { - body.pointer("/output_config/effort") - .and_then(Value::as_str) - .map(str::to_string) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn extracts_openai_chat_deepseek_fields() { - let body = json!({ - "model": "deepseek-v4-pro", - "thinking": { "type": "enabled" }, - "reasoning_effort": "max" - }); - let fields = extract_wire_reasoning_fields(&body); - assert_eq!(fields.thinking.as_deref(), Some("enabled")); - assert_eq!(fields.effort.as_deref(), Some("max")); - } - - #[test] - fn extracts_responses_effort_field() { - let body = json!({ - "model": "gpt-5", - "reasoning": { "effort": "high" } - }); - let fields = extract_wire_reasoning_fields(&body); - assert!(fields.thinking.is_none()); - assert_eq!(fields.effort.as_deref(), Some("high")); - } - - #[test] - fn extracts_anthropic_adaptive_fields() { - let body = json!({ - "model": "claude-sonnet-4-6", - "thinking": { "type": "adaptive" }, - "output_config": { "effort": "medium" } - }); - let fields = extract_wire_reasoning_fields(&body); - assert_eq!(fields.thinking.as_deref(), Some("adaptive")); - assert_eq!(fields.effort.as_deref(), Some("medium")); - } - - #[test] - fn returns_none_when_fields_absent() { - let body = json!({ "model": "gpt-4o", "messages": [] }); - let fields = extract_wire_reasoning_fields(&body); - assert!(fields.thinking.is_none()); - assert!(fields.effort.is_none()); - } -} diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index ffb25e433..7aad51371 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -124,7 +124,7 @@ impl RoundExecutor { let max_attempts = Self::MAX_STREAM_ATTEMPTS; let mut attempt_index = 0usize; - let (stream_result, send_to_stream_ms, stream_processing_ms, wire_reasoning) = loop { + let (stream_result, send_to_stream_ms, stream_processing_ms) = loop { // Check cancellation before opening a model stream. This catches // early cancellation registered before the first round starts. if cancel_token.is_cancelled() { @@ -193,7 +193,6 @@ impl RoundExecutor { }; // Destructure StreamResponse: get stream and raw SSE data receiver - let wire_reasoning = stream_response.wire_reasoning.clone(); let ai_stream = stream_response.stream; let raw_sse_rx = stream_response.raw_sse_rx; @@ -290,7 +289,7 @@ impl RoundExecutor { recovered .tool_calls .retain(|tool_call| tool_call.is_valid()); - break (recovered, send_to_stream_ms, stream_processing_ms, wire_reasoning); + break (recovered, send_to_stream_ms, stream_processing_ms); } self.emit_failed_partial_tool_calls( @@ -393,7 +392,7 @@ impl RoundExecutor { ); } - break (result, send_to_stream_ms, stream_processing_ms, wire_reasoning); + break (result, send_to_stream_ms, stream_processing_ms); } Err(stream_err) => { let err_msg = stream_err.error.to_string(); @@ -443,19 +442,9 @@ impl RoundExecutor { .iter() .map(|tc| tc.tool_name.as_str()) .collect(); - let output_tokens = stream_result - .usage - .as_ref() - .map(|usage| usage.candidates_token_count); - let tps = match (output_tokens, stream_processing_ms) { - (Some(tokens), ms) if ms > 0 && tokens > 0 => { - Some(tokens as f64 / (ms as f64 / 1000.0)) - } - _ => None, - }; debug!( target: "ai::model_response", - "Model response received: text_length={}, tool_calls={}, token_usage={:?}, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}, output_tokens={:?}, tps={:?}, wire_thinking={:?}, wire_effort={:?}", + "Model response received: text_length={}, tool_calls={}, token_usage={:?}, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}, reasoning_effort={:?}", stream_result.full_text.len(), if tool_names.is_empty() { "none".to_string() } else { tool_names.join(", ") }, stream_result.usage.as_ref().map(|u| format!("input={}, output={}, total={}", u.prompt_token_count, u.candidates_token_count, u.total_token_count)).unwrap_or_else(|| "none".to_string()), @@ -463,10 +452,7 @@ impl RoundExecutor { stream_processing_ms, stream_result.first_chunk_ms, stream_result.first_visible_output_ms, - output_tokens, - tps, - wire_reasoning.thinking.as_deref(), - wire_reasoning.effort.as_deref() + ai_client.config.reasoning_effort ); // Check cancellation token again after stream processing completes diff --git a/src/crates/core/src/infrastructure/ai/mod.rs b/src/crates/core/src/infrastructure/ai/mod.rs index 94b09c302..3fba730da 100644 --- a/src/crates/core/src/infrastructure/ai/mod.rs +++ b/src/crates/core/src/infrastructure/ai/mod.rs @@ -11,9 +11,8 @@ pub use bitfun_ai_adapters::providers; pub use bitfun_ai_adapters::stream as ai_stream_handlers; pub use bitfun_ai_adapters::{ - AIClient, StreamOptions, StreamResponse, WireReasoningFields, - DEFAULT_STREAM_IDLE_TIMEOUT_SECS, DEFAULT_STREAM_TTFT_TIMEOUT_SECS, - REASONING_STREAM_TTFT_TIMEOUT_SECS, + AIClient, StreamOptions, StreamResponse, DEFAULT_STREAM_IDLE_TIMEOUT_SECS, + DEFAULT_STREAM_TTFT_TIMEOUT_SECS, REASONING_STREAM_TTFT_TIMEOUT_SECS, }; pub use client_factory::{ get_global_ai_client_factory, initialize_global_ai_client_factory, AIClientFactory,