From 18fd7ba069e126386c93cb317b3005f9bedfaad1 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 14 Oct 2026 18:19:53 +0800 Subject: [PATCH 1/4] feat(agent): generate plain-text next-input suggestions with reasoning fallback --- .../rules/source/public-api-rules.mjs | 1 + .../src/agentic/coordination/coordinator.rs | 17 +- .../core/src/agentic/execution/AGENTS.md | 16 + .../src/agentic/execution/execution_engine.rs | 57 +- .../core/src/agentic/execution/mod.rs | 1 + .../agentic/execution/prompt_suggestion.rs | 810 ++++++++++++++++++ .../contracts/config-contracts/src/types.rs | 26 + src/crates/contracts/events/src/agentic.rs | 10 + .../events/src/frontend_projection.rs | 34 + src/crates/execution/agent-runtime/AGENTS.md | 7 + src/crates/execution/agent-runtime/src/lib.rs | 2 + .../agent-runtime/src/prompt_suggestion.rs | 431 ++++++++++ .../src/remote_connect/session_records.rs | 17 + 13 files changed, 1427 insertions(+), 2 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs create mode 100644 src/crates/execution/agent-runtime/src/prompt_suggestion.rs diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 113d4410a5..158d600827 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -39,6 +39,7 @@ export const agentRuntimeRootPublicModules = [ 'prompt', 'prompt_cache', 'prompt_markup', + 'prompt_suggestion', 'remote_file_delivery', 'runtime', 'scheduled_job', diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 28ab99326a..058e0faf2c 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -93,6 +93,9 @@ use openbitfun_agent_runtime::deep_review::FocusedReviewAssignment; use openbitfun_agent_runtime::permission::{ AUTO_APPROVE_ASK_CONTEXT_KEY, PERMISSION_MODE_CONTEXT_KEY, }; +use openbitfun_agent_runtime::prompt_suggestion::{ + prompt_suggestion_source_eligible, PROMPT_SUGGESTION_ELIGIBLE_CONTEXT_KEY, +}; use openbitfun_agent_runtime::remote_file_delivery::{ needs_computer_links_for_source, remote_file_delivery_reminder, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, @@ -6971,9 +6974,21 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet run_manifest.to_string(), ); } - if metadata_bool(user_message_metadata.as_ref(), "acp_transport") == Some(true) { + let acp_transport = + metadata_bool(user_message_metadata.as_ref(), "acp_transport") == Some(true); + if acp_transport { context_vars.insert("acp_transport".to_string(), "true".to_string()); } + if prompt_suggestion_source_eligible( + submission_policy.trigger_source, + session.kind, + acp_transport, + ) { + context_vars.insert( + PROMPT_SUGGESTION_ELIGIBLE_CONTEXT_KEY.to_string(), + "true".to_string(), + ); + } if let Some(user_input_available) = metadata_bool( user_message_metadata.as_ref(), USER_INPUT_AVAILABLE_CONTEXT_KEY, diff --git a/src/crates/assembly/core/src/agentic/execution/AGENTS.md b/src/crates/assembly/core/src/agentic/execution/AGENTS.md index 4c4cb83bba..5ab0283ba1 100644 --- a/src/crates/assembly/core/src/agentic/execution/AGENTS.md +++ b/src/crates/assembly/core/src/agentic/execution/AGENTS.md @@ -1,5 +1,21 @@ If you modify `stream_processor.rs`, run the stream integration tests before finishing. +For next-input suggestions (lowest supported reasoning with original-session +fallback, plain-text output, unchanged message prefix, and silent failure), use: + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib agentic::execution::prompt_suggestion::tests +``` + +Suggestion generation tries the lowest supported reasoning first. If preparing +that client or its model call fails, make one call with the completed turn's +original client and reasoning settings. Both attempts share one 80-second +deadline and session cancellation. Clear the turn's output schema, retain the +request prefix and cache routing, and request plain text on both attempts. +Empty output, the literal `null`, tool calls, and content filters do not trigger +fallback; all other candidate text is kept verbatim. Do not add JSON parsing, +format negotiation, correction retries, or text reconstruction. + For model retry admission and recovery, use: ```bash diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 8c9dadd86c..3119323eee 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -5,6 +5,7 @@ use super::model_exchange_trace::{ prepare_model_exchange_trace_for_workspace, ModelExchangeTraceOperation, }; +use super::prompt_suggestion::{spawn_prompt_suggestion, PromptSuggestionRequest}; use super::round_executor::{ModelRoundLifecycle, RoundExecutor}; use super::types::{ExecutionContext, ExecutionResult, RoundContext, RoundResult}; use crate::agentic::agents::{ @@ -68,6 +69,10 @@ use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; use dashmap::DashMap; use log::{debug, error, info, trace, warn}; use openbitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY; +use openbitfun_agent_runtime::prompt_suggestion::{ + turn_outcome_allows_prompt_suggestion, PromptSuggestionRuntime, PromptSuggestionTurnOutcome, + PROMPT_SUGGESTION_ELIGIBLE_CONTEXT_KEY, +}; use openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; use openbitfun_ai_adapters::ModelExchangeTraceConfig; use openbitfun_core_types::{ModelRequestContext, SessionModelBindingPolicy}; @@ -606,6 +611,7 @@ pub struct ExecutionEngine { context_compressor: Arc, config: ExecutionEngineConfig, generation_messages: DashMap<(String, String), Vec>, + prompt_suggestions: PromptSuggestionRuntime, } impl ExecutionEngine { @@ -686,6 +692,7 @@ impl ExecutionEngine { context_compressor, config, generation_messages: DashMap::new(), + prompt_suggestions: PromptSuggestionRuntime::new(), } } @@ -2077,7 +2084,7 @@ impl ExecutionEngine { .await } - async fn build_ai_messages_for_send( + pub(super) async fn build_ai_messages_for_send( messages: &[Message], provider: &str, workspace: Option<&WorkspaceBinding>, @@ -2898,6 +2905,12 @@ impl ExecutionEngine { let control_owner = context.session_id.clone(); self.generation_messages .remove(&(context.session_id.clone(), dialog_turn_id.clone())); + if self.prompt_suggestions.cancel_session(&context.session_id) { + debug!( + "Cancelled pending prompt suggestion for new dialog turn: session_id={}, dialog_turn_id={}", + context.session_id, dialog_turn_id + ); + } info!("Starting dialog turn: dialog_turn_id={}", dialog_turn_id); @@ -4732,6 +4745,48 @@ impl ExecutionEngine { debug!("DialogTurnCompleted event sent"); } + let prompt_suggestion_eligible = context + .context + .get(PROMPT_SUGGESTION_ELIGIBLE_CONTEXT_KEY) + .is_some_and(|value| value == "true"); + if prompt_suggestion_eligible + && last_partial_recovery_reason.is_none() + && turn_outcome_allows_prompt_suggestion(PromptSuggestionTurnOutcome { + finish_reason: effective_finish_reason, + has_final_response, + used_local_final_response_synthesis, + emits_lifecycle_events: context.emit_lifecycle_events, + is_subagent: context.subagent_parent_info.is_some(), + }) + && !self + .round_executor + .is_dialog_turn_cancelled(&dialog_turn_id) + { + spawn_prompt_suggestion( + &self.prompt_suggestions, + self.event_queue.clone(), + self.session_manager.clone(), + PromptSuggestionRequest { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + messages: messages.clone(), + model_id: resolved_primary_model_id.clone(), + ai_client: ai_client.clone(), + tool_definitions: tool_definitions.clone(), + model_request_context: model_request_context.clone(), + prepended_reminders: turn_prompt_scaffold + .prepended_prompt_reminders + .ordered_reminders() + .into_iter() + .map(str::to_string) + .collect(), + workspace: context.workspace.clone(), + workspace_services: context.workspace_services.clone(), + attach_images: primary_supports_image_understanding, + }, + ); + } + // Print dialog turn token statistics (from model's last returned usage) if let Some(usage) = last_usage { info!( diff --git a/src/crates/assembly/core/src/agentic/execution/mod.rs b/src/crates/assembly/core/src/agentic/execution/mod.rs index 03a675ba9a..1fe2ded676 100644 --- a/src/crates/assembly/core/src/agentic/execution/mod.rs +++ b/src/crates/assembly/core/src/agentic/execution/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod conditional_instructions; pub mod edit_constraint_guard; pub mod execution_engine; pub(crate) mod model_exchange_trace; +mod prompt_suggestion; pub mod round_executor; pub mod stream_processor; pub mod types; diff --git a/src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs b/src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs new file mode 100644 index 0000000000..373e1a4801 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs @@ -0,0 +1,810 @@ +//! Background next-input suggestion after a completed interactive turn. +//! +//! The request retains the turn's model, tools, cache routing identity and +//! send-time message rendering. It overrides reasoning to the lowest supported +//! preset and requests plain text without the turn's output schema. A failed +//! attempt falls back once to the turn's original client. Both attempts share +//! the timeout and cancellation scope. Cache reuse is provider-dependent. + +use super::execution_engine::ExecutionEngine; +use super::model_exchange_trace::{ + prepare_model_exchange_trace_for_workspace, ModelExchangeTraceOperation, +}; +use crate::agentic::core::Message; +use crate::agentic::events::{AgenticEvent, EventQueue}; +use crate::agentic::session::SessionManager; +use crate::agentic::workspace::WorkspaceServices; +use crate::agentic::WorkspaceBinding; +use crate::infrastructure::ai::reasoning_catalog::{ + load_models_dev_reasoning_catalog_without_refresh, project_model_reasoning_catalog, +}; +use crate::infrastructure::ai::AIClient; +use crate::util::types::{Message as AIMessage, ToolDefinition}; +use log::{debug, info, warn}; +use openbitfun_agent_runtime::prompt_suggestion::{ + lowest_prompt_suggestion_reasoning_preset, prompt_suggestion_instruction, + prompt_suggestion_text, PromptSuggestionRuntime, +}; +use openbitfun_ai_adapters::ModelExchangeTraceConfig; +use openbitfun_core_types::{ModelRequestContext, ReasoningCatalogProjection}; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +const PROMPT_SUGGESTION_TIMEOUT: Duration = Duration::from_secs(80); +const PROMPT_SUGGESTION_CONFIG_PATH: &str = "app.ai_experience.enable_prompt_suggestion"; + +/// Turn-constant request state captured when the turn completes. +pub(super) struct PromptSuggestionRequest { + pub session_id: String, + pub turn_id: String, + pub messages: Vec, + pub model_id: String, + pub ai_client: Arc, + pub tool_definitions: Option>, + pub model_request_context: ModelRequestContext, + pub prepended_reminders: Vec, + pub workspace: Option, + pub workspace_services: Option, + pub attach_images: bool, +} + +fn suggestion_request_context(context: &ModelRequestContext) -> ModelRequestContext { + ModelRequestContext { + output_schema: None, + ..context.clone() + } +} + +fn client_with_lowest_reasoning( + client: &AIClient, + projection: &ReasoningCatalogProjection, +) -> anyhow::Result { + match lowest_prompt_suggestion_reasoning_preset(&projection.presets) { + Some(preset) => { + client.validate_reasoning_preset(preset)?; + Ok(client.with_reasoning_preset(preset)) + } + None => { + if client.selected_reasoning_preset().is_some() + || client.model_reasoning_preset().is_some() + { + anyhow::bail!( + "Cannot resolve the lowest supported reasoning preset for this model" + ); + } + debug!("Prompt suggestion model has no selectable reasoning presets"); + Ok(client.clone()) + } + } +} + +async fn suggestion_client(request: &PromptSuggestionRequest) -> anyhow::Result { + let config = crate::service::config::get_global_config_service() + .await? + .get_effective_ai_config() + .await?; + let model = config + .models + .iter() + .find(|model| model.id == request.model_id) + .ok_or_else(|| anyhow::anyhow!("Prompt suggestion model configuration is unavailable"))?; + let catalog = load_models_dev_reasoning_catalog_without_refresh().await; + let projection = project_model_reasoning_catalog(model, catalog.catalog.as_deref()); + client_with_lowest_reasoning(request.ai_client.as_ref(), &projection) +} + +async fn prompt_suggestion_enabled() -> bool { + match crate::service::config::get_global_config_service().await { + Ok(service) => service + .get_config::(Some(PROMPT_SUGGESTION_CONFIG_PATH)) + .await + .unwrap_or(true), + Err(_) => false, + } +} + +/// Build the suggestion request messages: the final round's send-time messages +/// unchanged, then the suggestion instruction as the only appended message. +pub(super) async fn build_prompt_suggestion_messages( + request: &PromptSuggestionRequest, +) -> crate::util::errors::OpenBitFunResult> { + let reminders = request + .prepended_reminders + .iter() + .map(String::as_str) + .collect::>(); + let mut ai_messages = ExecutionEngine::build_ai_messages_for_send( + &request.messages, + &request.ai_client.config.format, + request.workspace.as_ref(), + request.workspace_services.as_ref(), + &request.turn_id, + request.attach_images, + &reminders, + ) + .await?; + ai_messages.push(AIMessage::user(prompt_suggestion_instruction().to_string())); + Ok(ai_messages) +} + +async fn generate_prompt_suggestion( + request: PromptSuggestionRequest, + session_manager: Arc, +) -> anyhow::Result> { + if !prompt_suggestion_enabled().await { + debug!( + "Prompt suggestion disabled by configuration: session_id={}, turn_id={}", + request.session_id, request.turn_id + ); + return Ok(None); + } + + let ai_client = suggestion_client(&request).await; + let ai_messages = build_prompt_suggestion_messages(&request).await?; + + let trace_dir = session_manager + .persistent_model_exchange_trace_dir(&request.session_id) + .await; + let trace = prepare_model_exchange_trace_for_workspace( + &request.session_id, + &request.turn_id, + request.workspace.as_ref(), + trace_dir.as_deref(), + ModelExchangeTraceOperation { + kind: "prompt_suggestion", + id: &request.turn_id, + trigger: None, + }, + request.ai_client.as_ref(), + ) + .await; + + request_prompt_suggestion_response(&request, ai_client, ai_messages, trace).await +} + +async fn request_prompt_suggestion_response( + request: &PromptSuggestionRequest, + lowest_reasoning_client: anyhow::Result, + ai_messages: Vec, + trace: Option, +) -> anyhow::Result> { + let context = suggestion_request_context(&request.model_request_context); + let response = match lowest_reasoning_client { + Ok(client) => { + client + .send_message_once_with_trace_and_request_context( + ai_messages.clone(), + request.tool_definitions.clone(), + Some(context.clone()), + trace.clone(), + ) + .await + } + Err(error) => Err(error), + }; + let response = match response { + Ok(response) => response, + Err(error) => { + debug!( + "Prompt suggestion falling back to original session model settings: session_id={}, turn_id={}, error={:#}", + request.session_id, request.turn_id, error + ); + request + .ai_client + .send_message_once_with_trace_and_request_context( + ai_messages, + request.tool_definitions.clone(), + Some(context), + trace, + ) + .await? + } + }; + + if let Some(usage) = response.usage.as_ref() { + info!( + "Prompt suggestion usage: session_id={}, turn_id={}, prompt_tokens={}, cached_tokens={}, completion_tokens={}", + request.session_id, + request.turn_id, + usage.prompt_token_count, + usage.cached_content_token_count.unwrap_or(0), + usage.candidates_token_count + ); + } + if response.tool_calls.is_some() || response.finish_reason.as_deref() == Some("content_filter") + { + debug!( + "Prompt suggestion discarded: session_id={}, turn_id={}, tool_call={}, finish_reason={:?}", + request.session_id, request.turn_id, response.tool_calls.is_some(), response.finish_reason + ); + return Ok(None); + } + Ok(prompt_suggestion_text(response.text)) +} + +/// Start the suggestion request in the background. A newer turn or +/// suggestion on the same session cancels it through `runtime`. +pub(super) fn spawn_prompt_suggestion( + runtime: &PromptSuggestionRuntime, + event_queue: Arc, + session_manager: Arc, + request: PromptSuggestionRequest, +) { + spawn_prompt_suggestion_generation( + runtime, + event_queue, + request.session_id.clone(), + request.turn_id.clone(), + generate_prompt_suggestion(request, session_manager), + ); +} + +/// Suggestions are best-effort: every expected failure stays inside this task. +/// Only a successful suggestion may enqueue an event; failures never alter the +/// completed dialog turn or emit an error to local/remote consumers. +fn spawn_prompt_suggestion_generation( + runtime: &PromptSuggestionRuntime, + event_queue: Arc, + session_id: String, + turn_id: String, + generation: impl Future>> + Send + 'static, +) -> tokio::task::JoinHandle<()> { + let token = runtime.begin(&session_id, &turn_id); + let runtime = runtime.clone(); + tokio::spawn(async move { + let generation = tokio::time::timeout(PROMPT_SUGGESTION_TIMEOUT, generation); + let outcome = tokio::select! { + _ = token.cancelled() => None, + outcome = generation => Some(outcome), + }; + match outcome { + None => debug!( + "Prompt suggestion cancelled by a newer turn: session_id={}, turn_id={}", + session_id, turn_id + ), + Some(Err(_)) => warn!( + "Prompt suggestion timed out: session_id={}, turn_id={}, timeout_secs={}", + session_id, + turn_id, + PROMPT_SUGGESTION_TIMEOUT.as_secs() + ), + Some(Ok(Err(error))) => warn!( + "Prompt suggestion skipped after background generation failed: session_id={}, turn_id={}, error={:#}", + session_id, turn_id, error + ), + Some(Ok(Ok(None))) => {} + Some(Ok(Ok(Some(suggestion)))) if !token.is_cancelled() => { + debug!( + "Prompt suggestion generated: session_id={}, turn_id={}, chars={}", + session_id, + turn_id, + suggestion.chars().count() + ); + if let Err(error) = event_queue + .enqueue( + AgenticEvent::PromptSuggestionGenerated { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + suggestion, + }, + None, + ) + .await + { + warn!( + "Failed to enqueue prompt suggestion: session_id={}, turn_id={}, error={}", + session_id, turn_id, error + ); + } + } + Some(Ok(Ok(Some(_)))) => {} + } + runtime.finish(&session_id, &turn_id); + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::events::EventQueueConfig; + use crate::agentic::execution::round_executor::tests::RetryTestServer; + use crate::service::config::types::AIModelConfig; + use openbitfun_core_types::{ + ReasoningCatalogBinding, ReasoningConfig, ReasoningPreset, ReasoningPresetAction, + }; + + async fn assert_silent_completion( + runtime: &PromptSuggestionRuntime, + queue: &EventQueue, + task: tokio::task::JoinHandle<()>, + ) { + task.await + .expect("suggestion failure must not panic the background task"); + assert!(!runtime.is_inflight("session-1")); + // The same queue feeds desktop, peer and relay consumers: no error, + // lifecycle change, or partial suggestion may escape this task. + assert_eq!(queue.stats().await.total_enqueued, 0); + } + + #[tokio::test(start_paused = true)] + async fn timeout_at_eighty_seconds_drops_generation_and_emits_nothing() { + use std::sync::atomic::{AtomicBool, Ordering}; + + struct DropWitness(Arc); + impl Drop for DropWitness { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let runtime = PromptSuggestionRuntime::new(); + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let dropped = Arc::new(AtomicBool::new(false)); + let witness = DropWitness(dropped.clone()); + let (started, ready) = tokio::sync::oneshot::channel(); + let task = spawn_prompt_suggestion_generation( + &runtime, + queue.clone(), + "session-1".into(), + "turn-1".into(), + async move { + let _witness = witness; + started.send(()).unwrap(); + std::future::pending().await + }, + ); + ready.await.unwrap(); + tokio::time::advance(Duration::from_secs(79)).await; + assert!(runtime.is_inflight("session-1")); + assert!(!task.is_finished()); + assert!(!dropped.load(Ordering::SeqCst)); + tokio::time::advance(Duration::from_secs(1)).await; + assert_silent_completion(&runtime, &queue, task).await; + assert!(dropped.load(Ordering::SeqCst)); + + spawn_prompt_suggestion_generation( + &runtime, + queue.clone(), + "session-1".into(), + "turn-2".into(), + async { Ok(Some("Run the tests".into())) }, + ) + .await + .unwrap(); + assert!(!runtime.is_inflight("session-1")); + let events = queue.dequeue_batch(10).await; + assert_eq!(events.len(), 1); + assert!( + matches!(&events[0].event, AgenticEvent::PromptSuggestionGenerated { turn_id, .. } if turn_id == "turn-2") + ); + } + + #[tokio::test] + async fn unsupported_reasoning_validation_uses_the_original_client_once() { + let server = RetryTestServer::new(vec![suggestion_response("Run the tests")]); + let projection = project_model_reasoning_catalog( + &AIModelConfig { + provider: "openai".into(), + reasoning: Some(ReasoningConfig { + catalog: ReasoningCatalogBinding::Disabled, + presets: vec![ReasoningPreset { + id: "lowest".into(), + actions: vec![ReasoningPresetAction::BudgetTokens { value: 1_024 }], + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }, + None, + ); + let request = test_suggestion_request(server.client()); + let lowest = client_with_lowest_reasoning(&request.ai_client, &projection); + assert!(lowest.is_err()); + let messages = build_prompt_suggestion_messages(&request).await.unwrap(); + assert_eq!( + request_prompt_suggestion_response(&request, lowest, messages, None) + .await + .unwrap(), + Some("Run the tests".into()) + ); + let requests = server.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert!(requests[0].get("reasoning_effort").is_none()); + assert!(requests[0].get("response_format").is_none()); + } + + #[tokio::test] + async fn provider_rejection_of_low_reasoning_retries_the_original_session_call() { + for original_effort in [None, Some("high")] { + let text = " Run the tests. "; + let server = RetryTestServer::new(vec![ + ( + 400, + serde_json::json!({ + "error": { "code": "unsupported_value", "param": "reasoning_effort", + "message": "This model does not support reasoning_effort low" } + }) + .to_string(), + ), + suggestion_response(text), + ]); + let client = server.client(); + let mut projection = project_model_reasoning_catalog( + &AIModelConfig { + provider: "openai".into(), + model_name: client.config.model.clone(), + base_url: client.config.base_url.clone(), + ..Default::default() + }, + None, + ); + let session_client = match original_effort { + Some(effort) => Arc::new( + client.with_reasoning_preset( + projection + .presets + .iter() + .find(|preset| preset.id == effort) + .unwrap(), + ), + ), + None => client, + }; + projection.presets.retain(|preset| preset.id == "low"); + assert_eq!(projection.presets.len(), 1); + let lowest = client_with_lowest_reasoning(&session_client, &projection); + let mut request = test_suggestion_request(session_client.clone()); + request.model_request_context.output_schema = Some(serde_json::json!({ + "type": "object", "properties": {"report": {"type": "string"}} + })); + let runtime = PromptSuggestionRuntime::new(); + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + spawn_prompt_suggestion_generation( + &runtime, + queue.clone(), + "session-1".into(), + "turn-1".into(), + async move { + let messages = build_prompt_suggestion_messages(&request).await?; + request_prompt_suggestion_response(&request, lowest, messages, None).await + }, + ) + .await + .unwrap(); + assert!(!runtime.is_inflight("session-1")); + let events = queue.dequeue_batch(10).await; + assert_eq!(events.len(), 1); + assert!( + matches!(&events[0].event, AgenticEvent::PromptSuggestionGenerated { suggestion, .. } if suggestion == text) + ); + assert_eq!( + session_client + .selected_reasoning_preset() + .map(|preset| preset.id.as_str()), + original_effort + ); + let requests = server.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0]["reasoning_effort"], "low"); + assert_eq!( + requests[1] + .get("reasoning_effort") + .and_then(|value| value.as_str()), + original_effort + ); + for body in requests.iter() { + assert!(body.get("response_format").is_none()); + } + let mut first = requests[0].clone(); + let mut second = requests[1].clone(); + first.as_object_mut().unwrap().remove("reasoning_effort"); + second.as_object_mut().unwrap().remove("reasoning_effort"); + assert_eq!( + first, second, + "fallback must retain all other request parameters" + ); + } + } + + fn suggestion_response(text: &str) -> (u16, String) { + suggestion_delta(serde_json::json!({"content": text}), "stop") + } + + fn suggestion_delta(delta: serde_json::Value, finish: &str) -> (u16, String) { + ( + 200, + format!( + "data: {}\n\ndata: [DONE]\n\n", + serde_json::json!({ + "id": "suggestion-response", "object": "chat.completion.chunk", + "created": 1, "model": "retry-test-model", + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}] + }) + ), + ) + } + + fn test_suggestion_request(ai_client: Arc) -> PromptSuggestionRequest { + PromptSuggestionRequest { + session_id: "session-1".into(), + turn_id: "turn-1".into(), + model_id: "test-model".into(), + messages: vec![ + Message::system("Follow the user's instructions".into()), + Message::user("Fix the build".into()), + Message::assistant("The build is fixed.".into()), + ], + ai_client, + tool_definitions: Some(vec![ToolDefinition { + name: "Read".into(), + description: "Read a file".into(), + parameters: serde_json::json!({"type": "object", "properties": {}}), + }]), + model_request_context: ModelRequestContext { + prompt_cache_route_key: Some("same-conversation".into()), + ..Default::default() + }, + prepended_reminders: Vec::new(), + workspace: None, + workspace_services: None, + attach_images: false, + } + } + + async fn run_test_suggestion( + request: PromptSuggestionRequest, + ) -> anyhow::Result> { + let messages = build_prompt_suggestion_messages(&request).await?; + request_prompt_suggestion_response( + &request, + Ok(request.ai_client.as_ref().clone()), + messages, + None, + ) + .await + } + + #[tokio::test] + async fn plain_text_response_emits_one_unchanged_candidate_without_output_schema() { + let text = " 运行测试,保留原文。 "; + let server = RetryTestServer::new(vec![suggestion_response(text)]); + let mut request = test_suggestion_request(server.client()); + request.model_request_context.output_schema = Some(serde_json::json!({ + "type": "object", "properties": {"report": {"type": "string"}} + })); + let runtime = PromptSuggestionRuntime::new(); + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + spawn_prompt_suggestion_generation( + &runtime, + queue.clone(), + "session-1".into(), + "turn-1".into(), + run_test_suggestion(request), + ) + .await + .unwrap(); + assert!(!runtime.is_inflight("session-1")); + let events = queue.dequeue_batch(10).await; + assert_eq!(events.len(), 1); + assert!( + matches!(&events[0].event, AgenticEvent::PromptSuggestionGenerated { suggestion, .. } if suggestion == text) + ); + let requests = server.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + let body = &requests[0]; + assert!(body.get("response_format").is_none()); + assert_eq!(body["model"], server.client().config.model); + assert_eq!(body["tools"][0]["function"]["name"], "Read"); + let messages = body["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 4); + assert_eq!(messages[1]["content"], "Fix the build"); + assert_eq!(messages[2]["content"], "The build is fixed."); + assert_eq!(messages[3]["content"], prompt_suggestion_instruction()); + } + + #[tokio::test] + async fn repeated_provider_failure_stays_silent_after_one_original_client_retry() { + let server = RetryTestServer::new(vec![( + 400, + serde_json::json!({"error": { + "message": "This response_format type is unavailable now", + "type": "invalid_request_error" + }}) + .to_string(), + )]); + let runtime = PromptSuggestionRuntime::new(); + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let task = spawn_prompt_suggestion_generation( + &runtime, + queue.clone(), + "session-1".into(), + "turn-1".into(), + run_test_suggestion(test_suggestion_request(server.client())), + ); + assert_silent_completion(&runtime, &queue, task).await; + let requests = server.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0], requests[1]); + } + + #[tokio::test] + async fn empty_null_tool_calls_and_content_filter_stay_silent_without_retry() { + for reply in [ + suggestion_response(""), + suggestion_response(" "), + suggestion_response("null"), + suggestion_delta(serde_json::json!({"content": ""}), "content_filter"), + suggestion_delta( + serde_json::json!({"tool_calls": [{ + "index": 0, "id": "read-1", "type": "function", + "function": {"name": "Read", "arguments": "{}"} + }]}), + "tool_calls", + ), + ] { + let server = RetryTestServer::new(vec![reply]); + let runtime = PromptSuggestionRuntime::new(); + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let task = spawn_prompt_suggestion_generation( + &runtime, + queue.clone(), + "session-1".into(), + "turn-1".into(), + run_test_suggestion(test_suggestion_request(server.client())), + ); + assert_silent_completion(&runtime, &queue, task).await; + assert_eq!(server.requests.lock().unwrap().len(), 1); + } + } + + #[test] + fn suggestion_context_clears_turn_schema_but_preserves_cache_routing() { + let context = ModelRequestContext { + prompt_cache_route_key: Some("lineage-1".to_string()), + output_schema: Some( + serde_json::json!({"type": "object", "properties": {"report": {"type": "string"}}}), + ), + }; + let suggestion = suggestion_request_context(&context); + assert_eq!( + suggestion.prompt_cache_route_key, + context.prompt_cache_route_key + ); + assert!(suggestion.output_schema.is_none()); + assert!(context.output_schema.is_some()); + } + + #[test] + fn suggestion_uses_lowest_supported_reasoning_without_mutating_turn_client() { + let model = AIModelConfig { + provider: "responses".to_string(), + model_name: "gpt-5.4".to_string(), + base_url: "https://api.openai.com/v1/responses".to_string(), + reasoning: Some(ReasoningConfig { + catalog: ReasoningCatalogBinding::Disabled, + default_preset: Some("high".to_string()), + presets: ["high", "low"] + .into_iter() + .enumerate() + .map(|(order, effort)| ReasoningPreset { + id: effort.to_string(), + order: Some(order as i32), + actions: vec![ReasoningPresetAction::Effort { + value: effort.to_string(), + }], + ..Default::default() + }) + .collect(), + }), + ..Default::default() + }; + let projection = project_model_reasoning_catalog(&model, None); + let high = projection + .presets + .iter() + .find(|preset| preset.id == "high") + .unwrap(); + let mut client = test_client().with_reasoning_preset(high); + client.config.format = "responses".to_string(); + client.config.model = model.model_name; + client.config.base_url = model.base_url.clone(); + client.config.request_url = model.base_url; + let suggestion = client_with_lowest_reasoning(&client, &projection).unwrap(); + assert_eq!(suggestion.selected_reasoning_preset().unwrap().id, "low"); + assert_eq!(client.selected_reasoning_preset().unwrap().id, "high"); + assert_eq!(suggestion.config.model, client.config.model); + assert_eq!(suggestion.config.max_tokens, client.config.max_tokens); + + let unsupported = project_model_reasoning_catalog( + &AIModelConfig { + reasoning: Some(ReasoningConfig { + catalog: ReasoningCatalogBinding::Disabled, + ..Default::default() + }), + ..Default::default() + }, + None, + ); + assert!(client_with_lowest_reasoning(&test_client(), &unsupported) + .unwrap() + .selected_reasoning_preset() + .is_none()); + assert!(client_with_lowest_reasoning(&client, &unsupported).is_err()); + } + + fn test_client() -> Arc { + Arc::new(AIClient::new(openbitfun_core_types::AIConfig { + name: "prompt-suggestion-test".to_string(), + base_url: "http://127.0.0.1:9".to_string(), + request_url: "http://127.0.0.1:9/v1/chat/completions".to_string(), + api_key: "prompt-suggestion-test-key".to_string(), + model: "prompt-suggestion-test-model".to_string(), + format: "openai".to_string(), + context_window: 4096, + max_tokens: Some(128), + temperature: None, + top_p: None, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + custom_request_body: None, + custom_request_body_mode: None, + })) + } + + #[tokio::test] + async fn suggestion_request_extends_the_final_round_prefix_with_one_instruction() { + let ai_client = test_client(); + let messages = vec![ + Message::system("system prompt".to_string()), + Message::user("Fix the failing build".to_string()).with_turn_id("turn-1".to_string()), + Message::assistant("The build is fixed.".to_string()) + .with_turn_id("turn-1".to_string()), + ]; + let reminders = vec!["skill listing".to_string()]; + let reminder_refs = reminders.iter().map(String::as_str).collect::>(); + let round_messages = ExecutionEngine::build_ai_messages_for_send( + &messages, + &ai_client.config.format, + None, + None, + "turn-1", + false, + &reminder_refs, + ) + .await + .expect("round messages"); + + let request = PromptSuggestionRequest { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + messages, + model_id: "test-model".to_string(), + ai_client, + tool_definitions: None, + model_request_context: ModelRequestContext::default(), + prepended_reminders: reminders, + workspace: None, + workspace_services: None, + attach_images: false, + }; + let suggestion_messages = build_prompt_suggestion_messages(&request) + .await + .expect("suggestion messages"); + + assert_eq!(suggestion_messages.len(), round_messages.len() + 1); + assert_eq!( + serde_json::to_value(&suggestion_messages[..round_messages.len()]).unwrap(), + serde_json::to_value(&round_messages).unwrap() + ); + let instruction = suggestion_messages.last().expect("instruction"); + assert_eq!(instruction.role, "user"); + assert_eq!( + instruction.content.as_deref(), + Some(prompt_suggestion_instruction()) + ); + } +} diff --git a/src/crates/contracts/config-contracts/src/types.rs b/src/crates/contracts/config-contracts/src/types.rs index 9e48fcc61e..929486d96d 100644 --- a/src/crates/contracts/config-contracts/src/types.rs +++ b/src/crates/contracts/config-contracts/src/types.rs @@ -630,6 +630,9 @@ pub struct AIExperienceConfig { /// Whether built-in commit workflows add OpenBitFun as a Git co-author. #[serde(default = "default_true")] pub enable_git_commit_coauthor: bool, + /// Whether a completed interactive turn offers a one-sentence next-input suggestion. + #[serde(default = "default_true")] + pub enable_prompt_suggestion: bool, } fn default_quick_actions() -> Vec { @@ -1959,6 +1962,7 @@ impl Default for AIExperienceConfig { voice_input: VoiceInputConfig::default(), quick_actions: default_quick_actions(), enable_git_commit_coauthor: true, + enable_prompt_suggestion: true, } } } @@ -2264,6 +2268,28 @@ mod tests { assert_eq!(serde_json::to_value(opted_out).unwrap(), persisted); } + #[test] + fn prompt_suggestion_defaults_on_for_legacy_settings_and_preserves_opt_out() { + let legacy = serde_json::json!({ + "enable_session_title_generation": false, + "enable_git_commit_coauthor": false + }); + let settings: super::AIExperienceConfig = serde_json::from_value(legacy).unwrap(); + assert!(settings.enable_prompt_suggestion); + assert!(!settings.enable_session_title_generation); + + let mut persisted = serde_json::to_value(settings).unwrap(); + assert_eq!( + persisted["enable_prompt_suggestion"], + serde_json::json!(true) + ); + persisted["enable_prompt_suggestion"] = serde_json::json!(false); + let opted_out: super::AIExperienceConfig = + serde_json::from_value(persisted.clone()).unwrap(); + assert!(!opted_out.enable_prompt_suggestion); + assert_eq!(serde_json::to_value(opted_out).unwrap(), persisted); + } + #[test] fn global_skill_settings_keep_legacy_values_and_project_scope_on_round_trip() { let legacy = r#"{"globally_disabled_user_skills":["user::home.agents::review"]}"#; diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index 5357524bc5..60329d7009 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -141,6 +141,14 @@ pub enum AgenticEvent { title: String, method: String, }, + + /// One-sentence next-input suggestion offered after a completed turn. + /// Session-scoped hint: it is not part of the turn lifecycle or journal. + PromptSuggestionGenerated { + session_id: String, + turn_id: String, + suggestion: String, + }, ImageAnalysisStarted { session_id: String, image_count: usize, @@ -662,6 +670,7 @@ impl AgenticEvent { | Self::SessionHistoryChanged { session_id, .. } | Self::SessionDeleted { session_id } | Self::SessionTitleGenerated { session_id, .. } + | Self::PromptSuggestionGenerated { session_id, .. } | Self::ImageAnalysisStarted { session_id, .. } | Self::ImageAnalysisCompleted { session_id, .. } | Self::DialogTurnStarted { session_id, .. } @@ -747,6 +756,7 @@ impl AgenticEvent { | Self::ContextCompressionStarted { .. } | Self::ThreadGoalUpdated { .. } | Self::UserSteeringInjected { .. } + | Self::PromptSuggestionGenerated { .. } | Self::ContextCompressionCompleted { .. } => AgenticEventPriority::Normal, Self::ToolEvent { tool_event, .. } => tool_event.default_priority(), diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index 791eb3cfc6..c90e08e69e 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -233,6 +233,20 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Some(AgenticFrontendEvent::new( + "agentic://prompt-suggestion", + // Not `turnId`: clients treat `turnId` as a write to that turn and + // drop it once the turn settled, which always precedes this hint. + json!({ + "sessionId": session_id, + "sourceTurnId": turn_id, + "suggestion": suggestion, + }), + )), AgenticEvent::DialogTurnCancelled { session_id, turn_id, @@ -788,6 +802,26 @@ mod tests { assert!(projected.payload["timestamp"].as_i64().is_some()); } + #[test] + fn prompt_suggestion_projects_session_scoped_hint() { + let projected = project_agentic_frontend_event(AgenticEvent::PromptSuggestionGenerated { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + suggestion: "Run the tests".to_string(), + }) + .expect("projected"); + + assert_eq!(projected.event_name, "agentic://prompt-suggestion"); + assert_eq!( + projected.payload, + json!({ + "sessionId": "session-1", + "sourceTurnId": "turn-1", + "suggestion": "Run the tests", + }) + ); + } + #[test] fn reasoning_preset_auto_clear_projects_canonical_auto_transition() { let projected = diff --git a/src/crates/execution/agent-runtime/AGENTS.md b/src/crates/execution/agent-runtime/AGENTS.md index 77d7f382d2..a290793992 100644 --- a/src/crates/execution/agent-runtime/AGENTS.md +++ b/src/crates/execution/agent-runtime/AGENTS.md @@ -115,6 +115,13 @@ so `autotests = false` cannot silently omit a new contract. ## Verification +For next-input suggestion eligibility, reasoning selection, plain-text output +without text post-processing, and cancellation: + +```bash +cargo test --locked -p openbitfun-agent-runtime --no-default-features --features agent-runtime --lib prompt_suggestion::tests +``` + Use the focused contract form by default. Run the package-wide form only when a change crosses several runtime targets: diff --git a/src/crates/execution/agent-runtime/src/lib.rs b/src/crates/execution/agent-runtime/src/lib.rs index c0a12bc25c..59ae31cf89 100644 --- a/src/crates/execution/agent-runtime/src/lib.rs +++ b/src/crates/execution/agent-runtime/src/lib.rs @@ -44,6 +44,8 @@ pub mod prompt_cache; #[cfg(feature = "agent-runtime")] pub mod prompt_markup; #[cfg(feature = "agent-runtime")] +pub mod prompt_suggestion; +#[cfg(feature = "agent-runtime")] pub mod remote_file_delivery; #[cfg(feature = "agent-runtime")] pub mod review_read_receipt; diff --git a/src/crates/execution/agent-runtime/src/prompt_suggestion.rs b/src/crates/execution/agent-runtime/src/prompt_suggestion.rs new file mode 100644 index 0000000000..4d77a17683 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/prompt_suggestion.rs @@ -0,0 +1,431 @@ +//! Next-input prompt suggestion policy. +//! +//! After an interactive turn completes, the host may ask the same model over +//! the same request prefix to predict the user's next message. This module +//! owns the provider-neutral facts: which turns qualify, the instruction that +//! is appended at the request tail, the plain-text response contract, +//! reasoning selection, and per-session cancellation of in-flight +//! requests. Provider calls and event emission stay in Product Assembly. + +use std::sync::Arc; + +use dashmap::DashMap; +use openbitfun_core_types::{ReasoningPresetAction, ReasoningPresetDescriptor, SessionKind}; +use openbitfun_runtime_ports::DialogTriggerSource; +use tokio_util::sync::CancellationToken; + +/// Execution-context flag set by the coordinator for turns whose submitter can +/// show a suggestion in a chat input. +pub const PROMPT_SUGGESTION_ELIGIBLE_CONTEXT_KEY: &str = "prompt_suggestion_eligible"; + +const PROMPT_SUGGESTION_INSTRUCTION: &str = "This is an app-generated request for a suggested next user message. \ +The user did not write this message. Your suggestion will appear in the user's empty message composer. \ +They can accept it, edit it, or ignore it; it will not be sent automatically. \ +Predict the single message this user is most likely to type next from the visible conversation. \ +Use the whole conversation to understand their current intent and the latest exchange. \ +Choose the action and specific object that best continue that intent, rather than a generally useful follow-up. \ +Express its core naturally in the user's voice and language, keeping the requirements needed to retain its meaning. \ +Choose the strongest grounded move even if several continuations are possible; uncertainty about exact wording or incidental details is not a reason to return null. \ +Use null only when the conversation offers no grounded next move at all. \ +Return exactly one natural user message as plain text, one line, at most 240 characters, or the literal null. \ +No JSON, wrapping quotes, alternatives, or explanation. Do not continue the task or call tools."; + +/// Instruction appended as the last user message of the suggestion request. +pub fn prompt_suggestion_instruction() -> &'static str { + PROMPT_SUGGESTION_INSTRUCTION +} + +/// Empty output and the literal null decline a suggestion. Preserve all other +/// model text verbatim; shape and length are guided only by the prompt. +pub fn prompt_suggestion_text(raw: String) -> Option { + match raw.trim() { + "" | "null" => None, + _ => Some(raw), + } +} + +/// Prefer disabled reasoning, then the lowest explicit effort or token budget. +/// Opaque custom presets use the catalog's declared order as the fallback. +pub fn lowest_prompt_suggestion_reasoning_preset( + presets: &[ReasoningPresetDescriptor], +) -> Option<&ReasoningPresetDescriptor> { + presets.iter().min_by_key(|preset| { + let rank = preset + .actions + .iter() + .filter_map(|action| match action { + ReasoningPresetAction::Toggle { enabled: false } => Some((0, 0)), + ReasoningPresetAction::Effort { value } => { + let effort = preset.effective_effort.as_deref().unwrap_or(value); + match effort { + "none" => Some((0, 0)), + "minimal" => Some((1, 0)), + "low" => Some((1, 1)), + "medium" => Some((1, 2)), + "high" => Some((1, 3)), + "xhigh" => Some((1, 4)), + "max" => Some((1, 5)), + "ultra" => Some((1, 6)), + _ => None, + } + } + ReasoningPresetAction::BudgetTokens { value } => Some((2, *value)), + _ => None, + }) + .min() + .unwrap_or((3, 0)); + (rank, preset.order) + }) +} + +/// Whether the submitter of a turn has a chat input that can show a suggestion. +/// +/// Only interactive desktop/peer surfaces and the mobile remote-control relay +/// qualify. Headless CLI, detached dispatch, scheduled jobs, bots, SDK hosts, +/// Agent-to-Agent messages, ACP clients, subagents, and `/btw` children do not. +pub fn prompt_suggestion_source_eligible( + trigger_source: DialogTriggerSource, + session_kind: SessionKind, + acp_transport: bool, +) -> bool { + !acp_transport + && session_kind == SessionKind::Standard + && matches!( + trigger_source, + DialogTriggerSource::DesktopUi | DialogTriggerSource::RemoteRelay + ) +} + +/// Terminal facts of a dialog turn relevant to offering a suggestion. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PromptSuggestionTurnOutcome<'a> { + pub finish_reason: &'a str, + pub has_final_response: bool, + pub used_local_final_response_synthesis: bool, + pub emits_lifecycle_events: bool, + pub is_subagent: bool, +} + +/// Only a turn that ended naturally with a model-authored final reply gets a +/// suggestion; forced finalization, steering, and truncation do not. +pub fn turn_outcome_allows_prompt_suggestion(outcome: PromptSuggestionTurnOutcome<'_>) -> bool { + outcome.finish_reason == "complete" + && outcome.has_final_response + && !outcome.used_local_final_response_synthesis + && outcome.emits_lifecycle_events + && !outcome.is_subagent +} + +#[derive(Debug)] +struct InflightPromptSuggestion { + turn_id: String, + token: CancellationToken, +} + +/// Per-session cancellation for in-flight suggestion requests. A newer turn +/// or suggestion on the same session supersedes the older request. +#[derive(Debug, Clone, Default)] +pub struct PromptSuggestionRuntime { + inflight: Arc>, +} + +impl PromptSuggestionRuntime { + pub fn new() -> Self { + Self::default() + } + + /// Register a request for `turn_id`, cancelling any older request on the session. + pub fn begin(&self, session_id: &str, turn_id: &str) -> CancellationToken { + let token = CancellationToken::new(); + if let Some(previous) = self.inflight.insert( + session_id.to_string(), + InflightPromptSuggestion { + turn_id: turn_id.to_string(), + token: token.clone(), + }, + ) { + previous.token.cancel(); + } + token + } + + /// Cancel the in-flight request of a session. Returns whether one existed. + pub fn cancel_session(&self, session_id: &str) -> bool { + match self.inflight.remove(session_id) { + Some((_, previous)) => { + previous.token.cancel(); + true + } + None => false, + } + } + + /// Release the registration of a finished request without touching a newer one. + pub fn finish(&self, session_id: &str, turn_id: &str) { + self.inflight + .remove_if(session_id, |_, inflight| inflight.turn_id == turn_id); + } + + pub fn is_inflight(&self, session_id: &str) -> bool { + self.inflight.contains_key(session_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_interactive_standard_sessions_are_eligible() { + assert!(prompt_suggestion_source_eligible( + DialogTriggerSource::DesktopUi, + SessionKind::Standard, + false + )); + assert!(prompt_suggestion_source_eligible( + DialogTriggerSource::RemoteRelay, + SessionKind::Standard, + false + )); + for source in [ + DialogTriggerSource::DesktopApi, + DialogTriggerSource::AgentSession, + DialogTriggerSource::ScheduledJob, + DialogTriggerSource::Bot, + DialogTriggerSource::Cli, + DialogTriggerSource::SdkHost, + ] { + assert!(!prompt_suggestion_source_eligible( + source, + SessionKind::Standard, + false + )); + } + assert!(!prompt_suggestion_source_eligible( + DialogTriggerSource::DesktopUi, + SessionKind::Subagent, + false + )); + assert!(!prompt_suggestion_source_eligible( + DialogTriggerSource::DesktopUi, + SessionKind::EphemeralChild, + false + )); + assert!(!prompt_suggestion_source_eligible( + DialogTriggerSource::DesktopUi, + SessionKind::Standard, + true + )); + } + + fn outcome(finish_reason: &str) -> PromptSuggestionTurnOutcome<'_> { + PromptSuggestionTurnOutcome { + finish_reason, + has_final_response: true, + used_local_final_response_synthesis: false, + emits_lifecycle_events: true, + is_subagent: false, + } + } + + #[test] + fn only_naturally_completed_turns_allow_a_suggestion() { + assert!(turn_outcome_allows_prompt_suggestion(outcome("complete"))); + for reason in [ + "max_rounds", + "repeated_tool_failures", + "user_steering", + "partial_truncated", + ] { + assert!(!turn_outcome_allows_prompt_suggestion(outcome(reason))); + } + assert!(!turn_outcome_allows_prompt_suggestion( + PromptSuggestionTurnOutcome { + has_final_response: false, + ..outcome("complete") + } + )); + assert!(!turn_outcome_allows_prompt_suggestion( + PromptSuggestionTurnOutcome { + used_local_final_response_synthesis: true, + ..outcome("complete") + } + )); + assert!(!turn_outcome_allows_prompt_suggestion( + PromptSuggestionTurnOutcome { + emits_lifecycle_events: false, + ..outcome("complete") + } + )); + assert!(!turn_outcome_allows_prompt_suggestion( + PromptSuggestionTurnOutcome { + is_subagent: true, + ..outcome("complete") + } + )); + } + + #[test] + fn plain_text_suggestion_preserves_the_model_text_verbatim() { + for text in [ + " Suggestion: \"Run the tests. Then commit.\" ".to_string(), + "我来运行测试。然后提交。\n不要裁剪这段文本。".to_string(), + "a".repeat(300), + "NONE".to_string(), + "null is the expected value".to_string(), + "{\"suggestion\":\"Run the tests\"}".to_string(), + ] { + assert_eq!(prompt_suggestion_text(text.clone()), Some(text)); + } + } + + #[test] + fn empty_output_and_literal_null_decline_a_suggestion() { + for raw in ["", " \n\t ", "null", " \nnull\t "] { + assert_eq!(prompt_suggestion_text(raw.to_string()), None, "{raw}"); + } + } + + fn preset( + id: &str, + order: i32, + actions: Vec, + ) -> ReasoningPresetDescriptor { + ReasoningPresetDescriptor { + id: id.to_string(), + label: id.to_string(), + order, + actions, + source: openbitfun_core_types::ReasoningPresetSource::ModelConfig, + effective_effort: None, + execution_provider: None, + execution_model: None, + } + } + + #[test] + fn lowest_reasoning_prefers_off_and_orders_efforts_by_strength() { + let mut presets = vec![ + preset( + "high", + 0, + vec![ReasoningPresetAction::Effort { + value: "high".into(), + }], + ), + preset( + "low", + 1, + vec![ReasoningPresetAction::Effort { + value: "low".into(), + }], + ), + preset( + "minimal", + 2, + vec![ReasoningPresetAction::Effort { + value: "minimal".into(), + }], + ), + ]; + assert_eq!( + lowest_prompt_suggestion_reasoning_preset(&presets) + .unwrap() + .id, + "minimal" + ); + presets.push(preset( + "off", + 3, + vec![ReasoningPresetAction::Toggle { enabled: false }], + )); + assert_eq!( + lowest_prompt_suggestion_reasoning_preset(&presets) + .unwrap() + .id, + "off" + ); + presets[3].actions = vec![ReasoningPresetAction::Effort { + value: "none".into(), + }]; + assert_eq!( + lowest_prompt_suggestion_reasoning_preset(&presets) + .unwrap() + .id, + "off" + ); + } + + #[test] + fn lowest_reasoning_uses_effective_effort_and_smallest_budget() { + let mut presets = vec![ + preset( + "alias", + 0, + vec![ReasoningPresetAction::Effort { + value: "low".into(), + }], + ), + preset( + "high", + 1, + vec![ReasoningPresetAction::Effort { + value: "high".into(), + }], + ), + ]; + presets[0].effective_effort = Some("max".into()); + assert_eq!( + lowest_prompt_suggestion_reasoning_preset(&presets) + .unwrap() + .id, + "high" + ); + let budgets = vec![ + preset( + "on", + 0, + vec![ReasoningPresetAction::Toggle { enabled: true }], + ), + preset( + "large", + 1, + vec![ReasoningPresetAction::BudgetTokens { value: 8192 }], + ), + preset( + "small", + 2, + vec![ReasoningPresetAction::BudgetTokens { value: 1024 }], + ), + ]; + assert_eq!( + lowest_prompt_suggestion_reasoning_preset(&budgets) + .unwrap() + .id, + "small" + ); + assert!(lowest_prompt_suggestion_reasoning_preset(&[]).is_none()); + } + + #[test] + fn runtime_supersedes_and_releases_requests_per_session() { + let runtime = PromptSuggestionRuntime::new(); + let first = runtime.begin("session-1", "turn-1"); + let second = runtime.begin("session-1", "turn-2"); + assert!(first.is_cancelled()); + assert!(!second.is_cancelled()); + + runtime.finish("session-1", "turn-1"); + assert!(runtime.is_inflight("session-1")); + + let other = runtime.begin("session-2", "turn-1"); + assert!(runtime.cancel_session("session-1")); + assert!(second.is_cancelled()); + assert!(!other.is_cancelled()); + assert!(!runtime.cancel_session("session-1")); + + runtime.finish("session-2", "turn-1"); + assert!(!runtime.is_inflight("session-2")); + assert!(!other.is_cancelled()); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/session_records.rs b/src/crates/services/services-integrations/src/remote_connect/session_records.rs index 6881ff3797..a9aa177067 100644 --- a/src/crates/services/services-integrations/src/remote_connect/session_records.rs +++ b/src/crates/services/services-integrations/src/remote_connect/session_records.rs @@ -23,6 +23,14 @@ pub fn session_event_publication(name: &str, payload: &Value) -> SessionEventPub persist_control: false, }; } + // A next-input suggestion is not transcript content; its source turn was + // already synchronized by the completion event that precedes it. + if name == "agentic://prompt-suggestion" { + return SessionEventPublication { + synchronize_records: false, + persist_control: true, + }; + } if name == "agentic://tool-event" { let kind = payload["toolEvent"]["event_type"].as_str().unwrap_or(""); return SessionEventPublication { @@ -153,4 +161,13 @@ mod publication_tests { .persist_control ); } + #[test] + fn prompt_suggestion_reaches_controllers_without_record_synchronization() { + let policy = session_event_publication( + "agentic://prompt-suggestion", + &json!({"sessionId":"session","sourceTurnId":"turn","suggestion":"Run the tests"}), + ); + assert!(!policy.synchronize_records); + assert!(policy.persist_control); + } } From f853b407cb8714f7b46df66e938460b8fc4926ff Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 14 Oct 2026 18:20:05 +0800 Subject: [PATCH 2/4] feat(chat): offer session suggestions in desktop and mobile web composers --- docs/interactive-capabilities/README.md | 12 +- .../capabilities.json | 91 +++++++++++- .../capabilities/setting.workspace.session.md | 5 + .../technical/product-control-open-audit.json | 2 +- .../technical/tauri-command-map.json | 2 +- .../generated/product-control-catalog.json | 98 ++++++++++++- .../src/product_control_owner_registry.rs | 6 + src/mobile-web/AGENTS.md | 3 +- src/mobile-web/package.json | 3 +- .../src/components/ChatComposerBar.tsx | 23 ++- src/mobile-web/src/i18n/messages.ts | 3 + src/mobile-web/src/pages/ChatPage.tsx | 17 +++ .../src/services/RemoteSessionManager.ts | 2 + .../src/services/SessionSynchronizer.ts | 37 ++++- .../src/styles/components/chat-input.scss | 13 ++ .../tests/fixtures/prompt-suggestion.tsx | 62 ++++++++ .../tests/prompt-suggestion-browser.test.mjs | 57 ++++++++ .../tests/prompt-suggestion.test.mjs | 135 ++++++++++++++++++ .../interactive-capabilities/catalog.json | 33 ++++- .../generated/interactive-capabilities.json | 91 +++++++++++- .../pages/ai/PromptSuggestionSection.test.tsx | 65 +++++++++ .../pages/ai/PromptSuggestionSection.tsx | 90 ++++++++++++ .../pages/ai/SessionMemorySettingsPage.tsx | 2 + .../shared/SettingsPageComposition.test.ts | 2 +- .../src/flow_chat/components/ChatInput.scss | 24 ++++ .../src/flow_chat/components/ChatInput.tsx | 58 +++++++- .../services/AgenticEventListener.test.ts | 8 ++ .../services/AgenticEventListener.ts | 13 ++ .../src/flow_chat/services/FlowChatManager.ts | 2 + .../EventHandlerModule.test.ts | 66 +++++++++ .../flow-chat-manager/EventHandlerModule.ts | 32 +++++ .../store/promptSuggestionStore.test.ts | 85 +++++++++++ .../flow_chat/store/promptSuggestionStore.ts | 80 +++++++++++ .../api/generated/productControl.ts | 4 +- .../api/service-api/AgentAPI.ts | 12 ++ .../AIExperienceConfigService.test.ts | 26 ++++ .../services/AIExperienceConfigService.ts | 5 + .../src/infrastructure/config/types/index.ts | 2 + src/web-ui/src/locales/en-US/flow-chat.json | 1 + .../src/locales/en-US/settings/models.json | 14 ++ src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + .../src/locales/zh-CN/settings/models.json | 14 ++ src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + .../src/locales/zh-TW/settings/models.json | 14 ++ tests/e2e/helpers/workspace-helper.ts | 1 + .../specs/l1-prompt-suggestion-live.spec.ts | 72 ++++++++++ 46 files changed, 1347 insertions(+), 42 deletions(-) create mode 100644 src/mobile-web/tests/fixtures/prompt-suggestion.tsx create mode 100644 src/mobile-web/tests/prompt-suggestion-browser.test.mjs create mode 100644 src/mobile-web/tests/prompt-suggestion.test.mjs create mode 100644 src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.test.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.tsx create mode 100644 src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts create mode 100644 src/web-ui/src/flow_chat/store/promptSuggestionStore.ts create mode 100644 tests/e2e/specs/l1-prompt-suggestion-live.spec.ts diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 51712ae6b8..7f68a0bed3 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**327 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**328 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 -OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **327** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **328** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -33,14 +33,14 @@ Docs, website, search, and agents see only features, settings, and documented su ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **49**、委托 **62**、需交互 **216**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **50**、委托 **62**、需交互 **216**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;智能体不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 327 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 328 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读智能体只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **49 direct**, **62 delegated**, **216 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **50 direct**, **62 delegated**, **216 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 327 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 328 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index eb885e40d5..fddc852c01 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825", - "ownerDigest": "c989ace53614c2a526fefe64816fdd6895ec71edcc28f44b6f80d1edb75bb33e", + "digest": "64a4a6c115b13f5aa15157d1affde2c7e901499e9941571a333d45bf242033ae", + "ownerDigest": "b96bf9292a92548facbd835b8b89c4865bfdd984b356c9001995f552c6a34f51", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -138,9 +138,9 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 327, + "documentedItems": 328, "controlCoverage": { - "direct": 49, + "direct": 50, "delegated": 62, "interactive": 216, "unsupported": 0 @@ -15007,6 +15007,7 @@ "default-agent-harness", "automatic-titles", "title-model", + "next-input-suggestion", "accelerated-search", "search-index", "remote-behavior" @@ -15094,6 +15095,50 @@ "pageId": "workspace.session" } }, + { + "id": "setting.workspace.session:option:prompt-suggestion", + "capabilityId": "setting.workspace.session", + "itemIds": [ + "next-input-suggestion" + ], + "kind": "option", + "risk": "write", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "boolean" + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "workspace.session" + } + }, { "id": "setting.workspace.session:option:workspace-search", "capabilityId": "setting.workspace.session", @@ -27504,13 +27549,15 @@ "跟随上次选择或固定默认执行框架", "自动生成会话标题", "本地工作区加速搜索", - "会话使用的标题模型" + "会话使用的标题模型", + "答复后的候选输入建议" ], "highlightsEn": [ "Follow the last selection or fix a default Harness", "Automatic session titles", "Accelerated local-workspace search", - "Model used for session titles" + "Model used for session titles", + "Next input suggestion after a reply" ], "items": [ { @@ -27549,6 +27596,20 @@ "reasonEn": "“Make title generation inherit the primary or fast model, or use a selected model” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "next-input-suggestion", + "titleZh": "启用或停用 Agent 答复后在输入框中显示的一句话候选输入", + "titleEn": "Enable or disable the one-sentence next input suggested in the input box after an Agent reply", + "control": { + "kind": "direct", + "operations": [], + "options": [ + { + "id": "prompt-suggestion" + } + ] + } + }, { "id": "accelerated-search", "titleZh": "为本地工作区启用 Flashgrep 加速搜索", @@ -27600,12 +27661,14 @@ "让新会话跟随 ChatInput 上一次选择的执行框架", "把新会话固定为 Ultimate 执行框架", "关闭自动生成会话标题", + "关闭答复后的候选输入建议", "启用工作区加速搜索" ], "agentExamplesEn": [ "Make new sessions follow the last Harness selected in ChatInput", "Fix Ultimate as the Harness for new sessions", "Turn off automatic session titles", + "Turn off next input suggestions after replies", "Enable accelerated workspace search" ], "destination": { @@ -27624,6 +27687,16 @@ "type": "boolean" } }, + { + "id": "prompt-suggestion", + "titleZh": "候选输入建议", + "titleEn": "Next input suggestion", + "descriptionZh": "Agent 答复完成后,在输入框中给出一句话的下一步输入建议。", + "descriptionEn": "Suggest a one-sentence next message in the input box after the Agent replies.", + "valueSchema": { + "type": "boolean" + } + }, { "id": "workspace-search", "titleZh": "工作区加速搜索", @@ -27659,16 +27732,20 @@ "自动生成会话标题", "本地工作区加速搜索", "会话使用的标题模型", + "答复后的候选输入建议", "Follow the last selection or fix a default Harness", "Automatic session titles", "Accelerated local-workspace search", "Model used for session titles", + "Next input suggestion after a reply", "让普通新会话跟随 ChatInput 上次选择,或固定使用指定执行框架", "Make regular new sessions follow the last ChatInput selection or use a fixed Harness", "启用或停用根据会话内容自动生成标题", "Enable or disable automatic titles generated from session content", "让标题模型继承主模型、快速模型或使用指定模型", "Make title generation inherit the primary or fast model, or use a selected model", + "启用或停用 Agent 答复后在输入框中显示的一句话候选输入", + "Enable or disable the one-sentence next input suggested in the input box after an Agent reply", "为本地工作区启用 Flashgrep 加速搜索", "Enable Flashgrep-backed accelerated search for local workspaces", "查看索引状态并在需要时重建工作区索引", @@ -27678,10 +27755,12 @@ "让新会话跟随 ChatInput 上一次选择的执行框架", "把新会话固定为 Ultimate 执行框架", "关闭自动生成会话标题", + "关闭答复后的候选输入建议", "启用工作区加速搜索", "Make new sessions follow the last Harness selected in ChatInput", "Fix Ultimate as the Harness for new sessions", "Turn off automatic session titles", + "Turn off next input suggestions after replies", "Enable accelerated workspace search" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.workspace.session/" diff --git a/docs/interactive-capabilities/capabilities/setting.workspace.session.md b/docs/interactive-capabilities/capabilities/setting.workspace.session.md index 964abc0413..73df9760db 100644 --- a/docs/interactive-capabilities/capabilities/setting.workspace.session.md +++ b/docs/interactive-capabilities/capabilities/setting.workspace.session.md @@ -23,6 +23,8 @@ Control the default Agent Harness, session-title generation, accelerated workspa - Enable or disable automatic titles generated from session content - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 让标题模型继承主模型、快速模型或使用指定模型 - Make title generation inherit the primary or fast model, or use a selected model +- **智能体可直接控制 / Direct Agent control** · 启用或停用 Agent 答复后在输入框中显示的一句话候选输入 + - Enable or disable the one-sentence next input suggested in the input box after an Agent reply - **智能体可直接控制 / Direct Agent control** · 为本地工作区启用 Flashgrep 加速搜索 - Enable Flashgrep-backed accelerated search for local workspaces - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 查看索引状态并在需要时重建工作区索引 @@ -52,6 +54,7 @@ Control the default Agent Harness, session-title generation, accelerated workspa | 选项 / Option | 可用值 / Values | 中文说明 | English description | | --- | --- | --- | --- | | 自动生成会话标题 / Automatic session titles | `boolean` | 根据会话内容生成易识别的标题。 | Generate a recognizable title from session content. | +| 候选输入建议 / Next input suggestion | `boolean` | Agent 答复完成后,在输入框中给出一句话的下一步输入建议。 | Suggest a one-sentence next message in the input box after the Agent replies. | | 工作区加速搜索 / Accelerated workspace search | `boolean` | 为本地项目启用 Flashgrep 加速搜索。 | Enable Flashgrep-backed search for local projects. | ## 可以直接对智能体说 / Try saying @@ -62,6 +65,8 @@ Control the default Agent Harness, session-title generation, accelerated workspa - “Fix Ultimate as the Harness for new sessions” - “关闭自动生成会话标题” - “Turn off automatic session titles” +- “关闭答复后的候选输入建议” + - “Turn off next input suggestions after replies” - “启用工作区加速搜索” - “Enable accelerated workspace search” diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 32b49113eb..22c8f91208 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825", + "catalogDigest": "64a4a6c115b13f5aa15157d1affde2c7e901499e9941571a333d45bf242033ae", "count": 216, "reasonCounts": { "externalAuth": 4, diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 64f50851c4..c61ee26b15 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825", + "catalogDigest": "64a4a6c115b13f5aa15157d1affde2c7e901499e9941571a333d45bf242033ae", "commandCount": 674, "coverage": { "commandCount": 674, diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 3c42b47440..1eab482603 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825", - "ownerDigest": "c989ace53614c2a526fefe64816fdd6895ec71edcc28f44b6f80d1edb75bb33e", + "digest": "64a4a6c115b13f5aa15157d1affde2c7e901499e9941571a333d45bf242033ae", + "ownerDigest": "b96bf9292a92548facbd835b8b89c4865bfdd984b356c9001995f552c6a34f51", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -138,9 +138,9 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 327, + "documentedItems": 328, "controlCoverage": { - "direct": 49, + "direct": 50, "delegated": 62, "interactive": 216, "unsupported": 0 @@ -15007,6 +15007,7 @@ "default-agent-harness", "automatic-titles", "title-model", + "next-input-suggestion", "accelerated-search", "search-index", "remote-behavior" @@ -15094,6 +15095,50 @@ "pageId": "workspace.session" } }, + { + "id": "setting.workspace.session:option:prompt-suggestion", + "capabilityId": "setting.workspace.session", + "itemIds": [ + "next-input-suggestion" + ], + "kind": "option", + "risk": "write", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "boolean" + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "workspace.session" + } + }, { "id": "setting.workspace.session:option:workspace-search", "capabilityId": "setting.workspace.session", @@ -27803,13 +27848,15 @@ "跟随上次选择或固定默认执行框架", "自动生成会话标题", "本地工作区加速搜索", - "会话使用的标题模型" + "会话使用的标题模型", + "答复后的候选输入建议" ], "highlightsEn": [ "Follow the last selection or fix a default Harness", "Automatic session titles", "Accelerated local-workspace search", - "Model used for session titles" + "Model used for session titles", + "Next input suggestion after a reply" ], "items": [ { @@ -27848,6 +27895,20 @@ "reasonEn": "“Make title generation inherit the primary or fast model, or use a selected model” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "next-input-suggestion", + "titleZh": "启用或停用 Agent 答复后在输入框中显示的一句话候选输入", + "titleEn": "Enable or disable the one-sentence next input suggested in the input box after an Agent reply", + "control": { + "kind": "direct", + "operations": [], + "options": [ + { + "id": "prompt-suggestion" + } + ] + } + }, { "id": "accelerated-search", "titleZh": "为本地工作区启用 Flashgrep 加速搜索", @@ -27899,12 +27960,14 @@ "让新会话跟随 ChatInput 上一次选择的执行框架", "把新会话固定为 Ultimate 执行框架", "关闭自动生成会话标题", + "关闭答复后的候选输入建议", "启用工作区加速搜索" ], "agentExamplesEn": [ "Make new sessions follow the last Harness selected in ChatInput", "Fix Ultimate as the Harness for new sessions", "Turn off automatic session titles", + "Turn off next input suggestions after replies", "Enable accelerated workspace search" ], "destination": { @@ -27930,6 +27993,23 @@ ] } }, + { + "id": "prompt-suggestion", + "titleZh": "候选输入建议", + "titleEn": "Next input suggestion", + "descriptionZh": "Agent 答复完成后,在输入框中给出一句话的下一步输入建议。", + "descriptionEn": "Suggest a one-sentence next message in the input box after the Agent replies.", + "valueSchema": { + "type": "boolean" + }, + "handler": { + "kind": "mergeConfig", + "path": "app.ai_experience", + "fields": [ + "enable_prompt_suggestion" + ] + } + }, { "id": "workspace-search", "titleZh": "工作区加速搜索", @@ -27972,16 +28052,20 @@ "自动生成会话标题", "本地工作区加速搜索", "会话使用的标题模型", + "答复后的候选输入建议", "Follow the last selection or fix a default Harness", "Automatic session titles", "Accelerated local-workspace search", "Model used for session titles", + "Next input suggestion after a reply", "让普通新会话跟随 ChatInput 上次选择,或固定使用指定执行框架", "Make regular new sessions follow the last ChatInput selection or use a fixed Harness", "启用或停用根据会话内容自动生成标题", "Enable or disable automatic titles generated from session content", "让标题模型继承主模型、快速模型或使用指定模型", "Make title generation inherit the primary or fast model, or use a selected model", + "启用或停用 Agent 答复后在输入框中显示的一句话候选输入", + "Enable or disable the one-sentence next input suggested in the input box after an Agent reply", "为本地工作区启用 Flashgrep 加速搜索", "Enable Flashgrep-backed accelerated search for local workspaces", "查看索引状态并在需要时重建工作区索引", @@ -27991,10 +28075,12 @@ "让新会话跟随 ChatInput 上一次选择的执行框架", "把新会话固定为 Ultimate 执行框架", "关闭自动生成会话标题", + "关闭答复后的候选输入建议", "启用工作区加速搜索", "Make new sessions follow the last Harness selected in ChatInput", "Fix Ultimate as the Harness for new sessions", "Turn off automatic session titles", + "Turn off next input suggestions after replies", "Enable accelerated workspace search" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.workspace.session/" diff --git a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs index 44113baf9e..332f3752ac 100644 --- a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs +++ b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs @@ -682,6 +682,12 @@ pub fn owner_definitions() -> Vec { boolean(), merge_config("app.ai_experience", &["enable_session_title_generation"]), ), + option( + "setting.workspace.session", + "prompt-suggestion", + boolean(), + merge_config("app.ai_experience", &["enable_prompt_suggestion"]), + ), option( "setting.workspace.session", "workspace-search", diff --git a/src/mobile-web/AGENTS.md b/src/mobile-web/AGENTS.md index 8e178621d3..1f6658db3d 100644 --- a/src/mobile-web/AGENTS.md +++ b/src/mobile-web/AGENTS.md @@ -55,7 +55,8 @@ pnpm --dir src/mobile-web run test:terminal-browser # real xterm keyboard, ANSI pnpm --dir src/mobile-web run test:ui-components pnpm --dir src/mobile-web run test:session-stream-browser # host-driven latest/backward pages, hints, restart gaps, no relay history routes pnpm --dir src/mobile-web run test:host-queue # idempotent outbox and real browser close/reopen -pnpm --dir src/mobile-web run test:host-stream # HostStream reader: hints, reconnects, host restart, unsupported hosts; catalog subscription +pnpm --dir src/mobile-web run test:host-stream # HostStream reader: hints, reconnects, host restart, unsupported hosts; catalog subscription; next-input suggestions +pnpm --dir src/mobile-web run test:prompt-suggestion-browser # Real composer and stream projection: compact/wide, original text, offline/sending gates, no automatic submission pnpm --dir src/mobile-web run test:account-login # account login without an online desktop pnpm --dir src/mobile-web run test:account-browser # real Chrome tabs, persistence, migration, races; simulated Relay pnpm --dir src/mobile-web run test:images # image preparation and upload limits diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 146660307b..838aa5757a 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -20,7 +20,8 @@ "build": "vite build", "preview": "vite preview", "test:host-queue": "node --test tests/host-dialog-queue.test.mjs tests/host-dialog-queue-browser.test.mjs", - "test:host-stream": "node --test tests/host-stream.test.mjs tests/host-catalog-subscription.test.mjs", + "test:host-stream": "node --test tests/host-stream.test.mjs tests/host-catalog-subscription.test.mjs tests/prompt-suggestion.test.mjs", + "test:prompt-suggestion-browser": "node --test tests/prompt-suggestion-browser.test.mjs", "test:session-stream-browser": "node --test tests/session-stream-browser.test.mjs", "test:runtime-files": "node --test tests/runtime-file-upload.test.mjs tests/runtime-file-download.test.mjs", "test:interaction-mailbox": "node --test tests/interaction-mailbox-browser.test.mjs", diff --git a/src/mobile-web/src/components/ChatComposerBar.tsx b/src/mobile-web/src/components/ChatComposerBar.tsx index ec6857a7e3..7fdfc3fc23 100644 --- a/src/mobile-web/src/components/ChatComposerBar.tsx +++ b/src/mobile-web/src/components/ChatComposerBar.tsx @@ -1,10 +1,11 @@ import { ArrowUp as LucideArrowUp, + CornerDownLeft as LucideCornerDownLeft, LoaderCircle as LucideLoaderCircle, Plus as LucidePlus, } from 'lucide-react'; import React from 'react'; -import { MobileComposer, MobileIconButton } from '@openbitfun/ui/mobile'; +import { MobileButton, MobileComposer, MobileIconButton } from '@openbitfun/ui/mobile'; import { useI18n } from '../i18n'; interface PendingImage { @@ -32,6 +33,8 @@ interface ChatComposerBarProps { onRemoveImage: (index: number) => void; onSend: () => void; pendingImages: PendingImage[]; + promptSuggestion?: string | null; + onAcceptPromptSuggestion?: (suggestion: string) => void; remoteUnavailable: boolean; streaming: boolean; } @@ -56,15 +59,33 @@ export default function ChatComposerBar({ onRemoveImage, onSend, pendingImages, + promptSuggestion, + onAcceptPromptSuggestion, remoteUnavailable, streaming, }: ChatComposerBarProps) { const { t } = useI18n(); const attachDisabled = imageAnalyzing || pendingImages.length >= 5; + const visibleSuggestion = promptSuggestion && onAcceptPromptSuggestion && !input && !streaming && !sending && !imageAnalyzing && !remoteUnavailable + ? promptSuggestion + : null; return (
{queueContent} + {visibleSuggestion ? ( + onAcceptPromptSuggestion?.(visibleSuggestion)} + size="sm" + trailing={ + ) : null} = { inputPlaceholder: 'How can I help you...', collapsedInputPlaceholder: 'Ask OpenBitFun', collapsedStreamingPlaceholder: 'Replying...', + promptSuggestionAccept: 'Use suggestion: {suggestion}', messageQueued: 'Message queued; it will run after the current step', imageAnalyzingPlaceholder: 'Sending images...', imageAttachmentFallback: '(see attached images)', @@ -621,6 +622,7 @@ export const messages: Record = { inputPlaceholder: '有什么可以帮您...', collapsedInputPlaceholder: '向 OpenBitFun 提问', collapsedStreamingPlaceholder: '正在回复...', + promptSuggestionAccept: '采纳建议:{suggestion}', messageQueued: '已加入队列,将在当前步骤结束后处理', imageAnalyzingPlaceholder: '正在发送图片...', imageAttachmentFallback: '(见附带图片)', @@ -986,6 +988,7 @@ export const messages: Record = { inputPlaceholder: '有什麼可以幫您...', collapsedInputPlaceholder: '向 OpenBitFun 提問', collapsedStreamingPlaceholder: '正在回覆...', + promptSuggestionAccept: '採納建議:{suggestion}', messageQueued: '已加入隊列,將在當前步驟結束後處理', imageAnalyzingPlaceholder: '正在傳送圖片...', imageAttachmentFallback: '(見附帶圖片)', diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 85da746b76..09a521d7ab 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -94,6 +94,7 @@ const ChatPage: React.FC = ({ const messages = getMessages(sessionId); const [input, setInput] = useState(''); + const [promptSuggestion, setPromptSuggestion] = useState(null); const [liveTitle, setLiveTitle] = useState(sessionName); const [modelCatalog, setModelCatalog] = useState(null); const [selectedModelId, setSelectedModelId] = useState('auto'); @@ -878,6 +879,7 @@ const ChatPage: React.FC = ({ resp.model_catalog, )); } + if (resp.prompt_suggestion !== undefined) setPromptSuggestion(resp.prompt_suggestion); setActiveTurn(resp.active_turn ?? null); }, initialCatalog?.version || 0, history => { if(isInitCurrent()){setHasMore(history.hasMore);hasMoreRef.current=history.hasMore;} }, () => { if(isInitCurrent())setMailboxInvalidation(value=>value+1); }, // Stream failures are stated, not hidden: an older host or a lost @@ -901,6 +903,7 @@ const ChatPage: React.FC = ({ streamRef.current?.stop(); streamRef.current = null; setActiveTurn(null); + setPromptSuggestion(null); }; }, [ appendNewMessages, @@ -1020,6 +1023,7 @@ const ChatPage: React.FC = ({ // remain retryable, and an acknowledgement must not erase newer typing. const draftUnchanged = inputRef.current?.value === input; setInput(current => current === input ? '' : current); + setPromptSuggestion(null); setPendingImages(current => current.filter(image => !imgs.includes(image))); if (!wasStreaming && draftUnchanged) setInputExpanded(false); streamRef.current?.nudge(); @@ -1074,6 +1078,17 @@ const ChatPage: React.FC = ({ requestAnimationFrame(() => inputRef.current?.focus()); }, []); + const acceptPromptSuggestion = useCallback((suggestion: string) => { + setInput(suggestion); + setInputExpanded(true); + requestAnimationFrame(() => { + const editor = inputRef.current; + if (!editor) return; + editor.focus(); + editor.setSelectionRange(editor.value.length, editor.value.length); + }); + }, []); + useEffect(() => { if (autoFocus) { requestAnimationFrame(() => inputRef.current?.focus()); @@ -1300,6 +1315,8 @@ const ChatPage: React.FC = ({ onRemoveImage={removeImage} onSend={handleSend} pendingImages={pendingImages} + promptSuggestion={promptSuggestion} + onAcceptPromptSuggestion={acceptPromptSuggestion} streaming={isStreaming} /> diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts index c2f80387fe..966d43912d 100644 --- a/src/mobile-web/src/services/RemoteSessionManager.ts +++ b/src/mobile-web/src/services/RemoteSessionManager.ts @@ -271,6 +271,8 @@ export interface PollResponse { message_snapshot?: ChatMessage[]; active_turn?: ActiveTurnSnapshot | null; model_catalog?: RemoteModelCatalog; + /** Next-input suggestion for the settled latest turn; `null` retires it, absence keeps it. */ + prompt_suggestion?: string | null; } export interface InitialSyncData { diff --git a/src/mobile-web/src/services/SessionSynchronizer.ts b/src/mobile-web/src/services/SessionSynchronizer.ts index d0cf2c1a0f..2792052520 100644 --- a/src/mobile-web/src/services/SessionSynchronizer.ts +++ b/src/mobile-web/src/services/SessionSynchronizer.ts @@ -15,7 +15,8 @@ export class SessionSynchronizer { private revision = 0; private replica: SessionRecordReplica; private readonly controls = new Map(); - private readonly turns = new Map(); + private readonly turns = new Map(); + private promptSuggestion: {turnId:string;text:string}|null = null; constructor(private readonly sessionMgr: RemoteSessionManager, private readonly sessionId: string, private readonly onUpdate: (state: PollResponse) => void, _knownModelCatalogVersion = 0, private readonly onHistoryState?: (state: SessionHistoryState) => void, private readonly onControlInvalidated?: () => void, private readonly onError?: (error: unknown) => void) { this.replica = new SessionRecordReplica(sessionId); } start(_initialMsgCount=0): void {this.stopped=false;void this.connect();} @@ -35,12 +36,26 @@ export class SessionSynchronizer { for(const control of controls)if(!items.some(item=>item.tool?.id===control.id))items.push({type:'tool',tool:control}); this.onUpdate({resp:'session_update',changed:true,version:++this.revision, message_snapshot:active?messages.slice(0,-1):messages, - active_turn:active?{turn_id:active.turn_id!,status:'active',text:active.content,thinking:active.thinking??'',items,tools,round_index:0}:null}); + active_turn:active?{turn_id:active.turn_id!,status:'active',text:active.content,thinking:active.thinking??'',items,tools,round_index:0}:null, + prompt_suggestion:this.currentPromptSuggestion()}); + } + private latestTurn():{id:string;index:number;status:string}|null { + let latest:{id:string;index:number;status:string}|null=null; + for(const [id,turn] of this.turns)if(!latest||turn.index>latest.index)latest={id,index:turn.index,status:turn.status}; + return latest; + } + /** A suggestion only answers the turn it was produced for; any newer turn retires it. */ + private currentPromptSuggestion():string|null { + const suggestion=this.promptSuggestion; + if(!suggestion)return null; + const latest=this.latestTurn(); + if(latest?.id!==suggestion.turnId||latest.status!=='completed'){this.promptSuggestion=null;return null;} + return suggestion.text; } /** Host restarts renumber records; derived state is rebuilt from the replayed page. */ private resetDerivedState():void { this.replica=new SessionRecordReplica(this.sessionId); - this.turns.clear();this.controls.clear(); + this.turns.clear();this.controls.clear();this.promptSuggestion=null; } private async connect():Promise { if(this.stopped||this.connecting||this.unsubscribe)return; @@ -55,7 +70,7 @@ export class SessionSynchronizer { if(change.turn) { const turn=change.turn as unknown as MobileStoredTurn; const messages=presentSessionTurn(turn); - this.turns.set(change.turnId,{index:turn.turnIndex,messages}); + this.turns.set(change.turnId,{index:turn.turnIndex,status:turn.status,messages}); for(const message of messages)for(const tool of message.tools??[])if(['completed','failed','cancelled','rejected','skipped'].includes(tool.status))this.controls.delete(tool.id); if(turn.status!=='inprogress')for(const [id,control] of this.controls)if(control.turnId===change.turnId)this.controls.delete(id); } else this.turns.delete(change.turnId); @@ -71,13 +86,25 @@ export class SessionSynchronizer { else if(['Confirmed','Completed','Failed','Cancelled','Rejected'].includes(tool.event_type??''))this.controls.delete(tool.tool_id); if(this.ready)this.publish(); } + } else if(event.event==='agentic://prompt-suggestion') { + const payload=event.payload as {sessionId?:unknown;sourceTurnId?:unknown;suggestion?:unknown}|null; + const text=typeof payload?.suggestion==='string'?payload.suggestion:''; + const latest=this.latestTurn(); + if(payload&&(payload.sessionId===undefined||payload.sessionId===this.sessionId)&& + typeof payload.sourceTurnId==='string'&&payload.sourceTurnId&&text&& + (!latest||latest.id===payload.sourceTurnId)){ + this.promptSuggestion={turnId:payload.sourceTurnId,text}; + if(this.ready)this.publish(); + } } else if(event.event==='session_title_generated') { this.onUpdate({resp:'session_update',changed:true,version:++this.revision,title:String((event.payload as {title?:string}).title??'')}); } },onError:error=>{console.error('[SessionSync] recovery failed',error);if(!this.stopped)this.onError?.(error);},onCaughtUp:()=>{ if(!this.stopped){this.ready=true;this.publish();} },onHistoryState:this.onHistoryState,onResumed:this.onControlInvalidated, - onGap:()=>{if(!this.stopped){this.resetDerivedState();this.onControlInvalidated?.();}}}); + onGap:()=>{if(!this.stopped){this.resetDerivedState(); + this.onUpdate({resp:'session_update',changed:true,version:++this.revision,prompt_suggestion:null}); + this.onControlInvalidated?.();}}}); if(this.stopped)unsubscribe.close();else{this.unsubscribe=unsubscribe;this.retryDelay=1000;} }catch(error){ if(this.stopped)return; diff --git a/src/mobile-web/src/styles/components/chat-input.scss b/src/mobile-web/src/styles/components/chat-input.scss index b903a26580..8771d9d0ac 100644 --- a/src/mobile-web/src/styles/components/chat-input.scss +++ b/src/mobile-web/src/styles/components/chat-input.scss @@ -79,6 +79,19 @@ text-overflow: ellipsis; } +.chat-page__prompt-suggestion { + align-self: flex-start; + max-width: 100%; + min-width: 0; + + [data-openbitfun-part="label"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + // ── Model and reasoning controls ───────────────────────────────────────── .chat-model-selector { diff --git a/src/mobile-web/tests/fixtures/prompt-suggestion.tsx b/src/mobile-web/tests/fixtures/prompt-suggestion.tsx new file mode 100644 index 0000000000..e808f8ca2d --- /dev/null +++ b/src/mobile-web/tests/fixtures/prompt-suggestion.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import '../../src/styles/reset.scss'; +import '@openbitfun/theme-openbitfun/default.css'; +import '@openbitfun/ui/mobile.css'; +import '../../src/styles/index.scss'; +import ChatComposerBar from '../../src/components/ChatComposerBar'; +import { I18nProvider } from '../../src/i18n'; +import { ThemeProvider } from '../../src/theme'; +import { SessionSynchronizer } from '../../src/services/SessionSynchronizer'; +import type { RemoteSessionManager } from '../../src/services/RemoteSessionManager'; + +/** Production stream projection and composer; only host IO is simulated. */ +export function mountPromptSuggestionFixture() { + const element = document.createElement('main'); + document.body.replaceChildren(element); + type StreamHandlers = Parameters[1]; + let handlers: StreamHandlers; + let setOffline: (value: boolean) => void; + let setSending: (value: boolean) => void; + const calls: string[] = []; + const manager = { subscribeSessionStream: async (_id: string, next: StreamHandlers) => { + handlers = next; + return { close() {}, wake() {}, loadOlder: async () => {} }; + } } as unknown as RemoteSessionManager; + const root = createRoot(element); + function Fixture() { + const inputRef = useRef(null); + const [input, setInput] = useState(''); + const [expanded, setExpanded] = useState(false); + const [suggestion, setSuggestion] = useState(null); + const [streaming, setStreaming] = useState(false); + const [offline, updateOffline] = useState(false); + const [sending, updateSending] = useState(false); + setOffline = updateOffline; + setSending = updateSending; + useEffect(() => { + const stream = new SessionSynchronizer(manager, 'session', update => { + setSuggestion(update.prompt_suggestion ?? null); + setStreaming(Boolean(update.active_turn)); + }); + stream.start(); + return () => stream.stop(); + }, []); + const noop = () => {}; + return
+ setExpanded(true)} onAttach={noop} onCancel={noop} onChange={setInput} + onCompositionEnd={noop} onCompositionStart={noop} onKeyDown={noop} onRemoveImage={noop} + onSend={() => calls.push('send')} pendingImages={[]} remoteUnavailable={offline} streaming={streaming} + promptSuggestion={suggestion} onAcceptPromptSuggestion={value => { + calls.push(value); setInput(value); setExpanded(true); + requestAnimationFrame(() => inputRef.current?.focus()); + }} /> +
; + } + root.render(); + return { calls, ready: () => Boolean(handlers), event: (event: Parameters[0]) => handlers.onEvent(event), + caughtUp: () => handlers.onCaughtUp?.(), gap: () => handlers.onGap?.('fixture restart'), + offline: (value: boolean) => setOffline(value), sending: (value: boolean) => setSending(value), dispose: () => root.unmount() }; +} diff --git a/src/mobile-web/tests/prompt-suggestion-browser.test.mjs b/src/mobile-web/tests/prompt-suggestion-browser.test.mjs new file mode 100644 index 0000000000..de3f880ecb --- /dev/null +++ b/src/mobile-web/tests/prompt-suggestion-browser.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { launchBrowser, startSourceServer } from './helpers/browser-account-harness.mjs'; + +test('suggestion stream and composer preserve text across compact and wide layouts without submitting', { timeout: 60_000 }, async () => { + const server = await startSourceServer(); + const browser = await launchBrowser(); + try { + const page = await browser.newPage(); + const selector = '[data-testid="chat-prompt-suggestion"]'; + const text = ' Review the tests\nand explain what still needs fixing '; + for (const width of [390, 1200]) { + await page.setViewport({ width, height: 844 }); + await page.goto(server.origin); + await page.evaluate(async () => { + const { mountPromptSuggestionFixture } = await import('/tests/fixtures/prompt-suggestion.tsx'); + window.suggestions = mountPromptSuggestionFixture(); + }); + await page.waitForFunction(() => window.suggestions.ready()); + await page.evaluate(text => { + const fixture = window.suggestions; + fixture.event({ event: 'session-record', session_id: 'session', payload: { + sessionId: 'session', id: 'turn/first', revision: 1, + turn: { turnId: 'first', sessionId: 'session', turnIndex: 0, status: 'completed', timestamp: 1, userMessage: { id: 'user-1', content: 'Review', timestamp: 1 } }, + } }); + fixture.event({ event: 'agentic://prompt-suggestion', session_id: 'session', + payload: { sessionId: 'session', sourceTurnId: 'first', suggestion: text } }); + fixture.caughtUp(); + }, text); + await page.waitForSelector(selector); + assert.equal(await page.$eval(selector, node => node.textContent), text); + assert.equal(await page.$eval('body', node => node.scrollWidth <= innerWidth), true); + for (const gate of ['offline', 'sending']) { + await page.evaluate(gate => window.suggestions[gate](true), gate); + await page.waitForSelector(selector, { hidden: true }); + await page.evaluate(gate => window.suggestions[gate](false), gate); + await page.waitForSelector(selector); + } + await page.screenshot({ path: `/tmp/mobile-prompt-suggestion-${width}.png` }); + await page.click(selector); + await page.waitForFunction(text => document.activeElement?.value === text, {}, text); + assert.deepEqual(await page.evaluate(() => window.suggestions.calls), [text]); + await page.waitForSelector(selector, { hidden: true }); + await page.$eval('textarea', node => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(node, ''); + node.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.waitForSelector(selector); + await page.evaluate(() => window.suggestions.event({ event: 'session-record', session_id: 'session', payload: { + sessionId: 'session', id: 'turn/second', revision: 2, + turn: { turnId: 'second', sessionId: 'session', turnIndex: 1, status: 'inprogress', timestamp: 2, userMessage: { id: 'user-2', content: 'Continue', timestamp: 2 } }, + } })); + await page.waitForSelector(selector, { hidden: true }); + assert.deepEqual(await page.evaluate(() => window.suggestions.calls), [text]); + } + } finally { await browser.close(); await server.close(); } +}); diff --git a/src/mobile-web/tests/prompt-suggestion.test.mjs b/src/mobile-web/tests/prompt-suggestion.test.mjs new file mode 100644 index 0000000000..50a75087a3 --- /dev/null +++ b/src/mobile-web/tests/prompt-suggestion.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import ts from 'typescript'; + +async function moduleUrl(relative, imports = {}) { + let transformed = ts.transpileModule(await readFile(new URL(relative, import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 }, + }).outputText; + transformed = transformed.replace(/from (['"])([^'"]+)\1/g, (_, quote, specifier) => { + assert.ok(imports[specifier], `unexpected dependency ${specifier}`); + return `from ${JSON.stringify(imports[specifier])}`; + }); + return `data:text/javascript;base64,${Buffer.from(transformed).toString('base64')}`; +} + +const hostStream = 'data:text/javascript,export const UNSUPPORTED_HOST_MESSAGE = "unsupported";'; +const replica = await moduleUrl('../../shared/relay-transport/SessionRecordReplica.ts'); +const presentation = await moduleUrl('../src/services/SessionRecordPresentation.ts'); +const { SessionSynchronizer } = await import(await moduleUrl('../src/services/SessionSynchronizer.ts', { + '../../../shared/relay-transport/HostStream': hostStream, + '../../../shared/relay-transport/SessionRecordReplica': replica, + './SessionRecordPresentation': presentation, +})); + +const SESSION = 'session-1'; +const turnRecord = (turnId, turnIndex, status, revision) => ({ + event: 'session-record', + session_id: SESSION, + payload: { + sessionId: SESSION, id: `turn/${turnId}`, revision, + turn: { + turnId, sessionId: SESSION, turnIndex, status, timestamp: turnIndex, + userMessage: { id: `${turnId}-user`, content: `request ${turnIndex}`, timestamp: turnIndex }, + }, + }, +}); +const suggestion = (sourceTurnId, text) => ({ + event: 'agentic://prompt-suggestion', + session_id: SESSION, + payload: { sessionId: SESSION, sourceTurnId, suggestion: text }, +}); + +async function connect() { + const updates = []; + let handlers; + const manager = { + subscribeSessionStream: async (_sessionId, next) => { + handlers = next; + return { close() {}, wake() {}, loadOlder: async () => {} }; + }, + }; + const synchronizer = new SessionSynchronizer(manager, SESSION, update => updates.push(update)); + synchronizer.start(); + while (!handlers) await new Promise(resolve => setImmediate(resolve)); + return { updates, handlers, synchronizer, latest: () => updates.at(-1) }; +} + +test('a suggestion for the settled latest turn reaches the composer unchanged', async () => { + const { handlers, latest, synchronizer } = await connect(); + handlers.onEvent(turnRecord('turn-1', 0, 'completed', 1)); + handlers.onCaughtUp(); + assert.equal(latest().prompt_suggestion, null); + + handlers.onEvent(suggestion('turn-1', ' Run the tests ')); + assert.equal(latest().prompt_suggestion, ' Run the tests '); + synchronizer.stop(); +}); + +test('a newer turn retires the suggestion and a stale one is never shown', async () => { + const { handlers, latest, synchronizer } = await connect(); + handlers.onEvent(turnRecord('turn-1', 0, 'completed', 1)); + handlers.onCaughtUp(); + handlers.onEvent(suggestion('turn-1', 'Commit the change')); + assert.equal(latest().prompt_suggestion, 'Commit the change'); + + handlers.onEvent(turnRecord('turn-2', 1, 'inprogress', 2)); + assert.equal(latest().prompt_suggestion, null); + assert.ok(latest().active_turn); + + handlers.onEvent(turnRecord('turn-2', 1, 'completed', 3)); + assert.equal(latest().prompt_suggestion, null); + handlers.onEvent(suggestion('turn-1', 'Old idea')); + assert.equal(latest().prompt_suggestion, null); + synchronizer.stop(); +}); + +test('a suggestion replayed during catch-up appears once the history is ready', async () => { + const { handlers, updates, latest, synchronizer } = await connect(); + handlers.onEvent(turnRecord('turn-1', 0, 'completed', 1)); + handlers.onEvent(suggestion('turn-1', 'Open a pull request')); + assert.equal(updates.length, 0); + handlers.onCaughtUp(); + assert.equal(latest().prompt_suggestion, 'Open a pull request'); + + handlers.onGap(); + assert.equal(latest().prompt_suggestion, null); + handlers.onEvent(turnRecord('turn-1', 0, 'completed', 1)); + handlers.onCaughtUp(); + assert.equal(latest().prompt_suggestion, null); + synchronizer.stop(); +}); + +test('old history hints and malformed or foreign payloads cannot replace the current hint', async () => { + const { handlers, latest, synchronizer } = await connect(); + handlers.onEvent(turnRecord('turn-2', 1, 'completed', 2)); + handlers.onCaughtUp(); + handlers.onEvent(suggestion('turn-2', 'Current idea')); + handlers.onEvent(turnRecord('turn-1', 0, 'completed', 1)); + for (const event of [ + suggestion('turn-1', 'Old replay'), + { ...suggestion('turn-2', 'Foreign'), payload: { sessionId: 'other', sourceTurnId: 'turn-2', suggestion: 'Foreign' } }, + { ...suggestion('turn-2', 'Invalid'), payload: null }, + ]) { + handlers.onEvent(event); + assert.equal(latest().prompt_suggestion, 'Current idea'); + } + synchronizer.stop(); +}); + +test('failed or resumed turns retire hints and deleting a newer turn does not revive them', async () => { + const { handlers, latest, synchronizer } = await connect(); + handlers.onEvent(turnRecord('turn-1', 0, 'completed', 1)); + handlers.onCaughtUp(); + handlers.onEvent(suggestion('turn-1', 'Old idea')); + handlers.onEvent(turnRecord('turn-2', 1, 'inprogress', 2)); + assert.equal(latest().prompt_suggestion, null); + handlers.onEvent({ event: 'session-record', session_id: SESSION, + payload: { sessionId: SESSION, id: 'turn/turn-2', revision: 3, deleted: true } }); + assert.equal(latest().prompt_suggestion, null); + handlers.onEvent(turnRecord('turn-1', 0, 'error', 4)); + handlers.onEvent(suggestion('turn-1', 'Failed turn')); + assert.equal(latest().prompt_suggestion, null); + synchronizer.stop(); +}); diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 1c9943ac0e..48ec191ddd 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -7153,13 +7153,15 @@ "跟随上次选择或固定默认执行框架", "自动生成会话标题", "本地工作区加速搜索", - "会话使用的标题模型" + "会话使用的标题模型", + "答复后的候选输入建议" ], "highlightsEn": [ "Follow the last selection or fix a default Harness", "Automatic session titles", "Accelerated local-workspace search", - "Model used for session titles" + "Model used for session titles", + "Next input suggestion after a reply" ], "items": [ { @@ -7209,6 +7211,24 @@ "source:src/web-ui/src/locales/zh-CN/settings/models.json#sessionTitle.model.label" ] }, + { + "id": "next-input-suggestion", + "titleZh": "启用或停用 Agent 答复后在输入框中显示的一句话候选输入", + "titleEn": "Enable or disable the one-sentence next input suggested in the input box after an Agent reply", + "control": { + "kind": "direct", + "operations": [], + "options": [ + { + "id": "prompt-suggestion" + } + ] + }, + "evidence": [ + "source:src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.tsx#PromptSuggestionSection", + "source:src/web-ui/src/locales/zh-CN/settings/models.json#promptSuggestion.title" + ] + }, { "id": "accelerated-search", "titleZh": "为本地工作区启用 Flashgrep 加速搜索", @@ -7272,12 +7292,14 @@ "让新会话跟随 ChatInput 上一次选择的执行框架", "把新会话固定为 Ultimate 执行框架", "关闭自动生成会话标题", + "关闭答复后的候选输入建议", "启用工作区加速搜索" ], "agentExamplesEn": [ "Make new sessions follow the last Harness selected in ChatInput", "Fix Ultimate as the Harness for new sessions", "Turn off automatic session titles", + "Turn off next input suggestions after replies", "Enable accelerated workspace search" ], "destination": { @@ -7293,6 +7315,13 @@ "descriptionZh": "根据会话内容生成易识别的标题。", "descriptionEn": "Generate a recognizable title from session content." }, + { + "id": "prompt-suggestion", + "titleZh": "候选输入建议", + "titleEn": "Next input suggestion", + "descriptionZh": "Agent 答复完成后,在输入框中给出一句话的下一步输入建议。", + "descriptionEn": "Suggest a one-sentence next message in the input box after the Agent replies." + }, { "id": "workspace-search", "titleZh": "工作区加速搜索", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index eb885e40d5..fddc852c01 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825", - "ownerDigest": "c989ace53614c2a526fefe64816fdd6895ec71edcc28f44b6f80d1edb75bb33e", + "digest": "64a4a6c115b13f5aa15157d1affde2c7e901499e9941571a333d45bf242033ae", + "ownerDigest": "b96bf9292a92548facbd835b8b89c4865bfdd984b356c9001995f552c6a34f51", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -138,9 +138,9 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 327, + "documentedItems": 328, "controlCoverage": { - "direct": 49, + "direct": 50, "delegated": 62, "interactive": 216, "unsupported": 0 @@ -15007,6 +15007,7 @@ "default-agent-harness", "automatic-titles", "title-model", + "next-input-suggestion", "accelerated-search", "search-index", "remote-behavior" @@ -15094,6 +15095,50 @@ "pageId": "workspace.session" } }, + { + "id": "setting.workspace.session:option:prompt-suggestion", + "capabilityId": "setting.workspace.session", + "itemIds": [ + "next-input-suggestion" + ], + "kind": "option", + "risk": "write", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "boolean" + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "workspace.session" + } + }, { "id": "setting.workspace.session:option:workspace-search", "capabilityId": "setting.workspace.session", @@ -27504,13 +27549,15 @@ "跟随上次选择或固定默认执行框架", "自动生成会话标题", "本地工作区加速搜索", - "会话使用的标题模型" + "会话使用的标题模型", + "答复后的候选输入建议" ], "highlightsEn": [ "Follow the last selection or fix a default Harness", "Automatic session titles", "Accelerated local-workspace search", - "Model used for session titles" + "Model used for session titles", + "Next input suggestion after a reply" ], "items": [ { @@ -27549,6 +27596,20 @@ "reasonEn": "“Make title generation inherit the primary or fast model, or use a selected model” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "next-input-suggestion", + "titleZh": "启用或停用 Agent 答复后在输入框中显示的一句话候选输入", + "titleEn": "Enable or disable the one-sentence next input suggested in the input box after an Agent reply", + "control": { + "kind": "direct", + "operations": [], + "options": [ + { + "id": "prompt-suggestion" + } + ] + } + }, { "id": "accelerated-search", "titleZh": "为本地工作区启用 Flashgrep 加速搜索", @@ -27600,12 +27661,14 @@ "让新会话跟随 ChatInput 上一次选择的执行框架", "把新会话固定为 Ultimate 执行框架", "关闭自动生成会话标题", + "关闭答复后的候选输入建议", "启用工作区加速搜索" ], "agentExamplesEn": [ "Make new sessions follow the last Harness selected in ChatInput", "Fix Ultimate as the Harness for new sessions", "Turn off automatic session titles", + "Turn off next input suggestions after replies", "Enable accelerated workspace search" ], "destination": { @@ -27624,6 +27687,16 @@ "type": "boolean" } }, + { + "id": "prompt-suggestion", + "titleZh": "候选输入建议", + "titleEn": "Next input suggestion", + "descriptionZh": "Agent 答复完成后,在输入框中给出一句话的下一步输入建议。", + "descriptionEn": "Suggest a one-sentence next message in the input box after the Agent replies.", + "valueSchema": { + "type": "boolean" + } + }, { "id": "workspace-search", "titleZh": "工作区加速搜索", @@ -27659,16 +27732,20 @@ "自动生成会话标题", "本地工作区加速搜索", "会话使用的标题模型", + "答复后的候选输入建议", "Follow the last selection or fix a default Harness", "Automatic session titles", "Accelerated local-workspace search", "Model used for session titles", + "Next input suggestion after a reply", "让普通新会话跟随 ChatInput 上次选择,或固定使用指定执行框架", "Make regular new sessions follow the last ChatInput selection or use a fixed Harness", "启用或停用根据会话内容自动生成标题", "Enable or disable automatic titles generated from session content", "让标题模型继承主模型、快速模型或使用指定模型", "Make title generation inherit the primary or fast model, or use a selected model", + "启用或停用 Agent 答复后在输入框中显示的一句话候选输入", + "Enable or disable the one-sentence next input suggested in the input box after an Agent reply", "为本地工作区启用 Flashgrep 加速搜索", "Enable Flashgrep-backed accelerated search for local workspaces", "查看索引状态并在需要时重建工作区索引", @@ -27678,10 +27755,12 @@ "让新会话跟随 ChatInput 上一次选择的执行框架", "把新会话固定为 Ultimate 执行框架", "关闭自动生成会话标题", + "关闭答复后的候选输入建议", "启用工作区加速搜索", "Make new sessions follow the last Harness selected in ChatInput", "Fix Ultimate as the Harness for new sessions", "Turn off automatic session titles", + "Turn off next input suggestions after replies", "Enable accelerated workspace search" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.workspace.session/" diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.test.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.test.tsx new file mode 100644 index 0000000000..411f8b3a49 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import PromptSuggestionSection from './PromptSuggestionSection'; + +const mocks = vi.hoisted(() => ({ get: vi.fn(), save: vi.fn(), error: vi.fn(), success: vi.fn() })); +vi.mock('@/infrastructure/config/services/AIExperienceConfigService', () => ({ + aiExperienceConfigService: { + getCachedSettings: () => null, + getSettingsAsync: mocks.get, + saveSettings: mocks.save, + addChangeListener: () => () => undefined, + }, +})); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ error: mocks.error, success: mocks.success }) })); + +let container: HTMLDivElement; +let root: Root; +const toggle = () => container.querySelector('input[type="checkbox"]')!; + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.clearAllMocks(); + mocks.get.mockResolvedValue({ enable_prompt_suggestion: true }); + mocks.save.mockResolvedValue(undefined); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); + +describe('Prompt suggestion settings', () => { + it('keeps older hosts without the optional setting explicitly unsupported', async () => { + mocks.get.mockResolvedValue({}); + await act(async () => root.render()); + expect(toggle().disabled).toBe(true); + expect(container.textContent).toContain('promptSuggestion.unsupported'); + expect(mocks.save).not.toHaveBeenCalled(); + }); + + it('saves only the prompt suggestion field and keeps the previous value on failure', async () => { + await act(async () => root.render()); + expect(toggle().checked).toBe(true); + mocks.save.mockRejectedValueOnce(new Error('Host unavailable')); + await act(async () => toggle().click()); + expect(toggle().checked).toBe(true); + expect(mocks.error).toHaveBeenCalledWith('promptSuggestion.messages.saveFailed'); + await act(async () => toggle().click()); + expect(mocks.save).toHaveBeenLastCalledWith({ enable_prompt_suggestion: false }); + expect(toggle().checked).toBe(false); + expect(mocks.success).toHaveBeenCalledWith('promptSuggestion.messages.saveSuccess'); + }); + + it('locks an unread configuration and retries without writing defaults', async () => { + mocks.get.mockRejectedValueOnce(new Error('Host unavailable')); + await act(async () => root.render()); + expect(toggle()).toBeNull(); + expect(container.textContent).toContain('promptSuggestion.loadFailedLocked'); + await act(async () => container.querySelector('button')!.click()); + expect(toggle().disabled).toBe(false); + expect(mocks.save).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.tsx new file mode 100644 index 0000000000..57d6bb5d64 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/ai/PromptSuggestionSection.tsx @@ -0,0 +1,90 @@ +import { ConfigLoadingState, ConfigPageRow, ConfigPageSection, ConfigRetryState } from '@/infrastructure/config/components/common'; +import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { Switch } from '@openbitfun/ui'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useI18n } from '@/infrastructure/i18n'; + +const log = createLogger('PromptSuggestionSection'); + +export default function PromptSuggestionSection() { + const { t } = useI18n('settings/models'); + const notification = useNotification(); + const [cachedSettings] = useState(() => aiExperienceConfigService.getCachedSettings()); + const [loading, setLoading] = useState(!cachedSettings); + const [loadFailed, setLoadFailed] = useState(false); + const [saving, setSaving] = useState(false); + const [enabled, setEnabled] = useState(cachedSettings?.enable_prompt_suggestion); + const saveInFlight = useRef(false); + + const load = useCallback(async () => { + setLoading(!cachedSettings); + setLoadFailed(false); + try { + const settings = await aiExperienceConfigService.getSettingsAsync({ forceRefresh: true, requireLoaded: true }); + setEnabled(settings.enable_prompt_suggestion); + } catch (error) { + log.error('Failed to load prompt suggestion settings', error); + setLoadFailed(true); + } finally { + setLoading(false); + } + }, [cachedSettings]); + + useEffect(() => { + if (!cachedSettings) void load(); + return aiExperienceConfigService.addChangeListener((next) => { + setEnabled(next.enable_prompt_suggestion); + }); + }, [load, cachedSettings]); + + const handleToggle = async (next: boolean) => { + if (saveInFlight.current || enabled === undefined) return; + saveInFlight.current = true; + setSaving(true); + try { + await aiExperienceConfigService.saveSettings({ enable_prompt_suggestion: next }); + setEnabled(next); + notification.success(t('promptSuggestion.messages.saveSuccess')); + } catch (error) { + log.error('Failed to save prompt suggestion settings', error); + notification.error(t('promptSuggestion.messages.saveFailed')); + } finally { + saveInFlight.current = false; + setSaving(false); + } + }; + + if (loading || loadFailed) { + return ( + + {loading + ? + : void load()} />} + + ); + } + + return ( + + + void handleToggle(event.currentTarget.checked)} + disabled={saving || enabled === undefined} + aria-label={t('promptSuggestion.label')} + /> + + + ); +} diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/SessionMemorySettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/SessionMemorySettingsPage.tsx index 3b00d77d01..6e48633623 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/SessionMemorySettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/SessionMemorySettingsPage.tsx @@ -3,6 +3,7 @@ import { SettingsPage } from '../shared/SettingsPage'; import { useSettingsSectionAnchor } from '../shared/useSettingsSectionAnchor'; import DefaultHarnessSection from './DefaultHarnessSection'; import MemorySettingsSection from './MemorySettingsSection'; +import PromptSuggestionSection from './PromptSuggestionSection'; import SessionTitleSection from './SessionTitleSection'; export default function SessionMemorySettingsPage() { @@ -12,6 +13,7 @@ export default function SessionMemorySettingsPage() { + diff --git a/src/web-ui/src/app/scenes/settings/pages/shared/SettingsPageComposition.test.ts b/src/web-ui/src/app/scenes/settings/pages/shared/SettingsPageComposition.test.ts index f63a9b3fd9..5d6b680aa4 100644 --- a/src/web-ui/src/app/scenes/settings/pages/shared/SettingsPageComposition.test.ts +++ b/src/web-ui/src/app/scenes/settings/pages/shared/SettingsPageComposition.test.ts @@ -7,7 +7,7 @@ describe('merged settings pages', () => { it.each([ ['../application/PetAssistantSettingsPage.tsx', ['../application/PetSettingsSection.tsx', '../application/RealtimeVoiceSettingsSection.tsx']], ['../application/InputSettingsPage.tsx', ['../application/VoiceSettingsSection.tsx', '../application/KeyboardShortcutsSection.tsx']], - ['../ai/SessionMemorySettingsPage.tsx', ['../ai/DefaultHarnessSection.tsx', '../ai/SessionTitleSection.tsx', '../ai/MemorySettingsSection.tsx']], + ['../ai/SessionMemorySettingsPage.tsx', ['../ai/DefaultHarnessSection.tsx', '../ai/SessionTitleSection.tsx', '../ai/PromptSuggestionSection.tsx', '../ai/MemorySettingsSection.tsx']], ['../development/WorkspaceGitSettingsPage.tsx', ['../development/WorkspaceSearchSection.tsx', '../development/GitCommitSettingsSection.tsx', '../development/WorktreeSettingsSection.tsx']], ['../tools/AutomationSettingsPage.tsx', ['../tools/QuickActionsSettingsSection.tsx', '../tools/HooksSettingsSection.tsx']], ])('keeps one page header and scroll owner for %s', (page, sections) => { diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index 256f5bdffd..22b7bff0d8 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -377,6 +377,30 @@ user-select: none; } + &__prompt-suggestion { + display: flex; + align-items: center; + gap: var(--openbitfun-control-flow-chat-inline-gap); + } + + &__prompt-suggestion-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + &__prompt-suggestion-hint { + flex-shrink: 0; + padding-inline: var(--openbitfun-space-1); + font-family: inherit; + font-size: var(--openbitfun-type-flow-support-font-size); + font-weight: var(--openbitfun-type-label-sm-font-weight); + letter-spacing: var(--openbitfun-type-modifier-tracking-subtle-letter-spacing); + color: var(--openbitfun-color-content-muted); + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + border-radius: var(--openbitfun-radius-sm); + } + // In multi-line card mode: align to top of text area &--multi-line &__placeholder { padding-top: 1px; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index ad2536d1fb..157a0f8809 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -71,6 +71,12 @@ import { CHAT_INPUT_CONFIG } from '../constants/chatInputConfig'; import { useMessageSender } from '../hooks/useMessageSender'; import { useChatInputState } from '../store/chatInputStateStore'; import { useInputHistoryStore } from '../store/inputHistoryStore'; +import { + isPromptSuggestionAcceptKey, + promptSuggestionKey, + usePromptSuggestionStore, + visiblePromptSuggestion, +} from '../store/promptSuggestionStore'; import { sessionComposerStore, type PendingLargePasteMap, @@ -818,6 +824,20 @@ export const ChatInput: React.FC = ({ const [isMultiLine, setIsMultiLine] = useState(compactComposer ? false : isNewSessionComposer); // showPlaceholder is true when the editor DOM is truly empty (value empty AND no residual
) const [showPlaceholder, setShowPlaceholder] = useState(true); + const storedPromptSuggestion = usePromptSuggestionStore(state => ( + effectiveTargetSessionId + ? state.suggestions[promptSuggestionKey(effectiveTargetSessionId, deviceSurfaceScope.surfaceId)] + : undefined + )); + const latestSuggestionTurn = effectiveTargetSession?.dialogTurns.at(-1); + const promptSuggestion = visiblePromptSuggestion(storedPromptSuggestion, { + editorEmpty: showPlaceholder, + value: inputState.value, + canCompose: targetCanCompose, + processing: Boolean(derivedState?.isProcessing), + latestTurnId: latestSuggestionTurn?.id, + latestTurnStatus: latestSuggestionTurn?.status, + }); const liveCapsuleInputWidthRef = useRef(null); const lockedCapsuleInputWidthRef = useRef(null); const collapseVerificationRafRef = useRef(null); @@ -5692,6 +5712,22 @@ export const ChatInput: React.FC = ({ [currentSessionId, workspace] ); + const acceptPromptSuggestion = useCallback((suggestion: string) => { + dispatchInput({ type: 'SET_VALUE', payload: suggestion }); + window.setTimeout(() => { + const editor = richTextInputRef.current; + if (!editor) return; + editor.focus(); + const selection = window.getSelection(); + if (!selection) return; + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + }, 0); + }, [dispatchInput]); + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { // Local /btw shortcut (Ctrl/Cmd+Alt+B) should work even when ChatInput is focused. if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey && e.key.toLowerCase() === 'b') { @@ -5829,6 +5865,15 @@ export const ChatInput: React.FC = ({ } } + if ( + promptSuggestion + && isPromptSuggestionAcceptKey(e, { composing: isComposing, tabOwnedByTargetSwitcher: showTargetSwitcher }) + ) { + e.preventDefault(); + acceptPromptSuggestion(promptSuggestion); + return; + } + // Tab key: toggle send target when the child session switcher is visible if (showTargetSwitcher && e.key === 'Tab' && !e.shiftKey && !slashCommandState.isActive) { e.preventDefault(); @@ -5942,7 +5987,7 @@ export const ChatInput: React.FC = ({ handleSendOrCancel(); } - }, [canUseThreadGoal, handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, slashCommandState, contextTriggerState.isActive, getActiveSlashPickerItems, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, getRichTextTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, hasSendableInput, currentSessionId, isBtwSession, showTargetSwitcher, inputTarget, setInputTarget, removeContext, t]); + }, [canUseThreadGoal, handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, slashCommandState, contextTriggerState.isActive, getActiveSlashPickerItems, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, getRichTextTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, hasSendableInput, currentSessionId, isBtwSession, showTargetSwitcher, inputTarget, setInputTarget, removeContext, promptSuggestion, acceptPromptSuggestion, t]); const handleImeCompositionStart = useCallback(() => { isImeComposingRef.current = true; @@ -6488,11 +6533,18 @@ export const ChatInput: React.FC = ({ contextStore.getState().updateContext(id, { comment })} /> - {showPlaceholder && ( + {showPlaceholder && (promptSuggestion ? ( + + {promptSuggestion} + + {t('input.promptSuggestionAccept', { key: showTargetSwitcher ? '→' : 'Tab' })} + + + ) : ( {t('input.placeholder')} - )} + ))} ({ getActiveSurfaceI vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: { onTextChunk: (listener: (event: any) => void) => { fixture.text = listener; return () => {}; }, onToolEvent: (listener: (event: any) => void) => { fixture.tool = listener; return () => {}; }, + onPromptSuggestion: () => () => {}, } })); import { agenticEventListener } from './AgenticEventListener'; afterEach(async () => { await agenticEventListener.stopListening(); fixture.surface = 'local'; }); @@ -28,3 +29,10 @@ it('uses records for remote content while local content and remote approvals kee fixture.tool?.({ toolEvent: { event_type: 'ConfirmationNeeded' } }); expect(onToolEvent).toHaveBeenCalledTimes(2); }); +it('routes peer prompt suggestions to the registered handler', async () => { + const onPromptSuggestion = vi.fn(); + await agenticEventListener.startListening({ onPromptSuggestion }); + const payload = { sessionId: 'peer-session', sourceTurnId: 'turn-1', suggestion: 'Run the tests' }; + expect(agenticEventListener.dispatchExternal('agentic://prompt-suggestion', payload)).toBe(true); + expect(onPromptSuggestion).toHaveBeenCalledExactlyOnceWith(payload); +}); diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index 2d6d56bccd..b272c3236d 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -14,6 +14,7 @@ import type { AgenticEvent, SubagentSessionLinkedEvent, SessionTitleGeneratedEvent, + PromptSuggestionEvent, SessionModelFallbackAppliedEvent, SessionReasoningPresetAutoClearedEvent, ImageAnalysisEvent, @@ -79,6 +80,7 @@ export interface AgenticEventCallbacks { onThreadGoalUpdated?: (event: { sessionId: string; goal?: Record | null }) => void; onOpenBuiltInBrowser?: (event: OpenBuiltInBrowserEvent) => void; onSessionTitleGenerated?: (event: SessionTitleGeneratedEvent) => void; + onPromptSuggestion?: (event: PromptSuggestionEvent) => void; onSessionModelFallbackApplied?: (event: SessionModelFallbackAppliedEvent) => void; onSessionReasoningPresetAutoCleared?: ( event: SessionReasoningPresetAutoClearedEvent @@ -317,6 +319,14 @@ export class AgenticEventListener { this.unlistenFunctions.push(unlisten); } + if (callbacks.onPromptSuggestion) { + const unlisten = agentAPI.onPromptSuggestion((event) => { + logger.debug('Prompt suggestion received:', { sessionId: event.sessionId }); + callbacks.onPromptSuggestion?.(event); + }); + this.unlistenFunctions.push(unlisten); + } + if (callbacks.onUserSteeringInjected) { const unlisten = agentAPI.onUserSteeringInjected((event) => { logger.debug('User steering injected:', event); @@ -455,6 +465,9 @@ export class AgenticEventListener { case 'session_title_generated': callbacks.onSessionTitleGenerated?.(payload as unknown as SessionTitleGeneratedEvent); break; + case 'agentic://prompt-suggestion': + callbacks.onPromptSuggestion?.(payload as unknown as PromptSuggestionEvent); + break; // Upgrade-only event-name alias for older Peer/Dispatch targets. case 'agentic://session-model-auto-migrated': case 'agentic://session-model-fallback-applied': diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 007d8776a8..844bfc5f3e 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -11,6 +11,7 @@ import { requireSessionWorkspaceId } from '../utils/sessionWorkspace'; import { processingStatusManager } from './ProcessingStatusManager'; import { FlowChatStore } from '../store/FlowChatStore'; import { useModernFlowChatStore } from '../store/modernFlowChatStore'; +import { usePromptSuggestionStore } from '../store/promptSuggestionStore'; import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; import { stateMachineManager } from '../state-machine'; import { EventBatcher } from './EventBatcher'; @@ -559,6 +560,7 @@ export class FlowChatManager { /** Permanently forget a peer that was explicitly detached or became lost. */ public discardDeviceSurface(surfaceId: DeviceSurfaceId): void { + usePromptSuggestionStore.getState().removeSurfaceSuggestions(surfaceId); this.context.flowChatStore.discardSurfaceState(surfaceId); sessionNavStatusService.clearSurface(surfaceId); stateMachineManager.clearSurface(surfaceId); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index 460b44c3ea..c6ba114097 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { __test_only__, handleDialogTurnComplete, + handlePromptSuggestion, handleSessionHistoryChanged, handleSessionStateChanged, insertSteeringItemIfAbsent, @@ -25,6 +26,7 @@ import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { localSessionDriver } from '../../session-drivers/local/LocalSessionDriver'; import { chatInputSessionSubscriptionKey } from '../../utils/chatInputSessionSubscription'; import { selectInterruptedTurnRecovery } from '../../utils/interruptedTurnRecovery'; +import { promptSuggestionKey, usePromptSuggestionStore } from '../../store/promptSuggestionStore'; const { buildBuiltInBrowserTabOptions, @@ -97,6 +99,70 @@ describe('Claw bootstrap cancellation', () => { }); }); +describe('prompt suggestion projection', () => { + beforeEach(() => { + resetFlowChatStore(); + usePromptSuggestionStore.setState({ suggestions: {} }); + }); + afterEach(() => { + resetFlowChatStore(); + usePromptSuggestionStore.setState({ suggestions: {} }); + }); + + it('preserves the model text for the latest turn without post-processing', () => { + createSessionWithTurn(createFinishingTurn()); + + handlePromptSuggestion(createFlowChatContext(), { + sessionId: 'session-1', sourceTurnId: 'turn-1', suggestion: ' Run the tests ', + }); + + expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('session-1')]).toEqual({ + suggestion: ' Run the tests ', sourceTurnId: 'turn-1', + }); + }); + + it('ignores a suggestion whose source turn is no longer the latest one', () => { + createSessionWithTurn(createFinishingTurn()); + + handlePromptSuggestion(createFlowChatContext(), { + sessionId: 'session-1', sourceTurnId: 'turn-0', suggestion: 'Old idea', + }); + handlePromptSuggestion(createFlowChatContext(), { + sessionId: 'missing', sourceTurnId: 'turn-1', suggestion: 'Unknown session', + }); + + expect(usePromptSuggestionStore.getState().suggestions).toEqual({}); + }); + + it('clears the visible suggestion when the session starts another turn', () => { + createSessionWithTurn(createFinishingTurn()); + const context = createFlowChatContext(); + handlePromptSuggestion(context, { + sessionId: 'session-1', sourceTurnId: 'turn-1', suggestion: 'Commit it', + }); + expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('session-1')]).toBeDefined(); + + __test_only__.handleDialogTurnStarted(context, { + sessionId: 'session-1', turnId: 'turn-2', turnIndex: 1, userInput: 'Next request', + }); + + expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('session-1')]).toBeUndefined(); + }); + + it('retires suggestions before a history rewrite is reloaded', () => { + createSessionWithTurn(createFinishingTurn()); + const context = createFlowChatContext(); + const reload = vi.spyOn(context.flowChatStore, 'loadSessionHistory').mockResolvedValue(undefined); + handlePromptSuggestion(context, { + sessionId: 'session-1', sourceTurnId: 'turn-1', suggestion: 'Commit it', + }); + handleSessionHistoryChanged(context, { sessionId: 'session-1' }); + expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('session-1')]).toBeUndefined(); + expect(reload).toHaveBeenCalled(); + reload.mockRestore(); + }); +}); + describe('built-in browser open request projection', () => { it('correlates replacement and new-tab requests with distinct stable keys', () => { expect(buildBuiltInBrowserTabOptions({ diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 4d994f262e..fefbb8f649 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -40,6 +40,7 @@ import type { ModelRoundAttemptSupersededEvent, OpenBuiltInBrowserEvent, AcpContextUsageUpdatedEvent, + PromptSuggestionEvent, SessionModelFallbackAppliedEvent, SessionReasoningPresetAutoClearedEvent, SubagentSessionLinkedEvent, @@ -58,6 +59,7 @@ import { useReviewActionBarStore } from '../../store/deepReviewActionBarStore'; import { buildDeepReviewCapacityQueueStateFromEvent } from '../../utils/deepReviewQueueStateEvents'; import { useBackgroundCommandActivityStore } from '../../store/backgroundCommandActivityStore'; import { useBackgroundSubagentActivityStore } from '../../store/backgroundSubagentActivityStore'; +import { clearPromptSuggestion, usePromptSuggestionStore } from '../../store/promptSuggestionStore'; import { createTab } from '@/shared/utils/tabUtils'; import type { TabCreationOptions } from '@/shared/utils/tabUtils'; import { interruptedTurnRecoveryGate } from '../interruptedTurnRecoveryGate'; @@ -931,6 +933,9 @@ export async function initializeEventListeners( onSessionTitleGenerated: (event) => { handleSessionTitleGenerated(event); }, + onPromptSuggestion: (event) => { + handlePromptSuggestion(context, event); + }, onSessionModelFallbackApplied: (event) => { handleSessionModelFallbackApplied(event); }, @@ -1366,6 +1371,30 @@ function handleSessionTitleGenerated(event: any): void { reconcileBackgroundSubagentSession(sessionId); } +/** + * Keep a next-input suggestion only while its source turn is still the + * session's latest turn; a newer turn makes it stale. + */ +export function handlePromptSuggestion(context: FlowChatContext, event: PromptSuggestionEvent): void { + const { sessionId, sourceTurnId, suggestion } = event ?? {}; + if (!sessionId || !sourceTurnId || typeof suggestion !== 'string' || !suggestion) return; + + const session = context.flowChatStore.getState().sessions.get(sessionId); + const latestTurn = session?.dialogTurns[session.dialogTurns.length - 1]; + if (!latestTurn || latestTurn.id !== sourceTurnId) { + log.debug('Ignoring stale prompt suggestion', { + sessionId, + sourceTurnId, + latestTurnId: latestTurn?.id, + }); + return; + } + usePromptSuggestionStore.getState().setSuggestion(sessionId, { + suggestion, + sourceTurnId, + }); +} + function handleSessionModelFallbackApplied(event: SessionModelFallbackAppliedEvent): void { const { sessionId, previousModelId, newModelId, reason } = event; if (!sessionId || !newModelId) return; @@ -1529,6 +1558,7 @@ function handleSessionDeleted(context: FlowChatContext, event: any): void { removedSessionIds.forEach(removedSessionId => { clearRecentHistorySessionOpenIntent(removedSessionId); clearHistorySessionOpenTransition(removedSessionId); + clearPromptSuggestion(removedSessionId); }); log.info('Remote session deleted', { sessionId }); @@ -1633,6 +1663,7 @@ export function handleSessionHistoryChanged(context: FlowChatContext, event: any // Undo/redo and rollback remove visible Turns. Repairing one old tail Turn // cannot remove that suffix, including when the history becomes empty. // Older hosts omit the optional identity, so use full invalidation there too. + clearPromptSuggestion(sessionId); context.pendingHistoryFenceSessions.delete(sessionId); void context.flowChatStore.loadSessionHistory(sessionId, { includeInternal: session.sessionKind === 'subagent', @@ -1812,6 +1843,7 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { return; } + clearPromptSuggestion(sessionId); finalizePendingTurnCompletionNow(context, sessionId); clearPendingTurnCompletion(context, sessionId, turnId); diff --git a/src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts b/src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts new file mode 100644 index 0000000000..71df0856f9 --- /dev/null +++ b/src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { activateSurface, LOCAL_SURFACE_ID } from '@/infrastructure/peer-device/deviceSurface'; +import { + clearPromptSuggestion, + isPromptSuggestionAcceptKey, + promptSuggestionKey, + usePromptSuggestionStore, + visiblePromptSuggestion, +} from './promptSuggestionStore'; + +const candidate = { suggestion: 'Run the tests', sourceTurnId: 't1' }; +const idle = { editorEmpty: true, value: '', canCompose: true, processing: false, latestTurnId: 't1', latestTurnStatus: 'completed' }; +const key = (name: string, modifiers: Partial> = {}) => ({ + key: name, shiftKey: false, ctrlKey: false, metaKey: false, altKey: false, ...modifiers, +}); + +afterEach(() => { + usePromptSuggestionStore.setState({ suggestions: {} }); + activateSurface(LOCAL_SURFACE_ID); +}); + +describe('prompt suggestion store', () => { + it('keeps one suggestion per session and clears only the requested one', () => { + const store = usePromptSuggestionStore.getState(); + store.setSuggestion('a', { suggestion: 'Run the tests', sourceTurnId: 't1' }); + store.setSuggestion('b', { suggestion: 'Commit it', sourceTurnId: 't2' }); + + clearPromptSuggestion('a'); + clearPromptSuggestion(null); + + expect(usePromptSuggestionStore.getState().suggestions).toEqual({ + [promptSuggestionKey('b')]: { suggestion: 'Commit it', sourceTurnId: 't2' }, + }); + }); + + it('isolates identical session IDs across devices and forgets only a disposed device', () => { + const store = usePromptSuggestionStore.getState(); + store.setSuggestion('same', candidate); + activateSurface('peer'); + expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('same')]).toBeUndefined(); + store.setSuggestion('same', { suggestion: 'Peer idea', sourceTurnId: 't1' }); + clearPromptSuggestion('same'); + activateSurface(LOCAL_SURFACE_ID); + expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('same')]).toEqual(candidate); + store.removeSurfaceSuggestions('peer'); + expect(Object.keys(usePromptSuggestionStore.getState().suggestions)).toHaveLength(1); + store.removeSurfaceSuggestions(LOCAL_SURFACE_ID); + expect(usePromptSuggestionStore.getState().suggestions).toEqual({}); + }); +}); + +describe('visiblePromptSuggestion', () => { + it('shows the suggestion only for an empty idle composer that can submit', () => { + expect(visiblePromptSuggestion(candidate, idle)).toBe('Run the tests'); + expect(visiblePromptSuggestion(undefined, idle)).toBeNull(); + expect(visiblePromptSuggestion(candidate, { ...idle, value: 'R' })).toBeNull(); + expect(visiblePromptSuggestion(candidate, { ...idle, editorEmpty: false })).toBeNull(); + expect(visiblePromptSuggestion(candidate, { ...idle, canCompose: false })).toBeNull(); + expect(visiblePromptSuggestion(candidate, { ...idle, processing: true })).toBeNull(); + expect(visiblePromptSuggestion(candidate, { ...idle, latestTurnId: 'newer' })).toBeNull(); + expect(visiblePromptSuggestion(candidate, { ...idle, latestTurnId: undefined })).toBeNull(); + for (const latestTurnStatus of ['processing', 'finishing', 'cancelled', 'error']) { + expect(visiblePromptSuggestion(candidate, { ...idle, latestTurnStatus })).toBeNull(); + } + }); +}); + +describe('isPromptSuggestionAcceptKey', () => { + const free = { composing: false, tabOwnedByTargetSwitcher: false }; + + it('accepts plain Tab and ArrowRight', () => { + expect(isPromptSuggestionAcceptKey(key('Tab'), free)).toBe(true); + expect(isPromptSuggestionAcceptKey(key('ArrowRight'), free)).toBe(true); + expect(isPromptSuggestionAcceptKey(key('Enter'), free)).toBe(false); + }); + + it('leaves Tab to the target switcher and ignores modified or composing keys', () => { + const switcher = { composing: false, tabOwnedByTargetSwitcher: true }; + expect(isPromptSuggestionAcceptKey(key('Tab'), switcher)).toBe(false); + expect(isPromptSuggestionAcceptKey(key('ArrowRight'), switcher)).toBe(true); + expect(isPromptSuggestionAcceptKey(key('Tab', { shiftKey: true }), free)).toBe(false); + expect(isPromptSuggestionAcceptKey(key('ArrowRight', { metaKey: true }), free)).toBe(false); + expect(isPromptSuggestionAcceptKey(key('Tab'), { ...free, composing: true })).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/store/promptSuggestionStore.ts b/src/web-ui/src/flow_chat/store/promptSuggestionStore.ts new file mode 100644 index 0000000000..c060021c0a --- /dev/null +++ b/src/web-ui/src/flow_chat/store/promptSuggestionStore.ts @@ -0,0 +1,80 @@ +import { create } from 'zustand'; +import { getActiveSurfaceId, surfaceScopedKey, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; + +/** A one-sentence next-input suggestion produced after a completed turn. */ +export interface PromptSuggestion { + suggestion: string; + sourceTurnId: string; +} + +interface PromptSuggestionState { + suggestions: Record; + setSuggestion: (sessionId: string, suggestion: PromptSuggestion, surfaceId?: DeviceSurfaceId) => void; + clearSuggestion: (sessionId: string, surfaceId?: DeviceSurfaceId) => void; + removeSurfaceSuggestions: (surfaceId: DeviceSurfaceId) => void; +} + +export function promptSuggestionKey(sessionId: string, surfaceId = getActiveSurfaceId()): string { + return surfaceScopedKey(surfaceId, sessionId); +} + +export const usePromptSuggestionStore = create((set) => ({ + suggestions: {}, + setSuggestion: (sessionId, suggestion, surfaceId) => set((state) => ({ + suggestions: { ...state.suggestions, [promptSuggestionKey(sessionId, surfaceId)]: suggestion }, + })), + clearSuggestion: (sessionId, surfaceId) => set((state) => { + const key = promptSuggestionKey(sessionId, surfaceId); + if (!(key in state.suggestions)) return state; + const { [key]: _removed, ...suggestions } = state.suggestions; + return { suggestions }; + }), + removeSurfaceSuggestions: (surfaceId) => set(state => ({ + suggestions: Object.fromEntries(Object.entries(state.suggestions) + .filter(([key]) => JSON.parse(key)[0] !== surfaceId)), + })), +})); + +export function clearPromptSuggestion(sessionId: string | null | undefined): void { + if (!sessionId) return; + usePromptSuggestionStore.getState().clearSuggestion(sessionId); +} + +export interface PromptSuggestionComposerState { + editorEmpty: boolean; + value: string; + canCompose: boolean; + processing: boolean; + latestTurnId?: string; + latestTurnStatus?: string; +} + +/** A suggestion only stands in for an empty, idle composer that may submit to its session. */ +export function visiblePromptSuggestion( + stored: PromptSuggestion | null | undefined, + composer: PromptSuggestionComposerState, +): string | null { + if (!stored || !composer.editorEmpty || composer.value || !composer.canCompose || composer.processing) { + return null; + } + if (stored.sourceTurnId !== composer.latestTurnId || composer.latestTurnStatus !== 'completed') return null; + return stored.suggestion; +} + +interface PromptSuggestionKey { + key: string; + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; +} + +/** Tab accepts unless the child-session target switcher owns it; ArrowRight always accepts. */ +export function isPromptSuggestionAcceptKey( + event: PromptSuggestionKey, + options: { composing: boolean; tabOwnedByTargetSwitcher: boolean }, +): boolean { + if (options.composing || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return false; + if (event.key === 'ArrowRight') return true; + return event.key === 'Tab' && !options.tabOwnedByTargetSwitcher; +} diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index c28cb5da84..766217571e 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "64a4a6c115b13f5aa15157d1affde2c7e901499e9941571a333d45bf242033ae" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; @@ -34,7 +34,7 @@ export interface ProductControlOptionIdsByCapability { "setting.application.development": "editor-font-family" | "editor-font-weight" | "editor-font-size" | "editor-line-height" | "editor-cursor-style" | "editor-cursor-blinking" | "editor-tab-size" | "editor-insert-spaces" | "editor-detect-indentation" | "editor-word-wrap" | "editor-line-numbers" | "editor-smooth-scrolling" | "editor-minimap-enabled" | "editor-minimap-side" | "editor-minimap-size" | "editor-render-whitespace" | "editor-render-line-highlight" | "editor-scroll-beyond-last-line" | "editor-auto-save" | "editor-auto-save-delay" | "editor-semantic-highlighting" | "editor-bracket-pair-colorization" | "editor-format-on-save" | "editor-format-on-paste" | "editor-trim-auto-whitespace"; "setting.ai.models": "stream-ttft-timeout-seconds" | "stream-idle-timeout-seconds"; "setting.ai.memory": "enabled" | "btw-sessions" | "external-context-policy" | "minimum-rollout-idle-hours" | "maximum-rollout-age-days" | "startup-rollout-limit" | "startup-rollout-scan-limit" | "extraction-concurrency" | "raw-memory-limit" | "memory-retention-days"; - "setting.workspace.session": "session-title-generation" | "workspace-search"; + "setting.workspace.session": "session-title-generation" | "prompt-suggestion" | "workspace-search"; "setting.workspace.worktrees": "worktree-root-path" | "worktree-branch-prefix" | "worktree-copy-local-changes" | "worktree-auto-delete-enabled" | "worktree-auto-delete-limit"; "setting.tools.execution": "show-permission-mode-control" | "deferred-tool-loading" | "subagent-batch-policy" | "subagent-max-concurrency" | "swarm-max-concurrency" | "tool-timeout-seconds" | "computer-use-enabled" | "browser-auto-connect" | "tool-json-repair"; "setting.application.terminal": "terminal-default-shell" | "terminal-panel-position"; diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 9c764f3c9b..f9481f8311 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -33,6 +33,14 @@ export interface SessionTitleGeneratedEvent { timestamp: number; } +/** One-sentence next-input suggestion offered after a completed turn. */ +export interface PromptSuggestionEvent { + sessionId: string; + /** Not `turnId`: the hint must not be treated as a write to the settled turn. */ + sourceTurnId: string; + suggestion: string; +} + export interface SessionModelFallbackAppliedEvent { sessionId: string; previousModelId: string; @@ -1465,6 +1473,10 @@ export class AgentAPI { return api.listen('agentic://dialog-turn-completed', callback); } + onPromptSuggestion(callback: (event: PromptSuggestionEvent) => void): () => void { + return api.listen('agentic://prompt-suggestion', callback); + } + onUserSteeringInjected( callback: (event: UserSteeringInjectedEvent) => void, ): () => void { diff --git a/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.test.ts b/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.test.ts index d99a02c855..f347db6d94 100644 --- a/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.test.ts +++ b/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.test.ts @@ -189,6 +189,32 @@ describe('AIExperienceConfigService startup behavior', () => { expect(settings.quick_actions).toEqual([]); }); + it('keeps prompt suggestion settings unavailable when an older host omits the field', async () => { + configApiMock.getConfig.mockResolvedValueOnce({ enable_session_title_generation: false }); + const { aiExperienceConfigService } = await import('./AIExperienceConfigService'); + + const settings = await aiExperienceConfigService.getSettingsAsync({ forceRefresh: true, requireLoaded: true }); + + expect(settings.enable_prompt_suggestion).toBeUndefined(); + expect(settings.enable_session_title_generation).toBe(false); + }); + + it('persists a prompt suggestion opt-out as a single field write', async () => { + const persisted = { enable_prompt_suggestion: true, quick_actions: [] as unknown[] }; + configManagerMock.getConfig.mockResolvedValue(persisted); + configManagerMock.setConfig.mockImplementation(async (path: string, value: unknown) => { + expect(path).toBe('app.ai_experience.enable_prompt_suggestion'); + persisted.enable_prompt_suggestion = value as boolean; + }); + const { aiExperienceConfigService } = await import('./AIExperienceConfigService'); + + await aiExperienceConfigService.saveSettings({ enable_prompt_suggestion: false }); + await aiExperienceConfigService.reload(); + + expect(configManagerMock.setConfig).toHaveBeenCalledTimes(1); + expect(aiExperienceConfigService.getSettings().enable_prompt_suggestion).toBe(false); + }); + it('persists a co-author opt-out without overwriting quick actions or voice settings', async () => { const persisted = { enable_git_commit_coauthor: true, diff --git a/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts b/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts index 24e5830608..32a95f6663 100644 --- a/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts +++ b/src/web-ui/src/infrastructure/config/services/AIExperienceConfigService.ts @@ -31,6 +31,8 @@ export interface AIExperienceSettings { quick_actions?: QuickAction[]; /** Absent on older execution hosts; their commit co-author policy is not configurable. */ enable_git_commit_coauthor?: boolean; + /** Absent on older execution hosts, which never offer next-input suggestions. */ + enable_prompt_suggestion?: boolean; } export type AIExperienceSettingsPatch = Partial> & { @@ -110,6 +112,9 @@ function normalizeSettings(settings: PersistedAIExperienceSettings | null | unde enable_git_commit_coauthor: typeof currentSettings.enable_git_commit_coauthor === 'boolean' ? currentSettings.enable_git_commit_coauthor : undefined, + enable_prompt_suggestion: typeof currentSettings.enable_prompt_suggestion === 'boolean' + ? currentSettings.enable_prompt_suggestion + : undefined, }; // Legacy configs used null to mean the built-in SVG panda. Resolve null to the current preset. if (!merged.agent_companion_pet) { diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index 6b81c215af..321a7f5b3d 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -170,6 +170,8 @@ export interface AIExperienceConfig { quick_actions?: Array<{ id: string; label: string; prompt: string; enabled: boolean }>; /** Absent on older hosts that do not support commit co-author preferences. */ enable_git_commit_coauthor?: boolean; + /** Absent on older hosts that do not offer next-input suggestions. */ + enable_prompt_suggestion?: boolean; } export interface VoiceInputSettings { diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index cc88c4849b..a1b308cb7a 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -631,6 +631,7 @@ "input": { "placeholder": "How can I help you...", "placeholderWithContext": "Type a message, or drag and drop files to add context...", + "promptSuggestionAccept": "{{key}}", "send": "Send", "sendShortcut": "Send (⏎)", "stop": "Stop", diff --git a/src/web-ui/src/locales/en-US/settings/models.json b/src/web-ui/src/locales/en-US/settings/models.json index a421ed2b08..9c1e3637fd 100644 --- a/src/web-ui/src/locales/en-US/settings/models.json +++ b/src/web-ui/src/locales/en-US/settings/models.json @@ -33,6 +33,20 @@ "updateFailed": "Update failed" } }, + "promptSuggestion": { + "title": "Next Input Suggestion", + "subtitle": "After the Agent finishes a reply, suggest a one-sentence next message in the input box", + "label": "Enable", + "description": "Press Tab or → in an empty input box to accept. The suggestion reuses the finished turn's prompt cache.", + "unsupported": "The connected host does not support next input suggestions.", + "loading": "Loading settings...", + "loadFailedLocked": "Next input suggestion settings could not be read. The switch is locked.", + "retry": "Reload", + "messages": { + "saveSuccess": "Settings saved", + "saveFailed": "Save failed" + } + }, "subscriptionAuth": { "sectionTitle": "Subscription accounts", "sectionDescription": "Connect Codex, Antigravity, OpenCode Console (Zen), xAI, or Hermes accounts", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index c34949a449..44f11cd46c 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -631,6 +631,7 @@ "input": { "placeholder": "有什么可以帮您...", "placeholderWithContext": "输入消息,或拖放文件添加上下文...", + "promptSuggestionAccept": "{{key}}", "send": "发送", "sendShortcut": "发送 (⏎)", "stop": "停止", diff --git a/src/web-ui/src/locales/zh-CN/settings/models.json b/src/web-ui/src/locales/zh-CN/settings/models.json index 39f985a71c..36580406e1 100644 --- a/src/web-ui/src/locales/zh-CN/settings/models.json +++ b/src/web-ui/src/locales/zh-CN/settings/models.json @@ -33,6 +33,20 @@ "updateFailed": "设置失败" } }, + "promptSuggestion": { + "title": "候选输入建议", + "subtitle": "Agent 答复完成后,在输入框中给出一句话的下一步输入建议", + "label": "启用", + "description": "输入框为空时按 Tab 或 → 采纳。建议复用本轮对话的提示缓存生成。", + "unsupported": "当前连接的主机不支持候选输入建议。", + "loading": "正在加载设置...", + "loadFailedLocked": "无法读取候选输入建议设置,开关已锁定。", + "retry": "重新加载", + "messages": { + "saveSuccess": "设置已保存", + "saveFailed": "保存失败" + } + }, "subscriptionAuth": { "sectionTitle": "订阅账号", "sectionDescription": "连接 Codex、Antigravity、OpenCode Console(Zen)、xAI 或 Hermes 账号", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 07b4da1982..38f040b5ac 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -631,6 +631,7 @@ "input": { "placeholder": "有什麼可以幫您...", "placeholderWithContext": "輸入消息,或拖放檔案新增上下文...", + "promptSuggestionAccept": "{{key}}", "send": "發送", "sendShortcut": "發送 (⏎)", "stop": "停止", diff --git a/src/web-ui/src/locales/zh-TW/settings/models.json b/src/web-ui/src/locales/zh-TW/settings/models.json index 8b2e62e50d..342068ce96 100644 --- a/src/web-ui/src/locales/zh-TW/settings/models.json +++ b/src/web-ui/src/locales/zh-TW/settings/models.json @@ -33,6 +33,20 @@ "updateFailed": "設定失敗" } }, + "promptSuggestion": { + "title": "候選輸入建議", + "subtitle": "Agent 回覆完成後,在輸入框中提供一句話的下一步輸入建議", + "label": "啟用", + "description": "輸入框為空時按 Tab 或 → 採納。建議沿用本輪對話的提示快取產生。", + "unsupported": "目前連線的主機不支援候選輸入建議。", + "loading": "正在載入設定...", + "loadFailedLocked": "無法讀取候選輸入建議設定,開關已鎖定。", + "retry": "重新載入", + "messages": { + "saveSuccess": "設定已儲存", + "saveFailed": "儲存失敗" + } + }, "subscriptionAuth": { "sectionTitle": "訂閱賬號", "sectionDescription": "連接 Codex、Antigravity、OpenCode Console(Zen)、xAI 或 Hermes 帳號", diff --git a/tests/e2e/helpers/workspace-helper.ts b/tests/e2e/helpers/workspace-helper.ts index a2259d48ec..a97a93aaff 100644 --- a/tests/e2e/helpers/workspace-helper.ts +++ b/tests/e2e/helpers/workspace-helper.ts @@ -248,6 +248,7 @@ export async function ensureCodeSessionOpen(): Promise { } const selectors = [ + '[data-testid="nav-workspace-new-session-btn"]', '.openbitfun-nav-panel__workspace-create-main--split-left', '[data-testid="chat-input-send-btn"]', ]; diff --git a/tests/e2e/specs/l1-prompt-suggestion-live.spec.ts b/tests/e2e/specs/l1-prompt-suggestion-live.spec.ts new file mode 100644 index 0000000000..c95d370136 --- /dev/null +++ b/tests/e2e/specs/l1-prompt-suggestion-live.spec.ts @@ -0,0 +1,72 @@ +/** + * Live opt-in: a completed real-model turn offers a one-sentence next input + * in the empty ChatInput, and Tab moves it into the editor. + * + * OPENBITFUN_E2E_PROMPT_SUGGESTION_MODELS points to a JSON file + * `{ "models": [...], "default_models": {...} }` applied to the isolated + * profile; credentials stay outside the test source and retained evidence. + */ + +import { $, browser, expect } from '@wdio/globals'; +import * as fs from 'fs'; +import { ChatInput } from '../page-objects/components/ChatInput'; +import { Header } from '../page-objects/components/Header'; +import { StartupPage } from '../page-objects/StartupPage'; +import { saveScreenshot } from '../helpers/screenshot-utils'; +import { ensureWorkspaceOpen } from '../helpers/workspace-utils'; + +const SUGGESTION = '[data-testid="chat-input-prompt-suggestion"]'; +const SUGGESTION_TEXT = '.openbitfun-chat-input__prompt-suggestion-text'; + +async function setConfig(path: string, value: unknown): Promise { + await browser.execute(async (configPath: string, configValue: unknown) => { + const host = window as typeof window & { + __TAURI__: { core: { invoke: (name: string, params: unknown) => Promise } }; + }; + await host.__TAURI__.core.invoke('set_config', { request: { path: configPath, value: configValue } }); + }, path, value); +} + +const modelsFile = process.env.OPENBITFUN_E2E_PROMPT_SUGGESTION_MODELS; +const live = modelsFile ? describe : describe.skip; + +live('L1 prompt suggestion with a real model', () => { + const chatInput = new ChatInput(); + const header = new Header(); + const startupPage = new StartupPage(); + + before(async () => { + if (process.env.OPENBITFUN_E2E_STORAGE_GUARD !== '1') throw new Error('Isolated E2E storage required'); + const { models, default_models: defaultModels } = JSON.parse(fs.readFileSync(modelsFile!, 'utf8')); + await header.waitForLoad(); + await setConfig('ai.models', models); + await setConfig('ai.default_models', defaultModels); + await setConfig('app.ai_experience.enable_prompt_suggestion', true); + await browser.refresh(); + await header.waitForLoad(); + expect(await ensureWorkspaceOpen(startupPage)).toBe(true); + await chatInput.waitForLoad(); + }); + + it('shows a one-sentence suggestion after the reply and accepts it with Tab', async () => { + await chatInput.sendMessageWithEnter('In one short sentence, what is 2 + 2? Do not use any tools.'); + + const suggestion = await $(SUGGESTION); + await suggestion.waitForExist({ timeout: 180000, interval: 500 }); + const text = (await $(SUGGESTION_TEXT).getText()).trim(); + console.log('PROMPT_SUGGESTION_TEXT', JSON.stringify(text)); + await saveScreenshot('prompt-suggestion-visible'); + + expect(text.length).toBeGreaterThan(0); + expect(text).not.toContain('\n'); + + await chatInput.focus(); + await browser.keys(['Tab']); + await browser.waitUntil(async () => (await chatInput.getValue()).trim() === text, { + timeout: 5000, + timeoutMsg: 'Tab did not move the suggestion into the editor', + }); + expect(await $(SUGGESTION).isExisting()).toBe(false); + await saveScreenshot('prompt-suggestion-accepted'); + }); +}); From 08484fafb0a04abc63cf0f3b3f3d0285224dd73c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 14 Oct 2026 18:20:11 +0800 Subject: [PATCH 3/4] feat(mobile): add replay-safe suggestions to Android and iOS composers --- .../mobile/app/ComposerModelSelectorTest.kt | 30 ++++++++ .../mobile/app/ui/chat/ComposerBar.kt | 25 ++++++- .../mobile/app/ui/chat/ConversationView.kt | 1 + .../app/src/main/res/values-zh/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + .../ios/OpenBitFun.xcodeproj/project.pbxproj | 6 +- .../App/MobileLaunchConfiguration.swift | 21 +++++- .../ios/OpenBitFun/App/OpenBitFunApp.swift | 3 +- .../Features/Chat/ComposerBar.swift | 25 +++++++ .../MobileAppModel+RemoteSession.swift | 8 +- .../Infrastructure/MobileAppModel.swift | 15 ++++ .../Resources/Localizable.xcstrings | 6 ++ .../PromptSuggestionUITests.swift | 27 +++++++ src/apps/mobile/ios/README.md | 6 ++ .../feature/session/RemoteSessionStore.kt | 27 ++++++- .../feature/session/RemoteSessionUiState.kt | 13 ++++ .../feature/session/SessionRecordReplica.kt | 30 +++++++- .../feature/session/RemoteSessionStoreTest.kt | 74 +++++++++++++++++++ 18 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 src/apps/mobile/ios/OpenBitFunUITests/PromptSuggestionUITests.swift diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ComposerModelSelectorTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ComposerModelSelectorTest.kt index fe785495c8..882a358a9a 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ComposerModelSelectorTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ComposerModelSelectorTest.kt @@ -7,6 +7,8 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.runtime.mutableStateOf import com.openbitfun.mobile.app.ui.chat.COMPOSER_INPUT_TEST_TAG import com.openbitfun.mobile.app.ui.chat.ComposerBar import com.openbitfun.mobile.app.ui.chat.MODEL_CONTROL_TEST_TAG @@ -25,6 +27,34 @@ class ComposerModelSelectorTest { @get:Rule val composeRule = createComposeRule() + @Test + fun suggestionFillsTheEmptyDraftExactlyWithoutSending() { + val draft = mutableStateOf("") + val streaming = mutableStateOf(false) + var sends = 0 + val suggestion = " Run the tests " + composeRule.setContent { + OpenBitFunTheme(dark = false) { + ComposerBar(draft = draft.value, images = emptyList(), busy = false, + streaming = streaming.value, phase = ConnectionPhase.CONNECTED, + model = null, capabilities = ChatComposerCapabilities.RemoteChat, placeholder = "Message", + promptSuggestion = suggestion, onDraftChange = {draft.value = it}, + onRemoveImage = {}, onAttach = {}, onVoice = {}, onSend = {sends++}, + onStop = {}, onOpenModels = {}, modifier = androidx.compose.ui.Modifier) + } + } + composeRule.onNodeWithTag("composer-prompt-suggestion").assertIsDisplayed() + capture("prompt-suggestion-before.png") + composeRule.onNodeWithTag("composer-prompt-suggestion").performClick() + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertTextEquals(suggestion) + composeRule.onNodeWithTag("composer-prompt-suggestion").assertDoesNotExist() + capture("prompt-suggestion-accepted.png") + composeRule.runOnIdle { assertEquals(0, sends); draft.value = ""; streaming.value = true } + composeRule.onNodeWithTag("composer-prompt-suggestion").assertDoesNotExist() + composeRule.runOnIdle { streaming.value = false } + composeRule.onNodeWithTag("composer-prompt-suggestion").assertIsDisplayed() + } + private fun capture(name: String) { composeRule.waitForIdle() val instrumentation = InstrumentationRegistry.getInstrumentation() diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt index c1e02751f5..0eac37e2d8 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet @@ -51,6 +52,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale @@ -149,6 +152,7 @@ internal fun ComposerBar( */ placeholder: String, onDraftChange: (String) -> Unit, + promptSuggestion: String? = null, onRemoveImage: (String) -> Unit, onAttach: () -> Unit, onVoice: () -> Unit, @@ -158,6 +162,7 @@ internal fun ComposerBar( onSelectModel: (String) -> Unit = {}, modifier: Modifier, ) { + val inputFocusRequester = remember { FocusRequester() } var modelSelectorOpen by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) } val expanded = ChatComposerPolicy.isExpanded( @@ -232,6 +237,24 @@ internal fun ComposerBar( ) .testTag(COMPOSER_TEST_TAG), ) { + if (!promptSuggestion.isNullOrEmpty() && draft.isEmpty() && !busy && !streaming && inputEnabled && + phase == ConnectionPhase.CONNECTED) { + val acceptLabel = stringResource(R.string.prompt_suggestion_accept) + TextButton( + onClick = { + onDraftChange(promptSuggestion) + inputFocusRequester.requestFocus() + }, + modifier = Modifier.fillMaxWidth() + .heightIn(min = MobileDesignGeometry.ControlTouchSize) + .testTag("composer-prompt-suggestion") + .semantics { contentDescription = "$acceptLabel: $promptSuggestion" }, + ) { + Text(promptSuggestion, style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth()) + } + } Surface( shape = RoundedCornerShape(radius), color = MaterialTheme.colorScheme.surface, @@ -283,7 +306,7 @@ internal fun ComposerBar( placeholder = placeholder, onDraftChange = onDraftChange, onFocusChange = { focused = it }, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).focusRequester(inputFocusRequester), ) AnimatedVisibility( visible = !expanded, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt index a0ef3cd1ab..70b338cdad 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt @@ -371,6 +371,7 @@ internal fun ConversationView( modelCatalogFailed = state.modelCatalogFailure != null, capabilities = ChatComposerCapabilities.RemoteChat, placeholder = stringResource(R.string.message_input_label), + promptSuggestion = state.promptSuggestion, onDraftChange = { submittedDraft = null onIntent(RemoteSessionIntent.UpdateDraft(it)) diff --git a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml index a20513b3d3..5d7d9b8801 100644 --- a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml @@ -1,5 +1,6 @@ + 填入输入框 OpenBitFun 聊天 远程 diff --git a/src/apps/mobile/android/app/src/main/res/values/strings.xml b/src/apps/mobile/android/app/src/main/res/values/strings.xml index 4cbc72e192..fedfeaf6c0 100644 --- a/src/apps/mobile/android/app/src/main/res/values/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ + Use suggestion OpenBitFun Chat Remote diff --git a/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj b/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj index e2c92a0f32..bca361c31a 100644 --- a/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj +++ b/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + A20000000000000000000011 /* PromptSuggestionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011; }; A9F000000000000000000003 = {isa = PBXBuildFile; fileRef = B9F000000000000000000003; }; A9F000000000000000000002 = {isa = PBXBuildFile; fileRef = B9F000000000000000000002; }; A9F000000000000000000001 /* SidebarToolsFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F000000000000000000001; }; @@ -63,6 +64,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + B20000000000000000000011 /* PromptSuggestionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSuggestionUITests.swift; sourceTree = ""; }; B9F000000000000000000003 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDeviceRow.swift; sourceTree = ""; }; B9F000000000000000000002 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenBitFun/Infrastructure/StartupRevealPreference.swift; sourceTree = SOURCE_ROOT; }; B9F000000000000000000001 /* SidebarToolsFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarToolsFooter.swift; sourceTree = ""; }; @@ -137,7 +139,7 @@ D10000000000000000000018 /* Models */ = {isa = PBXGroup; children = (B10000000000000000000036 /* MobilePresentationModels.swift */); path = Models; sourceTree = ""; }; D10000000000000000000017 /* Platform */ = {isa = PBXGroup; children = (B10000000000000000000041, B10000000000000000000040); path = Platform; sourceTree = ""; }; D10000000000000000000009 /* Products */ = {isa = PBXGroup; children = (B10000000000000000000000 /* OpenBitFun.app */, B20000000000000000000000 /* OpenBitFunUITests.xctest */); name = Products; sourceTree = ""; }; - D20000000000000000000001 /* OpenBitFunUITests */ = {isa = PBXGroup; children = (B20000000000000000000001 /* RemoteCodeSessionSendUITests.swift */); path = OpenBitFunUITests; sourceTree = ""; }; + D20000000000000000000001 /* OpenBitFunUITests */ = {isa = PBXGroup; children = (B20000000000000000000001 /* RemoteCodeSessionSendUITests.swift */, B20000000000000000000011 /* PromptSuggestionUITests.swift */); path = OpenBitFunUITests; sourceTree = ""; }; D10000000000000000000010 /* DesignSystem */ = {isa = PBXGroup; children = (B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */, B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */, B10000000000000000000015 /* MobileDesignGallery.swift */, B10000000000000000000018 /* AdaptiveModalComponents.swift */); path = DesignSystem; sourceTree = ""; }; D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B90000000000000000000003 /* StreamingTextState.swift */, B90000000000000000000005 /* ComposerDismissGesture.swift */, B90000000000000000000002, B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */, B10000000000000000000024 /* MobileAppModel+FilePreview.swift */, B10000000000000000000025 /* MobileAppModel+Account.swift */, B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */, B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */, B10000000000000000000028 /* RemoteAuthorityGate.swift */, B10000000000000000000029 /* AccountFailureCopy.swift */, D10000000000000000000017 /* Platform */); path = Infrastructure; sourceTree = ""; }; /* End PBXGroup section */ @@ -156,7 +158,7 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A9F000000000000000000003, A9F000000000000000000002, A9F000000000000000000001, A91000000000000000000001, A90000000000000000000003, A90000000000000000000004, A90000000000000000000002, A90000000000000000000001, A10000000000000000000041, A10000000000000000000040, A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019, A10000000000000000000020, A10000000000000000000021, A10000000000000000000022, A10000000000000000000024, A10000000000000000000025, A10000000000000000000026, A10000000000000000000027, A10000000000000000000028, A10000000000000000000029, A10000000000000000000030, A10000000000000000000031, A10000000000000000000032, A10000000000000000000033, A10000000000000000000035, A10000000000000000000036, A10000000000000000000037, A10000000000000000000038, A90000000000000000000005, A90000000000000000000006); runOnlyForDeploymentPostprocessing = 0; }; - C20000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A20000000000000000000001 /* RemoteCodeSessionSendUITests.swift in Sources */); runOnlyForDeploymentPostprocessing = 0; }; + C20000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A20000000000000000000001 /* RemoteCodeSessionSendUITests.swift in Sources */, A20000000000000000000011 /* PromptSuggestionUITests.swift in Sources */); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ diff --git a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift index cffa70cbfc..06aeadbc54 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift @@ -4,6 +4,13 @@ import OSLog @MainActor enum MobileLaunchConfiguration { + static var promptSuggestionPreview: Bool { + #if DEBUG + ProcessInfo.processInfo.arguments.contains("--prompt-suggestion-preview") + #else + false + #endif + } static var streamingRegressionPreview: Bool { #if DEBUG ProcessInfo.processInfo.arguments.contains("--streaming-regression") @@ -21,7 +28,7 @@ enum MobileLaunchConfiguration { ChatMessage(id: UUID(), role: .user, text: "你好"), ChatMessage(id: UUID(), role: .assistant, text: "这是 OpenBitFun 的移动端会话界面。你可以从手机连接桌面端,查看工作区、会话和智能体的执行状态。") ], - connectCore: !ProcessInfo.processInfo.arguments.contains("--composer-model-picker") && !ProcessInfo.processInfo.arguments.contains("--composer-draft-preview") && !streamingRegressionPreview && !ProcessInfo.processInfo.arguments.contains("--permission-mailbox-preview") && !ProcessInfo.processInfo.arguments.contains("--harness-preview") && designPreviewScenario() == nil + connectCore: !ProcessInfo.processInfo.arguments.contains("--composer-model-picker") && !ProcessInfo.processInfo.arguments.contains("--composer-draft-preview") && !streamingRegressionPreview && !promptSuggestionPreview && !ProcessInfo.processInfo.arguments.contains("--permission-mailbox-preview") && !ProcessInfo.processInfo.arguments.contains("--harness-preview") && designPreviewScenario() == nil ) return configure(model) } @@ -52,6 +59,18 @@ enum MobileLaunchConfiguration { if arguments.contains("--connected") { model.configureConnectedPreview() } + if promptSuggestionPreview { + model.configureConnectedPreview() + model.surface = .remote + model.remoteConnected = true + model.connectionPhase = .connected + model.remoteSessionSelected = true + model.remoteOpenedSessionID = model.selectedSessionID + model.busy = false + model.isSending = false + model.remotePromptSuggestion = " Run the tests and summarize any failures. " + model.draft = "" + } if arguments.contains("--remote-chat-section") { if !model.remoteConnected { model.configureConnectedPreview() } model.remoteSessions.append( diff --git a/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift b/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift index 4f342d4254..f5cfd1ba0d 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift @@ -3,6 +3,7 @@ import SwiftUI @main struct OpenBitFunApp: App { @State private var showStartupBrand = !MobileLaunchConfiguration.streamingRegressionPreview + && !MobileLaunchConfiguration.promptSuggestionPreview && MobileLaunchConfiguration.designPreviewScenario() == nil && StartupRevealPreference.claim() @State private var showColdStart = false @@ -40,7 +41,7 @@ struct OpenBitFunApp: App { .onAppear { resolveColdStart() } .onChange(of: model.launchAccountRestored) { _ in resolveColdStart() } .task(id: showStartupBrand || showColdStart || !coldStartConsumed) { - if !showStartupBrand && !showColdStart && coldStartConsumed { + if !showStartupBrand && !showColdStart && coldStartConsumed && !MobileLaunchConfiguration.promptSuggestionPreview { notificationOnboardingOpen = await TaskCompletionNotifier.shouldOfferOnboarding() } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift index de3dd8636f..4afae36c67 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift @@ -56,6 +56,31 @@ struct ComposerBar: View { } var body: some View { + VStack(spacing: 0) { + if let suggestion = model.visiblePromptSuggestion, !speech.isListening { + Button { + if model.acceptPromptSuggestion(suggestion) { + inputExpansionRequested = true + focused = true + } + } label: { + Text(suggestion) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(OpenBitFunTheme.muted) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, minHeight: MobileDesignGeometry.controlTouchSize, alignment: .leading) + .padding(.horizontal, 12) + } + .buttonStyle(.plain) + .accessibilityHint(model.localized("填入输入框")) + .accessibilityIdentifier("composer-prompt-suggestion") + .padding(.horizontal, MobileDesignGeometry.conversationOverlaySideInset) + } + composerBody + } + } + + private var composerBody: some View { VStack(spacing: 2) { if !model.composerImages.isEmpty { attachmentStrip diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index 6915a60ef2..d9b55b8d98 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -393,6 +393,7 @@ extension MobileAppModel { /// normally arrive inside the grace period; a relay fetch gets an explicit /// skeleton instead of leaving the previous session visible. func beginRemoteConversationOpen(sessionID: String) { + remotePromptSuggestion = nil remoteOpenedSessionID = nil remoteConversationLoadTask?.cancel() remoteConversationLoadGeneration &+= 1 @@ -449,10 +450,11 @@ extension MobileAppModel { "Remote session timeline ready elapsed_ms=\(elapsedMS, privacy: .public) generation=\(self.remoteConversationLoadGeneration, privacy: .public)" ) } - resetRemoteConversationOpen() + resetRemoteConversationOpen(clearSuggestion: false) } - func resetRemoteConversationOpen() { + func resetRemoteConversationOpen(clearSuggestion: Bool = true) { + if clearSuggestion { remotePromptSuggestion = nil } remoteConversationLoadTask?.cancel() remoteConversationLoadTask = nil remoteConversationLoadGeneration &+= 1 @@ -1145,6 +1147,8 @@ extension MobileAppModel { let acceptsTimeline = remoteConversationOpeningSessionID.map { ready.timeline?.sessionId == $0 } ?? true + setPublishedIfChanged(\.remotePromptSuggestion, + to: acceptsTimeline && ready.selectedSessionId == selectedSessionID ? ready.promptSuggestion : nil) activeTurnID = acceptsTimeline ? ready.timeline?.activeTurn?.turnId : nil setPublishedIfChanged(\.isSending, to: acceptsTimeline && ready.timeline?.activeTurn != nil) let projectedModelOptions = ready.createModelOptions(fallbackLabel: localized("模型")).map { option in diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index 48cc6833b3..1fa41e05e7 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -77,6 +77,21 @@ final class MobileAppModel: ObservableObject { @Published var remoteConnected = false @Published var remoteSessionSelected = false @Published var remoteOpenedSessionID: String? = nil + @Published var remotePromptSuggestion: String? = nil + + var visiblePromptSuggestion: String? { + guard surface == .remote, remoteSessionSelected, remoteConnected, + connectionPhase == .connected, remoteOpenedSessionID == selectedSessionID, + !busy, !isSending, pendingComposerSend == nil, draft.isEmpty else { return nil } + return remotePromptSuggestion + } + + @discardableResult + func acceptPromptSuggestion(_ suggestion: String) -> Bool { + guard visiblePromptSuggestion == suggestion else { return false } + draft = suggestion + return true + } @Published var localSessionSelected = false /// Account device picker. Native apps connect only through the signed-in /// account's device list; QR and link pairing belong to mobile web. diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings index 8043e6c9bf..c19c7fe866 100644 --- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings @@ -1,6 +1,12 @@ { "sourceLanguage": "zh-Hans", "strings": { + "填入输入框": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Use suggestion" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "填入输入框" } } + } + }, "旧版设备:显示最近访问的工作区": { "localizations": { "en": { diff --git a/src/apps/mobile/ios/OpenBitFunUITests/PromptSuggestionUITests.swift b/src/apps/mobile/ios/OpenBitFunUITests/PromptSuggestionUITests.swift new file mode 100644 index 0000000000..47a946bbd8 --- /dev/null +++ b/src/apps/mobile/ios/OpenBitFunUITests/PromptSuggestionUITests.swift @@ -0,0 +1,27 @@ +import XCTest + +final class PromptSuggestionUITests: XCTestCase { + func testSuggestionFillsTheDraftWithoutSending() { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["--prompt-suggestion-preview", "--english"] + app.launch() + let suggestion = app.buttons["composer-prompt-suggestion"] + XCTAssertTrue(suggestion.waitForExistence(timeout: 15)) + XCTAssertTrue(suggestion.isHittable) + let before = XCTAttachment(screenshot: app.screenshot()) + before.name = "Prompt suggestion before acceptance" + before.lifetime = .keepAlways + add(before) + suggestion.tap() + let editor = app.textViews.firstMatch.exists ? app.textViews.firstMatch : app.textFields.firstMatch + XCTAssertTrue(editor.waitForExistence(timeout: 5)) + XCTAssertEqual(editor.value as? String, " Run the tests and summarize any failures. ") + XCTAssertFalse(suggestion.exists) + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) + let after = XCTAttachment(screenshot: app.screenshot()) + after.name = "Prompt suggestion copied into draft" + after.lifetime = .keepAlways + add(after) + } +} diff --git a/src/apps/mobile/ios/README.md b/src/apps/mobile/ios/README.md index 76fbf7b404..a0142ec602 100644 --- a/src/apps/mobile/ios/README.md +++ b/src/apps/mobile/ios/README.md @@ -108,6 +108,12 @@ It exercises language switching, numbered file previews, plan capability gating, and all three bundled Mini Apps using isolated preview data. No host command is sent. The Mini App resource build phase requires Node.js on PATH. +For next-input suggestions, run the simulator suite with +`-only-testing:OpenBitFunUITests/PromptSuggestionUITests` on a phone and an iPad. +The Debug-only `--prompt-suggestion-preview` launch flag uses an isolated host +hint with the production composer; acceptance must preserve its whitespace, +focus the editor and leave the text unsent. It does not contact a remote host. + The MiniApp build phase generates resources directly into the app bundle, so incremental builds include CSS and script edits without relying on a folder reference's timestamp. `miniapps.css` owns iOS font-family adaptations; the iframe diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt index 75d676dcea..294c1e7606 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -606,7 +606,7 @@ public class RemoteSessionStore internal constructor( private fun publishAuthorityReady(ready: RemoteSessionUiState.Ready): Long { authorityRevision += 1 - _state.value = ready.copy(revision = authorityRevision) + _state.value = ready.copy(revision = authorityRevision, promptSuggestion = currentPromptSuggestion(ready.selectedSessionId)) return authorityRevision } @@ -622,6 +622,8 @@ public class RemoteSessionStore internal constructor( private fun isCurrentWork(token: Long): Boolean = token == workGeneration public fun stop() { + sessionRecords = null + (_state.value as? RemoteSessionUiState.Ready)?.let { _state.value = it.copy(promptSuggestion = null) } catalogSubscription?.cancel(); catalogRefresh?.cancel() permissionMailbox.select(null) setForeground(false) @@ -925,6 +927,8 @@ public class RemoteSessionStore internal constructor( return } val current = _state.value as? RemoteSessionUiState.Ready + sessionRecords = null + if (current != null) _state.value = current.copy(promptSuggestion = null) val restoredDraft = loadPersistedDraft(normalized) var resumableCursor: ChatSessionCursor? = null if (persistenceEnabled) { @@ -1008,7 +1012,7 @@ public class RemoteSessionStore internal constructor( modelCatalogFailure = modelCatalogFailure ?: current?.modelCatalogFailure, draft = restoredDraft, revision = current?.revision ?: authorityRevision, - ) + ).copy(promptSuggestion = currentPromptSuggestion(normalized)) markConnected() refreshModelCatalog() } catch (cancelled: CancellationException) { @@ -1022,6 +1026,10 @@ public class RemoteSessionStore internal constructor( } private var sessionHistoryHasMore = false + private var sessionRecords: SessionRecordReplica? = null + + private fun currentPromptSuggestion(sessionId: String?): String? = + sessionRecords?.takeIf { it.sessionId == sessionId }?.promptSuggestion() private var transcriptWrite: Job? = null /** @@ -1073,7 +1081,8 @@ public class RemoteSessionStore internal constructor( val current = _state.value as? RemoteSessionUiState.Ready ?: return val snapshot = timelineStore.snapshot() if (current.selectedSessionId != snapshot.sessionId) return - _state.value = current.copy(timeline = snapshot, hasMoreMessages = sessionHistoryHasMore) + _state.value = current.copy(timeline = snapshot, hasMoreMessages = sessionHistoryHasMore, + promptSuggestion = currentPromptSuggestion(current.selectedSessionId)) markConnected() } @@ -1092,6 +1101,7 @@ public class RemoteSessionStore internal constructor( // fail to parse. if (hostCapabilitiesKnown && REMOTE_CAPABILITY_HOST_STREAM_V1 !in hostCapabilities) throw HostStreamUnsupportedException() var records = SessionRecordReplica(sessionId) + sessionRecords = records var caughtUp = false var replayingHistory = false /** @@ -1139,6 +1149,10 @@ public class RemoteSessionStore internal constructor( check(event["session_id"]?.jsonPrimitive?.content == sessionId) { "Session binding mismatch" } val payload = event["payload"] as? JsonObject ?: error("Missing session event payload") when (event["event"]?.jsonPrimitive?.content) { + "agentic://prompt-suggestion" -> { + records.applyPromptSuggestion(payload) + if (caughtUp && !replayingHistory) publishDurableTimeline() + } "session-record", "agentic://tool-event" -> { if (event["event"]?.jsonPrimitive?.content == "session-record") records.apply(payload) else { records.applyControl(payload) @@ -1164,6 +1178,10 @@ public class RemoteSessionStore internal constructor( STREAM_EVENT_GAP -> { replayingHistory = true records = SessionRecordReplica(sessionId) + sessionRecords = records + (_state.value as? RemoteSessionUiState.Ready)?.let { + _state.value = it.copy(promptSuggestion = null) + } timelineStore.reset(sessionId) permissionMailbox.invalidate() } @@ -1665,6 +1683,7 @@ public class RemoteSessionStore internal constructor( if (sessionId.isEmpty() || (content.trim().isEmpty() && intent.images.isNullOrEmpty())) return val current = _state.value as? RemoteSessionUiState.Ready ?: return if (current.busy || current.selectedSessionId != sessionId || _connectionPhase.value != ConnectionPhase.CONNECTED) return + sessionRecords?.clearPromptSuggestion() val submittedDraftRevision = draftRevision val activeTurnId = current.timeline?.activeTurn?.turnId?.takeIf { it.isNotBlank() } val steering = plan == null && activeTurnId != null && "dialog_steer_v1" in hostCapabilities @@ -1702,7 +1721,7 @@ public class RemoteSessionStore internal constructor( timelineStore.appendOptimisticMessage(local) val pendingActiveId = timelineStore.setPendingActiveTurn(local.id) (transport as? RemoteSessionStreamTransport)?.wakeSessionStreams() - _state.value = current.copy(busy = true, timeline = timelineStore.snapshot()) + _state.value = current.copy(busy = true, timeline = timelineStore.snapshot(), promptSuggestion = null) val operationToken = beginWork() work = scope.launch { try { diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt index 395d0c8740..ba3f8b8622 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionUiState.kt @@ -294,7 +294,20 @@ public sealed interface RemoteSessionUiState { public val lastSentMessage: SentChatMessage?, public val permissionMailbox: PermissionMailboxUiState, public val historyLoadState: HistoryLoadState, + /** Host hint for the latest completed turn; never persisted with the transcript. */ + public val promptSuggestion: String?, ) : RemoteSessionUiState { + /** Preserve the existing Swift initializer when adding the optional host hint. */ + public constructor( + sessions: List, selectedSessionId: String?, timeline: ChatTimelineState?, busy: Boolean, + permissionMode: SessionPermissionMode?, permissionModeFailure: PermissionModeFailure?, query: String, + agentFilter: SessionAgentFilter, hasMore: Boolean, hasMoreMessages: Boolean, + modelCatalog: RemoteModelCatalog?, modelCatalogFailure: ModelCatalogFailure?, draft: String, + revision: Long, lastSentMessage: SentChatMessage?, permissionMailbox: PermissionMailboxUiState, + historyLoadState: HistoryLoadState, + ) : this(sessions, selectedSessionId, timeline, busy, permissionMode, permissionModeFailure, query, + agentFilter, hasMore, hasMoreMessages, modelCatalog, modelCatalogFailure, draft, revision, + lastSentMessage, permissionMailbox, historyLoadState, null) public constructor( sessions: List, selectedSessionId: String?, timeline: ChatTimelineState?, busy: Boolean, permissionMode: SessionPermissionMode?, permissionModeFailure: PermissionModeFailure?, query: String, diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplica.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplica.kt index 35b1a5c3b4..37a8fa3829 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplica.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/SessionRecordReplica.kt @@ -5,12 +5,40 @@ import com.openbitfun.mobile.core.protocol.* import kotlinx.serialization.json.* /** Revisioned canonical records retain the complete host DTO; rendering is a mobile adapter. */ -internal class SessionRecordReplica(private val sessionId: String) { +internal class SessionRecordReplica(val sessionId: String) { private data class Versioned(val revision: Long, val value: JsonObject, val authoritative: Boolean = false) private val turns = linkedMapOf() private val rounds = linkedMapOf() private val items = linkedMapOf() private val controls = mutableMapOf>() + private var suggestion: Pair? = null + + /** Optional host hints are ephemeral and never become transcript records. */ + fun applyPromptSuggestion(payload: JsonObject) { + val source = (payload["sourceTurnId"] as? JsonPrimitive)?.takeIf { it.isString }?.content + val text = (payload["suggestion"] as? JsonPrimitive)?.takeIf { it.isString }?.content + val owner = payload["sessionId"] + if (owner != null && (owner as? JsonPrimitive)?.contentOrNull != sessionId) return + val latest = latestTurn() + if (latest != null && latest.key != source) return + if (!source.isNullOrEmpty() && !text.isNullOrEmpty()) suggestion = source to text + } + + fun clearPromptSuggestion() { suggestion = null } + + /** Replayed older pages cannot revive a hint superseded by a newer turn. */ + fun promptSuggestion(): String? { + val hint = suggestion ?: return null + val latest = latestTurn() + if (latest == null || latest.key != hint.first || latest.value.value.string("status") != "completed") { + suggestion = null + return null + } + return hint.second + } + + private fun latestTurn() = turns.filter { (id, record) -> record.revision > (tombstones["turn/$id"] ?: -1) } + .maxByOrNull { it.value.value.number("turnIndex") } /** * True until the stream has delivered a record of its own. diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt index 197b96f32b..2520a97bbf 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt @@ -38,6 +38,80 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class RemoteSessionStoreTest { + private fun suggestion(turn: String, text: JsonElement = JsonPrimitive(" Run the tests "), session: String = "s-code") = buildJsonObject { + put("session_id", session) + put("event", "agentic://prompt-suggestion") + put("payload", buildJsonObject { + put("sessionId", session); put("sourceTurnId", turn); put("suggestion", text) + }) + } + + @Test + fun promptSuggestionReplaysUnchangedAndDoesNotWriteTheDraftOrTranscript() = runTest { + val transport = FakeSessionTransport().apply { + initialEvents = listOf(richRecord("s-code", "turn-1", 0, 1, "completed", "answer"), suggestion("turn-1")) + } + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + var ready = assertIs(store.state.value) + assertEquals(" Run the tests ", ready.promptSuggestion) + assertEquals("", ready.draft) + assertEquals(listOf("answer"), ready.timeline!!.persistedMessages.filter { it.role == "assistant" }.map { it.text }) + store.dispatch(RemoteSessionIntent.UpdateDraft("my draft")); runCurrent() + transport.streamEvents.emit(suggestion("turn-1", JsonPrimitive("Another idea"))); runCurrent() + ready = assertIs(store.state.value) + assertEquals("my draft", ready.draft) + assertEquals("Another idea", ready.promptSuggestion) + store.stop() + assertNull(assertIs(store.state.value).promptSuggestion) + } + + @Test + fun promptSuggestionRetiresForANewerTurnAndIgnoresLateHints() = runTest { + val transport = FakeSessionTransport().apply { + initialEvents = listOf(richRecord("s-code", "turn-1", 0, 1, "completed", "answer")) + } + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + transport.streamEvents.emit(suggestion("turn-1")); runCurrent() + assertNotNull(assertIs(store.state.value).promptSuggestion) + transport.streamEvents.emit(richRecord("s-code", "turn-2", 1, 2, "inprogress", "working")); runCurrent() + assertNull(assertIs(store.state.value).promptSuggestion) + transport.streamEvents.emit(richRecord("s-code", "turn-2", 1, 3, "completed", "done")) + transport.streamEvents.emit(suggestion("turn-1")); runCurrent() + assertNull(assertIs(store.state.value).promptSuggestion) + transport.streamEvents.emit(suggestion("turn-2", JsonPrimitive(42))); runCurrent() + assertNull(assertIs(store.state.value).promptSuggestion) + transport.streamEvents.emit(suggestion("turn-2", JsonPrimitive("Current idea"))); runCurrent() + transport.streamEvents.emit(suggestion("turn-1", JsonPrimitive("Old replay"))); runCurrent() + assertEquals("Current idea", assertIs(store.state.value).promptSuggestion) + store.stop() + } + + @Test + fun promptSuggestionIsClearedOnHostRestartAndSessionSwitch() = runTest { + val transport = FakeSessionTransport().apply { + initialEvents = listOf(richRecord("s-code", "turn-1", 0, 1, "completed", "answer"), suggestion("turn-1")) + } + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Open("s-code")); runCurrent() + assertNotNull(assertIs(store.state.value).promptSuggestion) + transport.streamEvents.emit(buildJsonObject { + put("session_id", "s-code"); put("event", "relay://session-gap"); put("payload", buildJsonObject {}) + }); runCurrent() + assertNull(assertIs(store.state.value).promptSuggestion) + transport.streamEvents.emit(richRecord("s-code", "turn-1", 0, 1, "completed", "answer")) + transport.streamEvents.emit(historyReady(false)); runCurrent() + assertNull(assertIs(store.state.value).promptSuggestion) + transport.streamEvents.emit(suggestion("turn-1")); runCurrent() + transport.initialEvents = emptyList() + store.dispatch(RemoteSessionIntent.Open("s-cowork")); runCurrent() + val ready = assertIs(store.state.value) + assertEquals("s-cowork", ready.selectedSessionId) + assertNull(ready.promptSuggestion) + store.stop() + } + @Test fun firstHistoryReadDoesNotPublishAnEmptyOrPartialConversation() = runTest { val transport = FakeSessionTransport().apply { From ec7e43f41804bdec7c7c88f6e5ce5f4f2f6212f2 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 14 Oct 2026 18:20:11 +0800 Subject: [PATCH 4/4] feat(harmony): show next-input suggestions across mobile layouts --- src/apps/mobile/AGENTS.md | 5 ++ src/apps/mobile/README.md | 1 + .../components/mobile-components.json | 7 ++ .../preview/generated/mobile-design-data.js | 21 ++++++ .../entry/src/main/ets/i18n/EnUsMessages.ets | 1 + .../entry/src/main/ets/i18n/ZhCnMessages.ets | 1 + .../main/ets/pages/components/ComposerBar.ets | 29 ++++++++ .../ets/pages/components/ConversationView.ets | 1 + .../ets/pages/state/ConversationCoreState.ets | 8 +- .../ets/pages/state/ConversationViewState.ets | 2 + .../viewmodel/RemoteTranscriptController.ets | 1 + .../ets/services/ChatSessionController.ets | 7 +- .../ets/services/DurableSessionReducer.ets | 34 +++++++++ .../tools/tests/composer-submit.test.cjs | 14 ++++ .../tools/tests/session-record.test.cjs | 73 +++++++++++++++++++ 15 files changed, 203 insertions(+), 2 deletions(-) diff --git a/src/apps/mobile/AGENTS.md b/src/apps/mobile/AGENTS.md index 1622b68c9f..686ac4c127 100644 --- a/src/apps/mobile/AGENTS.md +++ b/src/apps/mobile/AGENTS.md @@ -107,6 +107,11 @@ UiState or an Intent declared there, and no module above it is visible to them. ## Remote capability ownership +For next-input suggestion replay, stale-turn retirement, host restart and session +isolation, run from `shared/` with the Android guide's Java runtime: +`./gradlew :core-feature:jvmTest --tests '*promptSuggestion*' --tests '*SessionRecordReplicaTest*'`. +These are simulated host streams, not evidence of a live remote connection. + Native mobile surfaces are controllers. Desktop and CLI own execution, files, terminals and SSH credentials. Mobile workspace selection may use only connections already saved on the controlled runtime; it must not create an SSH connection or instantiate another runtime. Carry the selected connection identity through every operation, and report missing identity instead of falling back to local files. ## Offline Mini Apps diff --git a/src/apps/mobile/README.md b/src/apps/mobile/README.md index 3dc586d385..58aec703df 100644 --- a/src/apps/mobile/README.md +++ b/src/apps/mobile/README.md @@ -64,6 +64,7 @@ metadata, execution-mode IDs, speech draft merging, and completion observation i |---|---|---| | Execution modes | Minimal / Standard / Ultimate, with the HarmonyOS density glyph; workspace menus also retain Cowork | Enabled only by live `get_workspace_info.capabilities` containing `harness_profiles_v1`; older hosts keep Code / Cowork. Capability absence in old payloads is covered by a round-trip test. | | Running-turn input | A nonempty remote draft offers Send; an empty composer retains Stop | Hosts advertising `dialog_steer_v1` receive `steer_turn` with the active turn ID. Older hosts retain `send_message` queueing; failed sends retain the draft and attachments. | +| Next-input suggestions | Android, iOS and HarmonyOS show the host's suggestion above an empty, idle composer; tapping fills and focuses the editor without sending | Uses the existing host session stream. Hints retain their exact text and retire on newer turns, session changes or host restarts. Older hosts simply show no hint; no new Relay API or migration is needed. | | Plans | Legacy CreatePlan, structured plans and `.plan.md` writes show a plan card with file preview and Build | Build requires `plan_build_v1`, a completed plan tool and an idle connected session. Paths remain remote; the mobile app never runs the plan locally. | | Offline Mini Apps | Gomoku, Regex Playground and Daily Divination open from welcome and sidebar without login | Product-owned sources are bundled by `miniapps/generate.cjs`; native WebView hosts expose only per-app allowlisted storage and clipboard. No network, Node or shell bridge. | | Code preview | Native lexical colors, line numbers and referenced-line backgrounds | Both apps use the core-feature highlighter with native theme tokens; large files retain its bounded plain-text fallback. | diff --git a/src/apps/mobile/design-system/components/mobile-components.json b/src/apps/mobile/design-system/components/mobile-components.json index 3fd3ff16a8..e759987a6d 100644 --- a/src/apps/mobile/design-system/components/mobile-components.json +++ b/src/apps/mobile/design-system/components/mobile-components.json @@ -99,6 +99,13 @@ "tokens": ["composer_action_size", "composer_collapsed_height", "composer_expanded_height", "composer_collapsed_radius", "composer_expanded_radius"], "platformNotes": "Keyboard, dictation, and attachment pickers remain native adapters. The focused two-row form stays compact and exists to expose message context controls; multiline content and attachments may grow it further. The trailing action is the single filled action for Send, Dictate, or Stop." }, + "prompt_suggestion": { + "purpose": "Offer the host's next-input suggestion above the remote conversation composer.", + "anatomy": ["suggestion_text", "full_width_touch_target"], + "states": ["available", "draft_hidden", "busy_hidden", "disconnected_hidden", "retired"], + "tokens": ["body_medium", "muted", "control_touch_size", "conversation_overlay_side_inset"], + "platformNotes": "Compact and wide layouts use a quiet, leading-aligned button with wrapping text and a minimum 48-unit touch target. Show only for the latest completed turn when the draft is empty and the session is idle and connected. Tapping copies the exact text into the native editor and focuses it without sending. New turns, session changes and host stream gaps retire the hint. Suggestions stay outside persisted transcripts; older hosts need no fallback request." + }, "composer_model_selector": { "purpose": "Changes the model for the message being composed without leaving the conversation or creation flow.", "anatomy": ["optional_modal_header", "selected_mark", "primary_label", "supporting_label", "scrolling_model_rows"], diff --git a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js index 11baa9add1..84f7378e77 100644 --- a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js +++ b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js @@ -730,6 +730,27 @@ export const mobileComponents = { ], "platformNotes": "Keyboard, dictation, and attachment pickers remain native adapters. The focused two-row form stays compact and exists to expose message context controls; multiline content and attachments may grow it further. The trailing action is the single filled action for Send, Dictate, or Stop." }, + "prompt_suggestion": { + "purpose": "Offer the host's next-input suggestion above the remote conversation composer.", + "anatomy": [ + "suggestion_text", + "full_width_touch_target" + ], + "states": [ + "available", + "draft_hidden", + "busy_hidden", + "disconnected_hidden", + "retired" + ], + "tokens": [ + "body_medium", + "muted", + "control_touch_size", + "conversation_overlay_side_inset" + ], + "platformNotes": "Compact and wide layouts use a quiet, leading-aligned button with wrapping text and a minimum 48-unit touch target. Show only for the latest completed turn when the draft is empty and the session is idle and connected. Tapping copies the exact text into the native editor and focuses it without sending. New turns, session changes and host stream gaps retire the hint. Suggestions stay outside persisted transcripts; older hosts need no fallback request." + }, "composer_model_selector": { "purpose": "Changes the model for the message being composed without leaving the conversation or creation flow.", "anatomy": [ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index 94b100d4da..c0c069f58e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -101,6 +101,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['notification.taskCompletedBody', 'Your OpenBitFun task is ready. Tap to open OpenBitFun and view the result.'], ['chatHome.prompt', 'What do you want to do today?'], + ['chat.promptSuggestionAccept', 'Use suggestion'], ['chatHome.placeholder', 'Ask OpenBitFun'], ['chatHome.localHint', 'This chat stays on the phone. Switch to Remote to read and write projects on your computer.'], ['chatHome.research', 'Find sources'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index e3bf40e598..1d07c8f96e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -101,6 +101,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['notification.taskCompletedBody', 'OpenBitFun 任务已经完成,点击打开 OpenBitFun 查看结果。'], ['chatHome.prompt', '今天想做什么?'], + ['chat.promptSuggestionAccept', '填入输入框'], ['chatHome.placeholder', '问问 OpenBitFun'], ['chatHome.localHint', '这是手机上的对话。要读写电脑上的项目,请切换到远程。'], ['chatHome.research', '查找资料'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 0696cab2da..c566ef520c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -60,6 +60,7 @@ export struct ComposerBar { @Param capabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; @Param inputId: string = 'conversation-composer-input'; @Param chatInput: string = ''; + @Param promptSuggestion: string = ''; @Local inputText: string = ''; @Local inputFocused: boolean = false; @Local primaryActionPressed: boolean = false; @@ -118,6 +119,29 @@ export struct ComposerBar { build() { Column() { + if (this.canAcceptPromptSuggestion()) { + Button({ type: ButtonType.Normal }) { + Text(this.promptSuggestion) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontColor(MUTED) + .width('100%') + .textAlign(TextAlign.Start) + } + .id('composer-prompt-suggestion') + .width('100%') + .constraintSize({ minHeight: MobileDesignGeometry.controlTouchSize }) + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + .backgroundColor(TRANSPARENT) + .borderRadius(MobileDesignGeometry.controlTouchSize / 2) + .accessibilityText(this.promptSuggestion) + .accessibilityDescription(RemoteI18n.t('chat.promptSuggestionAccept')) + .onClick(() => { + if (!this.canAcceptPromptSuggestion()) return; + this.inputText = this.promptSuggestion; + this.onChatInputChange(this.promptSuggestion); + this.getUIContext().getFocusController().requestFocus(this.inputId); + }) + } this.AdaptiveComposer() } .width('100%') @@ -134,6 +158,11 @@ export struct ComposerBar { .backgroundColor(TRANSPARENT) } + private canAcceptPromptSuggestion(): boolean { + return this.promptSuggestion.length > 0 && this.inputText.length === 0 && this.chatInput.length === 0 && + !this.isBusy && !this.canStop && !this.isVoiceListening && this.connectionState === 'connected'; + } + @Builder AdaptiveComposer() { Column({ space: 2 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 0c5cc0fca8..e444956b6b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -558,6 +558,7 @@ export struct ConversationView { capabilities: this.composerCapabilities, inputId: this.composerInputId, chatInput: this.chatInput, + promptSuggestion: this.stagingTargetTurnId.length > 0 ? '' : this.state.promptSuggestion, selectedImages: this.selectedImages, isBusy: this.isBusy, canStop: this.canStop, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets index 1702f038cc..4fbe4f9c92 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets @@ -33,6 +33,7 @@ export class ConversationCoreState { @Trace selectedModelId: string = ''; @Trace statusText: string = ''; @Trace chatInput: string = ''; + @Trace promptSuggestion: string = ''; @Trace selectedImages: SelectedImageAttachment[] = []; @Trace isVoiceListening: boolean = false; private composerRevision: number = 0; @@ -50,7 +51,10 @@ export class ConversationCoreState { } setActiveSession(session: SessionSummary): void { - if (this.activeSession.sessionId !== session.sessionId) this.composerRevision++; + if (this.activeSession.sessionId !== session.sessionId) { + this.composerRevision++; + this.promptSuggestion = ''; + } this.activeSession = { sessionId: session.sessionId, title: session.title, @@ -89,6 +93,7 @@ export class ConversationCoreState { } clearTimeline(): void { + this.promptSuggestion = ''; this.persistedMessages = []; this.optimisticMessages = []; this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); @@ -152,6 +157,7 @@ export class ConversationCoreState { commit: (): void => { if (finished || committed || this.composerRevision !== capturedRevision || this.activeSession.sessionId !== sessionId) return; + this.promptSuggestion = ''; if (this.chatInput.trim() === text.trim()) this.setChatInput(''); this.setSelectedImages(this.selectedImages.filter((image: SelectedImageAttachment): boolean => !images.some((sent: SelectedImageAttachment): boolean => sent.id === image.id))); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets index 4be69c92a9..da62f331b6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets @@ -54,6 +54,7 @@ export class ConversationViewState { selectedImages: ConversationUiSelectedImage[] = []; isVoiceListening: boolean = false; chatInput: string = ''; + promptSuggestion: string = ''; /** * The facts a tap on the rollback entry is judged with. * @@ -145,6 +146,7 @@ export class ConversationViewState { state.selectedImages = core.selectedImages.map((image) => toConversationUiSelectedImage(image)); state.isVoiceListening = core.isVoiceListening; state.chatInput = core.chatInput; + state.promptSuggestion = core.promptSuggestion; return state; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets index 8435a7e976..ce3ad60f9b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets @@ -455,6 +455,7 @@ export class RemoteTranscriptController { } runtime.timeline.applySnapshot(snapshot); this.syncRemoteTimeline(); + this.remote.conversation.promptSuggestion = snapshot.promptSuggestion ?? ''; if (snapshot.historyLoading === true) { // A history page publishes once per record it contains, and every one of // them lands in front of the window, so no row already on disk survives: diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets index a0803dc740..89df9c4f73 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets @@ -35,6 +35,8 @@ export interface ChatSessionSnapshot { hasMoreMessages?: boolean; historyLoading?: boolean; historyFailed?: boolean; + /** Empty when no valid hint remains; older hosts simply never emit one. */ + promptSuggestion?: string; } export interface ChatSessionControllerCallbacks { @@ -106,6 +108,7 @@ export class ChatSessionController { if (!this.current(epoch)) return; this.historyReplaying = true; this.reducer = new DurableSessionReducer(); this.hasMoreMessages = false; + if (this.caughtUp) this.publish(); }, onCaughtUp: async (): Promise => { if (!this.current(epoch)) return; @@ -174,12 +177,14 @@ export class ChatSessionController { const catalog = this.pendingModelCatalog; this.pendingModelCatalog = undefined; if (catalog) this.cursor.knownModelCatalogVersion = catalog.version || 0; + const suggestion = this.reducer.promptSuggestion(); this.callbacks.onSnapshot({ sessionId: this.sessionId, cursor: this.cursor, changed: true, title: '', modelCatalog: catalog, sessionState: active ? 'active' : this.sessionState, newMessages: [], activeTurn: active, messageSnapshot: persisted, shouldSyncAfterTurnEnded: false, historyRewritten: false, completedTurnId: this.completedTurnId, hasMoreMessages: this.hasMoreMessages, - historyLoading: this.historyLoading, historyFailed: this.historyFailed + historyLoading: this.historyLoading, historyFailed: this.historyFailed, + promptSuggestion: active ? '' : suggestion }); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets index c32811d4ae..2cc1a18604 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets @@ -19,6 +19,7 @@ interface StoredTurn { revision: number; data?: TurnRecord; authoritative?: bool interface StoredRound { revision: number; data?: RoundRecord; } interface StoredItem { revision: number; turnId: string; roundId: string; data: ItemRecord; } interface RenderedTurn { generation: number; messages: ChatMessage[]; } +interface PromptSuggestion { sourceTurnId: string; suggestion: string; sessionId?: string; } /** Lossless record storage follows Happy's stable-id message reduction. Mobile * presentation adapts the existing persisted turn schema; it does not define a @@ -36,6 +37,29 @@ export class DurableSessionReducer { private turnGenerations: Map = new Map(); private renderedTurns: Map = new Map(); private generationSeq: number = 0; + private suggestion?: PromptSuggestion; + + /** Optional host hints stay outside durable transcript records. */ + promptSuggestion(): string { + const hint = this.suggestion; + if (!hint) return ''; + const latest = this.latestTurn(); + if (!latest || latest.turnId !== hint.sourceTurnId || latest.status !== 'completed') { + this.suggestion = undefined; + return ''; + } + return hint.suggestion; + } + + private latestTurn(): TurnRecord | undefined { + let latest: TurnRecord | undefined = undefined; + for (const stored of this.turns.values()) { + const turn = stored.data; + if (turn && stored.revision > (this.tombstones.get(`turn/${turn.turnId}`) ?? 0) && + (!latest || turn.turnIndex > latest.turnIndex)) latest = turn; + } + return latest; + } private touchTurn(turnId: string): void { this.generationSeq++; this.turnGenerations.set(turnId, this.generationSeq); @@ -46,6 +70,16 @@ export class DurableSessionReducer { if (turnId) this.touchTurn(turnId); else this.renderedTurns.clear(); } apply(event: HostSessionEvent): boolean { + if (event.event === 'agentic://prompt-suggestion') { + const hint = event.payload as PromptSuggestion; + if (!hint || typeof hint.sourceTurnId !== 'string' || !hint.sourceTurnId || + typeof hint.suggestion !== 'string' || !hint.suggestion || + (hint.sessionId !== undefined && hint.sessionId !== event.session_id)) return false; + const latest = this.latestTurn(); + if (latest && latest.turnId !== hint.sourceTurnId) return false; + this.suggestion = hint; + return true; + } if (event.event !== 'session-record') return false; const record = event.payload as SessionRecord; if (record.sessionId !== event.session_id || !record.id || !Number.isSafeInteger(record.revision) || record.revision < 1) throw new Error('Invalid session record identity'); diff --git a/src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs b/src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs index b35a18e86e..353d4f62ba 100644 --- a/src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs @@ -62,6 +62,20 @@ function composer() { core.setChatInput(' Original '); core.setSelectedImages([{id:'sent-image'}]); return core; } +test('prompt hints never overwrite a draft and retire on submit, selection change and reset', () => { + const core = composer(); + core.promptSuggestion = ' Run the tests '; + assert.equal(core.chatInput, ' Original '); + core.setChatInput(core.promptSuggestion); + assert.equal(core.chatInput, ' Run the tests '); + submitComposer(core, 'session', core.chatInput, []); + assert.equal(core.promptSuggestion, ''); + core.promptSuggestion = 'Old session'; + core.setActiveSession({sessionId: 'other'}); + assert.equal(core.promptSuggestion, ''); + core.promptSuggestion = 'Old target'; core.reset(); + assert.equal(core.promptSuggestion, ''); +}); test('submission owns an exact text/attachment snapshot and failure restores it once', () => { const core=composer(); const restore=submitComposer(core,'session','Original',[{id:'sent-image'}]); assert.equal(core.chatInput,''); assert.deepEqual(core.selectedImages,[]); diff --git a/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs b/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs index bca2e0b8f4..5f12b820f3 100644 --- a/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs @@ -13,6 +13,79 @@ function load(name, dependencies = {}) { const mailboxModule = load('InteractionMailboxStore', { '../model/InteractionMailbox': load('../model/InteractionMailbox') }); const reducerModule = load('DurableSessionReducer'); const { DurableSessionReducer } = reducerModule; +function suggestion(sourceTurnId, text = ' Run the tests ', session = 'session') { + return { session_id: session, event: 'agentic://prompt-suggestion', + payload: { sessionId: session, sourceTurnId, suggestion: text } }; +} + +function suggestionFixture() { + const { ChatSessionController } = load('ChatSessionController', { './DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule }); + let callbacks; + const snapshots = []; + const manager = { getModelCatalog: async () => ({version: 1, models: [], default_models: {}}), + subscribeSession: (_id, next) => { callbacks = next; return {wake() {}, close() {}}; } }; + const controller = new ChatSessionController(manager, {onSnapshot: value => snapshots.push(value), canPoll: () => true, onError: error => {throw error;}}); + controller.start('session', {pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0}); + return { controller, snapshots, stream: () => callbacks, latest: () => snapshots.at(-1) }; +} + +test('prompt suggestion replays unchanged outside the transcript and resets on a host gap', async () => { + const f = suggestionFixture(); + await f.stream().onEvent(record(1, 'answer', 'completed')); + await f.stream().onEvent(suggestion('turn')); + assert.equal(f.snapshots.length, 0); + await f.stream().onCaughtUp(); + assert.equal(f.latest().promptSuggestion, ' Run the tests '); + assert.equal(f.latest().messageSnapshot.at(-1).text, 'answer'); + await f.stream().onGap(); + assert.equal(f.latest().promptSuggestion, ''); + await f.stream().onEvent(record(1, 'answer', 'completed')); + await f.stream().onCaughtUp(); + assert.equal(f.latest().promptSuggestion, ''); + f.controller.stop(); +}); + +test('a newer turn retires suggestions and late or malformed hints stay invisible', async () => { + const f = suggestionFixture(); + await f.stream().onEvent(record(1, 'answer', 'completed')); + await f.stream().onCaughtUp(); + await f.stream().onEvent(suggestion('turn')); + assert.ok(f.latest().promptSuggestion); + const next = {session_id: 'session', event: 'session-record', payload: { + sessionId: 'session', id: 'turn/next', revision: 2, + turn: {turnId: 'next', turnIndex: 1, sessionId: 'session', timestamp: 2, status: 'inprogress', + userMessage: {id: 'next-user', content: 'next', timestamp: 2}} + }}; + await f.stream().onEvent(next); + assert.equal(f.latest().promptSuggestion, ''); + const complete = structuredClone(next); + complete.payload.revision = 3; complete.payload.turn.status = 'completed'; + await f.stream().onEvent(complete); + for (const event of [suggestion('turn'), suggestion('next', 42), suggestion('next', ''), suggestion('next', 'foreign', 'other')]) { + await f.stream().onEvent(event); + assert.equal(f.latest().promptSuggestion, ''); + } + await f.stream().onEvent(suggestion('next', 'New idea')); + assert.equal(f.latest().promptSuggestion, 'New idea'); + await f.stream().onEvent(suggestion('turn', 'Old replay')); + assert.equal(f.latest().promptSuggestion, 'New idea'); + f.controller.stop(); +}); + +test('switching sessions ignores the previous stream and older hosts work without hints', async () => { + const f = suggestionFixture(); + await f.stream().onEvent(record(1, 'answer', 'completed')); + await f.stream().onCaughtUp(); + assert.equal(f.latest().promptSuggestion, ''); + const previous = f.stream(); + await previous.onEvent(suggestion('turn')); + f.controller.start('other', {pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0}); + await f.stream().onCaughtUp(); + await previous.onEvent(suggestion('turn', 'Late old hint')); + assert.equal(f.latest().sessionId, 'other'); + assert.equal(f.latest().promptSuggestion, ''); + f.controller.stop(); +}); function record(revision, content, status = 'inprogress', itemId = 'text') { return { session_id: 'session', event: 'session-record', payload: { sessionId: 'session', id: `item/${itemId}`, revision,