Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
RemoteWorkspacePolicy::RemoteUnsupported,
),
("worktree_list", RemoteWorkspacePolicy::RemoteUnsupported),
("worktree_list_projects", RemoteWorkspacePolicy::LocalOnly),
("worktree_promote", RemoteWorkspacePolicy::RemoteUnsupported),
(
"worktree_recreate",
Expand Down
13 changes: 10 additions & 3 deletions src/apps/desktop/src/api/worktree_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
use bitfun_core::service::remote_ssh::lookup_remote_connection;
use bitfun_core::service::worktree::{
WorktreeCreateBranchRequest, WorktreeCreateRequest, WorktreeCreateResult, WorktreeListRequest,
WorktreeMutationResult, WorktreePromoteRequest, WorktreeRecreateRequest, WorktreeRemoveRequest,
WorktreeRemoveResult, WorktreeService, WorktreeSessionBindingRequest,
WorktreeSessionBindingResult,
WorktreeMutationResult, WorktreeProjectListRequest, WorktreeProjectSummary,
WorktreePromoteRequest, WorktreeRecreateRequest, WorktreeRemoveRequest, WorktreeRemoveResult,
WorktreeService, WorktreeSessionBindingRequest, WorktreeSessionBindingResult,
};
use bitfun_core_types::{WorktreeError, WorktreeErrorCode, WorktreeSummary};

Expand Down Expand Up @@ -36,6 +36,13 @@ pub async fn worktree_list(
WorktreeService::list(request).await
}

#[tauri::command]
pub async fn worktree_list_projects(
request: WorktreeProjectListRequest,
) -> Result<Vec<WorktreeProjectSummary>, WorktreeError> {
WorktreeService::list_projects(request).await
}

#[tauri::command]
pub async fn worktree_create(
request: WorktreeCreateRequest,
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,7 @@ pub async fn run() {
git_add_worktree,
git_remove_worktree,
api::worktree_api::worktree_list,
api::worktree_api::worktree_list_projects,
api::worktree_api::worktree_create,
api::worktree_api::worktree_create_branch,
api::worktree_api::worktree_promote,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ use bitfun_agent_runtime::prompt::{
render_workspace_context, PrependedPromptReminders, ProjectLayoutFacts, PromptRelatedPath,
RemoteExecutionHints, RuntimeContextFacts, RuntimeContextNeeds, RuntimeShellFacts,
ToolListingSections, UserContextPolicy, UserContextSection, WorkspaceContextFacts,
WorktreeContextFacts,
};
use bitfun_agent_runtime::remote_file_delivery::user_workspace_relative_file_link;
use bitfun_core_types::SessionExecutionTargetKind;
use log::{debug, info, warn};
use std::path::Path;

Expand All @@ -44,6 +46,8 @@ pub struct PromptBuilderContext {
pub model_name: Option<String>,
/// When set, file/shell tools target this remote environment; OS and path instructions follow it.
pub remote_execution: Option<RemoteExecutionHints>,
/// Explicit worktree identity and owning-project facts shown to the agent.
pub worktree: Option<WorktreeContextFacts>,
/// Pre-built tree text for `{PROJECT_LAYOUT}` when the workspace is not on the local disk.
pub remote_project_layout: Option<String>,
/// When `Some(false)`, runtime context includes Computer use text-only guidance (no screenshot tool output).
Expand Down Expand Up @@ -74,6 +78,7 @@ impl PromptBuilderContext {
session_id,
model_name,
remote_execution: None,
worktree: None,
remote_project_layout: None,
supports_image_understanding: None,
tool_listing_sections: ToolListingSections::default(),
Expand Down Expand Up @@ -115,6 +120,11 @@ impl PromptBuilderContext {
self
}

pub fn with_worktree_context(mut self, worktree: WorktreeContextFacts) -> Self {
self.worktree = Some(worktree);
self
}

pub fn with_remote_file_delivery_channel(mut self, enabled: bool) -> Self {
self.remote_file_delivery_channel = enabled;
self
Expand Down Expand Up @@ -165,6 +175,16 @@ pub async fn build_prompt_context_for_workspace(
.with_related_paths(related_paths)
.with_tool_listing_sections(tool_listing_sections)
.with_runtime_context_needs(runtime_context_needs);
if let Some(execution_target) = workspace
.execution_target
.as_ref()
.filter(|target| target.kind != SessionExecutionTargetKind::Local)
{
base = base.with_worktree_context(WorktreeContextFacts {
project_workspace_path: workspace.project_root_path_string(),
execution_target: execution_target.clone(),
});
}
if let Some(supports_image_understanding) = supports_image_understanding {
base = base.with_supports_image_understanding(supports_image_understanding);
}
Expand Down Expand Up @@ -282,6 +302,7 @@ impl PromptBuilder {
})
.collect(),
remote_execution: self.context.remote_execution.clone(),
worktree: self.context.worktree.clone(),
})
}

Expand Down Expand Up @@ -620,13 +641,19 @@ async fn memory_summary_enabled() -> bool {

#[cfg(test)]
mod tests {
use super::build_prompt_context_for_workspace;
use super::PromptBuilder;
use super::PromptBuilderContext;
use super::RemoteExecutionHints;
use super::RuntimeContextNeeds;
use super::ToolListingSections;
use crate::agentic::agents::UserContextPolicy;
use crate::agentic::WorkspaceBinding;
use crate::service::workspace::RelatedPath;
use bitfun_core_types::{
SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle,
};
use std::path::PathBuf;

#[tokio::test]
async fn builds_ordered_prepended_reminders_from_tool_listings_and_user_context() {
Expand Down Expand Up @@ -1057,4 +1084,43 @@ mod tests {
assert!(workspace_context.contains(" - monorepo/packages/payments"));
assert!(!workspace_context.contains("payments —"));
}

#[tokio::test]
async fn workspace_context_identifies_the_managed_worktree_binding() {
let execution_target = SessionExecutionTarget {
kind: SessionExecutionTargetKind::ManagedWorktree,
worktree_id: Some("wt-1".to_string()),
root_path: "/managed/BitFun-wt-1".to_string(),
base_ref: Some("HEAD".to_string()),
base_commit: Some("0123456789abcdef".to_string()),
branch: None,
lifecycle: Some(WorktreeLifecycle::Managed),
};
let workspace = WorkspaceBinding::new(
Some("workspace-1".to_string()),
PathBuf::from("/managed/BitFun-wt-1"),
)
.with_project_root_path(PathBuf::from("/projects/BitFun"))
.with_execution_target(Some(execution_target));
let context = build_prompt_context_for_workspace(
&workspace,
None,
"session-1",
Some("primary".to_string()),
None,
ToolListingSections::default(),
RuntimeContextNeeds::default(),
)
.await
.expect("prompt context should build");

let workspace_context = PromptBuilder::new(context).get_workspace_context();

assert!(workspace_context.contains("Managed Git worktree created for this session"));
assert!(workspace_context.contains("Owning project root"));
assert!(workspace_context.contains("/projects/BitFun"));
assert!(workspace_context.contains("Worktree ID: wt-1"));
assert!(workspace_context.contains("Worktree checkout: detached HEAD"));
assert!(workspace_context.contains("Worktree base commit: 0123456789abcdef"));
}
}
111 changes: 110 additions & 1 deletion src/crates/assembly/core/src/agentic/coordination/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,29 @@ async fn normalize_model_selection(model_id: &str) -> BitFunResult<String> {
}
}

fn inherit_matching_parent_workspace_binding(
parent_config: &SessionConfig,
child_config: &mut SessionConfig,
) {
let Some(parent_workspace_path) = parent_config.workspace_path.as_deref() else {
return;
};
let Some(child_workspace_path) = child_config.workspace_path.as_deref() else {
return;
};
if comparable_workspace_path(parent_workspace_path)
!= comparable_workspace_path(child_workspace_path)
{
return;
}

child_config.project_workspace_path = parent_config.project_workspace_path.clone();
child_config.execution_target = parent_config.execution_target.clone();
child_config.workspace_id = parent_config.workspace_id.clone();
child_config.remote_connection_id = parent_config.remote_connection_id.clone();
child_config.remote_ssh_host = parent_config.remote_ssh_host.clone();
}

fn resolve_subagent_model_selection(
explicit_model_id: Option<&str>,
configured_selection: &SubagentModelSelection,
Expand Down Expand Up @@ -7369,7 +7392,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
"session-{}",
request.subagent_parent_info.session_id
));
self.session_manager
let parent_session = self
.session_manager
.get_session(&request.subagent_parent_info.session_id)
.ok_or_else(|| {
BitFunError::NotFound(format!(
Expand Down Expand Up @@ -7522,6 +7546,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
Some(resolved_model_id),
)
.await;
inherit_matching_parent_workspace_binding(
&parent_session.config,
&mut session_config,
);
session_config.continuation_policy = request.continuation_policy;
session_config.model_binding_policy = request.model_binding_policy;
session_config.model_binding_fingerprint = approved_model_binding
Expand Down Expand Up @@ -9632,6 +9660,9 @@ mod tests {
use crate::service::session::{SessionMetadata, SessionStatus};
use crate::service::workspace::WorkspaceKind;
use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY;
use bitfun_core_types::{
SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle,
};
use bitfun_runtime_ports::{
AgentSessionArchiveRequest, AgentSessionCreateRequest, AgentSessionManagementPort,
AgentSessionRenameRequest, AgentSubmissionPort, AgentSubmissionRequest,
Expand Down Expand Up @@ -11949,6 +11980,84 @@ mod tests {
assert_eq!(model_id, "primary");
}

#[tokio::test]
async fn fresh_subagent_inherits_matching_parent_worktree_binding() {
let (coordinator, session_manager) = test_coordinator();
let temp_root = tempfile::tempdir().expect("temp root should exist");
let project_path = temp_root.path().join("BitFun");
let worktree_path = temp_root.path().join("managed-worktree");
std::fs::create_dir_all(&project_path).expect("project dir should exist");
std::fs::create_dir_all(&worktree_path).expect("worktree dir should exist");
let project_workspace_path = project_path.to_string_lossy().into_owned();
let workspace_path = worktree_path.to_string_lossy().into_owned();
let execution_target = SessionExecutionTarget {
kind: SessionExecutionTargetKind::ManagedWorktree,
worktree_id: Some("worktree-1".to_string()),
root_path: workspace_path.clone(),
base_ref: Some("HEAD".to_string()),
base_commit: Some("0123456789abcdef".to_string()),
branch: None,
lifecycle: Some(WorktreeLifecycle::Managed),
};
let parent_session = session_manager
.create_session(
"Parent".to_string(),
"agentic".to_string(),
SessionConfig {
model_id: Some("primary".to_string()),
workspace_path: Some(workspace_path.clone()),
project_workspace_path: Some(project_workspace_path.clone()),
execution_target: Some(execution_target.clone()),
workspace_id: Some("workspace-1".to_string()),
..Default::default()
},
)
.await
.expect("parent session should be created");

let resolved = coordinator
.resolve_hidden_subagent_execution_request(SubagentExecutionRequest {
task_description: "Inspect the managed worktree".to_string(),
context_mode: SubagentContextMode::Fresh,
target_session_id: None,
subagent_type: Some("Explore".to_string()),
logical_subagent_type: None,
continuation_policy: SessionContinuationPolicy::Reusable,
model_binding_policy: SessionModelBindingPolicy::Mutable,
workspace_path: Some(workspace_path.clone()),
model_id: Some("primary".to_string()),
inherit_parent_model: false,
subagent_parent_info: SubagentParentInfo {
session_id: parent_session.session_id,
dialog_turn_id: "parent-turn".to_string(),
tool_call_id: "task-tool".to_string(),
},
context: HashMap::new(),
permission_runtime_ceiling: PermissionRuntimeCeiling::default(),
delegation_policy: DelegationPolicy::top_level().spawn_child(),
external_generation_lease: None,
})
.await
.expect("fresh subagent request should resolve");

assert_eq!(
resolved.session_config.workspace_path.as_deref(),
Some(workspace_path.as_str())
);
assert_eq!(
resolved.session_config.project_workspace_path.as_deref(),
Some(project_workspace_path.as_str())
);
assert_eq!(
resolved.session_config.execution_target.as_ref(),
Some(&execution_target)
);
assert_eq!(
resolved.session_config.workspace_id.as_deref(),
Some("workspace-1")
);
}

#[tokio::test]
async fn fresh_subagent_inherits_transient_parent_persistence_boundary() {
let (coordinator, session_manager) = test_coordinator();
Expand Down
Loading