diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs b/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs index 73ad73050a..e228ddc373 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs @@ -95,7 +95,9 @@ impl Agent for CustomMode { } fn default_tools(&self) -> Vec { - self.data.tools.clone() + let mut tools = self.data.tools.clone(); + bitfun_agent_runtime::thread_goal_tools::ensure_thread_goal_tools(&mut tools); + tools } fn user_context_policy(&self) -> UserContextPolicy { diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index eb4135dc44..1a7cb1bef3 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -32,6 +32,9 @@ impl ClawMode { "Glob".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), "Skill".to_string(), "Git".to_string(), "SessionControl".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs index 4b1374e230..0011473285 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs @@ -25,6 +25,9 @@ impl DeepResearchMode { "AgentWait".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), "Read".to_string(), "view_image".to_string(), "analyze_image".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 3e3d4519cd..78216eef6b 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -37,6 +37,9 @@ impl TeamMode { "Glob".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), "TodoWrite".to_string(), "AskUserQuestion".to_string(), "Git".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index a9210cafc9..5cedaa8282 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -654,7 +654,10 @@ fn external_agent_info( projection: ExternalAgentProjection, ) -> AgentInfo { let agent = entry.registration.agent.as_ref(); - let default_tools = agent.default_tools(); + let mut default_tools = agent.default_tools(); + if matches!(projection, ExternalAgentProjection::Primary) { + bitfun_agent_runtime::thread_goal_tools::ensure_thread_goal_tools(&mut default_tools); + } AgentInfo { key: format!( "external::{}::{}", diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index dfa63a70dc..462445ae79 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -9,7 +9,9 @@ use crate::agentic::agents::registry::types::{ use crate::agentic::agents::registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, }; -use crate::agentic::agents::{resolve_mode_config_profile_id, Agent, UserContextPolicy}; +use crate::agentic::agents::{ + builtin_agent_specs, resolve_mode_config_profile_id, Agent, UserContextPolicy, +}; use crate::agentic::workspace::session_execution_workspace_root; use crate::service::config::types::AgentSubagentOverrideState; use async_trait::async_trait; @@ -19,6 +21,7 @@ use bitfun_agent_runtime::custom_agent::{ }; use bitfun_agent_runtime::sdk::{RuntimeAgentRegistry, RuntimeAgentRegistryQuery}; use bitfun_agent_runtime::session::SessionConfig; +use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES; use bitfun_product_domains::external_sources::EcosystemId; use bitfun_product_domains::external_subagents::ExternalSubagentMode; use std::collections::{BTreeMap, HashMap}; @@ -318,6 +321,25 @@ async fn computer_use_is_builtin_subagent_not_mode() { ); } +#[test] +fn every_builtin_primary_mode_defaults_to_the_thread_goal_lifecycle() { + for spec in builtin_agent_specs() + .iter() + .filter(|spec| spec.category == AgentCategory::Mode) + { + let mode = (spec.factory)(); + let default_tools = mode.default_tools(); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!( + default_tools.iter().any(|tool| tool == tool_name), + "builtin primary mode {} is missing {}", + mode.id(), + tool_name + ); + } + } +} + #[test] fn non_deep_review_builtin_subagents_default_to_primary() { for agent_type in [ @@ -803,10 +825,11 @@ async fn explicit_custom_mode_load_exposes_user_mode_metadata_in_modes_info() { assert_eq!(mode.source, AgentSource::User); assert_eq!(mode.path, Some(mode_path.to_string_lossy().to_string())); assert_eq!(mode.model, Some("primary".to_string())); - assert_eq!( - mode.default_tools, - vec!["Read".to_string(), "Grep".to_string()] - ); + assert!(mode.default_tools.contains(&"Read".to_string())); + assert!(mode.default_tools.contains(&"Grep".to_string())); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(mode.default_tools.iter().any(|tool| tool == tool_name)); + } assert!(mode.is_readonly); } @@ -1443,11 +1466,15 @@ async fn external_agent_role_controls_main_and_task_projection() { )], route("external::primary"), ); - assert!(registry + let primary = registry .get_modes_info_for_workspace(Some(&workspace), true) .await - .iter() - .any(|agent| agent.id == logical_id)); + .into_iter() + .find(|agent| agent.id == logical_id) + .expect("external primary projection should be visible"); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(primary.default_tools.iter().any(|tool| tool == tool_name)); + } assert!(!registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("agentic"), @@ -1473,7 +1500,7 @@ async fn external_agent_role_controls_main_and_task_projection() { .await .iter() .any(|agent| agent.id == logical_id)); - assert!(registry + let subagent = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("agentic"), workspace_root: Some(&workspace), @@ -1482,8 +1509,12 @@ async fn external_agent_role_controls_main_and_task_projection() { external_sources_supported: true, }) .await - .iter() - .any(|agent| agent.id == logical_id)); + .into_iter() + .find(|agent| agent.id == logical_id) + .expect("external subagent projection should be visible"); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(!subagent.default_tools.iter().any(|tool| tool == tool_name)); + } } #[test] diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 0ce48d0e5c..75b728cdfc 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -58,6 +58,7 @@ use crate::util::types::ToolDefinition; use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; use bitfun_agent_runtime::output_surface::TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY; use bitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; +use bitfun_agent_runtime::thread_goal_tools::ensure_thread_goal_tools; use bitfun_ai_adapters::ModelExchangeTraceConfig; use bitfun_core_types::SessionModelBindingPolicy; use log::{debug, error, info, trace, warn}; @@ -69,6 +70,12 @@ use std::sync::Arc; use tokio_util::sync::CancellationToken; use tool_runtime::context::PrimaryModelFacts; +fn ensure_primary_session_goal_tools(allowed_tools: &mut Vec, is_subagent: bool) { + if !is_subagent { + ensure_thread_goal_tools(allowed_tools); + } +} + /// Execution engine configuration #[derive(Debug, Clone)] pub struct ExecutionEngineConfig { @@ -2257,7 +2264,11 @@ impl ExecutionEngine { .map(|workspace| workspace.root_path()), ) .await; - let allowed_tools = tool_policy.allowed_tools.clone(); + let mut allowed_tools = tool_policy.allowed_tools.clone(); + ensure_primary_session_goal_tools( + &mut allowed_tools, + context.subagent_parent_info.is_some(), + ); let enable_tools = context .context .get("enable_tools") @@ -3182,7 +3193,11 @@ impl ExecutionEngine { .map(|workspace| workspace.root_path()), ) .await; - let allowed_tools = tool_policy.allowed_tools.clone(); + let mut allowed_tools = tool_policy.allowed_tools.clone(); + ensure_primary_session_goal_tools( + &mut allowed_tools, + context.subagent_parent_info.is_some(), + ); let enable_tools = context .context .get("enable_tools") @@ -4621,8 +4636,9 @@ impl ExecutionEngine { #[cfg(test)] mod tests { use super::{ - activate_conditional_instructions_after_round, manual_compaction_terminal_error, - ContextHealthSnapshot, ExecutionEngine, RoundResult, TurnPromptScaffold, + activate_conditional_instructions_after_round, ensure_primary_session_goal_tools, + manual_compaction_terminal_error, ContextHealthSnapshot, ExecutionEngine, RoundResult, + TurnPromptScaffold, }; use crate::agentic::agents::{ PrependedPromptReminders, PromptBuilderContext, UserContextPolicy, @@ -4642,6 +4658,7 @@ mod tests { use crate::service::config::types::AIModelConfig; use crate::service::remote_ssh::workspace_state::workspace_session_identity; use crate::util::types::ToolDefinition; + use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES; use bitfun_runtime_ports::{WorkspaceDirEntry, WorkspaceFileSystem, WorkspacePathKind}; use serde_json::json; use sha2::{Digest, Sha256}; @@ -4651,6 +4668,19 @@ mod tests { use std::sync::Arc; use std::time::Duration; + #[test] + fn primary_session_tool_policy_restores_goal_tools_but_subagents_stay_scoped() { + let mut primary_tools = vec!["Read".to_string()]; + ensure_primary_session_goal_tools(&mut primary_tools, false); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(primary_tools.iter().any(|tool| tool == tool_name)); + } + + let mut subagent_tools = vec!["Read".to_string()]; + ensure_primary_session_goal_tools(&mut subagent_tools, true); + assert_eq!(subagent_tools, vec!["Read".to_string()]); + } + #[test] fn manual_compaction_preserves_cancellation_as_a_terminal_cancellation() { let error = manual_compaction_terminal_error(crate::BitFunError::Cancelled( diff --git a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs index 0a85c55927..5498251c42 100644 --- a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs +++ b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs @@ -13,6 +13,7 @@ use crate::service::config::types::{ }; use crate::util::errors::*; use bitfun_agent_runtime::skills::normalize_user_mode_skill_overrides; +use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES; use bitfun_runtime_ports::PermissionRule; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -88,13 +89,13 @@ pub fn resolve_effective_tools( mode_config: Option<&AgentProfileConfig>, valid_tools: &HashSet, ) -> Vec { - let Some(config) = mode_config else { - return normalize_tools(default_tools.to_vec(), valid_tools); - }; - let default_tools = normalize_tools(default_tools.to_vec(), valid_tools); - let removed: HashSet = config.removed_tools.iter().cloned().collect(); - let added = normalize_tools(config.added_tools.clone(), valid_tools); + let removed: HashSet = mode_config + .map(|config| config.removed_tools.iter().cloned().collect()) + .unwrap_or_default(); + let added = mode_config + .map(|config| normalize_tools(config.added_tools.clone(), valid_tools)) + .unwrap_or_default(); let mut effective = Vec::new(); let mut seen = HashSet::new(); @@ -114,6 +115,16 @@ pub fn resolve_effective_tools( } } + // Thread goals are a main-session lifecycle capability, not an optional + // mode specialization. The UI and backend can activate a goal without a + // model tool call, so allowing a profile override to remove update_goal + // would strand the active goal in the automatic continuation loop. + for tool_name in THREAD_GOAL_TOOL_NAMES { + if valid_tools.contains(tool_name) && seen.insert(tool_name.to_string()) { + effective.push(tool_name.to_string()); + } + } + effective } @@ -195,6 +206,7 @@ fn stored_agent_profile_from_overrides( added_tools.retain(|tool| !default_set.contains(tool)); removed_tools.retain(|tool| default_set.contains(tool)); + removed_tools.retain(|tool| !THREAD_GOAL_TOOL_NAMES.contains(&tool.as_str())); let removed_set: HashSet = removed_tools.iter().cloned().collect(); added_tools.retain(|tool| !removed_set.contains(tool)); @@ -578,14 +590,55 @@ pub fn agent_profile_member_mode_ids_for(agent_id: &str) -> Vec { mod tests { use super::{ agent_profile_member_mode_ids_for, canonicalize_agent_profile, - normalize_skill_override_lists, stored_agent_profile_from_overrides, - StoredAgentProfileOverrides, + normalize_skill_override_lists, resolve_effective_tools, + stored_agent_profile_from_overrides, StoredAgentProfileOverrides, }; - use crate::service::config::types::AgentSubagentOverrideState; + use crate::service::config::types::{AgentProfileConfig, AgentSubagentOverrideState}; + use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES; use bitfun_runtime_ports::{PermissionEffect, PermissionRule}; use serde_json::Value; use std::collections::HashSet; + #[test] + fn mode_profiles_cannot_remove_required_thread_goal_tools() { + let default_tools = vec![ + "Read".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), + ]; + let valid_tools = default_tools.iter().cloned().collect(); + let stored = stored_agent_profile_from_overrides(StoredAgentProfileOverrides { + agent_id: "Claw", + added_tools: Vec::new(), + removed_tools: default_tools.clone(), + disabled_user_skills: Vec::new(), + enabled_user_skills: Vec::new(), + subagent_overrides: Default::default(), + tool_permission_rules: Vec::new(), + default_tools: &default_tools, + valid_tools: &valid_tools, + }) + .expect("the ordinary Read removal should keep the profile"); + + assert_eq!(stored.removed_tools, vec!["Read".to_string()]); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(!stored.removed_tools.iter().any(|tool| tool == tool_name)); + } + + let legacy_config = AgentProfileConfig { + profile_id: "Claw".to_string(), + removed_tools: default_tools.clone(), + ..AgentProfileConfig::default() + }; + let effective_tools = + resolve_effective_tools(&default_tools, Some(&legacy_config), &valid_tools); + assert!(!effective_tools.contains(&"Read".to_string())); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(effective_tools.iter().any(|tool| tool == tool_name)); + } + } + #[test] fn normalize_skill_override_lists_removes_duplicates_and_conflicts() { let (disabled, enabled) = normalize_skill_override_lists( diff --git a/src/crates/execution/agent-runtime/src/custom_agent.rs b/src/crates/execution/agent-runtime/src/custom_agent.rs index c0f6cf412c..6b6c0af6bd 100644 --- a/src/crates/execution/agent-runtime/src/custom_agent.rs +++ b/src/crates/execution/agent-runtime/src/custom_agent.rs @@ -22,6 +22,9 @@ pub const DEFAULT_CUSTOM_MODE_TOOLS: &[&str] = &[ "Skill", "WebSearch", "WebFetch", + "get_goal", + "create_goal", + "update_goal", ]; pub const DEFAULT_CUSTOM_SUBAGENT_TOOLS: &[&str] = &["LS", "Read", "Glob", "Grep"]; pub const DEFAULT_CUSTOM_MODE_READONLY: bool = false; @@ -783,8 +786,18 @@ fn custom_agent_markdown_metadata(definition: &CustomAgentDefinition) -> Value { #[cfg(test)] mod tests { use super::*; + use crate::thread_goal_tools::THREAD_GOAL_TOOL_NAMES; use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn custom_mode_defaults_include_the_thread_goal_lifecycle() { + let tools = default_custom_agent_tools(CustomAgentKind::Mode); + + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(tools.iter().any(|tool| tool == tool_name)); + } + } + #[test] fn custom_agent_user_context_policy_round_trips_memory_summary() { let definition = CustomAgentDefinition { diff --git a/src/crates/execution/agent-runtime/src/thread_goal_tools.rs b/src/crates/execution/agent-runtime/src/thread_goal_tools.rs index c30463d469..f755499f86 100644 --- a/src/crates/execution/agent-runtime/src/thread_goal_tools.rs +++ b/src/crates/execution/agent-runtime/src/thread_goal_tools.rs @@ -9,6 +9,24 @@ use std::fmt; pub const GET_GOAL_TOOL_NAME: &str = "get_goal"; pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal"; pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal"; +pub const THREAD_GOAL_TOOL_NAMES: [&str; 3] = [ + GET_GOAL_TOOL_NAME, + CREATE_GOAL_TOOL_NAME, + UPDATE_GOAL_TOOL_NAME, +]; + +/// Ensure a primary-session tool list exposes the complete thread-goal lifecycle. +/// +/// Goal state can be activated outside the model tool surface (for example by +/// the composer UI), so exposing only part of this bundle can leave an active +/// goal with no way for the model to inspect or finish it. +pub fn ensure_thread_goal_tools(tools: &mut Vec) { + for tool_name in THREAD_GOAL_TOOL_NAMES { + if !tools.iter().any(|tool| tool == tool_name) { + tools.push(tool_name.to_string()); + } + } +} #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -87,3 +105,26 @@ pub fn build_goal_tool_result( result_for_assistant, }) } + +#[cfg(test)] +mod tests { + use super::{ensure_thread_goal_tools, THREAD_GOAL_TOOL_NAMES}; + + #[test] + fn ensure_thread_goal_tools_adds_the_complete_bundle_without_duplicates() { + let mut tools = vec!["Read".to_string(), "get_goal".to_string()]; + + ensure_thread_goal_tools(&mut tools); + ensure_thread_goal_tools(&mut tools); + + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert_eq!( + tools + .iter() + .filter(|tool| tool.as_str() == tool_name) + .count(), + 1 + ); + } + } +} diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 8823d0316a..3b6fe87551 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -577,6 +577,7 @@ export const ChatInput: React.FC = ({ const { entries: acpPlanEntries } = useAcpPlan(acpSessionForInput?.sessionId ?? null); const threadGoalController = useThreadGoalController(effectiveTargetSession, { isBtwSession, + disabled: !caps.threadGoal, }); const currentSessionTitle = currentSession?.title?.trim() || t('session.untitled'); const activeBtwSession = activeBtwSessionId diff --git a/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts b/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts index 7a964fe1b9..7de061f39b 100644 --- a/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts +++ b/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts @@ -105,11 +105,12 @@ function useStableThreadGoalSnapshot(sessionId: string | undefined): ThreadGoalS export function useThreadGoalController( session: Session | undefined, - options?: { isBtwSession?: boolean } + options?: { isBtwSession?: boolean; disabled?: boolean } ): ThreadGoalController { const { t } = useTranslation('flow-chat'); const sessionId = session?.sessionId; const isBtwSession = Boolean(options?.isBtwSession); + const disabled = isBtwSession || Boolean(options?.disabled); const storeGoal = useStableThreadGoalSnapshot(sessionId); @@ -139,7 +140,7 @@ export function useThreadGoalController( ); const refreshGoal = useCallback(async () => { - if (!sessionId || isBtwSession) return; + if (!sessionId || disabled) return; const current = flowChatStore.getState().sessions.get(sessionId); if (!current?.workspacePath) return; try { @@ -147,10 +148,10 @@ export function useThreadGoalController( } catch { // best-effort; UI still works from events } - }, [isBtwSession, sessionId]); + }, [disabled, sessionId]); useEffect(() => { - if (!sessionId || isBtwSession) return; + if (!sessionId || disabled) return; if (session?.isHistorical) { const timeoutId = globalThis.setTimeout(() => { void refreshGoal(); @@ -158,14 +159,20 @@ export function useThreadGoalController( return () => globalThis.clearTimeout(timeoutId); } void refreshGoal(); - }, [session?.isHistorical, sessionId, isBtwSession, refreshGoal]); + }, [session?.isHistorical, sessionId, disabled, refreshGoal]); const goalId = goal?.goalId; const goalStatus = goal?.status; const goalUpdatedAt = goal?.updatedAt; useEffect(() => { - if (!sessionId || !goalId || !goalStatus || !threadGoalStatusNeedsResumePrompt(goalStatus)) { + if ( + disabled + || !sessionId + || !goalId + || !goalStatus + || !threadGoalStatusNeedsResumePrompt(goalStatus) + ) { return; } if (!goal || isResumePromptDismissed(sessionId, goal)) { @@ -177,7 +184,7 @@ export function useThreadGoalController( } lastResumePromptKey.current = key; setResumeOpen(true); - }, [goal, goalId, goalStatus, goalUpdatedAt, sessionId]); + }, [disabled, goal, goalId, goalStatus, goalUpdatedAt, sessionId]); const openMenu = useCallback(() => { setMenuOpen(true); @@ -206,18 +213,18 @@ export function useThreadGoalController( ); const openGoalEntry = useCallback(async () => { - if (!session?.workspacePath || isBtwSession) return; + if (!session?.workspacePath || disabled) return; const latest = await fetchSessionThreadGoal(session); if (latest) { setMenuOpen(true); } else { openEdit('create'); } - }, [isBtwSession, openEdit, session]); + }, [disabled, openEdit, session]); const runSlashAction = useCallback( async (message: string) => { - if (!session) return null; + if (!session || disabled) return null; const parsed = parseGoalCommand(message); if (!parsed) return null; @@ -240,12 +247,12 @@ export function useThreadGoalController( }, }); }, - [confirmReplaceGoal, openEdit, session, titles] + [confirmReplaceGoal, disabled, openEdit, session, titles] ); const runUiAction = useCallback( async (action: 'clear' | 'pause' | 'resume') => { - if (!session) return; + if (!session || disabled) return; try { await runThreadGoalUiAction(session, action, titles); if (action === 'clear') { @@ -259,12 +266,12 @@ export function useThreadGoalController( notificationService.error(message, { title: titles.failedTitle, duration: 5000 }); } }, - [session, titles] + [disabled, session, titles] ); const saveEdit = useCallback( async (objective: string) => { - if (!session) return; + if (!session || disabled) return; try { const saved = await saveThreadGoalObjective(session, objective, editMode, titles, { confirmReplaceGoal: editMode === 'create' ? confirmReplaceGoal : undefined, @@ -282,7 +289,7 @@ export function useThreadGoalController( notificationService.error(message, { title: titles.failedTitle, duration: 5000 }); } }, - [confirmReplaceGoal, editMode, session, titles] + [confirmReplaceGoal, disabled, editMode, session, titles] ); const confirmResume = useCallback(async () => { diff --git a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.test.ts b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.test.ts new file mode 100644 index 0000000000..fcc13d908c --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { sessionSupportsThreadGoal } from './useComposerCapabilities'; + +describe('sessionSupportsThreadGoal', () => { + it('supports BitFun runtime primary agents, including Claw', () => { + expect(sessionSupportsThreadGoal({ config: { agentType: 'Claw' }, mode: 'Claw' })).toBe(true); + }); + + it('does not advertise BitFun thread goals for ACP-owned agents', () => { + expect( + sessionSupportsThreadGoal({ config: { agentType: 'acp:codex' }, mode: 'acp:codex' }), + ).toBe(false); + expect(sessionSupportsThreadGoal({ config: {}, mode: 'acp:claude-code' })).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts index 1ca0c893b9..feb398eb57 100644 --- a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts +++ b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts @@ -12,6 +12,7 @@ import { useRuntimeStatusStore } from '../store/runtimeStatusStore'; import type { Session } from '../types/flow-chat'; +import { isAcpFlowSession } from '../utils/acpSession'; import { resolveSessionDriverId, type SessionDriverId } from './resolve'; export const DISPATCH_TRANSFER_ROUND_PREFIX = 'dispatch-transfer:'; @@ -22,6 +23,9 @@ export type ComposerSlashOp = 'btw' | 'compact' | 'goal' | 'usage' | 'init' | 'r const LOCAL_SLASH_OPS: ReadonlySet = new Set([ 'btw', 'compact', 'goal', 'usage', 'init', 'review', ]); +const LOCAL_SLASH_OPS_WITHOUT_THREAD_GOAL: ReadonlySet = new Set([ + 'btw', 'compact', 'usage', 'init', 'review', +]); /** Ops a detached target serves via its durable turn mailbox / query verb. */ const DISPATCH_SLASH_OPS: ReadonlySet = new Set(['compact', 'usage']); @@ -59,10 +63,20 @@ export interface ComposerCapabilityInput { displayAsChild: boolean; } +export function sessionSupportsThreadGoal( + session: Pick | undefined, +): boolean { + // ACP agents own their execution loop and tool surface. Until the protocol + // exposes the BitFun thread-goal lifecycle, advertising the local goal UI + // would create state the external agent cannot inspect or complete. + return !isAcpFlowSession(session); +} + export function useComposerCapabilities(input: ComposerCapabilityInput): ComposerCapabilities { const { sessionId, session, hostMasksDispatch, displayAsChild } = input; const driverId = resolveSessionDriverId(sessionId ?? '', session); const dispatchTransport = !hostMasksDispatch && driverId === 'dispatch'; + const threadGoalSupported = sessionSupportsThreadGoal(session); const transferInFlight = useRuntimeStatusStore(state => { const status = sessionId ? state.bySessionId.get(sessionId) : undefined; @@ -85,9 +99,13 @@ export function useComposerCapabilities(input: ComposerCapabilityInput): Compose driverId, dispatchTransport, localSlashCommands: !dispatchTransport, - ops: dispatchTransport ? DISPATCH_SLASH_OPS : LOCAL_SLASH_OPS, + ops: dispatchTransport + ? DISPATCH_SLASH_OPS + : threadGoalSupported + ? LOCAL_SLASH_OPS + : LOCAL_SLASH_OPS_WITHOUT_THREAD_GOAL, usageReport: true, - threadGoal: !displayAsChild && !dispatchTransport, + threadGoal: threadGoalSupported && !displayAsChild && !dispatchTransport, transferInFlight, submissionOptionsLocked, sessionScopedApproval: dispatchTransport,