diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index edbc69121d..07376c359d 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -17,10 +17,12 @@ use protocol::{ DispatchAnswerRequest, DispatchAnswerResponse, DispatchAppendRequest, DispatchAppendResponse, DispatchCancelRequest, DispatchCancelResponse, DispatchContinueRequest, DispatchContinueResponse, DispatchJobListEntry, DispatchJobState, DispatchListRequest, - DispatchProbeRequest, DispatchProbeResponse, DispatchStatusRequest, DispatchStatusResponse, - DispatchSubmitRequest, DispatchSubmitResponse, DispatchWorkspaceBundleBeginRequest, - DispatchWorkspaceBundleChunkRequest, DispatchWorkspaceBundleCommitRequest, - DispatchWorkspaceProbe, DispatchWorkspaceProvisionRequest, DispatchWorkspaceSyncChunkRequest, + DispatchProbeRequest, DispatchProbeResponse, DispatchQueryKind, DispatchQueryRequest, + DispatchStatusRequest, DispatchStatusResponse, + DispatchSubmitRequest, DispatchSubmitResponse, DispatchTurnKind, + DispatchWorkspaceBundleBeginRequest, DispatchWorkspaceBundleChunkRequest, + DispatchWorkspaceBundleCommitRequest, DispatchWorkspaceProbe, + DispatchWorkspaceProvisionRequest, DispatchWorkspaceSyncChunkRequest, DispatchWorkspaceSyncRequest, DISPATCH_PROTOCOL_VERSION, MAX_DISPATCH_TEXT_BYTES, }; use store::{CreateJobOutcome, DispatchStateRecord, DispatchStore}; @@ -63,6 +65,7 @@ pub(crate) async fn run_dispatch_verb( "append" => serde_json::to_value(append(parse(input)?)?).context("encode appended message"), "continue" => serde_json::to_value(continue_job(parse(input)?)?) .context("encode follow-up turn response"), + "query" => query(parse(input)?).await.context("encode query response"), "workspace-provision" => serde_json::to_value(workspace::provision(parse::< DispatchWorkspaceProvisionRequest, >(input)?)?) @@ -120,30 +123,16 @@ async fn probe(request: DispatchProbeRequest) -> Result { .as_deref() .map(inspect_workspace) .transpose()?; - let mut capabilities = vec![ - "persistent_jobs".to_string(), - "cursor_events".to_string(), - "workspace_serialization".to_string(), - "approval_auto".to_string(), - "approval_reject_and_report".to_string(), - "approval_remote".to_string(), - "frontend_event_projection".to_string(), - "append_message".to_string(), - "event_log_completeness".to_string(), - // Git-worktree delivery. A target without these cannot be provisioned - // at all — there is no snapshot fallback left — so controllers fail - // preflight rather than degrade. - "workspace_git_worktree".to_string(), - "workspace_git_bundle_upload".to_string(), - "workspace_git_sync".to_string(), - // A target may share the same package version while predating the - // dispatch entrypoint's early CLI-profile selection. Such a binary can - // accept a job but every detached worker then fails before execution. - // Advertise the behavioral fix explicitly so controllers fail closed. - "dispatch_worker_cli_profile".to_string(), - ]; + let mut capabilities: Vec = + bitfun_services_core::dispatch_contract::DISPATCH_BASE_TARGET_CAPABILITIES + .iter() + .map(|capability| capability.to_string()) + .collect(); if runner::is_supported() { - capabilities.push("detached_worker".to_string()); + capabilities.push( + bitfun_services_core::dispatch_contract::DISPATCH_DETACHED_WORKER_CAPABILITY + .to_string(), + ); } Ok(DispatchProbeResponse { protocol_version: DISPATCH_PROTOCOL_VERSION, @@ -225,12 +214,33 @@ fn continue_job(request: DispatchContinueRequest) -> Result { + if request.prompt.trim().is_empty() { + bail!("dispatch follow-up requires a prompt"); + } + } + DispatchTurnKind::Compact => { + if !request.prompt.trim().is_empty() { + bail!("dispatch compact turns do not take a prompt"); + } + if !request.attachments.is_empty() { + bail!("dispatch compact turns do not take attachments"); + } + } } + validate_attachments(&request.attachments)?; if request.prompt.len() > MAX_DISPATCH_TEXT_BYTES { bail!("dispatch follow-up prompt exceeds the 32 KiB safety limit"); } + if let Some(model) = &request.model { + if model.trim().is_empty() { + bail!("dispatch model override cannot be empty"); + } + if model.len() > 256 { + bail!("dispatch model override exceeds the 256 byte limit"); + } + } if !runner::is_supported() { bail!("dispatch detached workers are supported only on Linux and macOS"); } @@ -249,6 +259,45 @@ fn continue_job(request: DispatchContinueRequest) -> Result Result { + let store = DispatchStore::open_default()?; + let job = store.load_job(&request.job_id)?; + match request.kind { + DispatchQueryKind::UsageReport => { + let path_manager = bitfun_core::infrastructure::PathManager::new() + .map_err(|error| anyhow::anyhow!("resolve BitFun storage root: {error}"))?; + let persistence = bitfun_core::agentic::persistence::PersistenceManager::new( + std::sync::Arc::new(path_manager), + ) + .map_err(|error| anyhow::anyhow!("open session persistence: {error}"))?; + let report = bitfun_core::service::session_usage::generate_session_usage_report( + &persistence, + None, + bitfun_core::service::session_usage::SessionUsageReportRequest { + session_id: job.request.session_id.clone(), + workspace_path: Some(job.request.workspace_path.clone()), + remote_connection_id: None, + remote_ssh_host: None, + include_hidden_subagents: false, + }, + ) + .await + .map_err(|error| anyhow::anyhow!("generate dispatch usage report: {error}"))?; + serde_json::to_value(serde_json::json!({ + "kind": "usageReport", + "sessionId": job.request.session_id, + "report": report, + })) + .context("encode dispatch usage report") + } + } +} + fn ensure_worker_spawned( store: &DispatchStore, job_id: &str, @@ -706,6 +755,10 @@ fn canonical_workspace(workspace_path: &str) -> Result { Ok(canonical) } +fn validate_attachments(attachments: &[protocol::DispatchAttachment]) -> Result<()> { + protocol::validate_dispatch_attachments(attachments).map_err(anyhow::Error::msg) +} + fn validate_submit_request(request: &DispatchSubmitRequest) -> Result<()> { if request.protocol_version != DISPATCH_PROTOCOL_VERSION { bail!( @@ -729,6 +782,7 @@ fn validate_submit_request(request: &DispatchSubmitRequest) -> Result<()> { if request.prompt.len() > MAX_DISPATCH_TEXT_BYTES { bail!("dispatch prompt exceeds the 32 KiB request limit"); } + validate_attachments(&request.attachments)?; if request.setup_audit.len() > 32 { bail!("dispatch setup audit exceeds the 32-event safety limit"); } @@ -775,6 +829,7 @@ mod tests { approval_policy: DispatchApprovalPolicy::RejectAndReport, model: Some("model-1".to_string()), title: Some("Task".to_string()), + attachments: Vec::new(), setup_audit: Vec::new(), } } diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs index 0ef4faaa1f..5ababa9882 100644 --- a/src/apps/cli/src/dispatch/protocol.rs +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -2,7 +2,12 @@ use serde::{Deserialize, Serialize}; use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; -pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 3; +// The wire contract (version, capability names, attachment shape and +// limits) has one source of truth shared with the controller side. +pub(crate) use bitfun_services_core::dispatch_contract::{ + validate_dispatch_attachments, DispatchAttachment, DISPATCH_PROTOCOL_VERSION, +}; + pub(crate) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] @@ -55,6 +60,17 @@ pub(crate) enum DispatchApprovalPolicy { Remote, } +/// What a queued follow-up turn asks the worker to run. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum DispatchTurnKind { + /// An ordinary user prompt submitted as a dialog turn. + #[default] + Prompt, + /// Manual context compaction, run as a turn so its events attribute. + Compact, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DispatchSubmitRequest { @@ -69,6 +85,8 @@ pub(crate) struct DispatchSubmitRequest { pub(crate) model: Option, #[serde(default)] pub(crate) title: Option, + #[serde(default)] + pub(crate) attachments: Vec, /// Controller-side setup actions that happened before the target job could /// exist (currently the signed CLI auto-install). They are replayed into /// the durable job event log at creation time and are deliberately excluded @@ -338,6 +356,18 @@ pub(crate) struct DispatchContinueRequest { pub(crate) prompt: String, #[serde(default)] pub(crate) display_content: Option, + /// Per-turn model override. Absent keeps the job's current model; present + /// it also becomes the job's model for later turns. + #[serde(default)] + pub(crate) model: Option, + /// Per-turn approval-policy override with the same carry-forward rule. + #[serde(default)] + pub(crate) approval_policy: Option, + /// Operation the worker runs; defaults to an ordinary prompt turn. + #[serde(default)] + pub(crate) kind: DispatchTurnKind, + #[serde(default)] + pub(crate) attachments: Vec, } #[derive(Clone, Debug, Serialize, PartialEq, Eq)] @@ -356,6 +386,24 @@ pub(crate) struct DispatchCancelRequest { pub(crate) job_id: String, } +/// Read-only question about a job's persisted session state. +/// +/// Served by a short-lived process straight from persistence — no runtime is +/// initialized and no workspace runtime ownership is taken, so a query is +/// always safe next to a running detached worker. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchQueryRequest { + pub(crate) job_id: String, + pub(crate) kind: DispatchQueryKind, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum DispatchQueryKind { + UsageReport, +} + #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DispatchListRequest {} @@ -406,6 +454,14 @@ impl DispatchEvent { } } + pub(crate) fn model_selected(model: Option<&str>) -> Self { + Self::Audit { + timestamp: chrono::Utc::now().to_rfc3339(), + action: "modelSelected".to_string(), + details: serde_json::json!({ "model": model }), + } + } + pub(crate) fn cancel_requested() -> Self { Self::Audit { timestamp: chrono::Utc::now().to_rfc3339(), diff --git a/src/apps/cli/src/dispatch/store.rs b/src/apps/cli/src/dispatch/store.rs index 0c64ac392a..d9ad3e60dc 100644 --- a/src/apps/cli/src/dispatch/store.rs +++ b/src/apps/cli/src/dispatch/store.rs @@ -7,8 +7,9 @@ use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; use serde::{Deserialize, Serialize}; use super::protocol::{ - DispatchAppendRequest, DispatchContinueRequest, DispatchEvent, DispatchJobListEntry, - DispatchJobState, DispatchSubmitRequest, DISPATCH_PROTOCOL_VERSION, + DispatchAppendRequest, DispatchApprovalPolicy, DispatchAttachment, DispatchContinueRequest, + DispatchEvent, DispatchJobListEntry, DispatchJobState, DispatchSubmitRequest, DispatchTurnKind, + DISPATCH_PROTOCOL_VERSION, }; const JOB_RECORD_FILE: &str = "job.json"; @@ -161,9 +162,31 @@ pub(crate) struct StoredFollowUpTurn { pub(crate) prompt: String, #[serde(default)] pub(crate) display_content: Option, + /// Per-turn overrides. The worker applies them to the job record when it + /// claims the turn, so they carry forward to later turns and restarts. + #[serde(default)] + pub(crate) model: Option, + #[serde(default)] + pub(crate) approval_policy: Option, + #[serde(default)] + pub(crate) kind: DispatchTurnKind, + #[serde(default)] + pub(crate) attachments: Vec, pub(crate) created_at: String, } +impl StoredFollowUpTurn { + /// A retried turnId must carry the same submission, options included. + fn same_submission(&self, other: &Self) -> bool { + self.prompt == other.prompt + && self.display_content == other.display_content + && self.model == other.model + && self.approval_policy == other.approval_policy + && self.kind == other.kind + && self.attachments == other.attachments + } +} + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RepoCacheRetentionRecord { @@ -712,20 +735,24 @@ impl DispatchStore { turn_id: request.turn_id.clone(), prompt: request.prompt.clone(), display_content: request.display_content.clone(), + model: request.model.clone(), + approval_policy: request.approval_policy, + kind: request.kind, + attachments: request.attachments.clone(), created_at: chrono::Utc::now().to_rfc3339(), }; // A retried request must not start a second turn. Both mailboxes are // checked because the worker may already have claimed this one. let consumed_path = mailbox_path(&job_dir, CONSUMED_TURNS_DIR, &request.turn_id)?; if let Some(existing) = read_optional_regular_json::(&consumed_path)? { - if existing.prompt != request.prompt { + if !existing.same_submission(&stored) { bail!("dispatch turnId is already bound to different content"); } return self.load_state_unlocked(&job_dir); } let pending_path = mailbox_path(&job_dir, PENDING_TURNS_DIR, &request.turn_id)?; if let Some(existing) = read_optional_regular_json::(&pending_path)? { - if existing.prompt != request.prompt { + if !existing.same_submission(&stored) { bail!("dispatch turnId is already bound to different content"); } return self.load_state_unlocked(&job_dir); @@ -752,6 +779,50 @@ impl DispatchStore { Ok(state) } + /// Read the next queued turn without consuming it. + /// + /// The worker peeks before initializing the runtime because the approval + /// policy is baked into runtime bootstrap; the later claim takes the same + /// earliest turn (identical ordering), and nothing can enqueue in between + /// — `queue_follow_up_turn` requires a terminal state and the job is + /// already Queued/Running by then. + pub(crate) fn peek_follow_up_turn(&self, job_id: &str) -> Result> { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let mut pending = + read_json_directory::(&job_dir.join(PENDING_TURNS_DIR))?; + pending.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.turn_id.cmp(&right.turn_id)) + }); + Ok(pending.into_iter().next()) + } + + /// Persist the effective per-turn options onto the job record so `list`, + /// `status`, and any replacement worker observe the same choices the turn + /// runs with. Returns (model_changed, approval_policy_changed). + pub(crate) fn update_job_request_options( + &self, + job_id: &str, + model: Option<&str>, + approval_policy: DispatchApprovalPolicy, + ) -> Result<(bool, bool)> { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let mut job = self.load_job(job_id)?; + let model = model.map(str::to_string); + let model_changed = job.request.model != model; + let policy_changed = job.request.approval_policy != approval_policy; + if !model_changed && !policy_changed { + return Ok((false, false)); + } + job.request.model = model; + job.request.approval_policy = approval_policy; + atomic_write_json(&job_dir.join(JOB_RECORD_FILE), &job)?; + Ok((model_changed, policy_changed)) + } + /// Take the next queued turn and bind it to the runtime turn the worker is /// about to submit. /// @@ -2049,6 +2120,7 @@ mod tests { approval_policy: DispatchApprovalPolicy::RejectAndReport, model: Some("model-1".to_string()), title: None, + attachments: Vec::new(), setup_audit: Vec::new(), } } @@ -2161,6 +2233,10 @@ mod tests { turn_id: turn_id.to_string(), prompt: prompt.to_string(), display_content: None, + model: None, + approval_policy: None, + kind: DispatchTurnKind::Prompt, + attachments: Vec::new(), } } @@ -2202,6 +2278,67 @@ mod tests { .is_none()); } + #[test] + fn per_turn_options_are_peeked_applied_and_kept_idempotent() { + let (_dir, store) = store(); + store + .create_job(request("job-1"), "job title".to_string()) + .expect("create job"); + store + .mark_state("job-1", DispatchJobState::Succeeded, Some("turn-1"), None) + .expect("succeeded"); + + let mut follow_up = continue_request("job-1", "turn-2", "with new options"); + follow_up.model = Some("model-2".to_string()); + follow_up.approval_policy = Some(DispatchApprovalPolicy::Remote); + store + .queue_follow_up_turn(&follow_up) + .expect("queue follow-up"); + + // The worker reads the overrides before runtime bootstrap. + let peeked = store + .peek_follow_up_turn("job-1") + .expect("peek") + .expect("a queued turn"); + assert_eq!(peeked.model.as_deref(), Some("model-2")); + assert_eq!(peeked.approval_policy, Some(DispatchApprovalPolicy::Remote)); + + // A retried turnId bound to different options must be refused. + let mut conflicting = follow_up.clone(); + conflicting.model = Some("model-3".to_string()); + assert!(store.queue_follow_up_turn(&conflicting).is_err()); + + // Applying the effective options rewrites the job record... + let (model_changed, policy_changed) = store + .update_job_request_options("job-1", Some("model-2"), DispatchApprovalPolicy::Remote) + .expect("apply options"); + assert!(model_changed); + assert!(policy_changed); + let job = store.load_job("job-1").expect("job"); + assert_eq!(job.request.model.as_deref(), Some("model-2")); + assert_eq!(job.request.approval_policy, DispatchApprovalPolicy::Remote); + + // ...without breaking submit idempotency: the ORIGINAL submit retry + // still matches its stored fingerprint after the rewrite. + let existing = store + .load_existing_job_for_intent(&request("job-1")) + .expect("intent lookup") + .expect("existing job"); + assert_eq!(existing.0.request.model.as_deref(), Some("model-2")); + + // Re-applying identical options reports no change. + assert_eq!( + store + .update_job_request_options( + "job-1", + Some("model-2"), + DispatchApprovalPolicy::Remote + ) + .expect("idempotent apply"), + (false, false) + ); + } + #[test] fn a_retried_follow_up_request_never_starts_a_second_turn() { let (_dir, store) = store(); diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index 8a03e3ec34..740e6f90e9 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -9,12 +9,15 @@ use bitfun_agent_runtime::sdk::{ PermissionReplySource, PermissionRequest, PermissionRequestEvent, }; use bitfun_events::{project_agentic_frontend_event, AgenticEvent}; -use bitfun_runtime_ports::{AgentSubmissionSource, DialogSubmissionPolicy, SessionExecutionTarget}; +use bitfun_runtime_ports::{ + AgentSessionModelUpdateRequest, AgentSubmissionSource, DialogSubmissionPolicy, + SessionExecutionTarget, +}; use crate::{shutdown_mcp_servers, BootstrapProfile}; use super::permissions::{self, REJECT_AND_REPORT_REASON}; -use super::protocol::{DispatchApprovalPolicy, DispatchEvent, DispatchJobState}; +use super::protocol::{DispatchApprovalPolicy, DispatchEvent, DispatchJobState, DispatchTurnKind}; use super::store::{DispatchStore, WorkspaceLock}; const TURN_SETTLEMENT_TIMEOUT_MS: u64 = 5_000; @@ -77,7 +80,20 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { workspace.display() ); } - super::ensure_selected_model_ready(job.request.model.as_deref()).await?; + // Per-turn overrides are read before runtime bootstrap because the + // approval policy is baked into initialize_core_services. Nothing can + // enqueue another turn between this peek and the claim below: queueing + // requires a terminal state and the job is already non-terminal here. + let pending_turn = store.peek_follow_up_turn(job_id)?; + let effective_model = pending_turn + .as_ref() + .and_then(|turn| turn.model.clone()) + .or_else(|| job.request.model.clone()); + let effective_policy = pending_turn + .as_ref() + .and_then(|turn| turn.approval_policy) + .unwrap_or(job.request.approval_policy); + super::ensure_selected_model_ready(effective_model.as_deref()).await?; // Every detached worker takes the same stable lock for a canonical target // workspace. Waiting workers remain Queued and are visible/cancellable. @@ -98,9 +114,23 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { } store.mark_state(job_id, DispatchJobState::Running, None, None)?; + // Persist the effective options before execution so `list`/`status` and + // any replacement worker observe the same choices this turn runs with. + let (model_changed, policy_changed) = + store.update_job_request_options(job_id, effective_model.as_deref(), effective_policy)?; + if policy_changed { + store.append_event( + job_id, + &DispatchEvent::approval_policy_selected(effective_policy), + )?; + } + if model_changed { + store.append_event(job_id, &DispatchEvent::model_selected(effective_model.as_deref()))?; + } + let runtime = crate::initialize_core_services( workspace, - permissions::cli_policy(job.request.approval_policy), + permissions::cli_policy(effective_policy), BootstrapProfile::Execution, ) .await?; @@ -145,7 +175,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { workspace_id: None, remote_connection_id: None, remote_ssh_host: None, - model_id: job.request.model.clone(), + model_id: effective_model.clone(), metadata: serde_json::Map::new(), }, ) @@ -161,6 +191,17 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { } None => "create target-owned dispatch session".to_string(), })?; + } else if let Some(model) = effective_model.clone() { + // A restored session keeps the model of its previous turn; apply this + // turn's effective choice before submitting. + agent_runtime + .update_session_model(AgentSessionModelUpdateRequest { + session_id: job.request.session_id.clone(), + model_id: model, + }) + .await + .map_err(|error| anyhow!(error.into_message())) + .context("apply dispatch turn model to restored session")?; } let turn_id = uuid::Uuid::new_v4().to_string(); @@ -168,31 +209,51 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { // after the Runtime accepts the turn must never make a replacement worker // submit the prompt a second time. let follow_up = store.claim_follow_up_turn(job_id, &turn_id)?; - let prompt = follow_up - .as_ref() - .map(|turn| turn.prompt.clone()) - .unwrap_or_else(|| job.request.prompt.clone()); - agent_runtime - .submit_dialog_turn(AgentDialogTurnRequest { - session_id: job.request.session_id.clone(), - message: prompt, - original_message: None, - turn_id: Some(turn_id.clone()), - execution: Default::default(), - agent_type: job.request.agent_type.clone(), - workspace_path: Some(workspace_path), - remote_connection_id: None, - remote_ssh_host: None, - policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), - reply_route: None, - prepended_reminders: Vec::new(), - attachments: Vec::new(), - metadata: permissions::metadata(job.request.approval_policy), - }) - .await - .map_err(|error| anyhow!(error.into_message())) - .context("submit dispatch dialog turn")?; + let turn_kind = follow_up.as_ref().map(|turn| turn.kind).unwrap_or_default(); + match turn_kind { + DispatchTurnKind::Prompt => { + let prompt = follow_up + .as_ref() + .map(|turn| turn.prompt.clone()) + .unwrap_or_else(|| job.request.prompt.clone()); + let turn_attachments = follow_up + .as_ref() + .map(|turn| runtime_attachments(&turn.attachments)) + .unwrap_or_else(|| runtime_attachments(&job.request.attachments)); + agent_runtime + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: job.request.session_id.clone(), + message: prompt, + original_message: None, + turn_id: Some(turn_id.clone()), + execution: Default::default(), + agent_type: job.request.agent_type.clone(), + workspace_path: Some(workspace_path), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: turn_attachments, + metadata: permissions::metadata(effective_policy), + }) + .await + .map_err(|error| anyhow!(error.into_message())) + .context("submit dispatch dialog turn")?; + } + DispatchTurnKind::Compact => { + // The compaction runs as a turn with this worker's turn id, so + // its DialogTurn/ContextCompression events flow through the same + // event loop and settle the job like any other turn. + compatibility + .start_manual_compaction(job.request.session_id.clone(), turn_id.clone()) + .await + .map_err(anyhow::Error::msg) + .context("start dispatch manual compaction")?; + } + } + let mut event_scope = JobEventScope::new(job.request.session_id.clone(), turn_id.clone()); let mut initial_permissions = agent_runtime .pending_permission_requests() .unwrap_or_default() @@ -204,7 +265,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { let (terminal_state, terminal_error) = loop { if let Some(request) = initial_permissions.pop_front() { - if permission_targets_job(&request, &job.request.session_id) + if event_scope.permission_targets_job(&request) && handled_permissions.insert(request.request_id.clone()) { if let Some(reason) = handle_permission( @@ -214,7 +275,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { &job.request.session_id, &turn_id, request, - job.request.approval_policy, + effective_policy, ) .await? { @@ -243,7 +304,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { ); } }; - if !event_belongs_to_job(&envelope.event, &job.request.session_id, &turn_id) { + if !event_scope.admit(&envelope.event) { continue; } let projection = project_agentic_frontend_event(envelope.event.clone()) @@ -279,7 +340,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { let PermissionRequestEvent::Asked { request } = event else { continue; }; - if !permission_targets_job(&request, &job.request.session_id) + if !event_scope.permission_targets_job(&request) || !handled_permissions.insert(request.request_id.clone()) { continue; @@ -291,7 +352,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { &job.request.session_id, &turn_id, request, - job.request.approval_policy, + effective_policy, ) .await? { @@ -467,24 +528,79 @@ async fn cancel_turn( } } -fn permission_targets_job(request: &PermissionRequest, session_id: &str) -> bool { - crate::runtime::approval::permission_request_targets_session(request, session_id) +fn runtime_attachments( + attachments: &[super::protocol::DispatchAttachment], +) -> Vec { + attachments + .iter() + .map(|attachment| { + bitfun_runtime_ports::AgentInputAttachment::remote_image( + attachment.id.clone(), + attachment + .name + .clone() + .unwrap_or_else(|| attachment.id.clone()), + attachment.data_url.clone(), + ) + }) + .collect() } -fn event_belongs_to_job(event: &AgenticEvent, session_id: &str, turn_id: &str) -> bool { - if matches!(event, AgenticEvent::SubagentSessionLinked { .. }) { - // Detached dispatch has no child-session observer or dispatch marker. Publishing - // this link would create an empty local-looking child in the Web UI, - // while every later child event is correctly outside the parent scope. - return false; +/// Which sessions' events belong in this job's log. +/// +/// The job session's turn-scoped events must match the worker's turn, and any +/// subagent session linked under it (recursively) is admitted wholesale so +/// the controller can project child transcripts. +struct JobEventScope { + session_id: String, + turn_id: String, + children: std::collections::HashSet, +} + +impl JobEventScope { + fn new(session_id: String, turn_id: String) -> Self { + Self { + session_id, + turn_id, + children: std::collections::HashSet::new(), + } } - if event - .session_id() - .is_some_and(|event_session| event_session != session_id) - { - return false; + + fn admit(&mut self, event: &AgenticEvent) -> bool { + if let AgenticEvent::SubagentSessionLinked { + session_id: child_session, + parent_session_id, + .. + } = event + { + if parent_session_id == &self.session_id + || self.children.contains(parent_session_id) + { + self.children.insert(child_session.clone()); + return true; + } + return false; + } + match event.session_id() { + Some(event_session) if event_session == self.session_id => { + event_turn_id(event).is_none_or(|event_turn| event_turn == self.turn_id) + } + Some(event_session) => self.children.contains(event_session), + None => true, + } + } + + fn permission_targets_job(&self, request: &PermissionRequest) -> bool { + if crate::runtime::approval::permission_request_targets_session(request, &self.session_id) + { + return true; + } + self.children + .iter() + .any(|child| { + crate::runtime::approval::permission_request_targets_session(request, child) + }) } - event_turn_id(event).is_none_or(|event_turn| event_turn == turn_id) } fn event_turn_id(event: &AgenticEvent) -> Option<&str> { @@ -590,7 +706,20 @@ mod tests { } #[test] - fn dispatch_does_not_publish_subagent_sessions_before_child_observers_exist() { + fn linked_subagent_sessions_flow_into_the_job_event_scope() { + let mut scope = JobEventScope::new("session-1".to_string(), "turn-1".to_string()); + + let child_chunk = AgenticEvent::TextChunk { + session_id: "child-session".to_string(), + turn_id: "child-turn".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "child output".to_string(), + }; + // A child that was never linked stays outside the scope. + assert!(!scope.admit(&child_chunk)); + let linked = AgenticEvent::SubagentSessionLinked { session_id: "child-session".to_string(), subagent_dialog_turn_id: "child-turn".to_string(), @@ -601,18 +730,36 @@ mod tests { model_id: None, focused_review_display_label: None, }; - assert!(!event_belongs_to_job(&linked, "session-1", "turn-1")); + assert!(scope.admit(&linked)); + assert!(scope.admit(&child_chunk)); - let child_chunk = AgenticEvent::TextChunk { - session_id: "child-session".to_string(), - turn_id: "child-turn".to_string(), - round_id: "round-1".to_string(), - attempt_id: None, - attempt_index: None, - text: "child output".to_string(), + // Grandchildren link recursively through an admitted child. + let grandchild_link = AgenticEvent::SubagentSessionLinked { + session_id: "grandchild-session".to_string(), + subagent_dialog_turn_id: "grandchild-turn".to_string(), + parent_session_id: "child-session".to_string(), + parent_dialog_turn_id: "child-turn".to_string(), + parent_tool_call_id: "tool-2".to_string(), + agent_type: None, + model_id: None, + focused_review_display_label: None, }; - assert!(!event_belongs_to_job(&child_chunk, "session-1", "turn-1")); + assert!(scope.admit(&grandchild_link)); + // A link from an unrelated parent is refused. + let foreign_link = AgenticEvent::SubagentSessionLinked { + session_id: "other-child".to_string(), + subagent_dialog_turn_id: "t".to_string(), + parent_session_id: "unrelated-session".to_string(), + parent_dialog_turn_id: "t".to_string(), + parent_tool_call_id: "tool-3".to_string(), + agent_type: None, + model_id: None, + focused_review_display_label: None, + }; + assert!(!scope.admit(&foreign_link)); + + // Parent turn discipline is unchanged. let parent_chunk = AgenticEvent::TextChunk { session_id: "session-1".to_string(), turn_id: "turn-1".to_string(), @@ -621,6 +768,15 @@ mod tests { attempt_index: None, text: "parent output".to_string(), }; - assert!(event_belongs_to_job(&parent_chunk, "session-1", "turn-1")); + assert!(scope.admit(&parent_chunk)); + let stale_parent_chunk = AgenticEvent::TextChunk { + session_id: "session-1".to_string(), + turn_id: "turn-0".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "stale output".to_string(), + }; + assert!(!scope.admit(&stale_parent_chunk)); } } diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index d0e23fbdc0..2a76277f34 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -598,6 +598,10 @@ pub(crate) enum DispatchAction { Answer, /// Append a steering message to a queued or running job Append, + /// Queue the next turn of a dispatch session whose previous turn finished + Continue, + /// Read persisted session facts (usage report) without starting a turn + Query, #[command(name = "__workspace_provision", hide = true)] WorkspaceProvision, #[command(name = "__workspace_bundle_begin", hide = true)] diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 69396397e5..c83b4a015b 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -81,6 +81,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_sync_model_config", "dispatch_submit", "dispatch_status", + "dispatch_query", "dispatch_sync_result", "dispatch_cancel", "dispatch_list_jobs", @@ -142,6 +143,7 @@ mod tests { "dispatch_sync_model_config", "dispatch_submit", "dispatch_status", + "dispatch_query", "dispatch_sync_result", "dispatch_cancel", "dispatch_list_jobs", diff --git a/src/apps/cli/src/peer_host/dispatch.rs b/src/apps/cli/src/peer_host/dispatch.rs index a3bb692c1f..b42e16e6fa 100644 --- a/src/apps/cli/src/peer_host/dispatch.rs +++ b/src/apps/cli/src/peer_host/dispatch.rs @@ -129,6 +129,7 @@ fn dispatch_target_verb(command: &str) -> Option<&'static str> { "dispatch_target_answer" => Some("answer"), "dispatch_target_append" => Some("append"), "dispatch_target_continue" => Some("continue"), + "dispatch_target_query" => Some("query"), "dispatch_target_workspace_provision" => Some("workspace-provision"), "dispatch_target_workspace_bundle_begin" => Some("workspace-bundle-begin"), "dispatch_target_workspace_bundle_chunk" => Some("workspace-bundle-chunk"), diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 61de0020e6..3c876b9447 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -27,7 +27,9 @@ use crate::{ ExternalPolicyModeArg, ExternalPolicyScopeArg, SessionAction, }; -const MAX_DISPATCH_STDIN_BYTES: u64 = 2 * 1024 * 1024; +/// Sized for submit/continue requests carrying inline image attachments +/// (16 MiB of data URLs) plus headroom for the rest of the payload. +const MAX_DISPATCH_STDIN_BYTES: u64 = 24 * 1024 * 1024; pub(crate) struct ExecCommandArgs { pub message: Option, @@ -68,6 +70,8 @@ pub(crate) async fn handle_dispatch_action(action: DispatchAction) -> Result<()> DispatchAction::List => "list", DispatchAction::Answer => "answer", DispatchAction::Append => "append", + DispatchAction::Continue => "continue", + DispatchAction::Query => "query", DispatchAction::WorkspaceProvision => "workspace-provision", DispatchAction::WorkspaceBundleBegin => "workspace-bundle-begin", DispatchAction::WorkspaceBundleChunk => "workspace-bundle-chunk", @@ -85,7 +89,7 @@ pub(crate) async fn handle_dispatch_action(action: DispatchAction) -> Result<()> .read_to_string(&mut raw) .context("read dispatch JSON from stdin")?; if raw.len() as u64 > MAX_DISPATCH_STDIN_BYTES { - anyhow::bail!("dispatch JSON input exceeds the 2 MiB safety limit"); + anyhow::bail!("dispatch JSON input exceeds the 24 MiB safety limit"); } } let input = if raw.trim().is_empty() { diff --git a/src/apps/desktop/src/api/dispatch_api.rs b/src/apps/desktop/src/api/dispatch_api.rs index 68a714cd16..b34056f95c 100644 --- a/src/apps/desktop/src/api/dispatch_api.rs +++ b/src/apps/desktop/src/api/dispatch_api.rs @@ -17,14 +17,15 @@ use bitfun_core::service::dispatch::{ continue_device_dispatch_job, continue_dispatch_job, get_device_dispatch_status, get_dispatch_status, list_device_dispatch_jobs, list_dispatch_jobs, list_dispatch_targets, poll_dispatch_cli_install, probe_device_dispatch_target, probe_dispatch_target, - start_dispatch_cli_install, start_dispatch_cli_source_build, submit_device_dispatch, - submit_dispatch, sync_device_dispatch_result, sync_dispatch_model_config, sync_dispatch_result, + query_device_dispatch_job, query_dispatch_job, start_dispatch_cli_install, + start_dispatch_cli_source_build, submit_device_dispatch, submit_dispatch, + sync_device_dispatch_result, sync_dispatch_model_config, sync_dispatch_result, DeviceDispatchRpc, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchContinueRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, - DispatchProbeTargetRequest, DispatchSaveTranscriptRequest, DispatchStatusRequest, - DispatchSubmitRequest, DispatchSyncResultRequest, DispatchTarget, DispatchTargetOption, - DispatchTargetRequest, DispatchTranscriptRequest, OutboundDispatchStore, + DispatchProbeTargetRequest, DispatchQueryJobRequest, DispatchSaveTranscriptRequest, + DispatchStatusRequest, DispatchSubmitRequest, DispatchSyncResultRequest, DispatchTarget, + DispatchTargetOption, DispatchTargetRequest, DispatchTranscriptRequest, OutboundDispatchStore, }; use bitfun_core::service::remote_ssh::dispatch_ssh::{ DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, @@ -426,6 +427,34 @@ pub async fn dispatch_continue( .map_err(|error| error.to_string()) } +#[tauri::command] +pub async fn dispatch_query( + state: State<'_, AppState>, + path_manager: State<'_, Arc>, + request: DispatchQueryJobRequest, +) -> Result { + let store = OutboundDispatchStore::new(path_manager.as_ref()); + if matches!( + store + .get(&request.job_id) + .await + .map_err(|error| error.to_string())? + .map(|record| record.target), + Some(DispatchTarget::Device { .. }) + ) { + return query_device_dispatch_job(&AccountDeviceDispatchRpc, &store, request) + .await + .map_err(|error| error.to_string()); + } + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + query_dispatch_job(&manager, &store, request) + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn dispatch_cancel( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/dispatch_host.rs b/src/apps/desktop/src/api/dispatch_host.rs index 9c66eed426..71ebb3775e 100644 --- a/src/apps/desktop/src/api/dispatch_host.rs +++ b/src/apps/desktop/src/api/dispatch_host.rs @@ -40,6 +40,7 @@ fn target_cli_verb(command: &str) -> Option<&'static str> { "dispatch_target_answer" => Some("answer"), "dispatch_target_append" => Some("append"), "dispatch_target_continue" => Some("continue"), + "dispatch_target_query" => Some("query"), "dispatch_target_workspace_provision" => Some("__workspace_provision"), "dispatch_target_workspace_bundle_begin" => Some("__workspace_bundle_begin"), "dispatch_target_workspace_bundle_chunk" => Some("__workspace_bundle_chunk"), diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index aaead92a47..c00cde3205 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -104,6 +104,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_sync_model_config", "dispatch_submit", "dispatch_status", + "dispatch_query", "dispatch_cancel", "dispatch_sync_result", "dispatch_list_jobs", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index b8e4a662f8..c2284bfca5 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -392,6 +392,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::WorkspaceAgnostic, ), ("dispatch_status", RemoteWorkspacePolicy::WorkspaceAgnostic), + ("dispatch_query", RemoteWorkspacePolicy::WorkspaceAgnostic), ("dispatch_submit", RemoteWorkspacePolicy::WorkspaceAgnostic), ( "dismiss_announcement", diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index b7ddc7726b..1be84e089c 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1761,6 +1761,7 @@ pub async fn run() { api::dispatch_api::dispatch_answer, api::dispatch_api::dispatch_append, api::dispatch_api::dispatch_continue, + api::dispatch_api::dispatch_query, api::dispatch_api::dispatch_load_transcript, api::dispatch_api::dispatch_save_transcript, // Relay self-deploy API diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 33730b28f4..c448632d18 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -4797,6 +4797,19 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet })? } + /// Start a manual compaction bound to a caller-supplied turn id without + /// awaiting completion. The caller observes the outcome through the + /// turn's DialogTurn/ContextCompression events. + pub async fn start_manual_compaction_turn( + &self, + session_id: String, + turn_id: String, + ) -> BitFunResult<()> { + self.start_manual_compaction_task(session_id, Some(turn_id)) + .await + .map(|_task| ()) + } + #[allow(clippy::too_many_arguments)] async fn start_dialog_turn_internal( &self, diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index f57eacb911..fd5d4b9ff6 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -616,6 +616,22 @@ impl CoreAgentRuntimeCompatibility { .await } + /// Start a manual context compaction as a caller-identified turn. + /// + /// Detached dispatch supplies its own turn id so the compaction's + /// DialogTurn/ContextCompression events can be attributed in its event + /// log; completion is observed through those events, not awaited here. + pub async fn start_manual_compaction( + &self, + session_id: String, + turn_id: String, + ) -> Result<(), String> { + self.coordinator + .start_manual_compaction_turn(session_id, turn_id) + .await + .map_err(|error| error.to_string()) + } + /// Applies the same Core deployment owner before a product compatibility /// path attaches to or mutates a structured workspace scope. pub fn ensure_workspace_runtime_ownership( diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index 75f8544614..27db58d920 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -19,7 +19,8 @@ use super::{ OutboundDispatchStore, }; -pub(super) const DISPATCH_PROTOCOL_VERSION: u64 = 3; +pub(super) const DISPATCH_PROTOCOL_VERSION: u64 = + bitfun_services_core::dispatch_contract::DISPATCH_PROTOCOL_VERSION as u64; pub(super) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -78,6 +79,8 @@ pub struct DispatchSubmitRequest { pub source_workspace_path: Option, #[serde(default)] pub source_workspace_id: Option, + #[serde(default)] + pub attachments: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -135,6 +138,17 @@ pub struct DispatchContinueRequest { pub prompt: String, #[serde(default)] pub display_content: Option, + /// Per-turn model override; carries forward as the job's model. + #[serde(default)] + pub model: Option, + /// Per-turn approval-policy override with the same carry-forward rule. + #[serde(default)] + pub approval_policy: Option, + /// Operation kind understood by the target (`prompt` default, `compact`). + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub attachments: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -147,6 +161,44 @@ pub struct DispatchAppendRequest { pub display_content: Option, } +/// The wire shape and structural limits come from the shared contract; the +/// controller only adds transport-owned policy (the device inline budget). +pub use bitfun_services_core::dispatch_contract::DispatchAttachment as DispatchAttachmentPayload; + +pub(super) fn validate_attachment_payloads( + attachments: &[DispatchAttachmentPayload], +) -> anyhow::Result<()> { + bitfun_services_core::dispatch_contract::validate_dispatch_attachments(attachments) + .map_err(|error| anyhow::anyhow!(error)) +} + +pub(super) fn validate_device_attachment_budget( + attachments: &[DispatchAttachmentPayload], +) -> anyhow::Result<()> { + let total: usize = attachments + .iter() + .map(|attachment| attachment.data_url.len()) + .sum(); + if total + > bitfun_services_core::dispatch_contract::MAX_DEVICE_DISPATCH_ATTACHMENTS_TOTAL_BYTES + { + anyhow::bail!( + "Device dispatch carries at most 192 KiB of inline images; use an SSH target for larger screenshots" + ); + } + Ok(()) +} + +/// Read-only persisted-state question answered by the target without +/// starting a turn or initializing a runtime. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchQueryJobRequest { + pub job_id: String, + /// Query kind understood by the target (currently `usageReport`). + pub kind: String, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DispatchListJobsRequest { @@ -517,6 +569,9 @@ pub async fn submit( if let Some(title) = request.title.filter(|value| !value.trim().is_empty()) { protocol_request["title"] = Value::String(title); } + if !request.attachments.is_empty() { + protocol_request["attachments"] = serde_json::to_value(&request.attachments)?; + } let response = match dispatch_ssh::submit(manager, connection_id, &protocol_request).await { Ok(response) => response, @@ -783,6 +838,39 @@ pub async fn status( Ok(response) } +pub async fn query_job( + manager: &SSHConnectionManager, + store: &OutboundDispatchStore, + request: DispatchQueryJobRequest, +) -> anyhow::Result { + validate_query_request(&request)?; + let record = store + .get(&request.job_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Outbound dispatch job was not found"))?; + let DispatchTarget::Ssh { connection_id, .. } = &record.target else { + anyhow::bail!("SSH dispatch query requires an SSH target"); + }; + dispatch_ssh::query( + manager, + connection_id, + &json!({ "jobId": request.job_id, "kind": request.kind }), + ) + .await +} + +pub(super) fn validate_query_request(request: &DispatchQueryJobRequest) -> anyhow::Result<()> { + if request.job_id.trim().is_empty() { + anyhow::bail!("Dispatch query requires a jobId"); + } + if request.kind.trim().is_empty() || request.kind.len() > 64 { + anyhow::bail!("Dispatch query kind is invalid"); + } + // Which kinds exist is the target's contract; an unknown kind comes back + // as a clear target-side error instead of drifting a second list here. + Ok(()) +} + /// Bring the target's work back into this controller's baseline worktree. /// /// One button, two halves: the target commits and bundles its branch, then the @@ -953,7 +1041,7 @@ pub async fn continue_job( }; let response = dispatch_ssh::continue_job(manager, connection_id, &continue_payload(&request)).await?; - record_follow_up_state(store, &record, &response).await; + record_follow_up_state(store, &record, &request, &response).await; Ok(response) } @@ -973,6 +1061,34 @@ pub(super) fn continue_payload(request: &DispatchContinueRequest) -> Value { { payload["displayContent"] = Value::String(display.to_string()); } + if let Some(model) = request + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + payload["model"] = Value::String(model.to_string()); + } + if let Some(policy) = request + .approval_policy + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + payload["approvalPolicy"] = Value::String(policy.to_string()); + } + if let Some(kind) = request + .kind + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + payload["kind"] = Value::String(kind.to_string()); + } + if !request.attachments.is_empty() { + payload["attachments"] = serde_json::to_value(&request.attachments) + .unwrap_or(Value::Null); + } payload } @@ -984,8 +1100,22 @@ pub(super) fn continue_payload(request: &DispatchContinueRequest) -> Value { pub(super) async fn record_follow_up_state( store: &OutboundDispatchStore, record: &OutboundDispatchRecord, + request: &DispatchContinueRequest, response: &Value, ) { + if let Err(error) = store + .update_submission_options( + &record.job_id, + request.model.as_deref(), + request.approval_policy.as_deref(), + ) + .await + { + log::warn!( + "Failed to record dispatch follow-up options: job_id={} error={error}", + record.job_id + ); + } let Some(state) = response.get("state").and_then(Value::as_str) else { return; }; @@ -1060,6 +1190,7 @@ pub(super) fn validate_submit_request(request: &DispatchSubmitRequest) -> anyhow }) { anyhow::bail!("Dispatch baseRef is invalid"); } + validate_attachment_payloads(&request.attachments)?; Ok(()) } @@ -1084,9 +1215,9 @@ pub(super) fn validate_continue_request(request: &DispatchContinueRequest) -> an if request.turn_id.trim().is_empty() || request.turn_id.len() > 128 { anyhow::bail!("Dispatch turnId must contain 1-128 bytes"); } - if request.prompt.trim().is_empty() { - anyhow::bail!("Dispatch follow-up prompt cannot be empty"); - } + // Kind/prompt semantics (which kinds exist, which take a prompt) are the + // target's contract; duplicating that list here would drift. Only + // transport-owned limits are enforced below. let total_bytes = request .prompt .len() @@ -1094,6 +1225,17 @@ pub(super) fn validate_continue_request(request: &DispatchContinueRequest) -> an if total_bytes > MAX_DISPATCH_TEXT_BYTES { anyhow::bail!("Dispatch follow-up exceeds the 32 KiB request limit"); } + if let Some(model) = &request.model { + if model.trim().is_empty() || model.len() > 256 { + anyhow::bail!("Dispatch model override must contain 1-256 bytes"); + } + } + if let Some(policy) = &request.approval_policy { + if !matches!(policy.as_str(), "auto" | "reject-and-report" | "remote") { + anyhow::bail!("Dispatch approval policy override is not recognized"); + } + } + validate_attachment_payloads(&request.attachments)?; Ok(()) } diff --git a/src/crates/assembly/core/src/service/dispatch/device_controller.rs b/src/crates/assembly/core/src/service/dispatch/device_controller.rs index 68e602d958..0399af5eb4 100644 --- a/src/crates/assembly/core/src/service/dispatch/device_controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/device_controller.rs @@ -17,9 +17,10 @@ use super::controller::{ bind_outbound_record, continue_payload, finish_sync, provisioned_path, record_follow_up_state, release_unbound_preparation_baseline, result_bundle_path, same_target_identity, target_have_tips, validate_answer_request, validate_append_request, validate_continue_request, - validate_submission_preflight, validate_submit_ack, validate_submit_request, - DispatchAnswerRequest, DispatchAppendRequest, DispatchContinueRequest, DispatchJobRequest, - DispatchListJobsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, + validate_device_attachment_budget, validate_query_request, validate_submission_preflight, + validate_submit_ack, validate_submit_request, DispatchAnswerRequest, DispatchAppendRequest, + DispatchContinueRequest, DispatchJobRequest, DispatchListJobsRequest, + DispatchProbeTargetRequest, DispatchQueryJobRequest, DispatchStatusRequest, DispatchSubmitRequest, DispatchSyncResultRequest, DISPATCH_PROTOCOL_VERSION, }; use super::preparation::{DispatchPreparationRequest, DispatchPreparationTarget}; @@ -300,6 +301,11 @@ pub async fn submit_device( if let Some(title) = request.title.filter(|value| !value.trim().is_empty()) { payload["title"] = Value::String(title); } + if !request.attachments.is_empty() { + validate_device_attachment_budget(&request.attachments)?; + payload["attachments"] = + serde_json::to_value(&request.attachments).unwrap_or(Value::Null); + } let response = match rpc .invoke(device_id, "dispatch_target_submit", payload) @@ -344,6 +350,24 @@ pub async fn submit_device( Ok(response) } +pub async fn query_device_job( + rpc: &dyn DeviceDispatchRpc, + store: &OutboundDispatchStore, + request: DispatchQueryJobRequest, +) -> anyhow::Result { + validate_query_request(&request)?; + let record = load_device_record(store, &request.job_id).await?; + let DispatchTarget::Device { device_id, .. } = &record.target else { + unreachable!("load_device_record validates target kind") + }; + rpc.invoke( + device_id, + "dispatch_target_query", + json!({ "jobId": request.job_id, "kind": request.kind }), + ) + .await +} + pub async fn status_device( rpc: &dyn DeviceDispatchRpc, store: &OutboundDispatchStore, @@ -449,6 +473,7 @@ pub async fn continue_device_job( request: DispatchContinueRequest, ) -> anyhow::Result { validate_continue_request(&request)?; + validate_device_attachment_budget(&request.attachments)?; let record = load_device_record(store, &request.job_id).await?; let DispatchTarget::Device { device_id, .. } = &record.target else { unreachable!("load_device_record validates target kind") @@ -460,7 +485,7 @@ pub async fn continue_device_job( continue_payload(&request), ) .await?; - record_follow_up_state(store, &record, &response).await; + record_follow_up_state(store, &record, &request, &response).await; Ok(response) } diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index a21cd4d121..049e2fd46b 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -28,12 +28,13 @@ pub use controller::{ install_cli_source_start as start_dispatch_cli_source_build, install_cli_start as start_dispatch_cli_install, list_jobs as list_dispatch_jobs, list_targets as list_dispatch_targets, probe_target as probe_dispatch_target, - status as get_dispatch_status, submit as submit_dispatch, + query_job as query_dispatch_job, status as get_dispatch_status, submit as submit_dispatch, sync_model_config as sync_dispatch_model_config, sync_result as sync_dispatch_result, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchContinueRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, - DispatchPermissionReplyKind, DispatchProbeTargetRequest, DispatchStatusRequest, + DispatchPermissionReplyKind, DispatchProbeTargetRequest, DispatchQueryJobRequest, + DispatchStatusRequest, DispatchSubmitRequest, DispatchSyncResultRequest, DispatchTargetOption, }; #[cfg(feature = "ssh-remote")] @@ -41,6 +42,7 @@ pub use device_controller::{ answer_device as answer_device_dispatch, append_device as append_device_dispatch, cancel_device as cancel_device_dispatch, continue_device_job as continue_device_dispatch_job, list_device_jobs as list_device_dispatch_jobs, probe_device as probe_device_dispatch_target, + query_device_job as query_device_dispatch_job, status_device as get_device_dispatch_status, submit_device as submit_device_dispatch, sync_device_result as sync_device_dispatch_result, DeviceDispatchRpc, }; @@ -347,6 +349,38 @@ impl OutboundDispatchStore { Ok(record) } + /// Reflect per-turn option overrides in the observer index so a later + /// reconciliation cannot revert the UI to the pre-override values. + pub async fn update_submission_options( + &self, + job_id: &str, + model: Option<&str>, + approval_policy: Option<&str>, + ) -> Result<(), DispatchStoreError> { + if model.is_none() && approval_policy.is_none() { + return Ok(()); + } + let path = self.record_path(job_id)?; + let _lock = self.json_store.acquire_cross_process_lock(&path).await?; + let Some(mut record) = self + .json_store + .read_optional::(&path) + .await? + else { + return Ok(()); + }; + if let Some(model) = model { + record.model = Some(model.to_string()).filter(|value| !value.trim().is_empty()); + } + if let Some(policy) = approval_policy { + record.approval_policy = Some(policy.to_string()); + } + record.updated_at = Utc::now(); + self.json_store.write_atomic_strict(&path, &record).await?; + harden_file_permissions(&path).await?; + Ok(()) + } + pub async fn list(&self) -> Result, DispatchStoreError> { #[cfg(feature = "ssh-remote")] if let Err(error) = self.reconcile_expired_preparations().await { diff --git a/src/crates/services/services-core/src/dispatch_contract.rs b/src/crates/services/services-core/src/dispatch_contract.rs new file mode 100644 index 0000000000..68a59ff2f8 --- /dev/null +++ b/src/crates/services/services-core/src/dispatch_contract.rs @@ -0,0 +1,119 @@ +//! Single source of truth for the detached-dispatch wire contract. +//! +//! The target CLI advertises these capabilities, the controller requires +//! them, and the Web UI pins its own copy against this file in +//! `dispatch.contract.test.ts`. A protocol evolution is one edit here plus +//! that cross-language test — never a hunt across advertise/require lists. + +use serde::{Deserialize, Serialize}; + +pub const DISPATCH_PROTOCOL_VERSION: u32 = 4; + +/// Capabilities every v4 target advertises unconditionally. +pub const DISPATCH_BASE_TARGET_CAPABILITIES: &[&str] = &[ + "persistent_jobs", + "cursor_events", + "workspace_serialization", + "approval_auto", + "approval_reject_and_report", + "approval_remote", + "frontend_event_projection", + "append_message", + "event_log_completeness", + // Git-worktree delivery. A target without these cannot be provisioned at + // all — there is no snapshot fallback left — so controllers fail + // preflight rather than degrade. + "workspace_git_worktree", + "workspace_git_bundle_upload", + "workspace_git_sync", + // A target may share the same package version while predating the + // dispatch entrypoint's early CLI-profile selection. Such a binary can + // accept a job but every detached worker then fails before execution. + // Advertise the behavioral fix explicitly so controllers fail closed. + "dispatch_worker_cli_profile", + // v4: follow-up turns may override model and approval policy. + "per_turn_options", + // v4: read-only persisted-state queries (usage report) and compact turns + // delivered through the continue mailbox. + "session_query", + // v4: inline image attachments on submit and follow-up turns. + "inline_attachments", +]; + +/// Advertised only where detached workers can run (Linux/macOS), and +/// required by every controller: a target that cannot detach cannot dispatch. +pub const DISPATCH_DETACHED_WORKER_CAPABILITY: &str = "detached_worker"; + +/// Everything a controller refuses to submit without: the unconditional set +/// plus the platform-conditional detached worker. +pub fn dispatch_required_target_capabilities() -> impl Iterator { + DISPATCH_BASE_TARGET_CAPABILITIES + .iter() + .copied() + .chain(std::iter::once(DISPATCH_DETACHED_WORKER_CAPABILITY)) +} + +/// One inline image attachment on a submit/continue turn. +/// +/// v4 carries images as data URLs inside the request: SSH stages the request +/// as a file over SFTP so size is a policy choice, while the account-device +/// envelope keeps a much smaller controller-enforced budget. Staged chunked +/// transfer for larger payloads is a follow-up capability. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DispatchAttachment { + pub id: String, + #[serde(default)] + pub name: Option, + pub mime_type: String, + pub data_url: String, +} + +pub const MAX_DISPATCH_ATTACHMENTS: usize = 8; +pub const MAX_DISPATCH_ATTACHMENT_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_DISPATCH_ATTACHMENTS_TOTAL_BYTES: usize = 16 * 1024 * 1024; +/// Inline budget for the account-device envelope. SSH stages the request as +/// an SFTP file, so only relay-carried requests need this much smaller cap. +pub const MAX_DEVICE_DISPATCH_ATTACHMENTS_TOTAL_BYTES: usize = 192 * 1024; + +/// Shared structural validation, used verbatim by the controller (fail fast +/// before a transport round trip) and the target (authoritative). +pub fn validate_dispatch_attachments(attachments: &[DispatchAttachment]) -> Result<(), String> { + if attachments.len() > MAX_DISPATCH_ATTACHMENTS { + return Err(format!( + "dispatch accepts at most {MAX_DISPATCH_ATTACHMENTS} attachments per turn" + )); + } + let mut total = 0usize; + for attachment in attachments { + if attachment.id.trim().is_empty() || attachment.id.len() > 128 { + return Err("dispatch attachment id must contain 1-128 bytes".to_string()); + } + if !attachment.data_url.starts_with("data:image/") { + return Err("dispatch attachments must be image data URLs".to_string()); + } + if attachment.data_url.len() > MAX_DISPATCH_ATTACHMENT_BYTES { + return Err("a dispatch attachment exceeds the 8 MiB limit".to_string()); + } + total = total.saturating_add(attachment.data_url.len()); + } + if total > MAX_DISPATCH_ATTACHMENTS_TOTAL_BYTES { + return Err("dispatch attachments exceed the 16 MiB total limit".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn required_capabilities_are_base_plus_detached_worker() { + let required: Vec<&str> = dispatch_required_target_capabilities().collect(); + assert_eq!(required.len(), DISPATCH_BASE_TARGET_CAPABILITIES.len() + 1); + assert!(required.contains(&DISPATCH_DETACHED_WORKER_CAPABILITY)); + for capability in DISPATCH_BASE_TARGET_CAPABILITIES { + assert!(required.contains(capability)); + } + } +} diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 1e427037e8..6f965d23a4 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod bounded_fs; pub mod diagnostics; pub mod diff; +pub mod dispatch_contract; #[cfg(feature = "dispatch-workspace")] pub mod dispatch_workspace; mod file_lock; diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index ac392143b6..84094b03d0 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -84,8 +84,8 @@ const GLIBC_FLOOR: &str = "2.35"; /// Same figure the relay source build uses. const SOURCE_BUILD_FREE_KB: u64 = 6 * 1024 * 1024; const REPO_GIT_URL: &str = "https://github.com/GCWing/BitFun.git"; -const DISPATCH_PROTOCOL_VERSION: u64 = 3; -const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profile"; +const DISPATCH_PROTOCOL_VERSION: u64 = + bitfun_services_core::dispatch_contract::DISPATCH_PROTOCOL_VERSION as u64; /// First stable release whose CLI is known to contain every capability below. /// /// Development builds can require capabilities before their next stable @@ -93,24 +93,12 @@ const DISPATCH_WORKER_CLI_PROFILE_CAPABILITY: &str = "dispatch_worker_cli_profil /// previous release, so comparing only the installed and controller version /// strings is not a sound compatibility test. const FIRST_COMPATIBLE_STABLE_DISPATCH_RELEASE: (u64, u64, u64) = (0, 2, 16); -const REQUIRED_DISPATCH_CAPABILITIES: [&str; 14] = [ - "persistent_jobs", - "cursor_events", - "detached_worker", - "workspace_serialization", - "frontend_event_projection", - "approval_auto", - "approval_reject_and_report", - "approval_remote", - "append_message", - "event_log_completeness", - // Git-worktree delivery. There is no snapshot fallback, so these are hard - // requirements rather than feature-detected extras. - "workspace_git_worktree", - "workspace_git_bundle_upload", - "workspace_git_sync", - DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, -]; +/// Derived once from the shared contract: the unconditional target surface +/// plus the platform-conditional detached worker. +static REQUIRED_DISPATCH_CAPABILITIES: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + bitfun_services_core::dispatch_contract::dispatch_required_target_capabilities().collect() + }); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -510,31 +498,25 @@ pub fn validate_dispatch_protocol(protocol: &Value, approval_policy: Option<&str let Some(capabilities) = protocol.get("capabilities").and_then(Value::as_array) else { return Err(anyhow!("dispatch target returned no capability list")); }; - let mut required = vec![ - "persistent_jobs", - "cursor_events", - "detached_worker", - "workspace_serialization", - "frontend_event_projection", - "workspace_git_worktree", - "workspace_git_bundle_upload", - "workspace_git_sync", - DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, - ]; + // One source list: submission narrows the approval_* entries to the + // selected policy; probing (None) requires the complete surface. + let mut required: Vec<&'static str> = REQUIRED_DISPATCH_CAPABILITIES + .iter() + .copied() + .filter(|capability| !capability.starts_with("approval_")) + .collect(); match approval_policy { - Some("auto") => &["approval_auto"], - Some("reject-and-report") => &["approval_reject_and_report"], - Some("remote") => &["approval_remote"], + Some("auto") => required.push("approval_auto"), + Some("reject-and-report") => required.push("approval_reject_and_report"), + Some("remote") => required.push("approval_remote"), Some(_) => return Err(anyhow!("unsupported dispatch approval policy")), - None => REQUIRED_DISPATCH_CAPABILITIES.as_slice(), + None => required.extend( + REQUIRED_DISPATCH_CAPABILITIES + .iter() + .copied() + .filter(|capability| capability.starts_with("approval_")), + ), } - .iter() - .copied() - .for_each(|capability| { - if !required.contains(&capability) { - required.push(capability); - } - }); let missing = required .iter() .copied() @@ -1394,6 +1376,14 @@ pub async fn continue_job( invoke_json(manager, connection_id, "continue", request).await } +pub async fn query( + manager: &SSHConnectionManager, + connection_id: &str, + request: &Value, +) -> Result { + invoke_json(manager, connection_id, "query", request).await +} + /// Commit the target's worktree and fetch the Git bundle it produced. /// /// Downloads only. The controller decides separately whether to fast-forward @@ -2501,7 +2491,7 @@ COMMITTED=1 echo "Installed $installed at $HOME/.local/bin/bitfun" echo {INSTALL_DONE_MARKER} "#, - worker_profile_capability = DISPATCH_WORKER_CLI_PROFILE_CAPABILITY, + worker_profile_capability = "dispatch_worker_cli_profile", ) } @@ -3259,7 +3249,7 @@ mod tests { "{name} must keep rollback" ); assert!( - script.contains(DISPATCH_WORKER_CLI_PROFILE_CAPABILITY), + script.contains("dispatch_worker_cli_profile"), "{name} must reject a CLI whose detached worker can select the wrong profile" ); } @@ -4052,7 +4042,7 @@ mod tests { #[test] fn incompatible_dispatch_protocols_require_an_upgrade() { - let capabilities = REQUIRED_DISPATCH_CAPABILITIES; + let capabilities = REQUIRED_DISPATCH_CAPABILITIES.clone(); let compatible = serde_json::json!({ "protocolVersion": DISPATCH_PROTOCOL_VERSION, "capabilities": capabilities, @@ -4071,45 +4061,36 @@ mod tests { }); assert!(!dispatch_protocol_is_compatible(&missing)); + let mut reject_capabilities: Vec<&str> = REQUIRED_DISPATCH_CAPABILITIES + .iter() + .copied() + .filter(|capability| !capability.starts_with("approval_")) + .collect(); + reject_capabilities.push("approval_reject_and_report"); let reject_only = serde_json::json!({ "protocolVersion": DISPATCH_PROTOCOL_VERSION, - "capabilities": [ - "persistent_jobs", - "cursor_events", - "detached_worker", - "workspace_serialization", - "frontend_event_projection", - "approval_reject_and_report", - "workspace_git_worktree", - "workspace_git_bundle_upload", - "workspace_git_sync", - DISPATCH_WORKER_CLI_PROFILE_CAPABILITY - ], + "capabilities": reject_capabilities, }); validate_dispatch_protocol(&reject_only, Some("reject-and-report")) .expect("selected policy is supported"); assert!(validate_dispatch_protocol(&reject_only, Some("auto")).is_err()); + let mut unsafe_capabilities: Vec<&str> = REQUIRED_DISPATCH_CAPABILITIES + .iter() + .copied() + .filter(|capability| { + !capability.starts_with("approval_") && *capability != "dispatch_worker_cli_profile" + }) + .collect(); + unsafe_capabilities.push("approval_reject_and_report"); let unsafe_worker = serde_json::json!({ "protocolVersion": DISPATCH_PROTOCOL_VERSION, - "capabilities": [ - "persistent_jobs", - "cursor_events", - "detached_worker", - "workspace_serialization", - "frontend_event_projection", - "approval_reject_and_report", - "workspace_git_worktree", - "workspace_git_bundle_upload", - "workspace_git_sync" - ], + "capabilities": unsafe_capabilities, }); let error = validate_dispatch_protocol(&unsafe_worker, Some("reject-and-report")) .expect_err("a worker that can select product-full first must be rejected"); assert!( - error - .to_string() - .contains(DISPATCH_WORKER_CLI_PROFILE_CAPABILITY), + error.to_string().contains("dispatch_worker_cli_profile"), "{error}" ); } diff --git a/src/web-ui/eslint.config.mjs b/src/web-ui/eslint.config.mjs index 265f08c63e..426751c7cf 100644 --- a/src/web-ui/eslint.config.mjs +++ b/src/web-ui/eslint.config.mjs @@ -99,4 +99,41 @@ export default tseslint.config( parser: tseslint.parser, }, }, + { + // SessionDriver seam fence: flow_chat must reach dispatch machinery only + // through session-drivers. This is the executable form of "no dispatch + // branches in flow_chat" — reintroducing one fails the build. + // + // Exceptions, each with an owner: + // - session-drivers/**: the dispatch driver's own implementation. + // - types/flow-chat.ts: type-only SessionConfig fields (collapses into a + // single `config.dispatch` member in a follow-up). + // - store/FlowChatStore.ts: dispatch snapshot/cursor mutations kept + // in-place by design during the seam refactor (same follow-up). + // - ChatInput.tsx / ChatInputWorkspaceStrip.tsx: render the dispatch + // target-picker feature chip — feature UI, not transport branching. + files: ['src/flow_chat/**/*.{ts,tsx}'], + ignores: [ + 'src/flow_chat/session-drivers/**', + 'src/flow_chat/types/flow-chat.ts', + 'src/flow_chat/store/FlowChatStore.ts', + 'src/flow_chat/components/ChatInput.tsx', + 'src/flow_chat/components/ChatInputWorkspaceStrip.tsx', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@/features/dispatch/*'], + message: + 'flow_chat reaches dispatch only through session-drivers. ' + + 'Add the behavior to the SessionDriver interface instead.', + }, + ], + }, + ], + }, + }, ); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 997b815e23..4632eb79dd 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -1,3 +1,4 @@ +import { BASE_DISPATCH_CAPABILITIES } from './dispatchPreflight'; // @vitest-environment jsdom import React, { act } from 'react'; @@ -411,7 +412,7 @@ describe('DispatchInstallDialog installation lifecycle', () => { expect(container.textContent).not.toContain('dispatch.snapshotResultLocationHint'); }); - it('preserves protocol v3 target model facts without a delivery-mode choice', async () => { + it('preserves protocol v4 target model facts without a delivery-mode choice', async () => { const onReady = vi.fn(); mocks.probeTarget.mockResolvedValue({ cliInstalled: true, @@ -419,22 +420,11 @@ describe('DispatchInstallDialog installation lifecycle', () => { arch: 'x86_64', installSupported: false, protocol: { - protocolVersion: 3, + protocolVersion: 4, cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: [ - 'persistent_jobs', - 'cursor_events', - 'detached_worker', - 'frontend_event_projection', - 'workspace_serialization', - 'dispatch_worker_cli_profile', - 'workspace_git_worktree', - 'workspace_git_bundle_upload', - 'workspace_git_sync', - 'approval_remote', - ], + capabilities: [...BASE_DISPATCH_CAPABILITIES, 'approval_remote'], modelConfigured: true, availableModels: ['model-a', 'model-b'], defaultModel: 'model-b', @@ -577,21 +567,11 @@ describe('DispatchInstallDialog model configuration sync', () => { arch: 'x86_64', installSupported: true, protocol: { - protocolVersion: 3, + protocolVersion: 4, cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: [ - 'persistent_jobs', - 'cursor_events', - 'detached_worker', - 'frontend_event_projection', - 'workspace_serialization', - 'workspace_git_worktree', - 'workspace_git_bundle_upload', - 'workspace_git_sync', - 'dispatch_worker_cli_profile', - ], + capabilities: [...BASE_DISPATCH_CAPABILITIES], modelConfigured, availableModels: modelConfigured ? ['claude'] : [], defaultModel: modelConfigured ? 'claude' : undefined, @@ -735,21 +715,11 @@ describe('DispatchInstallDialog target model readout', () => { arch: 'x86_64', installSupported: true, protocol: { - protocolVersion: 3, + protocolVersion: 4, cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: [ - 'persistent_jobs', - 'cursor_events', - 'detached_worker', - 'frontend_event_projection', - 'workspace_serialization', - 'workspace_git_worktree', - 'workspace_git_bundle_upload', - 'workspace_git_sync', - 'dispatch_worker_cli_profile', - ], + capabilities: [...BASE_DISPATCH_CAPABILITIES], modelConfigured: true, availableModels, defaultModel, diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts index 99225e9372..bcdf961cd4 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts @@ -387,7 +387,9 @@ describe('DispatchJobObserver', () => { }); }); - it('ignores subagent links until child dispatch projections have an owner', () => { + it('projects subagent links so child sessions render under the projection', () => { + // Child ownership is driver-resolved through the parent chain, so the + // link event flows into the normal pipeline like any other event. expect(projectDispatchAgentEvent({ type: 'agentEvent', timestamp: '2026-07-28T00:00:00Z', @@ -404,7 +406,10 @@ describe('DispatchJobObserver', () => { child_session_id: 'child-1', }, }, - })).toBeNull(); + })).toMatchObject({ + eventName: 'agentic://subagent-session-linked', + envelopeId: 'event-child', + }); }); it('keeps the cursor until an event applies, then deduplicates it on replay', async () => { diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts index b1ef69ca02..f007de09b6 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -68,8 +68,10 @@ const RAW_EVENT_NAMES: Record = { ImageAnalysisStarted: 'agentic://image-analysis-started', ImageAnalysisCompleted: 'agentic://image-analysis-completed', DialogTurnStarted: 'agentic://dialog-turn-started', - // Detached dispatch has no child-observer ownership. Ignoring this link prevents an - // unmarked child projection from being mistaken for a local session. + // v4: the target admits linked subagent sessions into the job event log, + // and the driver resolver treats child sessions of a projection as + // observer-only through the parent chain. + SubagentSessionLinked: 'agentic://subagent-session-linked', ModelRoundStarted: 'agentic://model-round-started', ModelRoundCompleted: 'agentic://model-round-completed', ModelRoundAttemptSuperseded: 'agentic://model-round-attempt-superseded', @@ -124,9 +126,6 @@ export function projectDispatchAgentEvent( || (envelope?.frontendPayload && typeof envelope.frontendPayload === 'object' ? envelope.frontendPayload : undefined); - if (projectedName === 'agentic://subagent-session-linked') { - return null; - } if (projectedName && projectedPayload) { return { eventName: projectedName, diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md index 68712a93e8..913604ef26 100644 --- a/src/web-ui/src/features/dispatch/README.md +++ b/src/web-ui/src/features/dispatch/README.md @@ -6,7 +6,9 @@ dispatch. ## Invariants 1. A dispatch target is selected while creating a session and is immutable after - the first turn. + the first turn. The model and approval policy are not: protocol v4 carries + them per follow-up turn, and the target persists the effective values onto + the job. 1a. A dispatch session accepts follow-up messages. While a turn runs, a message is an `append` that steers it; once it has finished, a message is a `dispatch_continue` that queues the next turn against the same target @@ -67,10 +69,10 @@ dispatch. `dispatch_target_*` commands. They never attach Peer Device Mode and an offline or incompatible target never falls back to local execution. Device dispatch does not install software through the Relay. -14. Approval policy is explicit per job: `auto`, `reject-and-report`, or - `remote`. `remote` projects pending requests into the normal permission - panel. The selected policy is visible in the normal session controls; submit - must not add a second confirmation dialog. +14. Approval policy is explicit and editable between turns: `auto`, + `reject-and-report`, or `remote`. `remote` projects pending requests into + the normal permission panel. The selected policy is visible in the normal + session controls; submit must not add a second confirmation dialog. 15. MiniApp and quick-input hosts do not expose the dispatch picker. 16. Controller-side model settings never leak into an SSH dispatch. The submit omits `model` unless preflight recorded an explicit target model choice. @@ -89,9 +91,10 @@ dispatch. `knownHead` unchanged, and can be repeated after the lock clears. 19. Deleting or archiving a projection writes a local job tombstone so outbound reconciliation cannot silently reopen it. -20. The observer ignores `SubagentSessionLinked`. Child observer ownership is not - implemented, so creating an unmarked child projection would violate the - observer-only persistence and cancellation boundary. +20. Subagent sessions linked under a dispatch job flow through the event log + and render as child projections. Ownership is driver-resolved through the + parent chain: a child of a projection is itself observer-only (never + persisted locally, never driven as a local backend session). 21. Cursor reads are multi-observer safe. Truncation and omitted events are visible completeness facts and must not be rendered as a full transcript. 22. The observer continues bounded polling while the window is hidden so diff --git a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts index 6dbb4344f7..86a687321c 100644 --- a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts +++ b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts @@ -16,6 +16,7 @@ const OUTBOUND_DISPATCH_COMMANDS = [ 'dispatch_sync_model_config', 'dispatch_submit', 'dispatch_status', + 'dispatch_query', 'dispatch_cancel', 'dispatch_list_jobs', 'dispatch_answer', @@ -57,9 +58,44 @@ describe('dispatch controller-only routing contract', () => { } }); +describe('dispatch wire contract single source', () => { + // The Rust side has exactly one contract file; the Web UI's copies must + // track it. A capability or version bump that misses one side fails here + // instead of at runtime probe. + const contractSource = read( + '../../../../../src/crates/services/services-core/src/dispatch_contract.rs', + ); + + it('pins the protocol version to the shared Rust contract', () => { + expect(contractSource).toContain( + `pub const DISPATCH_PROTOCOL_VERSION: u32 = ${DISPATCH_PROTOCOL_VERSION};`, + ); + }); + + it('requires only capabilities the shared Rust contract defines', () => { + for (const capability of BASE_DISPATCH_CAPABILITIES) { + if (capability === 'detached_worker') { + expect(contractSource).toContain( + `DISPATCH_DETACHED_WORKER_CAPABILITY: &str = "${capability}"`, + ); + continue; + } + expect(contractSource).toContain(`"${capability}",`); + } + }); + + it('keeps the v4 feature capabilities required on both sides', () => { + for (const capability of ['per_turn_options', 'session_query', 'inline_attachments']) { + expect(BASE_DISPATCH_CAPABILITIES).toContain(capability); + expect(contractSource).toContain(`"${capability}",`); + } + }); +}); + describe('dispatch preflight contract', () => { - it('fails closed on protocol v3 Git worktree delivery', () => { - expect(DISPATCH_PROTOCOL_VERSION).toBe(3); + it('fails closed on protocol v4 Git worktree delivery with per-turn options', () => { + expect(DISPATCH_PROTOCOL_VERSION).toBe(4); + expect(BASE_DISPATCH_CAPABILITIES).toContain('per_turn_options'); expect(BASE_DISPATCH_CAPABILITIES).toEqual(expect.arrayContaining([ 'workspace_serialization', 'workspace_git_worktree', diff --git a/src/web-ui/src/features/dispatch/dispatchApi.ts b/src/web-ui/src/features/dispatch/dispatchApi.ts index 02f93fee0d..892934bd30 100644 --- a/src/web-ui/src/features/dispatch/dispatchApi.ts +++ b/src/web-ui/src/features/dispatch/dispatchApi.ts @@ -17,6 +17,14 @@ import type { OutboundDispatchRecord, } from './types'; +/** One inline image attachment forwarded to the target with the turn. */ +export interface DispatchInlineAttachment { + id: string; + name?: string; + mimeType: string; + dataUrl: string; +} + export const dispatchApi = { async listTargets(): Promise { return api.invoke('dispatch_list_targets', { @@ -90,6 +98,7 @@ export const dispatchApi = { title?: string; sourceWorkspacePath?: string; sourceWorkspaceId?: string; + attachments?: DispatchInlineAttachment[]; }): Promise { return api.invoke('dispatch_submit', { request, @@ -108,9 +117,34 @@ export const dispatchApi = { turnId: string, prompt: string, displayContent?: string, + options?: { + /** Per-turn model override; carries forward as the job's model. */ + model?: string; + /** Per-turn approval-policy override with the same carry-forward rule. */ + approvalPolicy?: DispatchApprovalPolicy; + /** Operation kind; defaults to an ordinary prompt turn. */ + kind?: 'prompt' | 'compact'; + attachments?: DispatchInlineAttachment[]; + }, ): Promise { return api.invoke('dispatch_continue', { - request: { jobId, turnId, prompt, displayContent }, + request: { + jobId, + turnId, + prompt, + displayContent, + model: options?.model, + approvalPolicy: options?.approvalPolicy, + kind: options?.kind, + attachments: options?.attachments, + }, + }); + }, + + /** Read-only persisted-state question answered without starting a turn. */ + async query(jobId: string, kind: 'usageReport'): Promise<{ kind: string; report: unknown }> { + return api.invoke<{ kind: string; report: unknown }>('dispatch_query', { + request: { jobId, kind }, }); }, diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts index 8d5db4d1d3..1c0987d6e1 100644 --- a/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.test.ts @@ -5,8 +5,8 @@ import { } from './dispatchPreflight'; describe('dispatch preflight', () => { - it('requires protocol v3 Git worktree delivery without a snapshot fallback', () => { - expect(DISPATCH_PROTOCOL_VERSION).toBe(3); + it('requires protocol v4 Git worktree delivery without a snapshot fallback', () => { + expect(DISPATCH_PROTOCOL_VERSION).toBe(4); expect(BASE_DISPATCH_CAPABILITIES).toEqual(expect.arrayContaining([ 'workspace_git_worktree', 'workspace_git_bundle_upload', diff --git a/src/web-ui/src/features/dispatch/dispatchPreflight.ts b/src/web-ui/src/features/dispatch/dispatchPreflight.ts index af0f58169a..7899023425 100644 --- a/src/web-ui/src/features/dispatch/dispatchPreflight.ts +++ b/src/web-ui/src/features/dispatch/dispatchPreflight.ts @@ -1,4 +1,4 @@ -export const DISPATCH_PROTOCOL_VERSION = 3; +export const DISPATCH_PROTOCOL_VERSION = 4; /** * Capabilities every dispatch target must advertise. @@ -17,4 +17,7 @@ export const BASE_DISPATCH_CAPABILITIES = [ 'workspace_git_bundle_upload', 'workspace_git_sync', 'dispatch_worker_cli_profile', + 'per_turn_options', + 'session_query', + 'inline_attachments', ] as const; diff --git a/src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts b/src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts index 2f77c84f38..f8446cfe7d 100644 --- a/src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts +++ b/src/web-ui/src/features/dispatch/optimisticDispatchTurn.ts @@ -1,29 +1,29 @@ -import type { DialogTurn } from '@/flow_chat/types/flow-chat'; +/** + * Dispatch-named wrappers over the generic optimistic-turn adoption helpers. + * The metadata key and behavior are unchanged; see + * `flow_chat/utils/optimisticTurnAdoption.ts`. + */ -const OPTIMISTIC_DISPATCH_JOB_ID_KEY = '__bitfunOptimisticDispatchJobId'; +import type { DialogTurn } from '@/flow_chat/types/flow-chat'; +import { + markOptimisticTurnAdoption, + optimisticTurnAdoptionKey, + stripOptimisticTurnAdoption, +} from '@/flow_chat/utils/optimisticTurnAdoption'; export function markOptimisticDispatchTurnMetadata( metadata: Record | undefined, jobId: string, ): Record { - return { - ...metadata, - [OPTIMISTIC_DISPATCH_JOB_ID_KEY]: jobId, - }; + return markOptimisticTurnAdoption(metadata, jobId); } export function optimisticDispatchTurnJobId(turn: DialogTurn): string | undefined { - const value = turn.userMessage.metadata?.[OPTIMISTIC_DISPATCH_JOB_ID_KEY]; - return typeof value === 'string' && value ? value : undefined; + return optimisticTurnAdoptionKey(turn); } export function stripOptimisticDispatchTurnMetadata( metadata: Record | undefined, ): Record | undefined { - if (!metadata) { - return undefined; - } - const next = { ...metadata }; - delete next[OPTIMISTIC_DISPATCH_JOB_ID_KEY]; - return Object.keys(next).length > 0 ? next : undefined; + return stripOptimisticTurnAdoption(metadata); } diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index cdb34a8fc1..ad0802d8f3 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -66,7 +66,6 @@ import { successfulRetryCleanupTarget, } from './chatInputDraftRecovery'; import { startBtwThread } from '../services/BtwThreadService'; -import { runUsageReportCommand } from '../services/usageReportService'; import { buildImagePayload } from '../utils/imagePayload'; import { isGoalSlashCommand, parseGoalCommand } from '../services/goalService'; import { @@ -134,7 +133,7 @@ import { import type { DispatchSelection, DispatchTarget } from '@/features/dispatch/types'; import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; -import { useRuntimeStatusStore } from '../store/runtimeStatusStore'; +import { useComposerCapabilities } from '../session-drivers/useComposerCapabilities'; import { ComposerVoiceInputButton } from './voice/ComposerVoiceInputButton'; import { useComposerVoiceInput } from './voice/useComposerVoiceInput'; import { expandWidgetPromptReferenceTokens } from '@/tools/generative-widget/widgetPromptReference'; @@ -509,25 +508,20 @@ export const ChatInput: React.FC = ({ const jobId = effectiveTargetSession?.config.dispatchJobId; return jobId ? state.jobs[jobId] : undefined; }); - const isDispatchInputSession = isNonLocalDispatchTarget( - effectiveTargetSession?.config.dispatchTarget, - ); - const usesDispatchTransport = !registration && isDispatchInputSession; - const dispatchSubmissionInFlight = useRuntimeStatusStore(state => { - const status = effectiveTargetSessionId - ? state.bySessionId.get(effectiveTargetSessionId) - : undefined; - return usesDispatchTransport - && status?.roundId.startsWith('dispatch-transfer:') === true; + const effectiveTargetRelationship = resolveSessionRelationship(effectiveTargetSession); + const isBtwSession = effectiveTargetRelationship.displayAsChild; + const isSubagentInputTarget = effectiveTargetRelationship.isSubagent; + const caps = useComposerCapabilities({ + sessionId: effectiveTargetSessionId, + session: effectiveTargetSession, + hostMasksDispatch: !!registration, + displayAsChild: isBtwSession, }); const historySessionOpenTransition = useSyncExternalStore( subscribeHistorySessionOpenTransition, getHistorySessionOpenTransitionSnapshot, getHistorySessionOpenTransitionSnapshot, ); - const effectiveTargetRelationship = resolveSessionRelationship(effectiveTargetSession); - const isBtwSession = effectiveTargetRelationship.displayAsChild; - const isSubagentInputTarget = effectiveTargetRelationship.isSubagent; const acpSessionForInput = useMemo( () => acpSessionRef(effectiveTargetSession), [effectiveTargetSession], @@ -537,7 +531,7 @@ export const ChatInput: React.FC = ({ const reloadContextSupported = supportsLocalReloadContext({ desktopRuntime: isTauriRuntime(), acpSession: isAcpInputSession, - dispatchTransport: usesDispatchTransport, + dispatchTransport: caps.dispatchTransport, }); const canReloadContext = reloadContextSupported && Boolean(effectiveTargetSessionId); const { entries: acpPlanEntries } = useAcpPlan(acpSessionForInput?.sessionId ?? null); @@ -2015,12 +2009,7 @@ export const ChatInput: React.FC = ({ : effectiveTargetSession?.config.dispatchApprovalPolicy === 'reject-and-report' ? 'reject' : 'ask'; - const dispatchSubmissionOptionsLocked = - dispatchSubmissionInFlight - || ( - effectiveTargetSession?.config.dispatchJobState !== 'submitting' - && effectiveTargetSession?.config.dispatchJobState !== 'submission_unknown' - ); + const dispatchSubmissionOptionsLocked = caps.submissionOptionsLocked; const handleDispatchPermissionModeChange = useCallback(( nextMode: Exclude, ) => { @@ -2061,7 +2050,7 @@ export const ChatInput: React.FC = ({ // A dispatch always executes against a managed worktree baseline of this // repository, so the chip reports that state instead of disappearing. It is // never togglable: the baseline is chosen with the target, not after. - if (usesDispatchTransport) { + if (caps.worktreeBaselineLocked) { return { enabled: true, locked: true, @@ -2104,7 +2093,7 @@ export const ChatInput: React.FC = ({ isSubagentInputTarget, remoteWorkspaceSession, tWorktrees, - usesDispatchTransport, + caps.worktreeBaselineLocked, ]); const handleSelectDispatchTarget = useCallback(async (selection: DispatchSelection) => { @@ -2189,7 +2178,7 @@ export const ChatInput: React.FC = ({ ]); const dispatchModelSelection = useMemo(() => { - if (!usesDispatchTransport || !effectiveTargetSession) { + if (!caps.targetModelSelection || !effectiveTargetSession) { return undefined; } const target = effectiveTargetSession.config.dispatchTarget; @@ -2199,18 +2188,12 @@ export const ChatInput: React.FC = ({ : t('chatInput.dispatch.remoteTarget'); const sessionId = effectiveTargetSession.sessionId; const jobId = effectiveTargetSession.config.dispatchJobId; - const state = effectiveTargetSession.config.dispatchJobState; return { models: effectiveTargetSession.config.dispatchAvailableModels ?? [], selectedModelId: effectiveTargetSession.config.dispatchModel, defaultModelId: effectiveTargetSession.config.dispatchDefaultModel, providerLabel, - disabled: - dispatchSubmissionInFlight - || ( - state !== 'submitting' - && state !== 'submission_unknown' - ), + disabled: caps.submissionOptionsLocked, onSelect: (modelId: string) => { FlowChatStore.getInstance().updateSessionDispatchModel(sessionId, modelId); if (jobId) { @@ -2218,7 +2201,7 @@ export const ChatInput: React.FC = ({ } }, }; - }, [dispatchSubmissionInFlight, effectiveTargetSession, t, usesDispatchTransport]); + }, [caps.submissionOptionsLocked, caps.targetModelSelection, effectiveTargetSession, t]); const handleHidePermissionModeControl = useCallback(async () => { try { @@ -2739,17 +2722,22 @@ export const ChatInput: React.FC = ({ : []), ]; const q = (slashCommandState.query || '').trim().toLowerCase(); - const visibleItems = items.filter(item => isChatInputActionVisibleForTarget({ - actionId: item.id, - isSubagentTarget: isSubagentInputTarget, - })); + // The picker offers exactly what this session can execute. Without this, + // an unsupported command picked from the list falls through the per-op + // submit gates and is sent to the agent as literal prompt text. + const visibleItems = items.filter(item => + (item.id === 'reload' || caps.ops.has(item.id)) + && isChatInputActionVisibleForTarget({ + actionId: item.id, + isSubagentTarget: isSubagentInputTarget, + })); if (!q) return visibleItems; return visibleItems.filter(i => { const cmd = i.command.slice(1).toLowerCase(); return cmd.includes(q) || i.label.toLowerCase().includes(q); }); - }, [canLaunchReview, canReloadContext, derivedState?.isProcessing, isAcpInputSession, isBtwSession, isSubagentInputTarget, slashCommandState.query, t]); + }, [canLaunchReview, canReloadContext, caps.ops, derivedState?.isProcessing, isAcpInputSession, isBtwSession, isSubagentInputTarget, slashCommandState.query, t]); const getFilteredMcpPromptCommands = useCallback((): SlashMcpPromptItem[] => { if (isAcpInputSession) { @@ -2928,13 +2916,18 @@ export const ChatInput: React.FC = ({ } const promptSlashCommandsEnabled = !isAcpInputSession; - const localSlashCommandsEnabled = promptSlashCommandsEnabled && !usesDispatchTransport; + const localSlashCommandsEnabled = promptSlashCommandsEnabled && caps.localSlashCommands; const trimmed = text.trim(); - const isBtwCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/btw'); - const isCompactCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/compact'); - const isGoalCommand = localSlashCommandsEnabled && isGoalSlashCommand(text); - const isUsageCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/usage'); - const isReviewCommand = localSlashCommandsEnabled && isReviewSlashCommand(text); + const isBtwCommand = + promptSlashCommandsEnabled && caps.ops.has('btw') && isSlashCommand(trimmed, '/btw'); + const isCompactCommand = + promptSlashCommandsEnabled && caps.ops.has('compact') && isSlashCommand(trimmed, '/compact'); + const isGoalCommand = + promptSlashCommandsEnabled && caps.ops.has('goal') && isGoalSlashCommand(text); + const isUsageCommand = + promptSlashCommandsEnabled && caps.ops.has('usage') && isSlashCommand(trimmed, '/usage'); + const isReviewCommand = + promptSlashCommandsEnabled && caps.ops.has('review') && isReviewSlashCommand(text); const isProcessing = !!derivedState?.isProcessing; // Don't queue /btw or /goal while the main session is processing; they have dedicated flows. @@ -3006,7 +2999,7 @@ export const ChatInput: React.FC = ({ selectedIndex: 0, }); } - }, [contexts, derivedState, dispatchInput, externalPromptCommands, inputState.isActive, isAcpInputSession, prunePendingLargePastes, removeContext, resolveTypedMcpPromptCommand, selectedExternalPromptCandidateId, selectedNonExternalSlashCommand, setQueuedInput, slashCommandState.isActive, slashCommandState.kind, usesDispatchTransport]); + }, [contexts, derivedState, dispatchInput, externalPromptCommands, inputState.isActive, isAcpInputSession, prunePendingLargePastes, removeContext, resolveTypedMcpPromptCommand, selectedExternalPromptCandidateId, selectedNonExternalSlashCommand, setQueuedInput, slashCommandState.isActive, slashCommandState.kind, caps.localSlashCommands, caps.ops]); const submitBtwFromInput = useCallback(async () => { if (!derivedState) return; @@ -3115,12 +3108,7 @@ export const ChatInput: React.FC = ({ setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); try { - await agentAPI.compactSession({ - sessionId: effectiveTargetSessionId, - workspacePath: effectiveTargetSession.workspacePath, - remoteConnectionId: effectiveTargetSession.remoteConnectionId, - remoteSshHost: effectiveTargetSession.remoteSshHost, - }); + await FlowChatManager.getInstance().compactSession(effectiveTargetSessionId); } catch (error) { log.error('Failed to trigger /compact', { error, @@ -3155,15 +3143,17 @@ export const ChatInput: React.FC = ({ } try { - const result = await runUsageReportCommand({ - session: effectiveTargetSession, - isProcessing: !!derivedState?.isProcessing, - busyMessage: t('chatInput.usageBusy'), - noWorkspaceMessage: t('chatInput.usageNoWorkspace'), - failedTitle: t('chatInput.usageFailed'), - unknownErrorMessage: t('error.unknown'), - loadingMarkdown: t('usage.loading.markdown'), - }); + const result = await FlowChatManager.getInstance().runSessionUsageReport( + effectiveTargetSessionId, + { + isProcessing: !!derivedState?.isProcessing, + busyMessage: t('chatInput.usageBusy'), + noWorkspaceMessage: t('chatInput.usageNoWorkspace'), + failedTitle: t('chatInput.usageFailed'), + unknownErrorMessage: t('error.unknown'), + loadingMarkdown: t('usage.loading.markdown'), + }, + ); if (result.inserted) { dispatchInput({ type: 'DEACTIVATE' }); @@ -3930,7 +3920,7 @@ export const ChatInput: React.FC = ({ const handleSendOrCancel = useCallback(async (messageOverride?: string) => { if (!derivedState) return; - if (dispatchSubmissionInFlight) return; + if (caps.transferInFlight) return; const { sendButtonMode } = derivedState; const draftTrimmed = (messageOverride ?? inputState.value).trim(); @@ -3976,7 +3966,7 @@ export const ChatInput: React.FC = ({ messageOverride === undefined; const localSlashCommandsEnabled = promptSlashCommandsEnabled && - !usesDispatchTransport; + caps.localSlashCommands; const parsedReload = messageOverride === undefined ? parseReloadCommand(message) : null; @@ -3989,33 +3979,33 @@ export const ChatInput: React.FC = ({ return; } - if (localSlashCommandsEnabled && isSlashCommand(message, '/btw')) { + if (promptSlashCommandsEnabled && caps.ops.has('btw') && isSlashCommand(message, '/btw')) { // When idle, /btw can be sent via the normal send button. await submitBtwFromInput(); return; } - if (localSlashCommandsEnabled && isGoalSlashCommand(message)) { + if (promptSlashCommandsEnabled && caps.ops.has('goal') && isGoalSlashCommand(message)) { await submitGoalFromInput(); return; } - if (localSlashCommandsEnabled && /^\/compact\s*$/i.test(message)) { + if (promptSlashCommandsEnabled && caps.ops.has('compact') && /^\/compact\s*$/i.test(message)) { await submitCompactFromInput(); return; } - if (localSlashCommandsEnabled && /^\/usage\s*$/i.test(message)) { + if (promptSlashCommandsEnabled && caps.ops.has('usage') && /^\/usage\s*$/i.test(message)) { await submitUsageFromInput(); return; } - if (localSlashCommandsEnabled && /^\/init\s*$/i.test(message)) { + if (promptSlashCommandsEnabled && caps.ops.has('init') && /^\/init\s*$/i.test(message)) { await submitInitFromInput(); return; } - if (localSlashCommandsEnabled && isReviewSlashCommand(message)) { + if (promptSlashCommandsEnabled && caps.ops.has('review') && isReviewSlashCommand(message)) { await submitReviewFromInput(); return; } @@ -4034,21 +4024,21 @@ export const ChatInput: React.FC = ({ return; } - if (localSlashCommandsEnabled && isSlashCommand(message, '/compact')) { + if (promptSlashCommandsEnabled && caps.ops.has('compact') && isSlashCommand(message, '/compact')) { notificationService.warning( t('chatInput.compactUsage') ); return; } - if (localSlashCommandsEnabled && isSlashCommand(message, '/usage')) { + if (promptSlashCommandsEnabled && caps.ops.has('usage') && isSlashCommand(message, '/usage')) { notificationService.warning( t('chatInput.usageCommandUsage') ); return; } - if (localSlashCommandsEnabled && isSlashCommand(message, '/init')) { + if (promptSlashCommandsEnabled && caps.ops.has('init') && isSlashCommand(message, '/init')) { notificationService.warning( t('chatInput.initUsage') ); @@ -4140,7 +4130,7 @@ export const ChatInput: React.FC = ({ } }, [ isModelSwitching, - dispatchSubmissionInFlight, + caps.transferInFlight, inputState.value, derivedState, dispatchInput, @@ -4173,7 +4163,8 @@ export const ChatInput: React.FC = ({ t, resolveTypedMcpPromptCommand, submitExternalPromptCommandFromInput, - usesDispatchTransport, + caps.localSlashCommands, + caps.ops, composerMutationRevision, ]); @@ -5012,7 +5003,7 @@ export const ChatInput: React.FC = ({ void handleSendOrCancel()} - disabled={isModelSwitching || dispatchSubmissionInFlight} + disabled={isModelSwitching || caps.transferInFlight} tooltip={t('input.retry')} size="small" > @@ -5038,7 +5029,7 @@ export const ChatInput: React.FC = ({ void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching || dispatchSubmissionInFlight} + disabled={!inputState.value.trim() || isModelSwitching || caps.transferInFlight} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} size="small" @@ -5053,7 +5044,7 @@ export const ChatInput: React.FC = ({ void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching || dispatchSubmissionInFlight} + disabled={!inputState.value.trim() || isModelSwitching || caps.transferInFlight} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} size="small" @@ -5090,9 +5081,9 @@ export const ChatInput: React.FC = ({ >
{recommendationContext && ( = ({ onCompositionStart={handleImeCompositionStart} onCompositionEnd={handleImeCompositionEnd} placeholder="" - disabled={dispatchSubmissionInFlight} + disabled={caps.transferInFlight} contexts={contexts} onRemoveContext={removeContext} onMentionStateChange={setMentionState} @@ -5767,7 +5758,7 @@ export const ChatInput: React.FC = ({
) : null} - {!dispatchSubmissionInFlight ? ( + {!caps.transferInFlight ? ( ) : null} {voiceInput.phase === 'idle' ? renderActionButton() : null} @@ -5784,7 +5775,7 @@ export const ChatInput: React.FC = ({ worktreeControl={worktreeControl} deferPassiveGitRefresh={deferChatStripPassiveGitRefresh} permissionControl={showPermissionModeControl - ? usesDispatchTransport + ? caps.sessionScopedApproval ? { mode: dispatchPermissionMode, disabled: dispatchSubmissionOptionsLocked, @@ -5801,15 +5792,14 @@ export const ChatInput: React.FC = ({ } : undefined} usageReport={ - effectiveTargetSessionId && effectiveTargetSession && !usesDispatchTransport + effectiveTargetSessionId && effectiveTargetSession && caps.usageReport ? { visible: true, onOpen: handleToolbarUsageReport } : undefined } threadGoal={ effectiveTargetSessionId && effectiveTargetSession && - !isBtwSession && - !usesDispatchTransport + caps.threadGoal ? { visible: true, goal: threadGoalController.goal, @@ -5820,7 +5810,7 @@ export const ChatInput: React.FC = ({ : undefined } /> - {effectiveTargetSession && !isBtwSession && !usesDispatchTransport ? ( + {effectiveTargetSession && caps.threadGoal ? ( = ( activeBatch: activePermissionBatch, respond: respondPermission, respondBatch: respondPermissionBatch, - } = usePermissionRequests( - activeSession?.sessionId, - activeSession?.config.dispatchJobId, - ); + } = usePermissionRequests(activeSession?.sessionId); const visibleTurnInfo = useVisibleTurnInfo(); const [queuedTurnPinId, setQueuedTurnPinId] = useState(null); const [pendingHistoryOpenSession, setPendingHistoryOpenSession] = useState(null); diff --git a/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts b/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts index 4fb8c25027..5dd210b727 100644 --- a/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts +++ b/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { agentAPI, type PermissionReplyKind, @@ -11,30 +11,43 @@ import { selectActivePermissionBatch, selectPermissionRequestsForSession, } from './permissionRequestRouting'; -import { dispatchApi } from '@/features/dispatch/dispatchApi'; -import { useDispatchJobStore } from '@/features/dispatch/dispatchJobStore'; -import { requestDispatchJobRefresh } from '@/features/dispatch/DispatchJobObserver'; +import { FlowChatStore } from '../../store/FlowChatStore'; +import { driverForSession } from '../../session-drivers/registry'; -const EMPTY_DISPATCH_PERMISSIONS: Array> = []; +const EMPTY_EXTERNAL_REQUESTS: PermissionRequest[] = []; +const noopSubscribe = () => () => {}; +const emptyExternalSnapshot = () => EMPTY_EXTERNAL_REQUESTS; -export function usePermissionRequests(sessionId?: string, dispatchJobId?: string) { - const [requests, setRequests] = useState([]); +export function usePermissionRequests(sessionId?: string) { + const [liveRequests, setLiveRequests] = useState([]); const resolvedIds = useRef(new Set()); - const dispatchRequests = useDispatchJobStore(state => ( - dispatchJobId - ? state.jobs[dispatchJobId]?.pendingPermissions ?? EMPTY_DISPATCH_PERMISSIONS - : EMPTY_DISPATCH_PERMISSIONS - )) as unknown as PermissionRequest[]; + + // Driver resolution is per-render: the caller re-renders on any session + // change, so a projection whose dispatch config binds late is picked up. + const session = sessionId + ? FlowChatStore.getInstance().getState().sessions.get(sessionId) + : undefined; + const driver = driverForSession(sessionId ?? '', session); + const source = useMemo( + () => driver.permissionRequestSource(sessionId ?? ''), + [driver, sessionId], + ); + const isLiveSource = source === 'live'; + + const externalRequests = useSyncExternalStore( + isLiveSource ? noopSubscribe : source.subscribe, + isLiveSource ? emptyExternalSnapshot : source.getSnapshot, + ) as unknown as PermissionRequest[]; useEffect(() => { - if (dispatchJobId) { - setRequests([]); + if (!isLiveSource) { + setLiveRequests([]); return undefined; } let disposed = false; const unlisten = agentAPI.onPermissionRequestEvent((event: PermissionRequestEvent) => { if (disposed) return; - setRequests((current) => { + setLiveRequests((current) => { if (event.event === 'asked') { resolvedIds.current.delete(event.request.requestId); } else { @@ -49,12 +62,12 @@ export function usePermissionRequests(sessionId?: string, dispatchJobId?: string await agentAPI.subscribePermissionRequests(); const pending = await agentAPI.listPendingPermissionRequests(); if (!disposed) { - setRequests((current) => + setLiveRequests((current) => reconcilePermissionRequestSnapshot(current, pending, resolvedIds.current), ); } } catch { - if (!disposed) setRequests([]); + if (!disposed) setLiveRequests([]); } })(); @@ -62,47 +75,37 @@ export function usePermissionRequests(sessionId?: string, dispatchJobId?: string disposed = true; unlisten(); }; - }, [dispatchJobId]); + }, [isLiveSource]); const respond = useCallback( async (requestId: string, reply: PermissionReplyKind, feedback?: string) => { - if (dispatchJobId) { - await dispatchApi.answerPermission(dispatchJobId, requestId, reply, feedback); - requestDispatchJobRefresh(dispatchJobId); - return; + await driver.respondPermission(sessionId ?? '', requestId, reply, feedback); + if (isLiveSource) { + resolvedIds.current.add(requestId); + setLiveRequests((current) => current.filter((request) => request.requestId !== requestId)); } - await agentAPI.respondPermission(requestId, reply, feedback); - resolvedIds.current.add(requestId); - setRequests((current) => current.filter((request) => request.requestId !== requestId)); }, - [dispatchJobId], + [driver, sessionId, isLiveSource], ); const respondBatch = useCallback( async (requestId: string, reply: PermissionReplyKind, feedback?: string) => { - if (dispatchJobId) { - const batch = selectActivePermissionBatch(dispatchRequests as PermissionRequest[], sessionId); - const requestIds = batch?.requests.map(request => request.requestId) ?? [requestId]; - for (const pendingRequestId of requestIds) { - await dispatchApi.answerPermission( - dispatchJobId, - pendingRequestId, - reply, - feedback, - ); - } - requestDispatchJobRefresh(dispatchJobId); - return; + const resolvedRequestIds = await driver.respondPermissionBatch( + sessionId ?? '', + requestId, + reply, + feedback, + ); + if (isLiveSource) { + const resolved = new Set(resolvedRequestIds); + resolvedRequestIds.forEach((id) => resolvedIds.current.add(id)); + setLiveRequests((current) => current.filter((request) => !resolved.has(request.requestId))); } - const resolvedRequestIds = await agentAPI.respondPermissionBatch(requestId, reply, feedback); - const resolved = new Set(resolvedRequestIds); - resolvedRequestIds.forEach((id) => resolvedIds.current.add(id)); - setRequests((current) => current.filter((request) => !resolved.has(request.requestId))); }, - [dispatchJobId, dispatchRequests, sessionId], + [driver, sessionId, isLiveSource], ); - const effectiveRequests = dispatchJobId ? dispatchRequests : requests; + const effectiveRequests = isLiveSource ? liveRequests : externalRequests; const sessionRequests = useMemo( () => selectPermissionRequestsForSession(effectiveRequests, sessionId), [effectiveRequests, sessionId], diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 662e77a9fa..67faa592fe 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -58,7 +58,9 @@ import { } from './flow-chat-manager'; import { ensureBackendSession } from './flow-chat-manager/SessionModule'; import { installPeerSessionRefresh } from './flow-chat-manager/PeerSessionRefreshModule'; -import { installDispatchJobObserver } from '@/features/dispatch/DispatchJobObserver'; +import { installDispatchJobObserver } from '../session-drivers/dispatch/install'; +import { driverForSession } from '../session-drivers/registry'; +import { registerDriverSessionLookup } from '../session-drivers/resolve'; const log = createLogger('FlowChatManager'); @@ -100,6 +102,9 @@ export class FlowChatManager { }; this.agentService = AgentService.getInstance(); + registerDriverSessionLookup( + sessionId => this.context.flowChatStore.getState().sessions.get(sessionId), + ); installPendingQueueDrainListener(this.context); this.peerSessionRefreshCleanup = installPeerSessionRefresh(this.context); this.dispatchJobObserverCleanup = installDispatchJobObserver(this.context); @@ -694,6 +699,21 @@ export class FlowChatManager { return cancelSessionTaskModule(this.context, sessionId); } + /** Manually compact a session's context through its driver. */ + async compactSession(sessionId: string): Promise { + const session = this.context.flowChatStore.getState().sessions.get(sessionId); + return driverForSession(sessionId, session).compactSession(this.context, sessionId); + } + + /** Generate and insert the session usage report through its driver. */ + async runSessionUsageReport( + sessionId: string, + uiParams: import('../session-drivers/types').UsageReportUiParams, + ): Promise<{ inserted: boolean }> { + const session = this.context.flowChatStore.getState().sessions.get(sessionId); + return driverForSession(sessionId, session).runUsageReport(this.context, sessionId, uiParams); + } + public async saveAllInProgressTurns(): Promise { return saveAllInProgressTurns(this.context); } 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 926e0dadcc..a03342752d 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 @@ -79,10 +79,10 @@ import { import { requestPeerSessionRefresh } from './PeerSessionRefreshModule'; import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { - optimisticDispatchTurnJobId, - stripOptimisticDispatchTurnMetadata, -} from '@/features/dispatch/optimisticDispatchTurn'; -import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; + optimisticTurnAdoptionKey, + sessionPendingTurnAdoptionKey, + stripOptimisticTurnAdoption, +} from '../../utils/optimisticTurnAdoption'; const log = createLogger('EventHandlerModule'); const TURN_COMPLETION_QUIET_WINDOW_MS = 500; @@ -1573,18 +1573,20 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { let dialogTurn = freshSession?.dialogTurns.find((turn: DialogTurn) => turn.id === turnId); let projectedNewTurn = false; - if ( - !dialogTurn - && freshSession - && isNonLocalDispatchTarget(freshSession.config.dispatchTarget) - && freshSession.config.dispatchJobId - ) { - const optimisticTurn = freshSession.dialogTurns.find( - turn => optimisticDispatchTurnJobId(turn) === freshSession.config.dispatchJobId, - ); + if (!dialogTurn && freshSession) { + // Adoption is keyed purely by turn metadata: a driver that projected an + // optimistic turn marked it with the session's pending adoption key, and + // the executor's own DialogTurnStarted adopts it in place. No transport + // check — a session whose turns carry no key never matches. + const pendingAdoptionKey = sessionPendingTurnAdoptionKey(freshSession); + const optimisticTurn = pendingAdoptionKey + ? freshSession.dialogTurns.find( + turn => optimisticTurnAdoptionKey(turn) === pendingAdoptionKey, + ) + : undefined; if (optimisticTurn) { store.updateDialogTurn(sessionId, optimisticTurn.id, turn => { - const optimisticMetadata = stripOptimisticDispatchTurnMetadata( + const optimisticMetadata = stripOptimisticTurnAdoption( turn.userMessage.metadata, ); const mergedMetadata = diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index f80fcfea3b..ed0d2ba709 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -587,6 +587,24 @@ describe('MessageModule detached dispatch', () => { expect(mockDispatchSubmit).toHaveBeenCalledTimes(1); }); + it('steers a busy running dispatch instead of queueing the message', async () => { + const { context, session } = createDispatchContext('remote'); + session.config.dispatchJobState = 'running'; + // Busy state machine + non-empty queue would normally enqueue; a running + // dispatch must steer instead, because queued items only drain for + // sessions that drive the local state machine. + mockGetCurrentState.mockReturnValue('processing'); + mockPendingList.mockReturnValue([{ id: 'queued-1' }]); + + await expect( + sendMessage(context, 'steer the remote turn', 'dispatch-session'), + ).resolves.toBeUndefined(); + + expect(mockDispatchAppend).toHaveBeenCalledTimes(1); + expect(mockPendingEnqueue).not.toHaveBeenCalled(); + expect(mockDispatchSubmit).not.toHaveBeenCalled(); + }); + it('reuses the append message id after an ambiguous transport failure', async () => { const { context, session } = createDispatchContext('remote'); session.config.dispatchJobState = 'running'; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index cd43344fc9..c7f45776d6 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -1,41 +1,25 @@ /** * Message handling module - * Handles message sending, cancellation, and other operations + * Shared submission choreography: busy-gate planning, queueing, mode + * switching, conflict retries, and the error path. Flavor-specific transport + * work (optimistic turns, dialog-turn start, steering) lives in the session + * drivers. */ -import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; -import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; -import { worktreeAPI } from '@/infrastructure/api/service-api/WorktreeAPI'; -import { configManager } from '@/infrastructure/config/services/ConfigManager'; -import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import { notificationService } from '../../../shared/notification-system'; import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; -import { generateTempTitle } from '../../utils/titleUtils'; import { createLogger } from '@/shared/utils/logger'; -import type { FlowChatContext, DialogTurn } from './types'; -import { ensureBackendSession, getModelMaxTokens, retryCreateBackendSession } from './SessionModule'; -import { cleanupSessionBuffers } from './TextChunkModule'; +import type { FlowChatContext } from './types'; import type { ImageContextData as ImageInputContextData } from '@/infrastructure/api/service-api/ImageContextTypes'; -import { globalEventBus } from '@/infrastructure/event-bus'; -import { - FLOWCHAT_PIN_TURN_TO_TOP_EVENT, - type FlowChatPinTurnToTopRequest, -} from '../../events/flowchatNavigation'; import { pendingQueueManager } from './PendingQueueModule'; -import { sessionProjectWorkspacePath } from '../../utils/sessionWorkspace'; -import { sessionWorktreeMaterializationPlan } from '../../utils/sessionWorktree'; -import { dispatchApi } from '@/features/dispatch/dispatchApi'; -import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; -import { requestDispatchJobRefresh } from '@/features/dispatch/DispatchJobObserver'; -import { isDispatchJobTerminal, isNonLocalDispatchTarget } from '@/features/dispatch/types'; -import { markOptimisticDispatchTurnMetadata } from '@/features/dispatch/optimisticDispatchTurn'; import { isSessionInUseError } from '@/infrastructure/api/errors/TauriCommandError'; import { i18nService } from '@/infrastructure/i18n'; -import { - clearRuntimeStatusState, - showRuntimeStatus, -} from '@/flow_chat/store/runtimeStatusStore'; +import { driverForSession } from '../../session-drivers/registry'; +import type { SendMessageOptions, SubmissionDraft, TurnTracker } from '../../session-drivers/types'; + +export { syncSessionModelSelection } from '../../utils/modelSync'; +export { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; const log = createLogger('MessageModule'); @@ -74,27 +58,6 @@ function completeSessionSend( retrySuccess?.(); } -interface PendingDispatchAppendRetry { - content: string; - displayContent?: string; - messageId: string; -} - -// Keep the id stable across an ambiguous transport failure. A retry with the -// same message can then ask the target mailbox for the same idempotent append -// instead of injecting the steering text twice. -const pendingDispatchAppendRetries = new Map(); - -interface PendingDispatchContinueRetry { - content: string; - displayContent?: string; - turnId: string; -} - -// Same reasoning as the append retries above: an ambiguous transport failure -// must be retryable without starting the follow-up turn twice on the target. -const pendingDispatchContinueRetries = new Map(); - function acpClientIdFromMode(mode: string | undefined): string | null { const value = mode?.trim(); if (!value?.startsWith('acp:')) return null; @@ -102,92 +65,6 @@ function acpClientIdFromMode(mode: string | undefined): string | null { return clientId || null; } -function normalizeModelSelection( - modelId: string | undefined, - models: AIModelConfig[], - defaultModels: DefaultModelsConfig, -): string { - const value = modelId?.trim(); - if (!value || value === 'auto') return 'auto'; - - if (value === 'primary' || value === 'fast') { - const resolvedDefaultId = value === 'primary' ? defaultModels.primary : defaultModels.fast; - const matchedModel = models.find(model => model.id === resolvedDefaultId); - return matchedModel ? value : 'auto'; - } - - const matchedModel = models.find(model => - model.id === value || model.name === value || model.model_name === value, - ); - return matchedModel ? value : 'auto'; -} - -export async function syncSessionModelSelection( - context: FlowChatContext, - sessionId: string, - agentType: string, -): Promise { - const session = context.flowChatStore.getState().sessions.get(sessionId); - if (!session) { - throw new Error(`Session does not exist: ${sessionId}`); - } - - const sessionModelId = session.config.modelName?.trim(); - - // Any stored selector, including "auto", belongs to the session. Still sync - // it to the backend in case the restored runtime session lost that state. - if (sessionModelId) { - const desiredMaxContextTokens = await getModelMaxTokens(sessionModelId, agentType); - if (session.maxContextTokens !== desiredMaxContextTokens) { - context.flowChatStore.updateSessionMaxContextTokens(sessionId, desiredMaxContextTokens); - } - await agentAPI.updateSessionModel({ - sessionId, - modelName: sessionModelId, - workspacePath: sessionProjectWorkspacePath(session), - remoteConnectionId: session.remoteConnectionId, - remoteSshHost: session.remoteSshHost, - includeInternal: session.sessionKind === 'subagent', - }); - return; - } - - const configData = await configManager.getConfigs([ - 'ai.agent_model_defaults', - 'ai.models', - 'ai.default_models', - ]); - const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; - const allModels = (configData['ai.models'] as AIModelConfig[] | undefined) || []; - const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; - - const desiredModelId = normalizeModelSelection(agentModelDefaults?.mode, allModels, defaultModels); - const shouldForceAutoSync = desiredModelId === 'auto'; - const desiredMaxContextTokens = await getModelMaxTokens(desiredModelId, agentType); - const shouldSyncContextWindow = session.maxContextTokens !== desiredMaxContextTokens; - - context.flowChatStore.updateSessionModelName(sessionId, desiredModelId); - if (shouldSyncContextWindow) { - context.flowChatStore.updateSessionMaxContextTokens(sessionId, desiredMaxContextTokens); - } - await agentAPI.updateSessionModel({ - sessionId, - modelName: desiredModelId, - workspacePath: sessionProjectWorkspacePath(session), - remoteConnectionId: session.remoteConnectionId, - remoteSshHost: session.remoteSshHost, - includeInternal: session.sessionKind === 'subagent', - }); - - log.info('Session model synchronized before send', { - sessionId, - agentType, - previousModelId: null, - nextModelId: desiredModelId, - forcedAutoSync: shouldForceAutoSync, - }); -} - /** * Send message and handle response * @param message - Message sent to backend @@ -203,154 +80,17 @@ export async function sendMessage( displayMessage?: string, agentType?: string, switchToMode?: string, - options?: { - imageContexts?: ImageInputContextData[]; - imageDisplayData?: Array<{ id: string; name: string; dataUrl?: string; imagePath?: string; mimeType?: string }>; - /** - * When true, bypass the pending-queue check. Used by the queue drain path - * to actually start a new dialog turn after the previous one finished. - * Callers should not set this directly. - */ - bypassPendingQueue?: boolean; - userMessageMetadata?: Record; - execution?: import('@/infrastructure/api/service-api/AgentAPI').AgentDialogTurnExecution; - turnId?: string; - preserveTurnOnStartError?: boolean; - onSessionConflictRetryStart?: () => void; - onSessionConflictRetrySuccess?: () => void; - fromSessionConflictRetry?: boolean; - } + options?: SendMessageOptions ): Promise { const session = context.flowChatStore.getState().sessions.get(sessionId); if (!session) { throw new Error(`Session does not exist: ${sessionId}`); } const sendAttempt = beginSessionSend(sessionId); - - /** - * Start the next turn of a finished dispatch job. - * - * The optimistic turn mirrors the first-message path so the user sees their - * message immediately; the target's own `DialogTurnStarted` adopts it once - * the follow-up worker starts. - */ - const continueDispatchJob = async (jobId: string): Promise => { - const followUpSession = context.flowChatStore.getState().sessions.get(sessionId) ?? session; - const followUpAgentType = (agentType?.trim() || followUpSession.mode || 'agentic').trim(); - const current = pendingDispatchContinueRetries.get(sessionId); - const retry = - current?.content === message && current.displayContent === displayMessage - ? current - : { - content: message, - displayContent: displayMessage, - // Reused across retries so a lost response cannot start two turns. - turnId: - globalThis.crypto?.randomUUID?.() - ?? `dispatch-turn-${Date.now()}-${Math.random().toString(36).slice(2)}`, - }; - pendingDispatchContinueRetries.set(sessionId, retry); - - const optimisticTurnId = `dispatch_pending_${jobId}`; - context.flowChatStore.addDialogTurn(sessionId, { - id: optimisticTurnId, - sessionId, - agentType: followUpAgentType, - userMessage: { - id: `user_dispatch_${retry.turnId}`, - content: displayMessage || message, - timestamp: Date.now(), - metadata: markOptimisticDispatchTurnMetadata( - options?.userMessageMetadata, - jobId, - ), - }, - modelRounds: [], - status: 'pending', - startTime: Date.now(), - }); - globalEventBus.emit( - FLOWCHAT_PIN_TURN_TO_TOP_EVENT, - { - sessionId, - turnId: optimisticTurnId, - behavior: 'auto', - source: 'send-message', - pinMode: 'sticky-latest', - } satisfies FlowChatPinTurnToTopRequest, - 'MessageModule', - ); - - try { - const response = await dispatchApi.continueJob( - jobId, - retry.turnId, - message, - displayMessage, - ); - if (!response.accepted) { - throw new Error('Dispatch target did not accept the follow-up turn'); - } - // The target owns the job state; the refresh below reads it back rather - // than this side guessing what the follow-up did to it. - } catch (error) { - context.flowChatStore.deleteDialogTurn(sessionId, optimisticTurnId); - throw error; - } finally { - if (pendingDispatchContinueRetries.get(sessionId)?.turnId === retry.turnId) { - pendingDispatchContinueRetries.delete(sessionId); - } - } - requestDispatchJobRefresh(jobId); - }; - - const appendToDispatchJob = async (jobId: string): Promise => { - const current = pendingDispatchAppendRetries.get(sessionId); - const retry = - current?.content === message && current.displayContent === displayMessage - ? current - : { - content: message, - displayContent: displayMessage, - messageId: - globalThis.crypto?.randomUUID?.() - ?? `dispatch-message-${Date.now()}-${Math.random().toString(36).slice(2)}`, - }; - pendingDispatchAppendRetries.set(sessionId, retry); - const response = await dispatchApi.append( - jobId, - message, - displayMessage, - retry.messageId, - ); - if (!response.accepted) { - if (pendingDispatchAppendRetries.get(sessionId)?.messageId === retry.messageId) { - pendingDispatchAppendRetries.delete(sessionId); - } - throw new Error('Dispatch target did not accept the appended message'); - } - if (pendingDispatchAppendRetries.get(sessionId)?.messageId === retry.messageId) { - pendingDispatchAppendRetries.delete(sessionId); - } - requestDispatchJobRefresh(jobId); - }; - - const appendToRunningDispatch = async (): Promise => { - if ( - !isNonLocalDispatchTarget(session.config.dispatchTarget) - || !session.config.dispatchJobId - || ( - session.config.dispatchJobState !== 'queued' - && session.config.dispatchJobState !== 'running' - ) - ) { - return false; - } - if ((options?.imageContexts?.length ?? 0) > 0) { - throw new Error('Image attachments are not supported for detached dispatch yet'); - } - await appendToDispatchJob(session.config.dispatchJobId); - return true; + const draft: SubmissionDraft = { + message, + displayMessage, + hasImages: (options?.imageContexts?.length ?? 0) > 0, }; if (!options?.bypassPendingQueue) { @@ -364,7 +104,16 @@ export async function sendMessage( if (options?.execution?.kind === 'fresh_external_subagent') { throw new Error('External subagent command delegation requires an idle session'); } - if (await appendToRunningDispatch()) { + // Steer-eligibility must be decided before queueing: a steerable + // message that gets queued never drains for flavors that do not drive + // the local state machine. + const plan = driverForSession(sessionId, session) + .planSubmission(context, sessionId, draft); + if (plan.kind === 'reject') { + throw new Error(plan.reason); + } + if (plan.kind === 'steer') { + await driverForSession(sessionId, session).steer(context, sessionId, draft); return; } try { @@ -411,15 +160,17 @@ export async function sendMessage( })); } - let createdLocalTurnId: string | null = null; + const turnTracker: TurnTracker = { createdLocalTurnId: null }; try { const refreshedSession = context.flowChatStore.getState().sessions.get(sessionId) ?? session; const currentAgentType = (agentType?.trim() || refreshedSession.mode || 'agentic').trim(); const acpClientId = acpClientIdFromMode(currentAgentType); - const isDispatched = isNonLocalDispatchTarget(refreshedSession.config.dispatchTarget); - const delegatesExternalSubagent = options?.execution?.kind === 'fresh_external_subagent'; - if (delegatesExternalSubagent && (acpClientId || isDispatched)) { + const driver = driverForSession(sessionId, refreshedSession); + if ( + options?.execution?.kind === 'fresh_external_subagent' + && (acpClientId || driver.id !== 'local') + ) { throw new Error('External subagent command delegation requires the local BitFun runtime'); } @@ -435,8 +186,14 @@ export async function sendMessage( throw new Error('Session history is still restoring, please retry once loading finishes'); } - if (!acpClientId && !isDispatched) { - await ensureBackendSession(context, sessionId); + if (!acpClientId) { + // A driver with nothing to prepare returns void; awaiting only real + // promises keeps the projection's optimistic turn synchronous with the + // user's send action. + const readiness = driver.ensureReady(context, sessionId); + if (readiness) { + await readiness; + } } const readySession = context.flowChatStore.getState().sessions.get(sessionId); @@ -446,305 +203,26 @@ export async function sendMessage( const isFirstMessage = readySession.dialogTurns.length === 0 && readySession.titleStatus !== 'generated'; - if (isDispatched) { - const targetRequest = readySession.config.dispatchTargetRequest; - const jobId = readySession.config.dispatchJobId; - const approvalPolicy = readySession.config.dispatchApprovalPolicy; - if (!targetRequest || targetRequest.kind === 'local' || !jobId || !approvalPolicy) { - throw new Error('Dispatch session is missing its immutable target or approval policy'); - } - if ((options?.imageContexts?.length ?? 0) > 0) { - throw new Error('Image attachments are not supported for detached dispatch yet'); - } - const dispatchState = readySession.config.dispatchJobState; - if (dispatchState === 'queued' || dispatchState === 'running') { - // A turn is already in flight; this message steers it rather than - // starting another one underneath it. - await appendToDispatchJob(jobId); - return; - } - if (dispatchState && isDispatchJobTerminal(dispatchState)) { - // The previous turn finished. A dispatch session is a conversation, so - // this starts the next turn against the same target session, worktree, - // and event log instead of refusing the message. - await continueDispatchJob(jobId); - return; - } - if ( - dispatchState !== 'submitting' && - dispatchState !== 'submission_unknown' - ) { - throw new Error('This dispatch session is not ready to accept a message'); - } - if (isFirstMessage) { - handleTitleGeneration(context, sessionId, message); - } - - const optimisticTurnId = `dispatch_pending_${jobId}`; - const optimisticTurn: DialogTurn = { - id: optimisticTurnId, - sessionId, - agentType: currentAgentType, - userMessage: { - id: `user_dispatch_${Date.now()}`, - content: displayMessage || message, - timestamp: Date.now(), - metadata: markOptimisticDispatchTurnMetadata( - options?.userMessageMetadata, - jobId, - ), - }, - modelRounds: [], - status: 'pending', - startTime: Date.now(), - }; - context.flowChatStore.addDialogTurn(sessionId, optimisticTurn); - createdLocalTurnId = optimisticTurnId; - globalEventBus.emit( - FLOWCHAT_PIN_TURN_TO_TOP_EVENT, - { - sessionId, - turnId: optimisticTurnId, - behavior: 'auto', - source: 'send-message', - pinMode: 'sticky-latest', - } satisfies FlowChatPinTurnToTopRequest, - 'MessageModule', - ); - - const includeUncommitted = readySession.config.dispatchIncludeUncommitted ?? false; - const baseRef = readySession.config.dispatchBaseRef?.trim() || 'HEAD'; - const sourceWorkspacePath = sessionProjectWorkspacePath(readySession); - const sourceWorkspaceId = - readySession.workspaceId || readySession.config.workspaceId; - const transferRoundId = `dispatch-transfer:${jobId}`; - // Provisioning is always more than a submit now — a worktree is created, - // the target checks out the baseline, and objects may be transferred — - // so the transfer label always applies. - showRuntimeStatus({ - sessionId, - turnId: optimisticTurnId, - roundId: transferRoundId, - label: i18nService.t('flow-chat:chatInput.dispatch.transferInProgress'), - }); - let response: Awaited>; - try { - response = await dispatchApi.submit({ - target: targetRequest, - baseRef, - includeUncommitted, - jobId, - sessionId, - agentType: currentAgentType, - prompt: message, - approvalPolicy, - model: readySession.config.dispatchModel?.trim() || undefined, - ...(sourceWorkspacePath ? { sourceWorkspacePath } : {}), - ...(sourceWorkspaceId ? { sourceWorkspaceId } : {}), - }); - } finally { - clearRuntimeStatusState({ - sessionId, - turnId: optimisticTurnId, - roundId: transferRoundId, - }); - } - if (!response.accepted || response.jobId !== jobId || response.sessionId !== sessionId) { - throw new Error('Dispatch target returned a mismatched acknowledgement'); - } - context.flowChatStore.applyDispatchSnapshot(sessionId, { - jobId, - state: response.state, - cursor: readySession.config.dispatchCursor ?? 0, - expectedCursor: readySession.config.dispatchCursor ?? 0, - }); - dispatchJobStore.getState().updateProgress(jobId, { - state: response.state, - }); - context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); - requestDispatchJobRefresh(jobId); - completeSessionSend( + const outcome = await driver.startTurn( + context, + { sessionId, - sendAttempt, - options?.fromSessionConflictRetry - ? options.onSessionConflictRetrySuccess - : undefined, - ); - return; - } - - const dialogTurnId = options?.turnId?.trim() || - `dialog_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const hasImages = (options?.imageContexts?.length ?? 0) > 0; - - const dialogTurn: DialogTurn = { - id: dialogTurnId, - sessionId: sessionId, - agentType: currentAgentType, - userMessage: { - id: `user_${Date.now()}`, - content: displayMessage || message, - timestamp: Date.now(), - hasImages, - images: options?.imageDisplayData, - metadata: options?.userMessageMetadata, + message, + displayMessage, + currentAgentType, + acpClientId, + isFirstMessage, + readySession, + options, }, - modelRounds: [], - // Images are attached for multimodal primary models or reduced to text placeholders for text-only models. - // We don't run a separate frontend "image pre-analysis" phase here. - status: 'pending', - startTime: Date.now() - }; - - context.flowChatStore.addDialogTurn(sessionId, dialogTurn); - createdLocalTurnId = dialogTurnId; - const pinRequest: FlowChatPinTurnToTopRequest = { - sessionId, - turnId: dialogTurnId, - behavior: 'auto', - source: 'send-message', - pinMode: 'sticky-latest', - }; - globalEventBus.emit(FLOWCHAT_PIN_TURN_TO_TOP_EVENT, pinRequest, 'MessageModule'); - - const isRestoringHistoricalSession = - readySession.isHistorical || context.pendingHistoryLoads.has(sessionId); - if (isRestoringHistoricalSession) { - context.processingManager.clearSessionStatus(sessionId); - context.flowChatStore.deleteDialogTurn(sessionId, dialogTurnId); - throw new Error('Session history is still restoring, please retry once loading finishes'); - } - - const startOk = await stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { - taskId: sessionId, - dialogTurnId, - }); - if (!startOk) { - const currentState = stateMachineManager.getCurrentState(sessionId); - throw new Error(`Session is still busy finishing the previous turn (current state: ${currentState})`); - } - - context.processingManager.registerStatus({ - sessionId: sessionId, - status: 'thinking', - message: '', - metadata: { sessionId: sessionId, dialogTurnId } - }); - - if (readySession.config.worktreeIsolationRequested !== undefined) { - const materialization = sessionWorktreeMaterializationPlan(readySession); - if (materialization) { - log.info('Materializing requested worktree after prompt submission', { - sessionId, - enabled: materialization.enabled, - projectWorkspacePath: materialization.projectWorkspacePath, - }); - const result = await worktreeAPI.bindSession( - sessionId, - materialization.enabled, - globalThis.crypto?.randomUUID?.() ?? `worktree-first-turn-${Date.now()}`, - materialization.projectWorkspacePath, - ); - context.flowChatStore.updateSessionExecutionTarget(sessionId, { - workspacePath: result.workspacePath, - projectWorkspacePath: result.projectWorkspacePath, - workspaceId: result.workspaceId, - executionTarget: result.executionTarget, - }); - if (result.retainedWorktreePath) { - log.warn('Released worktree retained because it contains local work', { - sessionId, - retainedWorktreePath: result.retainedWorktreePath, - }); - } - } - context.flowChatStore.setSessionWorktreeIsolationRequested(sessionId, undefined); - } - - if (isFirstMessage) { - handleTitleGeneration(context, sessionId, message); - } - - if (!acpClientId) { - await syncSessionModelSelection(context, sessionId, currentAgentType); - } - - const updatedSession = context.flowChatStore.getState().sessions.get(sessionId); - if (!updatedSession) { - throw new Error(`Session lost after adding dialog turn: ${sessionId}`); - } - - context.contentBuffers.set(sessionId, new Map()); - context.activeTextItems.set(sessionId, new Map()); - - const workspacePath = updatedSession.workspacePath; - const projectWorkspacePath = sessionProjectWorkspacePath(updatedSession); - - if (acpClientId) { - await ACPClientAPI.startDialogTurn({ - sessionId, - clientId: acpClientId, - userInput: message, - originalUserInput: displayMessage || message, - turnId: dialogTurnId, - workspacePath, - imageContexts: options?.imageContexts, - userMessageMetadata: options?.userMessageMetadata, - remoteConnectionId: updatedSession.remoteConnectionId, - remoteSshHost: updatedSession.remoteSshHost, - }); - context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); - } else { - try { - await agentAPI.startDialogTurn({ - sessionId: sessionId, - userInput: message, - originalUserInput: displayMessage || message, - turnId: dialogTurnId, - agentType: currentAgentType, - workspacePath, - projectWorkspacePath, - remoteConnectionId: updatedSession.remoteConnectionId, - remoteSshHost: updatedSession.remoteSshHost, - imageContexts: options?.imageContexts, - userMessageMetadata: options?.userMessageMetadata, - execution: options?.execution, - }); - context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); - } catch (error: any) { - if (error?.message?.includes('Session does not exist') || error?.message?.includes('Not found')) { - log.warn('Backend session still not found, retrying creation', { - sessionId: sessionId, - dialogTurnsCount: updatedSession.dialogTurns.length - }); - - await retryCreateBackendSession(context, sessionId); - - await agentAPI.startDialogTurn({ - sessionId: sessionId, - userInput: message, - originalUserInput: displayMessage || message, - turnId: dialogTurnId, - agentType: currentAgentType, - workspacePath, - projectWorkspacePath, - remoteConnectionId: updatedSession.remoteConnectionId, - remoteSshHost: updatedSession.remoteSshHost, - imageContexts: options?.imageContexts, - userMessageMetadata: options?.userMessageMetadata, - execution: options?.execution, - }); - context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); - } else { - throw error; - } - } + turnTracker, + ); + if (outcome === 'detached') { + // The message steered or continued target-owned work; the shared + // post-submission bookkeeping does not apply. + return; } - const sessionStateMachine = stateMachineManager.get(sessionId); - if (sessionStateMachine) { - sessionStateMachine.getContext().taskId = sessionId; - } completeSessionSend( sessionId, sendAttempt, @@ -755,29 +233,29 @@ export async function sendMessage( } catch (error) { log.error('Failed to send message', { sessionId: sessionId, error }); - + const errorMessage = error instanceof Error ? error.message : 'Failed to send message'; - + const currentState = stateMachineManager.getCurrentState(sessionId); const activeDialogTurnId = stateMachineManager .get(sessionId) ?.getContext().currentDialogTurnId; const ownsProcessingTurn = - createdLocalTurnId !== null && - activeDialogTurnId === createdLocalTurnId; + turnTracker.createdLocalTurnId !== null && + activeDialogTurnId === turnTracker.createdLocalTurnId; if (currentState === SessionExecutionState.PROCESSING && ownsProcessingTurn) { await stateMachineManager.transition(sessionId, SessionExecutionEvent.ERROR_OCCURRED, { error: errorMessage }); await stateMachineManager.transition(sessionId, SessionExecutionEvent.RESET); } - + const state = context.flowChatStore.getState(); const currentSession = state.sessions.get(sessionId); - if (createdLocalTurnId && currentSession && !options?.preserveTurnOnStartError) { - context.flowChatStore.deleteDialogTurn(sessionId, createdLocalTurnId); + if (turnTracker.createdLocalTurnId && currentSession && !options?.preserveTurnOnStartError) { + context.flowChatStore.deleteDialogTurn(sessionId, turnTracker.createdLocalTurnId); } - + if (!options?.preserveTurnOnStartError) { if (isSessionInUseError(error)) { if (latestSendBySession.get(sessionId) !== sendAttempt) { @@ -832,59 +310,23 @@ export async function sendMessage( } else if (latestSendBySession.get(sessionId) === sendAttempt) { latestSendBySession.delete(sessionId); } - + throw error; } } -function handleTitleGeneration( - context: FlowChatContext, - sessionId: string, - message: string -): void { - const tempTitle = generateTempTitle(message, 20); - // Show a readable placeholder immediately; backend later confirms the - // authoritative title via AI or local fallback generation. - context.flowChatStore.updateSessionTitle(sessionId, tempTitle, 'generating'); -} - export async function cancelSessionTask(context: FlowChatContext, requestedSessionId?: string): Promise { try { const state = context.flowChatStore.getState(); const sessionId = requestedSessionId || state.activeSessionId; - + if (!sessionId) { log.debug('No active session to cancel'); return false; } const session = state.sessions.get(sessionId); - if (isNonLocalDispatchTarget(session?.config.dispatchTarget)) { - const jobId = session?.config.dispatchJobId; - if (!jobId) { - return false; - } - const response = await dispatchApi.cancel(jobId); - if (response.cancelled) { - context.userCancelledSessionIds.add(sessionId); - requestDispatchJobRefresh(jobId); - } - return response.cancelled; - } - - const currentState = stateMachineManager.getCurrentState(sessionId); - const success = currentState === SessionExecutionState.PROCESSING - ? await stateMachineManager.transition(sessionId, SessionExecutionEvent.USER_CANCEL) - : false; - - if (success) { - context.userCancelledSessionIds.add(sessionId); - markCurrentTurnItemsAsCancelled(context, sessionId); - cleanupSessionBuffers(context, sessionId); - } - - return success; - + return await driverForSession(sessionId, session).cancel(context, sessionId); } catch (error) { log.error('Failed to cancel current task', error); return false; @@ -986,42 +428,3 @@ export function installPendingQueueDrainListener(context: FlowChatContext): void void drainPendingQueue(queueDrainContext, sessionId); }); } - -export function markCurrentTurnItemsAsCancelled( - context: FlowChatContext, - sessionId: string -): void { - const state = context.flowChatStore.getState(); - const session = state.sessions.get(sessionId); - if (!session) return; - - const lastDialogTurn = session.dialogTurns[session.dialogTurns.length - 1]; - if (!lastDialogTurn) return; - - if (lastDialogTurn.status === 'completed' || lastDialogTurn.status === 'cancelled') { - return; - } - - lastDialogTurn.modelRounds.forEach(round => { - round.items.forEach(item => { - if (item.status === 'completed' || item.status === 'cancelled' || item.status === 'error') { - return; - } - - context.flowChatStore.updateModelRoundItem(sessionId, lastDialogTurn.id, item.id, { - status: 'cancelled', - ...(item.type === 'text' && { isStreaming: false }), - ...(item.type === 'tool' && { - isParamsStreaming: false, - endTime: Date.now() - }) - } as any); - }); - }); - - context.flowChatStore.updateDialogTurn(sessionId, lastDialogTurn.id, turn => ({ - ...turn, - status: 'cancelled', - endTime: Date.now() - })); -} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 8375793507..dbf6ed3f83 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -12,6 +12,7 @@ import { effectiveToolInvocation, } from '../../utils/toolInvocationIdentity'; import { requireSessionProjectWorkspacePath } from '../../utils/sessionWorkspace'; +import { resolveSessionDriverId } from '../../session-drivers/resolve'; const log = createLogger('PersistenceModule'); const COALESCED_IMMEDIATE_SAVE_DELAY_MS = 500; @@ -20,10 +21,17 @@ function isTransientSession(session: { isTransient?: boolean } | undefined): boo return session?.isTransient === true; } +/** + * Observer projections are target-owned; the controller must never persist + * them as local sessions. Uses the driver resolver so a projection whose + * config is not bound yet (startup race) is still recognized via the + * observer-store membership signal. + */ function isObserverOnlyDispatchSession( - session: { config?: { dispatchTarget?: { kind?: string } } } | undefined, + sessionId: string, + session: Parameters[1], ): boolean { - return !!session?.config?.dispatchTarget && session.config.dispatchTarget.kind !== 'local'; + return resolveSessionDriverId(sessionId, session) === 'dispatch'; } function requireWorkspacePath(sessionId: string, workspacePath?: string): string { @@ -282,7 +290,7 @@ async function performSaveDialogTurnToDisk( log.debug('Session not found, skipping save', { sessionId, turnId }); return; } - if (isTransientSession(session) || isObserverOnlyDispatchSession(session)) { + if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) { return; } @@ -320,7 +328,7 @@ export async function saveAllInProgressTurns(context: FlowChatContext): Promise< const savePromises: Promise[] = []; for (const [sessionId, session] of state.sessions.entries()) { - if (isTransientSession(session) || isObserverOnlyDispatchSession(session)) { + if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) { continue; } const lastTurn = session.dialogTurns[session.dialogTurns.length - 1]; @@ -525,7 +533,7 @@ export async function updateSessionMetadata( const session = context.flowChatStore.getState().sessions.get(sessionId); if (!session) return; - if (isTransientSession(session) || isObserverOnlyDispatchSession(session)) return; + if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) return; const workspacePath = requireSessionProjectWorkspacePath(session, sessionId); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index ce1836a4cf..213dbce8fa 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -16,15 +16,13 @@ import { workspaceManager } from '@/infrastructure/services/business/workspaceMa import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { normalizeRemoteWorkspacePath } from '@/shared/utils/pathUtils'; import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; -import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import type { FlowChatContext, SessionConfig, SessionHistoryHydrationLocation, } from './types'; import type { Session } from '../../types/flow-chat'; -import { touchSessionActivity, cleanupSaveState } from './PersistenceModule'; -import { cleanupSessionBuffers } from './TextChunkModule'; +import { touchSessionActivity } from './PersistenceModule'; import { createTextSessionTitleDescriptor, createDefaultSessionTitleDescriptor, @@ -50,59 +48,10 @@ import { requireSessionProjectWorkspacePath, sessionProjectWorkspacePath, } from '../../utils/sessionWorkspace'; -import { - isNonLocalDispatchTarget, - type DispatchTarget, -} from '@/features/dispatch/types'; -import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; -import { forgetDispatchTranscript } from '@/features/dispatch/dispatchTranscriptCache'; +import { driverForCreation, driverForSession } from '../../session-drivers/registry'; const log = createLogger('SessionModule'); const pendingSessionCreations = new Map>(); -const DISPATCH_OBSERVER_MAX_CONTEXT_TOKENS = 128128; - -function isDispatchObserverProjection( - sessionId: string, - session: Session | undefined, -): boolean { - if ( - isNonLocalDispatchTarget(session?.config.dispatchTarget) - || Boolean(session?.config.dispatchJobId?.trim()) - ) { - return true; - } - - return Object.values(dispatchJobStore.getState().jobs) - .some(job => job.sessionId === sessionId); -} - -function dismissDispatchObserverProjection( - sessionId: string, - session: Session | undefined, -): void { - // Collect before dismissing: dismissSession removes the store entries that - // are the only remaining link from this session to its cached transcripts. - const jobIds = new Set( - Object.values(dispatchJobStore.getState().jobs) - .filter(job => job.sessionId === sessionId) - .map(job => job.jobId), - ); - const configuredJobId = session?.config.dispatchJobId?.trim(); - if (configuredJobId) { - jobIds.add(configuredJobId); - } - - dispatchJobStore.getState().dismissSession( - sessionId, - session?.config.dispatchJobId, - ); - - // The projection is gone for good, so its cached transcript must not stay - // readable on disk until retention gets around to it. - jobIds.forEach(jobId => { - void forgetDispatchTranscript(jobId); - }); -} const getHydrationLocationKey = ( location: SessionHistoryHydrationLocation | undefined, @@ -585,101 +534,7 @@ function requireSessionWorkspacePath( return workspacePath; } -/** - * Get model's maximum token count - */ -function findEnabledModel(models: AIModelConfig[], modelRef: string | null | undefined): AIModelConfig | null { - const value = modelRef?.trim(); - if (!value) return null; - return models.find(model => - model.enabled !== false - && (model.id === value || model.name === value || model.model_name === value) - ) ?? null; -} - -function resolveModelForContextWindow( - modelRef: string | null | undefined, - models: AIModelConfig[], - defaultModels: DefaultModelsConfig, -): AIModelConfig | null { - const value = modelRef?.trim(); - if (!value) return null; - - if (value === 'primary') { - return findEnabledModel(models, defaultModels.primary); - } - - if (value === 'fast') { - return findEnabledModel(models, defaultModels.fast) ?? findEnabledModel(models, defaultModels.primary); - } - - if (value === 'auto' || value === 'default') { - return null; - } - - return findEnabledModel(models, value); -} - -export async function getModelMaxTokens(modelName?: string, agentType?: string): Promise { - try { - const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); - const configData = await configManager.getConfigs([ - 'ai.models', - 'ai.default_models', - 'ai.agent_model_defaults', - ]); - const models = (configData['ai.models'] as AIModelConfig[] | undefined) || []; - const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; - const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; - - const normalizedModelName = modelName?.trim(); - const explicitModel = resolveModelForContextWindow(modelName, models, defaultModels); - if (explicitModel?.context_window) { - return explicitModel.context_window; - } - - // Only legacy sessions without a model selector inherit the current mode - // default. Explicit symbolic selectors such as "auto" remain session-owned. - if (!normalizedModelName) { - const modeModel = resolveModelForContextWindow( - agentModelDefaults?.mode, - models, - defaultModels, - ); - if (modeModel?.context_window) { - return modeModel.context_window; - } - } - - const primaryModel = resolveModelForContextWindow('primary', models, defaultModels); - if (primaryModel?.context_window) { - return primaryModel.context_window; - } - - log.debug('Model context_window config not found, using default', { modelName, agentType }); - return 128128; - } catch (error) { - log.warn('Failed to get model max tokens', { modelName, agentType, error }); - return 128128; - } -} - -async function resolveModelForSessionCreation(modelName?: string): Promise { - const explicitModelName = modelName?.trim(); - if (explicitModelName) { - return explicitModelName; - } - - try { - const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); - const configData = await configManager.getConfigs(['ai.agent_model_defaults']); - const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; - return agentModelDefaults?.mode?.trim() || 'auto'; - } catch (error) { - log.warn('Failed to resolve model default during session creation', { error }); - return 'auto'; - } -} +export { getModelMaxTokens } from '../../utils/modelResolution'; /** * Create new chat session (managed by backend) @@ -743,146 +598,17 @@ export async function createChatSession( ); const sessionName = titleDescriptor.text; - if (isNonLocalDispatchTarget(config.dispatchTargetRequest)) { - const dispatchTarget: DispatchTarget = config.dispatchTarget - ?? ( - config.dispatchTargetRequest.kind === 'ssh' - ? { - ...config.dispatchTargetRequest, - displayName: config.dispatchTargetRequest.connectionId, - } - : { - ...config.dispatchTargetRequest, - displayName: config.dispatchTargetRequest.deviceId, - } - ); - const sessionId = - globalThis.crypto?.randomUUID?.() - ?? `dispatch-session-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const jobId = - config.dispatchJobId?.trim() - || `dispatch-${globalThis.crypto?.randomUUID?.() - ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`; - const approvalPolicy = config.dispatchApprovalPolicy; - if (!approvalPolicy) { - throw new Error('Dispatch approval policy must be selected before creating a session'); - } - const resolvedConfig: SessionConfig = { - ...config, - // A dispatch projection must not inherit or resolve a controller-side - // provider. The target selection, when explicit, lives in dispatchModel. - modelName: undefined, - workspaceId: workspace?.id ?? config.workspaceId, - workspacePath, - projectWorkspacePath, - dispatchTargetRequest: config.dispatchTargetRequest, - dispatchTarget, - dispatchJobId: jobId, - dispatchApprovalPolicy: approvalPolicy, - dispatchIncludeUncommitted: config.dispatchIncludeUncommitted ?? false, - dispatchBaseRef: config.dispatchBaseRef?.trim() || 'HEAD', - dispatchJobState: 'submitting', - dispatchCursor: 0, - }; - - // This is an observer projection only. In particular, do not call - // agentAPI.createSession: the target CLI owns the durable session. - context.flowChatStore.createSession( - sessionId, - resolvedConfig, - undefined, - sessionName, - DISPATCH_OBSERVER_MAX_CONTEXT_TOKENS, - agentType, - workspacePath, - remoteConnectionId, - remoteSshHost, - titleDescriptor, - ); - dispatchJobStore.getState().registerJob({ - jobId, - sessionId, - targetRequest: config.dispatchTargetRequest, - target: dispatchTarget, - sourceWorkspacePath: workspacePath, - sourceWorkspaceId: resolvedConfig.workspaceId, - title: sessionName, - agentType, - approvalPolicy, - // Do not inherit the controller's model selector. An omitted target - // model lets the probed target use its own configured default. - model: config.dispatchModel?.trim() || undefined, - availableModels: config.dispatchAvailableModels, - defaultModel: config.dispatchDefaultModel, - cursor: 0, - state: 'submitting', - appliedEventIds: [], - pendingPermissions: [], - eventLogComplete: true, - historyTruncated: false, - omittedEventCount: 0, - createdAt: Date.now(), - updatedAt: Date.now(), - }); - return sessionId; - } - - const sessionModelName = await resolveModelForSessionCreation(config.modelName); - const maxContextTokens = await getModelMaxTokens(sessionModelName, agentType); - const mergedConfig: SessionConfig = { - ...config, - modelName: sessionModelName, - workspaceId: workspace?.id ?? config.workspaceId, - }; - - const response = await agentAPI.createSession({ - sessionName, + return driverForCreation(config).createSession(context, { + config, agentType, + sessionName, + titleDescriptor, workspacePath, projectWorkspacePath, - executionTarget: config.executionTargetRequest, - requestId: globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}-${Math.random()}`, - workspaceId: mergedConfig.workspaceId, + workspaceId: workspace?.id, remoteConnectionId, remoteSshHost, - config: { - modelName: sessionModelName, - enableTools: true, - safeMode: true, - autoCompact: true, - maxContextTokens: maxContextTokens, - enableContextCompression: true, - remoteConnectionId, - remoteSshHost, - } }); - - const effectiveWorkspacePath = - response.workspacePath || response.executionTarget?.rootPath || workspacePath; - const effectiveProjectWorkspacePath = - response.projectWorkspacePath || projectWorkspacePath || workspacePath; - const resolvedConfig: SessionConfig = { - ...mergedConfig, - workspacePath: effectiveWorkspacePath, - projectWorkspacePath: effectiveProjectWorkspacePath, - workspaceId: response.workspaceId ?? mergedConfig.workspaceId, - executionTarget: response.executionTarget, - }; - - context.flowChatStore.createSession( - response.sessionId, - resolvedConfig, - undefined, - sessionName, - maxContextTokens, - agentType, - effectiveWorkspacePath, - remoteConnectionId, - remoteSshHost, - titleDescriptor, - ); - - return response.sessionId; }); pendingSessionCreations.set(creationKey, createPromise); @@ -929,7 +655,7 @@ export async function switchChatSession( }); const touchActiveSessionInBackground = () => { - if (isNonLocalDispatchTarget(session?.config.dispatchTarget)) { + if (driverForSession(sessionId, session).id === 'dispatch') { return; } scheduleSessionActivityTouch(() => { @@ -1036,49 +762,9 @@ export async function deleteChatSession( && removedSessionIdSet.has(stateBeforeDelete.activeSessionId) ); const session = stateBeforeDelete.sessions.get(sessionId); - const observerJobIds = Object.values(dispatchJobStore.getState().jobs) - .filter(job => job.sessionId === sessionId) - .map(job => job.jobId); - const deleteAsDispatchProjection = isDispatchObserverProjection(sessionId, session); - log.info('Dispatch diagnostic: session delete evaluated', { - sessionId, - sessionFound: Boolean(session), - dispatchTargetKind: session?.config.dispatchTarget?.kind, - dispatchJobId: session?.config.dispatchJobId, - observerJobIds, - deleteAsDispatchProjection, - cascadeSessionIds: removedSessionIds, - }); - if (deleteAsDispatchProjection) { - dismissDispatchObserverProjection(sessionId, session); - const locallyRemovedSessionIds = context.flowChatStore.removeSession( - sessionId, - removedActiveSession ? { nextActiveSessionId: null } : undefined, - ); - removedSessionIds.forEach(id => { - context.processingManager.clearSessionStatus(id); - cleanupSaveState(context, id); - cleanupSessionBuffers(context, id); - }); - log.info('Dispatch diagnostic: projection removed from flow chat store', { - sessionId, - locallyRemovedSessionIds, - activeSessionId: context.flowChatStore.getState().activeSessionId, - }); - return; - } - log.info('Dispatch diagnostic: delete routed to persisted backend session', { - sessionId, - hasWorkspacePath: Boolean(session && sessionProjectWorkspacePath(session)), - }); - await context.flowChatStore.deleteSession( - sessionId, - removedActiveSession ? { nextActiveSessionId: null } : undefined, - ); - - removedSessionIds.forEach(id => { - context.processingManager.clearSessionStatus(id); - cleanupSaveState(context, id); + await driverForSession(sessionId, session).deleteSession(context, sessionId, { + removedSessionIds, + removedActiveSession, }); } catch (error) { log.error('Failed to delete chat session', { sessionId, error }); @@ -1107,38 +793,9 @@ export async function archiveChatSession( && removedSessionIdSet.has(stateBeforeArchive.activeSessionId) ); - if (isDispatchObserverProjection(sessionId, session)) { - dismissDispatchObserverProjection(sessionId, session); - context.flowChatStore.removeSession( - sessionId, - removedActiveSession ? { nextActiveSessionId: null } : undefined, - ); - removedSessionIds.forEach(id => { - context.processingManager.clearSessionStatus(id); - cleanupSaveState(context, id); - cleanupSessionBuffers(context, id); - }); - return; - } - - await sessionAPI.archiveSession( - sessionId, - requireSessionProjectWorkspacePath(session, sessionId), - session.remoteConnectionId, - session.remoteSshHost, - ); - - const { stateMachineManager } = await import('../../state-machine'); - context.flowChatStore.removeSession( - sessionId, - removedActiveSession ? { nextActiveSessionId: null } : undefined, - ); - - removedSessionIds.forEach(id => { - stateMachineManager.delete(id); - context.processingManager.clearSessionStatus(id); - cleanupSaveState(context, id); - cleanupSessionBuffers(context, id); + await driverForSession(sessionId, session).archiveSession(context, sessionId, { + removedSessionIds, + removedActiveSession, }); } catch (error) { log.error('Failed to archive chat session', { sessionId, error }); @@ -1167,24 +824,7 @@ export async function renameChatSessionTitle( await context.flowChatStore.updateSessionTitle(sessionId, trimmedTitle, 'generated'); return trimmedTitle; } - if (isNonLocalDispatchTarget(session.config.dispatchTarget)) { - await context.flowChatStore.updateSessionTitle(sessionId, trimmedTitle, 'generated'); - if (session.config.dispatchJobId) { - dispatchJobStore.getState().updateTitle(session.config.dispatchJobId, trimmedTitle); - } - return trimmedTitle; - } - - const updatedTitle = await agentAPI.updateSessionTitle({ - sessionId, - title: trimmedTitle, - workspacePath: sessionProjectWorkspacePath(session), - remoteConnectionId: session.remoteConnectionId, - remoteSshHost: session.remoteSshHost, - }); - - await context.flowChatStore.updateSessionTitle(sessionId, updatedTitle, 'generated'); - return updatedTitle; + return driverForSession(sessionId, session).renameSession(context, sessionId, trimmedTitle); } export async function reloadSessionTitle( @@ -1226,7 +866,7 @@ export async function forkChatSession( if (!sourceSession) { throw new Error(`Session does not exist: ${sourceSessionId}`); } - if (isNonLocalDispatchTarget(sourceSession.config.dispatchTarget)) { + if (driverForSession(sourceSessionId, sourceSession).id === 'dispatch') { throw new Error('Forking a detached dispatch session is not supported'); } @@ -1299,7 +939,7 @@ export async function ensureBackendSession( if (session.isTransient) { return; } - if (isNonLocalDispatchTarget(session.config.dispatchTarget)) { + if (driverForSession(sessionId, session).id === 'dispatch') { return; } diff --git a/src/web-ui/src/flow_chat/services/usageReportService.ts b/src/web-ui/src/flow_chat/services/usageReportService.ts index 983da658d0..fb2402e0df 100644 --- a/src/web-ui/src/flow_chat/services/usageReportService.ts +++ b/src/web-ui/src/flow_chat/services/usageReportService.ts @@ -19,6 +19,16 @@ export interface UsageReportCommandParams { failedTitle: string; unknownErrorMessage: string; loadingMarkdown: string; + /** + * Where the report comes from. Defaults to the local backend; a dispatch + * projection supplies the target's `query` verb instead. + */ + fetchReport?: () => Promise; + /** + * Persist the rendered turn to the backend session. Observer projections + * must not persist locally — their transcript cache captures the turn. + */ + persistTurn?: boolean; } export interface UsageReportCommandResult { @@ -58,13 +68,15 @@ export async function runUsageReportCommand( let finalizedPendingTurn = false; try { - const rawReport = await sessionAPI.getSessionUsageReport({ - sessionId: params.session.sessionId, - workspacePath: projectWorkspacePath, - remoteConnectionId: params.session.remoteConnectionId, - remoteSshHost: params.session.remoteSshHost, - includeHiddenSubagents: true, - }); + const rawReport = params.fetchReport + ? await params.fetchReport() + : await sessionAPI.getSessionUsageReport({ + sessionId: params.session.sessionId, + workspacePath: projectWorkspacePath, + remoteConnectionId: params.session.remoteConnectionId, + remoteSshHost: params.session.remoteSshHost, + includeHiddenSubagents: true, + }); const report = enrichUsageReportModelIdentity(rawReport, params.session); const markdown = renderUsageReportMarkdown(report); const turn = pendingTurn @@ -84,7 +96,7 @@ export async function runUsageReportCommand( }); finalizedPendingTurn = !!pendingTurn; - if (turn) { + if (turn && params.persistTurn !== false) { await sessionAPI.saveSessionTurn( toPersistedLocalReportTurn(turn), projectWorkspacePath, diff --git a/src/web-ui/src/flow_chat/session-drivers/dispatch/DispatchSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/dispatch/DispatchSessionDriver.ts new file mode 100644 index 0000000000..c4ccbd612d --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/dispatch/DispatchSessionDriver.ts @@ -0,0 +1,763 @@ +/** + * Dispatch session driver: controller-side observer projections of jobs that + * execute on a detached target (SSH host or paired device). + * + * The projection contract (see `features/dispatch/README.md`): the target CLI + * owns the durable session and event log; this controller owns only the + * outbound observer index and a rendered transcript cache. Nothing here may + * call `agentAPI.createSession`, `start_dialog_turn`, restore, or local + * session persistence for a projection. + */ + +import { createLogger } from '@/shared/utils/logger'; +import { globalEventBus } from '@/infrastructure/event-bus'; +import { i18nService } from '@/infrastructure/i18n'; +import type { + PermissionReplyKind, + PermissionRequest, +} from '@/infrastructure/api/service-api/AgentAPI'; +import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; +import type { FlowChatContext, SessionConfig } from '../../services/flow-chat-manager/types'; +import type { DialogTurn, Session } from '../../types/flow-chat'; +import { FlowChatStore } from '../../store/FlowChatStore'; +import { selectActivePermissionBatch } from '../../components/modern/permissionRequestRouting'; +import type { + SessionCascadeRemoval, + SessionCreationSeed, + SessionDriver, + StartTurnInput, + StartTurnResult, + SubmissionDraft, + SubmissionPlan, + TurnTracker, + UsageReportUiParams, +} from '../types'; +import { + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + type FlowChatPinTurnToTopRequest, +} from '../../events/flowchatNavigation'; +import { + isDispatchJobTerminal, + isNonLocalDispatchTarget, + type DispatchTarget, +} from '@/features/dispatch/types'; +import { dispatchApi } from '@/features/dispatch/dispatchApi'; +import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; +import { forgetDispatchTranscript } from '@/features/dispatch/dispatchTranscriptCache'; +import { requestDispatchJobRefresh } from '@/features/dispatch/DispatchJobObserver'; +import { markOptimisticDispatchTurnMetadata } from '@/features/dispatch/optimisticDispatchTurn'; +import { cleanupSaveState } from '../../services/flow-chat-manager/PersistenceModule'; +import { cleanupSessionBuffers } from '../../services/flow-chat-manager/TextChunkModule'; +import { sessionProjectWorkspacePath } from '../../utils/sessionWorkspace'; +import { + clearRuntimeStatusState, + showRuntimeStatus, +} from '../../store/runtimeStatusStore'; +import { claimSubmissionRetry, releaseSubmissionRetry } from '../idempotency'; +import { applyGeneratingTitlePlaceholder } from '../shared'; + +const log = createLogger('DispatchSessionDriver'); + +const IMAGES_WHILE_RUNNING_MESSAGE = + 'Images join the next turn; wait for the current dispatch turn to finish'; +const DEVICE_ATTACHMENT_BUDGET_BYTES = 192 * 1024; +const APPEND_RETRY_SCOPE = 'dispatch-append'; +const CONTINUE_RETRY_SCOPE = 'dispatch-continue'; +const COMPACT_RETRY_SCOPE = 'dispatch-compact'; +const EMPTY_PENDING_PERMISSIONS: ReadonlyArray> = []; + +/** + * The durable job observed by a projection session. The bound config id wins; + * the observer-store fallback covers the startup window before + * `ensureProjection` binds the config. + */ +function jobIdForSession(sessionId: string): string | undefined { + const session = FlowChatStore.getInstance().getState().sessions.get(sessionId); + const configured = session?.config.dispatchJobId?.trim(); + if (configured) { + return configured; + } + return Object.values(dispatchJobStore.getState().jobs) + .find(job => job.sessionId === sessionId)?.jobId; +} + +/** + * Convert composer image contexts into inline wire attachments. Throws when a + * context has no data URL (path-only) or a device target's inline budget is + * exceeded — both need the user to adjust, not silent truncation. + */ +function dispatchAttachments( + session: Session | undefined, + imageContexts: readonly { id: string; data_url?: string; mime_type: string; metadata?: Record }[] | undefined, +): import('@/features/dispatch/dispatchApi').DispatchInlineAttachment[] | undefined { + if (!imageContexts?.length) { + return undefined; + } + const attachments = imageContexts.map(image => { + const dataUrl = image.data_url?.trim(); + if (!dataUrl) { + throw new Error('This image has no inline data and cannot be sent to a dispatch target'); + } + const name = typeof image.metadata?.name === 'string' ? image.metadata.name : undefined; + return { + id: image.id, + name, + mimeType: image.mime_type, + dataUrl, + }; + }); + if (session?.config.dispatchTarget?.kind === 'device') { + const total = attachments.reduce((sum, attachment) => sum + attachment.dataUrl.length, 0); + if (total > DEVICE_ATTACHMENT_BUDGET_BYTES) { + throw new Error( + 'Device dispatch carries at most 192 KiB of inline images; use an SSH target for larger screenshots', + ); + } + } + return attachments; +} + +function pinTurnToTop(sessionId: string, turnId: string): void { + globalEventBus.emit( + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + { + sessionId, + turnId, + behavior: 'auto', + source: 'send-message', + pinMode: 'sticky-latest', + } satisfies FlowChatPinTurnToTopRequest, + 'DispatchSessionDriver', + ); +} + +async function appendToDispatchJob( + sessionId: string, + jobId: string, + message: string, + displayMessage: string | undefined, +): Promise { + // Keep the id stable across an ambiguous transport failure. A retry with + // the same message can then ask the target mailbox for the same idempotent + // append instead of injecting the steering text twice. + const retry = claimSubmissionRetry( + APPEND_RETRY_SCOPE, + sessionId, + message, + displayMessage, + () => + globalThis.crypto?.randomUUID?.() + ?? `dispatch-message-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const response = await dispatchApi.append( + jobId, + message, + displayMessage, + retry.id, + ); + if (!response.accepted) { + releaseSubmissionRetry(APPEND_RETRY_SCOPE, sessionId, retry.id); + throw new Error('Dispatch target did not accept the appended message'); + } + releaseSubmissionRetry(APPEND_RETRY_SCOPE, sessionId, retry.id); + requestDispatchJobRefresh(jobId); +} + +/** + * Start the next turn of a finished dispatch job. + * + * The optimistic turn mirrors the first-message path so the user sees their + * message immediately; the target's own `DialogTurnStarted` adopts it once + * the follow-up worker starts. + */ +async function continueDispatchJob( + context: FlowChatContext, + input: StartTurnInput, + jobId: string, +): Promise { + const { sessionId, message, displayMessage } = input; + const followUpSession = + context.flowChatStore.getState().sessions.get(sessionId) ?? input.readySession; + const followUpAgentType = + (input.currentAgentType?.trim() || followUpSession.mode || 'agentic').trim(); + // The composer edits these between turns; the follow-up carries them as + // per-turn overrides which the target persists onto the job. + const turnModel = followUpSession.config.dispatchModel?.trim() || undefined; + const turnApprovalPolicy = followUpSession.config.dispatchApprovalPolicy; + const turnAttachments = dispatchAttachments(followUpSession, input.options?.imageContexts); + // Reused across retries so a lost response cannot start two turns. The + // match key includes the per-turn options and attachment ids: the target + // refuses a turnId bound to different content, so changed inputs must mint + // a new turn id. + const retry = claimSubmissionRetry( + CONTINUE_RETRY_SCOPE, + sessionId, + JSON.stringify([ + message, + turnModel ?? null, + turnApprovalPolicy ?? null, + turnAttachments?.map(attachment => attachment.id) ?? null, + ]), + displayMessage, + () => + globalThis.crypto?.randomUUID?.() + ?? `dispatch-turn-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + const optimisticTurnId = `dispatch_pending_${jobId}`; + context.flowChatStore.addDialogTurn(sessionId, { + id: optimisticTurnId, + sessionId, + agentType: followUpAgentType, + userMessage: { + id: `user_dispatch_${retry.id}`, + content: displayMessage || message, + timestamp: Date.now(), + hasImages: (input.options?.imageDisplayData?.length ?? 0) > 0, + images: input.options?.imageDisplayData, + metadata: markOptimisticDispatchTurnMetadata( + input.options?.userMessageMetadata, + jobId, + ), + }, + modelRounds: [], + status: 'pending', + startTime: Date.now(), + }); + pinTurnToTop(sessionId, optimisticTurnId); + + try { + const response = await dispatchApi.continueJob( + jobId, + retry.id, + message, + displayMessage, + { + model: turnModel, + approvalPolicy: turnApprovalPolicy, + ...(turnAttachments ? { attachments: turnAttachments } : {}), + }, + ); + if (!response.accepted) { + throw new Error('Dispatch target did not accept the follow-up turn'); + } + // The target owns the job state; the refresh below reads it back rather + // than this side guessing what the follow-up did to it. + } catch (error) { + context.flowChatStore.deleteDialogTurn(sessionId, optimisticTurnId); + throw error; + } finally { + releaseSubmissionRetry(CONTINUE_RETRY_SCOPE, sessionId, retry.id); + } + requestDispatchJobRefresh(jobId); +} + +/** + * Fixed context budget for projections: no controller-side provider is ever + * resolved for them, so there is no model to read a real window from. + */ +const DISPATCH_OBSERVER_MAX_CONTEXT_TOKENS = 128128; + +function dismissDispatchObserverProjection( + sessionId: string, + session: Session | undefined, +): void { + // Collect before dismissing: dismissSession removes the store entries that + // are the only remaining link from this session to its cached transcripts. + const jobIds = new Set( + Object.values(dispatchJobStore.getState().jobs) + .filter(job => job.sessionId === sessionId) + .map(job => job.jobId), + ); + const configuredJobId = session?.config.dispatchJobId?.trim(); + if (configuredJobId) { + jobIds.add(configuredJobId); + } + + dispatchJobStore.getState().dismissSession( + sessionId, + session?.config.dispatchJobId, + ); + + // The projection is gone for good, so its cached transcript must not stay + // readable on disk until retention gets around to it. + jobIds.forEach(jobId => { + void forgetDispatchTranscript(jobId); + }); +} + +function removeProjectionLocally( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, +): void { + const session = context.flowChatStore.getState().sessions.get(sessionId); + dismissDispatchObserverProjection(sessionId, session); + context.flowChatStore.removeSession( + sessionId, + removal.removedActiveSession ? { nextActiveSessionId: null } : undefined, + ); + removal.removedSessionIds.forEach(id => { + context.processingManager.clearSessionStatus(id); + cleanupSaveState(context, id); + cleanupSessionBuffers(context, id); + }); +} + +export const dispatchSessionDriver: SessionDriver = { + id: 'dispatch', + + async createSession(context: FlowChatContext, seed: SessionCreationSeed): Promise { + const { + config, + agentType, + sessionName, + titleDescriptor, + workspacePath, + projectWorkspacePath, + workspaceId, + remoteConnectionId, + remoteSshHost, + } = seed; + + if (!isNonLocalDispatchTarget(config.dispatchTargetRequest)) { + throw new Error('Dispatch driver requires a non-local dispatch target request'); + } + const dispatchTarget: DispatchTarget = config.dispatchTarget + ?? ( + config.dispatchTargetRequest.kind === 'ssh' + ? { + ...config.dispatchTargetRequest, + displayName: config.dispatchTargetRequest.connectionId, + } + : { + ...config.dispatchTargetRequest, + displayName: config.dispatchTargetRequest.deviceId, + } + ); + const sessionId = + globalThis.crypto?.randomUUID?.() + ?? `dispatch-session-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const jobId = + config.dispatchJobId?.trim() + || `dispatch-${globalThis.crypto?.randomUUID?.() + ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`; + const approvalPolicy = config.dispatchApprovalPolicy; + if (!approvalPolicy) { + throw new Error('Dispatch approval policy must be selected before creating a session'); + } + const resolvedConfig: SessionConfig = { + ...config, + // A dispatch projection must not inherit or resolve a controller-side + // provider. The target selection, when explicit, lives in dispatchModel. + modelName: undefined, + workspaceId: workspaceId ?? config.workspaceId, + workspacePath, + projectWorkspacePath, + dispatchTargetRequest: config.dispatchTargetRequest, + dispatchTarget, + dispatchJobId: jobId, + dispatchApprovalPolicy: approvalPolicy, + dispatchIncludeUncommitted: config.dispatchIncludeUncommitted ?? false, + dispatchBaseRef: config.dispatchBaseRef?.trim() || 'HEAD', + dispatchJobState: 'submitting', + dispatchCursor: 0, + }; + + // This is an observer projection only. In particular, do not call + // agentAPI.createSession: the target CLI owns the durable session. + context.flowChatStore.createSession( + sessionId, + resolvedConfig, + undefined, + sessionName, + DISPATCH_OBSERVER_MAX_CONTEXT_TOKENS, + agentType, + workspacePath, + remoteConnectionId, + remoteSshHost, + titleDescriptor, + ); + dispatchJobStore.getState().registerJob({ + jobId, + sessionId, + targetRequest: config.dispatchTargetRequest, + target: dispatchTarget, + sourceWorkspacePath: workspacePath, + sourceWorkspaceId: resolvedConfig.workspaceId, + title: sessionName, + agentType, + approvalPolicy, + // Do not inherit the controller's model selector. An omitted target + // model lets the probed target use its own configured default. + model: config.dispatchModel?.trim() || undefined, + availableModels: config.dispatchAvailableModels, + defaultModel: config.dispatchDefaultModel, + cursor: 0, + state: 'submitting', + appliedEventIds: [], + pendingPermissions: [], + eventLogComplete: true, + historyTruncated: false, + omittedEventCount: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + return sessionId; + }, + + async deleteSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + const observerJobIds = Object.values(dispatchJobStore.getState().jobs) + .filter(job => job.sessionId === sessionId) + .map(job => job.jobId); + log.info('Dispatch diagnostic: projection delete evaluated', { + sessionId, + sessionFound: Boolean(session), + dispatchTargetKind: session?.config.dispatchTarget?.kind, + dispatchJobId: session?.config.dispatchJobId, + observerJobIds, + cascadeSessionIds: removal.removedSessionIds, + }); + removeProjectionLocally(context, sessionId, removal); + log.info('Dispatch diagnostic: projection removed from flow chat store', { + sessionId, + activeSessionId: context.flowChatStore.getState().activeSessionId, + }); + }, + + async archiveSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise { + // Archiving an observer projection is a local dismiss: the target keeps + // its durable session; the tombstone stops reconciliation reviving it. + removeProjectionLocally(context, sessionId, removal); + }, + + async renameSession( + context: FlowChatContext, + sessionId: string, + title: string, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + await context.flowChatStore.updateSessionTitle(sessionId, title, 'generated'); + if (session.config.dispatchJobId) { + dispatchJobStore.getState().updateTitle(session.config.dispatchJobId, title); + } + return title; + }, + + ensureReady(): void { + // Nothing to prepare: the target owns the durable session, and submission + // itself is what creates the job. Deliberately synchronous — see the + // interface note about the optimistic-projection timing invariant. + }, + + async cancel(context: FlowChatContext, sessionId: string): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + const jobId = session?.config.dispatchJobId; + if (!jobId) { + return false; + } + const response = await dispatchApi.cancel(jobId); + if (response.cancelled) { + context.userCancelledSessionIds.add(sessionId); + requestDispatchJobRefresh(jobId); + } + return response.cancelled; + }, + + async compactSession(context: FlowChatContext, sessionId: string): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + const jobId = session?.config.dispatchJobId; + if (!jobId) { + throw new Error('Dispatch session is missing its job id'); + } + const state = session?.config.dispatchJobState; + if (!isDispatchJobTerminal(state)) { + throw new Error('Wait for the current dispatch turn to finish before compacting'); + } + // Same idempotency contract as a prompt follow-up: a retried request + // reuses the turn id so a lost response cannot start two compactions. + const retry = claimSubmissionRetry( + COMPACT_RETRY_SCOPE, + sessionId, + 'compact', + undefined, + () => + globalThis.crypto?.randomUUID?.() + ?? `dispatch-compact-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + try { + const response = await dispatchApi.continueJob(jobId, retry.id, '', undefined, { + kind: 'compact', + }); + if (!response.accepted) { + throw new Error('Dispatch target did not accept the compact turn'); + } + } finally { + releaseSubmissionRetry(COMPACT_RETRY_SCOPE, sessionId, retry.id); + } + requestDispatchJobRefresh(jobId); + }, + + async runUsageReport( + context: FlowChatContext, + sessionId: string, + uiParams: UsageReportUiParams, + ): Promise<{ inserted: boolean }> { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + const jobId = jobIdForSession(sessionId); + if (!jobId) { + throw new Error('Dispatch session is missing its job id'); + } + const { runUsageReportCommand } = await import('../../services/usageReportService'); + const result = await runUsageReportCommand({ + session, + ...uiParams, + // The target computes the report from its persisted session; the + // rendered turn stays in the projection (transcript cache), never in + // local session persistence. + fetchReport: async () => { + const response = await dispatchApi.query(jobId, 'usageReport'); + return response.report as SessionUsageReport; + }, + persistTurn: false, + }); + return { inserted: result.inserted }; + }, + + permissionRequestSource(sessionId: string) { + return { + subscribe: (listener: () => void) => dispatchJobStore.subscribe(listener), + getSnapshot: () => { + const jobId = jobIdForSession(sessionId); + if (!jobId) { + return EMPTY_PENDING_PERMISSIONS; + } + return dispatchJobStore.getState().jobs[jobId]?.pendingPermissions + ?? EMPTY_PENDING_PERMISSIONS; + }, + }; + }, + + async respondPermission( + sessionId: string, + requestId: string, + reply: PermissionReplyKind, + feedback?: string, + ): Promise { + const jobId = jobIdForSession(sessionId); + if (!jobId) { + throw new Error('Dispatch session is missing its job id'); + } + await dispatchApi.answerPermission(jobId, requestId, reply, feedback); + requestDispatchJobRefresh(jobId); + }, + + async respondPermissionBatch( + sessionId: string, + requestId: string, + reply: PermissionReplyKind, + feedback?: string, + ): Promise { + const jobId = jobIdForSession(sessionId); + if (!jobId) { + throw new Error('Dispatch session is missing its job id'); + } + const pending = (dispatchJobStore.getState().jobs[jobId]?.pendingPermissions + ?? []) as unknown as PermissionRequest[]; + const batch = selectActivePermissionBatch(pending, sessionId); + const requestIds = batch?.requests.map(request => request.requestId) ?? [requestId]; + for (const pendingRequestId of requestIds) { + await dispatchApi.answerPermission( + jobId, + pendingRequestId, + reply, + feedback, + ); + } + requestDispatchJobRefresh(jobId); + // The observer store refreshes from the target; there is no local + // subscription state to reconcile. + return []; + }, + + planSubmission( + context: FlowChatContext, + sessionId: string, + draft: SubmissionDraft, + ): SubmissionPlan { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if ( + !isNonLocalDispatchTarget(session?.config.dispatchTarget) + || !session?.config.dispatchJobId + || ( + session.config.dispatchJobState !== 'queued' + && session.config.dispatchJobState !== 'running' + ) + ) { + return { kind: 'queue' }; + } + if (draft.hasImages) { + // Steering has no attachment channel; the runtime accepts images only + // at turn boundaries. + return { kind: 'reject', reason: IMAGES_WHILE_RUNNING_MESSAGE }; + } + return { kind: 'steer' }; + }, + + async steer( + context: FlowChatContext, + sessionId: string, + draft: SubmissionDraft, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + const jobId = session?.config.dispatchJobId; + if (!jobId) { + throw new Error('Dispatch session is missing its job id'); + } + await appendToDispatchJob(sessionId, jobId, draft.message, draft.displayMessage); + }, + + async startTurn( + context: FlowChatContext, + input: StartTurnInput, + tracker: TurnTracker, + ): Promise { + const { + sessionId, + message, + displayMessage, + currentAgentType, + isFirstMessage, + readySession, + options, + } = input; + + const targetRequest = readySession.config.dispatchTargetRequest; + const jobId = readySession.config.dispatchJobId; + const approvalPolicy = readySession.config.dispatchApprovalPolicy; + if (!targetRequest || targetRequest.kind === 'local' || !jobId || !approvalPolicy) { + throw new Error('Dispatch session is missing its immutable target or approval policy'); + } + const dispatchState = readySession.config.dispatchJobState; + if (dispatchState === 'queued' || dispatchState === 'running') { + if ((options?.imageContexts?.length ?? 0) > 0) { + // Steering has no attachment channel; images ride turn boundaries. + throw new Error(IMAGES_WHILE_RUNNING_MESSAGE); + } + // A turn is already in flight; this message steers it rather than + // starting another one underneath it. + await appendToDispatchJob(sessionId, jobId, message, displayMessage); + return 'detached'; + } + if (dispatchState && isDispatchJobTerminal(dispatchState)) { + // The previous turn finished. A dispatch session is a conversation, so + // this starts the next turn against the same target session, worktree, + // and event log instead of refusing the message. + await continueDispatchJob(context, input, jobId); + return 'detached'; + } + if ( + dispatchState !== 'submitting' && + dispatchState !== 'submission_unknown' + ) { + throw new Error('This dispatch session is not ready to accept a message'); + } + if (isFirstMessage) { + applyGeneratingTitlePlaceholder(context, sessionId, message); + } + + const submitAttachments = dispatchAttachments(readySession, options?.imageContexts); + const optimisticTurnId = `dispatch_pending_${jobId}`; + const optimisticTurn: DialogTurn = { + id: optimisticTurnId, + sessionId, + agentType: currentAgentType, + userMessage: { + id: `user_dispatch_${Date.now()}`, + content: displayMessage || message, + timestamp: Date.now(), + hasImages: (options?.imageDisplayData?.length ?? 0) > 0, + images: options?.imageDisplayData, + metadata: markOptimisticDispatchTurnMetadata( + options?.userMessageMetadata, + jobId, + ), + }, + modelRounds: [], + status: 'pending', + startTime: Date.now(), + }; + context.flowChatStore.addDialogTurn(sessionId, optimisticTurn); + tracker.createdLocalTurnId = optimisticTurnId; + pinTurnToTop(sessionId, optimisticTurnId); + + const includeUncommitted = readySession.config.dispatchIncludeUncommitted ?? false; + const baseRef = readySession.config.dispatchBaseRef?.trim() || 'HEAD'; + const sourceWorkspacePath = sessionProjectWorkspacePath(readySession); + const sourceWorkspaceId = + readySession.workspaceId || readySession.config.workspaceId; + const transferRoundId = `dispatch-transfer:${jobId}`; + // Provisioning is always more than a submit now — a worktree is created, + // the target checks out the baseline, and objects may be transferred — + // so the transfer label always applies. + showRuntimeStatus({ + sessionId, + turnId: optimisticTurnId, + roundId: transferRoundId, + label: i18nService.t('flow-chat:chatInput.dispatch.transferInProgress'), + }); + let response: Awaited>; + try { + response = await dispatchApi.submit({ + target: targetRequest, + baseRef, + includeUncommitted, + jobId, + sessionId, + agentType: currentAgentType, + prompt: message, + approvalPolicy, + model: readySession.config.dispatchModel?.trim() || undefined, + ...(sourceWorkspacePath ? { sourceWorkspacePath } : {}), + ...(sourceWorkspaceId ? { sourceWorkspaceId } : {}), + ...(submitAttachments ? { attachments: submitAttachments } : {}), + }); + } finally { + clearRuntimeStatusState({ + sessionId, + turnId: optimisticTurnId, + roundId: transferRoundId, + }); + } + if (!response.accepted || response.jobId !== jobId || response.sessionId !== sessionId) { + throw new Error('Dispatch target returned a mismatched acknowledgement'); + } + context.flowChatStore.applyDispatchSnapshot(sessionId, { + jobId, + state: response.state, + cursor: readySession.config.dispatchCursor ?? 0, + expectedCursor: readySession.config.dispatchCursor ?? 0, + }); + dispatchJobStore.getState().updateProgress(jobId, { + state: response.state, + }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); + requestDispatchJobRefresh(jobId); + return 'completed'; + }, +}; diff --git a/src/web-ui/src/flow_chat/session-drivers/dispatch/install.ts b/src/web-ui/src/flow_chat/session-drivers/dispatch/install.ts new file mode 100644 index 0000000000..7a89269dd1 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/dispatch/install.ts @@ -0,0 +1,7 @@ +/** + * Boot-time installation of the dispatch driver's background machinery. + * FlowChatManager installs this once; the observer itself lives in + * features/dispatch and stays private to the dispatch driver. + */ + +export { installDispatchJobObserver } from '@/features/dispatch/DispatchJobObserver'; diff --git a/src/web-ui/src/flow_chat/session-drivers/idempotency.ts b/src/web-ui/src/flow_chat/session-drivers/idempotency.ts new file mode 100644 index 0000000000..c96d306262 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/idempotency.ts @@ -0,0 +1,59 @@ +/** + * Per-session idempotent submission ids. + * + * A transport call whose response was lost must be retryable without running + * the operation twice on the other side. The id is minted once per + * (session, scope, content) and reused verbatim by retries with the same + * content; the durable target dedupes on it. + */ + +export interface PendingSubmissionRetry { + content: string; + displayContent?: string; + id: string; +} + +const pendingByScope = new Map>(); + +function scopeMap(scope: string): Map { + let map = pendingByScope.get(scope); + if (!map) { + map = new Map(); + pendingByScope.set(scope, map); + } + return map; +} + +/** + * Return the pending retry for this content, or mint and remember a new id. + * Different content for the same session replaces the pending entry — only + * the latest attempt is retryable. + */ +export function claimSubmissionRetry( + scope: string, + sessionId: string, + content: string, + displayContent: string | undefined, + mintId: () => string, +): PendingSubmissionRetry { + const map = scopeMap(scope); + const current = map.get(sessionId); + const retry = + current?.content === content && current.displayContent === displayContent + ? current + : { content, displayContent, id: mintId() }; + map.set(sessionId, retry); + return retry; +} + +/** Forget the retry, but only if it is still the current one for the session. */ +export function releaseSubmissionRetry( + scope: string, + sessionId: string, + id: string, +): void { + const map = scopeMap(scope); + if (map.get(sessionId)?.id === id) { + map.delete(sessionId); + } +} diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts new file mode 100644 index 0000000000..3db883bb70 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -0,0 +1,471 @@ +/** + * Local session driver: the default flavor backed by this machine's (or the + * attached peer's) agent runtime via `agentAPI`. + * + * Bodies were moved verbatim from SessionModule/MessageModule; behavior is + * unchanged. Peer Device Mode is invisible here by design — it swaps the + * transport underneath `api.invoke`, so this driver must never consult it. + */ + +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; +import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; +import { worktreeAPI } from '@/infrastructure/api/service-api/WorktreeAPI'; +import { globalEventBus } from '@/infrastructure/event-bus'; +import { createLogger } from '@/shared/utils/logger'; +import { stateMachineManager } from '../../state-machine'; +import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; +import type { FlowChatContext, SessionConfig, DialogTurn } from '../../services/flow-chat-manager/types'; +import type { + SessionCascadeRemoval, + SessionCreationSeed, + SessionDriver, + StartTurnInput, + StartTurnResult, + SubmissionPlan, + TurnTracker, + UsageReportUiParams, +} from '../types'; +import { + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + type FlowChatPinTurnToTopRequest, +} from '../../events/flowchatNavigation'; +import { + getModelMaxTokens, + resolveModelForSessionCreation, +} from '../../utils/modelResolution'; +import { syncSessionModelSelection } from '../../utils/modelSync'; +import { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; +import { + requireSessionProjectWorkspacePath, + sessionProjectWorkspacePath, +} from '../../utils/sessionWorkspace'; +import { sessionWorktreeMaterializationPlan } from '../../utils/sessionWorktree'; +import { cleanupSaveState } from '../../services/flow-chat-manager/PersistenceModule'; +import { cleanupSessionBuffers } from '../../services/flow-chat-manager/TextChunkModule'; +import { applyGeneratingTitlePlaceholder } from '../shared'; + +const log = createLogger('LocalSessionDriver'); + +export const localSessionDriver: SessionDriver = { + id: 'local', + + async createSession(context: FlowChatContext, seed: SessionCreationSeed): Promise { + const { + config, + agentType, + sessionName, + titleDescriptor, + workspacePath, + projectWorkspacePath, + workspaceId, + remoteConnectionId, + remoteSshHost, + } = seed; + + const sessionModelName = await resolveModelForSessionCreation(config.modelName); + const maxContextTokens = await getModelMaxTokens(sessionModelName, agentType); + const mergedConfig: SessionConfig = { + ...config, + modelName: sessionModelName, + workspaceId: workspaceId ?? config.workspaceId, + }; + + const response = await agentAPI.createSession({ + sessionName, + agentType, + workspacePath, + projectWorkspacePath, + executionTarget: config.executionTargetRequest, + requestId: globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}-${Math.random()}`, + workspaceId: mergedConfig.workspaceId, + remoteConnectionId, + remoteSshHost, + config: { + modelName: sessionModelName, + enableTools: true, + safeMode: true, + autoCompact: true, + maxContextTokens: maxContextTokens, + enableContextCompression: true, + remoteConnectionId, + remoteSshHost, + } + }); + + const effectiveWorkspacePath = + response.workspacePath || response.executionTarget?.rootPath || workspacePath; + const effectiveProjectWorkspacePath = + response.projectWorkspacePath || projectWorkspacePath || workspacePath; + const resolvedConfig: SessionConfig = { + ...mergedConfig, + workspacePath: effectiveWorkspacePath, + projectWorkspacePath: effectiveProjectWorkspacePath, + workspaceId: response.workspaceId ?? mergedConfig.workspaceId, + executionTarget: response.executionTarget, + }; + + context.flowChatStore.createSession( + response.sessionId, + resolvedConfig, + undefined, + sessionName, + maxContextTokens, + agentType, + effectiveWorkspacePath, + remoteConnectionId, + remoteSshHost, + titleDescriptor, + ); + + return response.sessionId; + }, + + async deleteSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + log.info('Dispatch diagnostic: delete routed to persisted backend session', { + sessionId, + hasWorkspacePath: Boolean(session && sessionProjectWorkspacePath(session)), + }); + await context.flowChatStore.deleteSession( + sessionId, + removal.removedActiveSession ? { nextActiveSessionId: null } : undefined, + ); + + removal.removedSessionIds.forEach(id => { + context.processingManager.clearSessionStatus(id); + cleanupSaveState(context, id); + }); + }, + + async archiveSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + + await sessionAPI.archiveSession( + sessionId, + requireSessionProjectWorkspacePath(session, sessionId), + session.remoteConnectionId, + session.remoteSshHost, + ); + + context.flowChatStore.removeSession( + sessionId, + removal.removedActiveSession ? { nextActiveSessionId: null } : undefined, + ); + + removal.removedSessionIds.forEach(id => { + stateMachineManager.delete(id); + context.processingManager.clearSessionStatus(id); + cleanupSaveState(context, id); + cleanupSessionBuffers(context, id); + }); + }, + + async renameSession( + context: FlowChatContext, + sessionId: string, + title: string, + ): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + const updatedTitle = await agentAPI.updateSessionTitle({ + sessionId, + title, + workspacePath: sessionProjectWorkspacePath(session), + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + }); + + await context.flowChatStore.updateSessionTitle(sessionId, updatedTitle, 'generated'); + return updatedTitle; + }, + + async ensureReady(context: FlowChatContext, sessionId: string): Promise { + // Deliberate lazy import: SessionModule routes lifecycle calls through the + // driver registry, so a static import here would create a module cycle. + const { ensureBackendSession } = await import('../../services/flow-chat-manager/SessionModule'); + await ensureBackendSession(context, sessionId); + }, + + async cancel(context: FlowChatContext, sessionId: string): Promise { + const currentState = stateMachineManager.getCurrentState(sessionId); + const success = currentState === SessionExecutionState.PROCESSING + ? await stateMachineManager.transition(sessionId, SessionExecutionEvent.USER_CANCEL) + : false; + + if (success) { + context.userCancelledSessionIds.add(sessionId); + markCurrentTurnItemsAsCancelled(context, sessionId); + cleanupSessionBuffers(context, sessionId); + } + + return success; + }, + + planSubmission(): SubmissionPlan { + // Local sessions park messages while busy; the drain listener replays + // them when the state machine returns to IDLE. + return { kind: 'queue' }; + }, + + async compactSession(context: FlowChatContext, sessionId: string): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + await agentAPI.compactSession({ + sessionId, + workspacePath: session.workspacePath, + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + }); + }, + + async runUsageReport( + context: FlowChatContext, + sessionId: string, + uiParams: UsageReportUiParams, + ): Promise<{ inserted: boolean }> { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + const { runUsageReportCommand } = await import('../../services/usageReportService'); + const result = await runUsageReportCommand({ session, ...uiParams }); + return { inserted: result.inserted }; + }, + + permissionRequestSource(): 'live' { + return 'live'; + }, + + async respondPermission( + _sessionId: string, + requestId: string, + reply: 'once' | 'always' | 'reject', + feedback?: string, + ): Promise { + await agentAPI.respondPermission(requestId, reply, feedback); + }, + + async respondPermissionBatch( + _sessionId: string, + requestId: string, + reply: 'once' | 'always' | 'reject', + feedback?: string, + ): Promise { + return agentAPI.respondPermissionBatch(requestId, reply, feedback); + }, + + async steer(): Promise { + throw new Error('Local sessions queue messages while a turn is running'); + }, + + async startTurn( + context: FlowChatContext, + input: StartTurnInput, + tracker: TurnTracker, + ): Promise { + const { + sessionId, + message, + displayMessage, + currentAgentType, + acpClientId, + isFirstMessage, + readySession, + options, + } = input; + + const dialogTurnId = options?.turnId?.trim() || + `dialog_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const hasImages = (options?.imageContexts?.length ?? 0) > 0; + + const dialogTurn: DialogTurn = { + id: dialogTurnId, + sessionId: sessionId, + agentType: currentAgentType, + userMessage: { + id: `user_${Date.now()}`, + content: displayMessage || message, + timestamp: Date.now(), + hasImages, + images: options?.imageDisplayData, + metadata: options?.userMessageMetadata, + }, + modelRounds: [], + // Images are attached for multimodal primary models or reduced to text placeholders for text-only models. + // We don't run a separate frontend "image pre-analysis" phase here. + status: 'pending', + startTime: Date.now() + }; + + context.flowChatStore.addDialogTurn(sessionId, dialogTurn); + tracker.createdLocalTurnId = dialogTurnId; + const pinRequest: FlowChatPinTurnToTopRequest = { + sessionId, + turnId: dialogTurnId, + behavior: 'auto', + source: 'send-message', + pinMode: 'sticky-latest', + }; + globalEventBus.emit(FLOWCHAT_PIN_TURN_TO_TOP_EVENT, pinRequest, 'MessageModule'); + + const isRestoringHistoricalSession = + readySession.isHistorical || context.pendingHistoryLoads.has(sessionId); + if (isRestoringHistoricalSession) { + context.processingManager.clearSessionStatus(sessionId); + context.flowChatStore.deleteDialogTurn(sessionId, dialogTurnId); + throw new Error('Session history is still restoring, please retry once loading finishes'); + } + + const startOk = await stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { + taskId: sessionId, + dialogTurnId, + }); + if (!startOk) { + const currentState = stateMachineManager.getCurrentState(sessionId); + throw new Error(`Session is still busy finishing the previous turn (current state: ${currentState})`); + } + + context.processingManager.registerStatus({ + sessionId: sessionId, + status: 'thinking', + message: '', + metadata: { sessionId: sessionId, dialogTurnId } + }); + + if (readySession.config.worktreeIsolationRequested !== undefined) { + const materialization = sessionWorktreeMaterializationPlan(readySession); + if (materialization) { + log.info('Materializing requested worktree after prompt submission', { + sessionId, + enabled: materialization.enabled, + projectWorkspacePath: materialization.projectWorkspacePath, + }); + const result = await worktreeAPI.bindSession( + sessionId, + materialization.enabled, + globalThis.crypto?.randomUUID?.() ?? `worktree-first-turn-${Date.now()}`, + materialization.projectWorkspacePath, + ); + context.flowChatStore.updateSessionExecutionTarget(sessionId, { + workspacePath: result.workspacePath, + projectWorkspacePath: result.projectWorkspacePath, + workspaceId: result.workspaceId, + executionTarget: result.executionTarget, + }); + if (result.retainedWorktreePath) { + log.warn('Released worktree retained because it contains local work', { + sessionId, + retainedWorktreePath: result.retainedWorktreePath, + }); + } + } + context.flowChatStore.setSessionWorktreeIsolationRequested(sessionId, undefined); + } + + if (isFirstMessage) { + applyGeneratingTitlePlaceholder(context, sessionId, message); + } + + if (!acpClientId) { + await syncSessionModelSelection(context, sessionId, currentAgentType); + } + + const updatedSession = context.flowChatStore.getState().sessions.get(sessionId); + if (!updatedSession) { + throw new Error(`Session lost after adding dialog turn: ${sessionId}`); + } + + context.contentBuffers.set(sessionId, new Map()); + context.activeTextItems.set(sessionId, new Map()); + + const workspacePath = updatedSession.workspacePath; + const projectWorkspacePath = sessionProjectWorkspacePath(updatedSession); + + if (acpClientId) { + await ACPClientAPI.startDialogTurn({ + sessionId, + clientId: acpClientId, + userInput: message, + originalUserInput: displayMessage || message, + turnId: dialogTurnId, + workspacePath, + imageContexts: options?.imageContexts, + userMessageMetadata: options?.userMessageMetadata, + remoteConnectionId: updatedSession.remoteConnectionId, + remoteSshHost: updatedSession.remoteSshHost, + }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); + } else { + try { + await agentAPI.startDialogTurn({ + sessionId: sessionId, + userInput: message, + originalUserInput: displayMessage || message, + turnId: dialogTurnId, + agentType: currentAgentType, + workspacePath, + projectWorkspacePath, + remoteConnectionId: updatedSession.remoteConnectionId, + remoteSshHost: updatedSession.remoteSshHost, + imageContexts: options?.imageContexts, + userMessageMetadata: options?.userMessageMetadata, + execution: options?.execution, + }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); + } catch (error: any) { + if (error?.message?.includes('Session does not exist') || error?.message?.includes('Not found')) { + log.warn('Backend session still not found, retrying creation', { + sessionId: sessionId, + dialogTurnsCount: updatedSession.dialogTurns.length + }); + + // Lazy import: SessionModule routes lifecycle calls through the + // driver registry, so a static import would create a module cycle. + const { retryCreateBackendSession } = + await import('../../services/flow-chat-manager/SessionModule'); + await retryCreateBackendSession(context, sessionId); + + await agentAPI.startDialogTurn({ + sessionId: sessionId, + userInput: message, + originalUserInput: displayMessage || message, + turnId: dialogTurnId, + agentType: currentAgentType, + workspacePath, + projectWorkspacePath, + remoteConnectionId: updatedSession.remoteConnectionId, + remoteSshHost: updatedSession.remoteSshHost, + imageContexts: options?.imageContexts, + userMessageMetadata: options?.userMessageMetadata, + execution: options?.execution, + }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); + } else { + throw error; + } + } + } + + const sessionStateMachine = stateMachineManager.get(sessionId); + if (sessionStateMachine) { + sessionStateMachine.getContext().taskId = sessionId; + } + return 'completed'; + }, +}; diff --git a/src/web-ui/src/flow_chat/session-drivers/registry.ts b/src/web-ui/src/flow_chat/session-drivers/registry.ts new file mode 100644 index 0000000000..1ca4e8098b --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/registry.ts @@ -0,0 +1,38 @@ +/** + * Static driver registry. All drivers are registered at module load — no + * dynamic registration, no ordering hazard, and resolution stays synchronous + * so hot paths can use it. + */ + +import type { SessionConfig } from '../services/flow-chat-manager/types'; +import { + resolveSessionDriverId, + resolveSessionDriverIdForCreation, + type DriverResolvableSession, + type SessionDriverId, +} from './resolve'; +import type { SessionDriver } from './types'; +import { localSessionDriver } from './local/LocalSessionDriver'; +import { dispatchSessionDriver } from './dispatch/DispatchSessionDriver'; + +const drivers: Record = { + local: localSessionDriver, + dispatch: dispatchSessionDriver, +}; + +export function sessionDriverById(id: SessionDriverId): SessionDriver { + return drivers[id]; +} + +export function driverForSession( + sessionId: string, + session: DriverResolvableSession | undefined, +): SessionDriver { + return drivers[resolveSessionDriverId(sessionId, session)]; +} + +export function driverForCreation( + config: Pick, +): SessionDriver { + return drivers[resolveSessionDriverIdForCreation(config)]; +} diff --git a/src/web-ui/src/flow_chat/session-drivers/resolve.test.ts b/src/web-ui/src/flow_chat/session-drivers/resolve.test.ts new file mode 100644 index 0000000000..3f4bc7d6ba --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/resolve.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + dispatchJobStoreObservesSession, + resolveSessionDriverId, + resolveSessionDriverIdForCreation, + resolveSessionDriverIdWith, +} from './resolve'; +import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; +import type { DispatchObserverJob } from '@/features/dispatch/dispatchJobStore'; + +const never = () => false; + +function observerJob(overrides: Partial): DispatchObserverJob { + return { + jobId: 'job-1', + sessionId: 'session-1', + targetRequest: { kind: 'ssh', connectionId: 'ssh-user@host', workspacePath: '/repo' }, + target: { + kind: 'ssh', + connectionId: 'ssh-user@host', + workspacePath: '/repo', + displayName: 'host', + }, + sourceWorkspacePath: '/controller/repo', + title: 'job', + agentType: 'agentic', + approvalPolicy: 'remote', + cursor: 0, + state: 'submitting', + appliedEventIds: [], + pendingPermissions: [], + eventLogComplete: true, + historyTruncated: false, + omittedEventCount: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + } as DispatchObserverJob; +} + +describe('resolveSessionDriverIdWith', () => { + it('resolves local when no dispatch signal is present', () => { + expect(resolveSessionDriverIdWith('s1', { config: {} }, never)).toBe('local'); + expect(resolveSessionDriverIdWith('s1', undefined, never)).toBe('local'); + }); + + it('treats a local dispatch target as local execution', () => { + expect( + resolveSessionDriverIdWith('s1', { config: { dispatchTarget: { kind: 'local' } } }, never), + ).toBe('local'); + }); + + it('resolves dispatch from a bound non-local target', () => { + expect( + resolveSessionDriverIdWith( + 's1', + { + config: { + dispatchTarget: { + kind: 'ssh', + connectionId: 'ssh-user@host', + workspacePath: '/repo', + displayName: 'host', + }, + }, + }, + never, + ), + ).toBe('dispatch'); + }); + + it('resolves dispatch from a configured job id even without a bound target', () => { + expect( + resolveSessionDriverIdWith('s1', { config: { dispatchJobId: 'dispatch-abc' } }, never), + ).toBe('dispatch'); + expect( + resolveSessionDriverIdWith('s1', { config: { dispatchJobId: ' ' } }, never), + ).toBe('local'); + }); + + it('resolves dispatch from observer-store membership alone', () => { + // The startup race: ensureProjection has not bound config yet, but the + // observer store already knows the session belongs to a target-owned job. + const observes = (sessionId: string) => sessionId === 's1'; + expect(resolveSessionDriverIdWith('s1', { config: {} }, observes)).toBe('dispatch'); + expect(resolveSessionDriverIdWith('s2', { config: {} }, observes)).toBe('local'); + }); +}); + +describe('resolveSessionDriverId (dispatchJobStore integration)', () => { + afterEach(() => { + dispatchJobStore.getState().clear(); + }); + + it('sees sessions registered in the observer store', () => { + expect(dispatchJobStoreObservesSession('session-1')).toBe(false); + dispatchJobStore.getState().registerJob(observerJob({ sessionId: 'session-1' })); + expect(dispatchJobStoreObservesSession('session-1')).toBe(true); + expect(resolveSessionDriverId('session-1', { config: {} })).toBe('dispatch'); + expect(resolveSessionDriverId('other-session', { config: {} })).toBe('local'); + }); +}); + +describe('parent-chain inheritance', () => { + it('treats a child of a dispatch projection as dispatch-driven', () => { + const parent = { + config: { + dispatchTarget: { + kind: 'ssh' as const, + connectionId: 'ssh-user@host', + workspacePath: '/repo', + displayName: 'host', + }, + }, + }; + const child = { parentSessionId: 'parent-1', config: {} }; + const grandchild = { parentSessionId: 'child-1', config: {} }; + const sessions = new Map([ + ['parent-1', parent], + ['child-1', child], + ]); + const lookup = (id: string) => sessions.get(id); + + expect(resolveSessionDriverIdWith('child-1', child, never, lookup)).toBe('dispatch'); + expect(resolveSessionDriverIdWith('grandchild-1', grandchild, never, lookup)).toBe('dispatch'); + // A child of a local parent stays local. + const localChild = { parentSessionId: 'local-parent', config: {} }; + const localSessions = new Map([['local-parent', { config: {} }]]); + expect( + resolveSessionDriverIdWith('c', localChild, never, id => localSessions.get(id)), + ).toBe('local'); + }); +}); + +describe('resolveSessionDriverIdForCreation', () => { + it('keys off the requested dispatch target', () => { + expect(resolveSessionDriverIdForCreation({})).toBe('local'); + expect( + resolveSessionDriverIdForCreation({ dispatchTargetRequest: { kind: 'local' } }), + ).toBe('local'); + expect( + resolveSessionDriverIdForCreation({ + dispatchTargetRequest: { kind: 'device', deviceId: 'd1', workspacePath: '/repo' }, + }), + ).toBe('dispatch'); + }); +}); diff --git a/src/web-ui/src/flow_chat/session-drivers/resolve.ts b/src/web-ui/src/flow_chat/session-drivers/resolve.ts new file mode 100644 index 0000000000..09f69aaa90 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/resolve.ts @@ -0,0 +1,117 @@ +/** + * Session driver resolution. + * + * A session's driver decides which transport family owns its lifecycle, + * submission, permissions, and persistence. The id is always derived — never + * stored on the session — because a projection can exist before its dispatch + * config is bound (startup metadata races `DispatchJobObserver.ensureProjection`) + * and a stored id would go stale in that window. + */ + +import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; +import type { DispatchTarget, DispatchTargetRequest } from '@/features/dispatch/types'; +import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; + +export type SessionDriverId = 'local' | 'dispatch'; + +/** + * Structural view of the session fields the resolver reads. Kept minimal so + * store modules and tests can pass lightweight objects. + */ +export interface DriverResolvableSession { + /** Child sessions (subagents, /btw) inherit the driver of their parent. */ + parentSessionId?: string; + config?: { + dispatchTarget?: DispatchTarget; + dispatchJobId?: string; + }; +} + +type SessionLookup = (sessionId: string) => DriverResolvableSession | undefined; + +let parentSessionLookup: SessionLookup | null = null; + +/** + * Register how the resolver walks a session's parent chain. Installed once by + * FlowChatManager; kept as an injected function because the store module + * itself depends on this resolver. + */ +export function registerDriverSessionLookup(lookup: SessionLookup): void { + parentSessionLookup = lookup; +} + +const MAX_PARENT_CHAIN_DEPTH = 4; + +/** + * Three-signal core. All three are required because each covers a window the + * others miss: + * - `dispatchTarget` is unbound while `ensureProjection` races startup + * workspace metadata; + * - `dispatchJobId` survives on config after the observer store was pruned; + * - the job-store membership catches sessions created by the observer before + * any config binding happened. + * + * A weaker predicate here persisted observer projections to disk or created + * controller-side backend sessions for target-owned jobs. + */ +export function resolveSessionDriverIdWith( + sessionId: string, + session: DriverResolvableSession | undefined, + isDispatchObservedSession: (sessionId: string) => boolean, + lookupSession: SessionLookup | null = parentSessionLookup, +): SessionDriverId { + if (isNonLocalDispatchTarget(session?.config?.dispatchTarget)) { + return 'dispatch'; + } + if (session?.config?.dispatchJobId?.trim()) { + return 'dispatch'; + } + if (isDispatchObservedSession(sessionId)) { + return 'dispatch'; + } + // Fourth signal: a child session (subagent projection) inherits the driver + // of its parent chain — it has no dispatch config of its own, but its + // events come from the target and it must never be persisted or driven as + // a local backend session. + let parentId = session?.parentSessionId?.trim(); + for (let depth = 0; parentId && lookupSession && depth < MAX_PARENT_CHAIN_DEPTH; depth += 1) { + if (isDispatchObservedSession(parentId)) { + return 'dispatch'; + } + const parent = lookupSession(parentId); + if (!parent) { + break; + } + if ( + isNonLocalDispatchTarget(parent.config?.dispatchTarget) + || parent.config?.dispatchJobId?.trim() + ) { + return 'dispatch'; + } + parentId = parent.parentSessionId?.trim(); + } + return 'local'; +} + +export function dispatchJobStoreObservesSession(sessionId: string): boolean { + // `jobs` always exists on the real store; the fallback tolerates partial + // test doubles of dispatchJobStore so an unrelated suite cannot turn a + // resolver lookup into a thrown error. + return Object.values(dispatchJobStore.getState().jobs ?? {}) + .some(job => job.sessionId === sessionId); +} + +/** The single source of truth for "which driver owns this session". */ +export function resolveSessionDriverId( + sessionId: string, + session: DriverResolvableSession | undefined, +): SessionDriverId { + return resolveSessionDriverIdWith(sessionId, session, dispatchJobStoreObservesSession); +} + +/** Creation has no session yet; the requested target decides the driver. */ +export function resolveSessionDriverIdForCreation(config: { + dispatchTargetRequest?: DispatchTargetRequest; +}): SessionDriverId { + return isNonLocalDispatchTarget(config.dispatchTargetRequest) ? 'dispatch' : 'local'; +} diff --git a/src/web-ui/src/flow_chat/session-drivers/shared.ts b/src/web-ui/src/flow_chat/session-drivers/shared.ts new file mode 100644 index 0000000000..76f7f9cfd6 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/shared.ts @@ -0,0 +1,19 @@ +/** + * Small helpers shared by driver implementations. + */ + +import { generateTempTitle } from '../utils/titleUtils'; +import type { FlowChatContext } from '../services/flow-chat-manager/types'; + +/** + * Show a readable placeholder title immediately; the backend later confirms + * the authoritative title via AI or local fallback generation. + */ +export function applyGeneratingTitlePlaceholder( + context: FlowChatContext, + sessionId: string, + message: string, +): void { + const tempTitle = generateTempTitle(message, 20); + context.flowChatStore.updateSessionTitle(sessionId, tempTitle, 'generating'); +} diff --git a/src/web-ui/src/flow_chat/session-drivers/types.ts b/src/web-ui/src/flow_chat/session-drivers/types.ts new file mode 100644 index 0000000000..9854d83be9 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/types.ts @@ -0,0 +1,258 @@ +/** + * SessionDriver — the seam between flow-chat orchestration and a session's + * transport family. + * + * A driver owns everything flavor-specific about a session: how it is + * created, deleted, renamed, readied, submitted to, and cancelled. Shared + * choreography (optimistic turns, queues, title generation, store plumbing) + * stays in flow-chat-manager and calls into the driver at the points where + * flavors diverge. New chat features land for every flavor by adding one + * driver member instead of branching in eight files. + * + * Naming note: this is deliberately not called "backend" — the codebase uses + * "backend session" for the Rust-side agent session (`ensureBackendSession`). + */ + +import type { FlowChatContext, SessionConfig } from '../services/flow-chat-manager/types'; +import type { Session } from '../types/flow-chat'; +import type { SessionTitleDescriptor } from '../utils/sessionTitle'; +import type { ImageContextData as ImageInputContextData } from '@/infrastructure/api/service-api/ImageContextTypes'; +import type { SessionDriverId } from './resolve'; + +export type { SessionDriverId } from './resolve'; + +/** Options accepted by FlowChatManager.sendMessage, threaded to the driver. */ +export interface SendMessageOptions { + imageContexts?: ImageInputContextData[]; + imageDisplayData?: Array<{ + id: string; + name: string; + dataUrl?: string; + imagePath?: string; + mimeType?: string; + }>; + /** + * When true, bypass the pending-queue check. Used by the queue drain path + * to actually start a new dialog turn after the previous one finished. + * Callers should not set this directly. + */ + bypassPendingQueue?: boolean; + userMessageMetadata?: Record; + execution?: import('@/infrastructure/api/service-api/AgentAPI').AgentDialogTurnExecution; + turnId?: string; + preserveTurnOnStartError?: boolean; + onSessionConflictRetryStart?: () => void; + onSessionConflictRetrySuccess?: () => void; + fromSessionConflictRetry?: boolean; +} + +/** The message content of one submission, before flavor routing. */ +export interface SubmissionDraft { + message: string; + displayMessage?: string; + hasImages: boolean; +} + +/** + * What to do with a submission while the session is busy or has queued + * messages. `steer` injects into the running work, `queue` parks the message + * for the drain listener, `reject` refuses with a reason. + */ +export type SubmissionPlan = + | { kind: 'queue' } + | { kind: 'steer' } + | { kind: 'reject'; reason: string }; + +export interface StartTurnInput { + sessionId: string; + message: string; + displayMessage?: string; + /** Resolved agent type for this turn (falls back to session mode). */ + currentAgentType: string; + /** Non-null when the session is driven by an external ACP client. */ + acpClientId: string | null; + isFirstMessage: boolean; + /** Session snapshot taken after ensureReady. */ + readySession: Session; + options?: SendMessageOptions; +} + +/** + * Written by the driver as soon as it adds an optimistic turn it wants the + * shared error path to delete on failure. Read by sendMessage's catch block. + */ +export interface TurnTracker { + createdLocalTurnId: string | null; +} + +/** + * `completed` runs the shared post-submission bookkeeping; + * `detached` means the message was steered into (or continued) target-owned + * work and the shared epilogue must be skipped. + */ +export type StartTurnResult = 'completed' | 'detached'; + +/** Localized strings the usage-report flow surfaces; supplied by the caller. */ +export interface UsageReportUiParams { + isProcessing: boolean; + busyMessage: string; + noWorkspaceMessage: string; + failedTitle: string; + unknownErrorMessage: string; + loadingMarkdown: string; +} + +/** + * Where a session's pending permission requests come from. + * + * `'live'` — the runtime pushes permission events over the transport; the + * shared hook owns the subscription and reconciliation. + * Otherwise — an external-store pair (`useSyncExternalStore`-shaped) over the + * driver's own state; used when requests arrive by polling a durable mailbox. + */ +export type PermissionRequestSource = + | 'live' + | { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => readonly PermissionRequestLike[]; + }; + +/** + * Structural stand-in for AgentAPI's PermissionRequest wire shape. `object` + * so both the typed interface and raw store records assign without casts. + */ +export type PermissionRequestLike = object; + +export type PermissionReplyKindLike = 'once' | 'always' | 'reject'; + +/** + * Everything flavor-independent that session creation resolved before the + * driver takes over: workspace identity, agent type, and the initial title. + */ +export interface SessionCreationSeed { + config: SessionConfig; + agentType: string; + sessionName: string; + titleDescriptor: SessionTitleDescriptor; + workspacePath: string; + projectWorkspacePath: string; + workspaceId?: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +/** Cascade facts computed once by the shared caller before removal. */ +export interface SessionCascadeRemoval { + removedSessionIds: string[]; + removedActiveSession: boolean; +} + +export interface SessionDriver { + readonly id: SessionDriverId; + + /** Create the flavor's session and return its id. */ + createSession(context: FlowChatContext, seed: SessionCreationSeed): Promise; + + /** Remove the session (and its cascade) from this controller. */ + deleteSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise; + + /** Archive the session; observer flavors treat this as a local dismiss. */ + archiveSession( + context: FlowChatContext, + sessionId: string, + removal: SessionCascadeRemoval, + ): Promise; + + /** Rename and return the effective title. */ + renameSession( + context: FlowChatContext, + sessionId: string, + title: string, + ): Promise; + + /** + * Make the session able to accept a submission (backend session, + * hydration). Drivers with nothing to prepare return void — synchronously — + * so the caller introduces no microtask boundary before the optimistic + * turn: a projection must show the user's message before any async work. + */ + ensureReady(context: FlowChatContext, sessionId: string): Promise | void; + + /** Cancel the in-flight work for this session. Returns whether anything was cancelled. */ + cancel(context: FlowChatContext, sessionId: string): Promise; + + /** + * Decide the fate of a submission arriving while the session is busy or + * already has queued messages. Must test steer-eligibility before falling + * back to queue: a steerable message that gets queued never drains for + * flavors that do not drive the local state machine. + */ + planSubmission( + context: FlowChatContext, + sessionId: string, + draft: SubmissionDraft, + ): SubmissionPlan; + + /** Inject a message into the session's currently running work. */ + steer( + context: FlowChatContext, + sessionId: string, + draft: SubmissionDraft, + ): Promise; + + /** + * Start (or continue) a turn for an idle session. Owns the flavor-specific + * optimistic turn, transport call, and post-acknowledgement bookkeeping. + * Reports any optimistic turn to `tracker` so the shared error path can + * clean it up when this throws. + */ + startTurn( + context: FlowChatContext, + input: StartTurnInput, + tracker: TurnTracker, + ): Promise; + + /** + * Manually compact the session's context. Local sessions call the runtime + * directly; dispatch sessions queue a compact turn on the target. + */ + compactSession(context: FlowChatContext, sessionId: string): Promise; + + /** + * Generate and insert the session usage report turn. `uiParams` carries the + * caller's localized strings; the driver decides where the report data + * comes from and whether the rendered turn persists. + */ + runUsageReport( + context: FlowChatContext, + sessionId: string, + uiParams: UsageReportUiParams, + ): Promise<{ inserted: boolean }>; + + /** How pending permission requests for this session are obtained. */ + permissionRequestSource(sessionId: string): PermissionRequestSource; + + /** Answer one permission request over this session's transport. */ + respondPermission( + sessionId: string, + requestId: string, + reply: PermissionReplyKindLike, + feedback?: string, + ): Promise; + + /** + * Answer a request's whole batch. Returns the ids the transport reports as + * resolved so a live-subscription caller can reconcile its local state; + * external-store flavors return an empty list (their store refreshes). + */ + respondPermissionBatch( + sessionId: string, + requestId: string, + reply: PermissionReplyKindLike, + feedback?: string, + ): Promise; +} diff --git a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts new file mode 100644 index 0000000000..1ca0c893b9 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts @@ -0,0 +1,97 @@ +/** + * Composer-facing session capabilities. + * + * One derivation for every affordance the chat input used to gate with + * scattered "is this a dispatch session" branches. UI code reads semantic + * flags; only this module knows which driver implies which affordance. + * + * Uses the driver resolver (three signals) rather than the bound dispatch + * target alone, so a projection whose config has not bound yet is already + * treated as remote instead of briefly offering local-only affordances. + */ + +import { useRuntimeStatusStore } from '../store/runtimeStatusStore'; +import type { Session } from '../types/flow-chat'; +import { resolveSessionDriverId, type SessionDriverId } from './resolve'; + +export const DISPATCH_TRANSFER_ROUND_PREFIX = 'dispatch-transfer:'; + +/** Renderer-side slash commands the composer can offer for a session. */ +export type ComposerSlashOp = 'btw' | 'compact' | 'goal' | 'usage' | 'init' | 'review'; + +const LOCAL_SLASH_OPS: ReadonlySet = new Set([ + 'btw', 'compact', 'goal', 'usage', 'init', 'review', +]); +/** Ops a detached target serves via its durable turn mailbox / query verb. */ +const DISPATCH_SLASH_OPS: ReadonlySet = new Set(['compact', 'usage']); + +export interface ComposerCapabilities { + driverId: SessionDriverId; + /** + * Raw flag for residual plumbing (reload-context support, picker guards). + * Prefer the semantic flags below for new gates. + */ + dispatchTransport: boolean; + /** Renderer-side slash-command pickers (full local command surface). */ + localSlashCommands: boolean; + /** Individual slash commands this session can execute when typed. */ + ops: ReadonlySet; + usageReport: boolean; + threadGoal: boolean; + /** The submission is being transferred to the target; composer is inert. */ + transferInFlight: boolean; + /** Approval-policy and model choices are no longer editable. */ + submissionOptionsLocked: boolean; + /** Approval control edits this session's policy, not the global config. */ + sessionScopedApproval: boolean; + /** Worktree chip is forced-locked: execution uses a managed baseline. */ + worktreeBaselineLocked: boolean; + /** Model choice comes from the target's probed model list. */ + targetModelSelection: boolean; +} + +export interface ComposerCapabilityInput { + sessionId: string | null | undefined; + session: Session | undefined; + /** Host mask: miniapp / quick-input hosts hide dispatch affordances. */ + hostMasksDispatch: boolean; + /** Relationship input: /btw child sessions have no thread goal of their own. */ + displayAsChild: boolean; +} + +export function useComposerCapabilities(input: ComposerCapabilityInput): ComposerCapabilities { + const { sessionId, session, hostMasksDispatch, displayAsChild } = input; + const driverId = resolveSessionDriverId(sessionId ?? '', session); + const dispatchTransport = !hostMasksDispatch && driverId === 'dispatch'; + + const transferInFlight = useRuntimeStatusStore(state => { + const status = sessionId ? state.bySessionId.get(sessionId) : undefined; + return dispatchTransport + && status?.roundId.startsWith(DISPATCH_TRANSFER_ROUND_PREFIX) === true; + }); + + // Options are editable whenever no turn is in flight: before the first + // submit and between turns. Protocol v4 carries them per follow-up turn. + const dispatchJobState = session?.config.dispatchJobState; + const submissionOptionsLocked = + dispatchTransport + && ( + transferInFlight + || dispatchJobState === 'queued' + || dispatchJobState === 'running' + ); + + return { + driverId, + dispatchTransport, + localSlashCommands: !dispatchTransport, + ops: dispatchTransport ? DISPATCH_SLASH_OPS : LOCAL_SLASH_OPS, + usageReport: true, + threadGoal: !displayAsChild && !dispatchTransport, + transferInFlight, + submissionOptionsLocked, + sessionScopedApproval: dispatchTransport, + worktreeBaselineLocked: dispatchTransport, + targetModelSelection: dispatchTransport, + }; +} diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 4e426710b3..b86d752bf3 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -73,6 +73,7 @@ import { isNonLocalDispatchTarget, } from '@/features/dispatch/types'; import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; +import { resolveSessionDriverId } from '../session-drivers/resolve'; const log = createLogger('FlowChatStore'); @@ -80,11 +81,7 @@ function dispatchObserverOwnsSession( sessionId: string, session?: Session, ): boolean { - if (isNonLocalDispatchTarget(session?.config.dispatchTarget)) { - return true; - } - return Object.values(dispatchJobStore.getState().jobs) - .some(job => job.sessionId === sessionId); + return resolveSessionDriverId(sessionId, session) === 'dispatch'; } function logPersistedDispatchMetadataOverlap( @@ -2217,7 +2214,7 @@ export class FlowChatStore { }); } - /** Update the immutable-at-submit approval policy while the job is still local. */ + /** Update the approval policy; the next turn carries it to the target. */ public updateSessionDispatchApprovalPolicy( sessionId: string, approvalPolicy: NonNullable, diff --git a/src/web-ui/src/flow_chat/utils/modelResolution.ts b/src/web-ui/src/flow_chat/utils/modelResolution.ts new file mode 100644 index 0000000000..0fda729bc8 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/modelResolution.ts @@ -0,0 +1,108 @@ +/** + * Model selector resolution shared by session creation and the composer. + * + * Leaf module: session drivers and flow-chat-manager modules both depend on + * it, so it must not import either. + */ + +import { createLogger } from '@/shared/utils/logger'; +import type { + AIModelConfig, + AgentModelDefaultsConfig, + DefaultModelsConfig, +} from '@/infrastructure/config/types'; + +const log = createLogger('ModelResolution'); + +function findEnabledModel(models: AIModelConfig[], modelRef: string | null | undefined): AIModelConfig | null { + const value = modelRef?.trim(); + if (!value) return null; + return models.find(model => + model.enabled !== false + && (model.id === value || model.name === value || model.model_name === value) + ) ?? null; +} + +function resolveModelForContextWindow( + modelRef: string | null | undefined, + models: AIModelConfig[], + defaultModels: DefaultModelsConfig, +): AIModelConfig | null { + const value = modelRef?.trim(); + if (!value) return null; + + if (value === 'primary') { + return findEnabledModel(models, defaultModels.primary); + } + + if (value === 'fast') { + return findEnabledModel(models, defaultModels.fast) ?? findEnabledModel(models, defaultModels.primary); + } + + if (value === 'auto' || value === 'default') { + return null; + } + + return findEnabledModel(models, value); +} + +export async function getModelMaxTokens(modelName?: string, agentType?: string): Promise { + try { + const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); + const configData = await configManager.getConfigs([ + 'ai.models', + 'ai.default_models', + 'ai.agent_model_defaults', + ]); + const models = (configData['ai.models'] as AIModelConfig[] | undefined) || []; + const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; + const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; + + const normalizedModelName = modelName?.trim(); + const explicitModel = resolveModelForContextWindow(modelName, models, defaultModels); + if (explicitModel?.context_window) { + return explicitModel.context_window; + } + + // Only legacy sessions without a model selector inherit the current mode + // default. Explicit symbolic selectors such as "auto" remain session-owned. + if (!normalizedModelName) { + const modeModel = resolveModelForContextWindow( + agentModelDefaults?.mode, + models, + defaultModels, + ); + if (modeModel?.context_window) { + return modeModel.context_window; + } + } + + const primaryModel = resolveModelForContextWindow('primary', models, defaultModels); + if (primaryModel?.context_window) { + return primaryModel.context_window; + } + + log.debug('Model context_window config not found, using default', { modelName, agentType }); + return 128128; + } catch (error) { + log.warn('Failed to get model max tokens', { modelName, agentType, error }); + return 128128; + } +} + +export async function resolveModelForSessionCreation(modelName?: string): Promise { + const explicitModelName = modelName?.trim(); + if (explicitModelName) { + return explicitModelName; + } + + try { + const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); + const configData = await configManager.getConfigs(['ai.agent_model_defaults']); + const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; + return agentModelDefaults?.mode?.trim() || 'auto'; + } catch (error) { + log.warn('Failed to resolve model default during session creation', { error }); + return 'auto'; + } +} diff --git a/src/web-ui/src/flow_chat/utils/modelSync.ts b/src/web-ui/src/flow_chat/utils/modelSync.ts new file mode 100644 index 0000000000..4af908cf08 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/modelSync.ts @@ -0,0 +1,102 @@ +/** + * Session model-selection synchronization before a local dialog turn. + * + * Leaf module: shared by the local session driver and re-exported by + * MessageModule for existing callers. + */ + +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; +import { createLogger } from '@/shared/utils/logger'; +import type { FlowChatContext } from '../services/flow-chat-manager/types'; +import { getModelMaxTokens } from './modelResolution'; +import { sessionProjectWorkspacePath } from './sessionWorkspace'; + +const log = createLogger('ModelSync'); + +function normalizeModelSelection( + modelId: string | undefined, + models: AIModelConfig[], + defaultModels: DefaultModelsConfig, +): string { + const value = modelId?.trim(); + if (!value || value === 'auto') return 'auto'; + + if (value === 'primary' || value === 'fast') { + const resolvedDefaultId = value === 'primary' ? defaultModels.primary : defaultModels.fast; + const matchedModel = models.find(model => model.id === resolvedDefaultId); + return matchedModel ? value : 'auto'; + } + + const matchedModel = models.find(model => + model.id === value || model.name === value || model.model_name === value, + ); + return matchedModel ? value : 'auto'; +} + +export async function syncSessionModelSelection( + context: FlowChatContext, + sessionId: string, + agentType: string, +): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + + const sessionModelId = session.config.modelName?.trim(); + + // Any stored selector, including "auto", belongs to the session. Still sync + // it to the backend in case the restored runtime session lost that state. + if (sessionModelId) { + const desiredMaxContextTokens = await getModelMaxTokens(sessionModelId, agentType); + if (session.maxContextTokens !== desiredMaxContextTokens) { + context.flowChatStore.updateSessionMaxContextTokens(sessionId, desiredMaxContextTokens); + } + await agentAPI.updateSessionModel({ + sessionId, + modelName: sessionModelId, + workspacePath: sessionProjectWorkspacePath(session), + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + includeInternal: session.sessionKind === 'subagent', + }); + return; + } + + const configData = await configManager.getConfigs([ + 'ai.agent_model_defaults', + 'ai.models', + 'ai.default_models', + ]); + const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; + const allModels = (configData['ai.models'] as AIModelConfig[] | undefined) || []; + const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; + + const desiredModelId = normalizeModelSelection(agentModelDefaults?.mode, allModels, defaultModels); + const shouldForceAutoSync = desiredModelId === 'auto'; + const desiredMaxContextTokens = await getModelMaxTokens(desiredModelId, agentType); + const shouldSyncContextWindow = session.maxContextTokens !== desiredMaxContextTokens; + + context.flowChatStore.updateSessionModelName(sessionId, desiredModelId); + if (shouldSyncContextWindow) { + context.flowChatStore.updateSessionMaxContextTokens(sessionId, desiredMaxContextTokens); + } + await agentAPI.updateSessionModel({ + sessionId, + modelName: desiredModelId, + workspacePath: sessionProjectWorkspacePath(session), + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + includeInternal: session.sessionKind === 'subagent', + }); + + log.info('Session model synchronized before send', { + sessionId, + agentType, + previousModelId: null, + nextModelId: desiredModelId, + forcedAutoSync: shouldForceAutoSync, + }); +} diff --git a/src/web-ui/src/flow_chat/utils/optimisticTurnAdoption.ts b/src/web-ui/src/flow_chat/utils/optimisticTurnAdoption.ts new file mode 100644 index 0000000000..1e742e8ab6 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/optimisticTurnAdoption.ts @@ -0,0 +1,52 @@ +/** + * Optimistic-turn adoption. + * + * A driver that projects a user turn before the executing side confirms it + * marks that turn with an adoption key. When the executor's own + * DialogTurnStarted arrives with a different turn id, the event handler + * adopts the marked turn in place instead of duplicating the message. + * + * The metadata key is wire-compatible with the original dispatch-only + * implementation and must not change: cached transcripts persist it. + */ + +import type { DialogTurn, Session } from '../types/flow-chat'; + +export const OPTIMISTIC_TURN_ADOPTION_KEY = '__bitfunOptimisticDispatchJobId'; + +export function markOptimisticTurnAdoption( + metadata: Record | undefined, + adoptionKey: string, +): Record { + return { + ...metadata, + [OPTIMISTIC_TURN_ADOPTION_KEY]: adoptionKey, + }; +} + +export function optimisticTurnAdoptionKey(turn: DialogTurn): string | undefined { + const value = turn.userMessage.metadata?.[OPTIMISTIC_TURN_ADOPTION_KEY]; + return typeof value === 'string' && value ? value : undefined; +} + +export function stripOptimisticTurnAdoption( + metadata: Record | undefined, +): Record | undefined { + if (!metadata) { + return undefined; + } + const next = { ...metadata }; + delete next[OPTIMISTIC_TURN_ADOPTION_KEY]; + return Object.keys(next).length > 0 ? next : undefined; +} + +/** + * The adoption key a not-yet-confirmed turn of this session would carry. + * Currently only dispatch projections mark turns (their durable job id); any + * future driver that sets an adoption key gets in-place adoption for free. + */ +export function sessionPendingTurnAdoptionKey( + session: Pick, +): string | undefined { + return session.config.dispatchJobId?.trim() || undefined; +} diff --git a/src/web-ui/src/flow_chat/utils/turnCancellation.ts b/src/web-ui/src/flow_chat/utils/turnCancellation.ts new file mode 100644 index 0000000000..a2abf994ed --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/turnCancellation.ts @@ -0,0 +1,46 @@ +/** + * Store choreography for marking a session's in-flight turn as cancelled. + * + * Leaf module shared by the local session driver and flow-chat-manager. + */ + +import type { FlowChatContext } from '../services/flow-chat-manager/types'; + +export function markCurrentTurnItemsAsCancelled( + context: FlowChatContext, + sessionId: string +): void { + const state = context.flowChatStore.getState(); + const session = state.sessions.get(sessionId); + if (!session) return; + + const lastDialogTurn = session.dialogTurns[session.dialogTurns.length - 1]; + if (!lastDialogTurn) return; + + if (lastDialogTurn.status === 'completed' || lastDialogTurn.status === 'cancelled') { + return; + } + + lastDialogTurn.modelRounds.forEach(round => { + round.items.forEach(item => { + if (item.status === 'completed' || item.status === 'cancelled' || item.status === 'error') { + return; + } + + context.flowChatStore.updateModelRoundItem(sessionId, lastDialogTurn.id, item.id, { + status: 'cancelled', + ...(item.type === 'text' && { isStreaming: false }), + ...(item.type === 'tool' && { + isParamsStreaming: false, + endTime: Date.now() + }) + } as any); + }); + }); + + context.flowChatStore.updateDialogTurn(sessionId, lastDialogTurn.id, turn => ({ + ...turn, + status: 'cancelled', + endTime: Date.now() + })); +} diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index 88206c1a75..a016f9a7e2 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -68,6 +68,7 @@ const LOCAL_ONLY_COMMANDS = new Set([ 'dispatch_sync_model_config', 'dispatch_submit', 'dispatch_status', + 'dispatch_query', 'dispatch_cancel', 'dispatch_list_jobs', 'dispatch_answer',