From 47b553df623496628a3e521395ec7177d02dba2d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 12 Aug 2026 16:04:51 +0300 Subject: [PATCH 01/15] fix(codex): retain worktree context across turns Co-authored-by: Medulla --- src/sdk/src/daemon/mappers/mod.rs | 3 +- src/sdk/src/daemon/mappers/workspace/mod.rs | 11 +++- .../daemon/providers/codex_server/README.md | 2 +- .../providers/codex_server/execution.rs | 17 ++++- .../src/daemon/providers/codex_server/fold.rs | 66 +++++++++++++++++-- .../daemon/providers/codex_server/tests.rs | 52 ++++++++++++++- src/sdk/src/daemon/providers/execute.rs | 24 +++++++ src/sdk/src/daemon/providers/tests.rs | 34 ++++++++++ src/sdk/tests/e2e_codex_app_server.rs | 20 ++++++ 9 files changed, 218 insertions(+), 11 deletions(-) diff --git a/src/sdk/src/daemon/mappers/mod.rs b/src/sdk/src/daemon/mappers/mod.rs index 336b28781..51e11e4bb 100644 --- a/src/sdk/src/daemon/mappers/mod.rs +++ b/src/sdk/src/daemon/mappers/mod.rs @@ -33,7 +33,8 @@ mod usage; mod work; mod workspace; pub(crate) use workspace::{ - pull_request_command, workspace_event_from_output, PendingPullRequestCall, + pull_request_command, workspace_event_from_output, worktree_checkout_from_output, + PendingPullRequestCall, }; #[cfg(test)] diff --git a/src/sdk/src/daemon/mappers/workspace/mod.rs b/src/sdk/src/daemon/mappers/workspace/mod.rs index b893e9a9f..319323b67 100644 --- a/src/sdk/src/daemon/mappers/workspace/mod.rs +++ b/src/sdk/src/daemon/mappers/workspace/mod.rs @@ -35,7 +35,7 @@ pub(crate) fn workspace_event_from_output( ts: i64, record_type: &str, ) -> Option { - let checkout = json_report(output).or_else(|| text_report(output)); + let checkout = worktree_checkout_from_output(output); let pull_request = pull_request_command.and_then(|command| match command { PullRequestCommand::Create => pull_request_url(output), PullRequestCommand::View => pull_request_url_from_json(output), @@ -68,6 +68,15 @@ pub(crate) fn workspace_event_from_output( )) } +/// Read a stable worktree-helper report from command output. +/// +/// Shared with transports that do not use the JSONL mapper (notably Codex +/// app-server), so every Codex execution path applies the same signature checks +/// before changing a session's runtime directory. +pub(crate) fn worktree_checkout_from_output(output: &str) -> Option<(String, String)> { + json_report(output).or_else(|| text_report(output)) +} + /// Read the `--json` report, allowing command output around the object. fn json_report(output: &str) -> Option<(String, String)> { output.match_indices('{').find_map(|(start, _)| { diff --git a/src/sdk/src/daemon/providers/codex_server/README.md b/src/sdk/src/daemon/providers/codex_server/README.md index 980b52f83..c82df16b3 100644 --- a/src/sdk/src/daemon/providers/codex_server/README.md +++ b/src/sdk/src/daemon/providers/codex_server/README.md @@ -15,7 +15,7 @@ Selected by naming the `codex-server` harness, which reaches here as `HarnessTra ## What this reports -Deliberately minimal: lifecycle status, the assistant's messages, and token usage. The app-server reports far more — per-item reasoning deltas, command output streams, patch previews — and the CLI transport's mappers turn the equivalent into the rich agent-rail detail an operator watches. +Deliberately minimal: lifecycle status, the assistant's messages, token usage, and stable worktree reports that determine where a resumed turn executes. The app-server reports far more — per-item reasoning deltas, command output streams, patch previews — and the CLI transport's mappers turn the equivalent into the rich agent-rail detail an operator watches. Reproducing that surface here would mean a second implementation of every mapper, tracking a wire format still marked experimental, for a transport chosen when throughput is what matters. So a `codex-server` lane reports that it is working, what it finally said, and what it cost — and an operator who wants to watch a lane work runs it on `codex`. diff --git a/src/sdk/src/daemon/providers/codex_server/execution.rs b/src/sdk/src/daemon/providers/codex_server/execution.rs index bff89add7..86679216e 100644 --- a/src/sdk/src/daemon/providers/codex_server/execution.rs +++ b/src/sdk/src/daemon/providers/codex_server/execution.rs @@ -88,11 +88,14 @@ async fn run(options: RunTaskOptions) -> Result { &connection, &mut subscription, &thread_id, + thread.cwd, options.prompt, options.model, options.timeout_ms, options.abort, options.on_event, + options.workspace_context, + options.on_workspace_context, ) .await?; @@ -212,16 +215,23 @@ async fn drive_turn( connection: &Connection, subscription: &mut ThreadSubscription, thread_id: &str, + cwd: String, prompt: String, model: Option, timeout_ms: u64, abort: super::super::types::Abort, on_event: Option, + workspace_context: crate::sessions::WorkspaceContext, + on_workspace_context: Option, ) -> Result { // Shared because the idle watchdog reads the fold's last-activity stamp // while the notification branch writes to it, and both live in one // `select!`. - let fold = Arc::new(Mutex::new(FoldState::new(on_event))); + let fold = Arc::new(Mutex::new(FoldState::with_workspace( + on_event, + workspace_context, + on_workspace_context, + ))); let timeout = Duration::from_millis(timeout_ms); // Tracked from `turn/started` so an interrupt can name the turn it stops; // the protocol requires both ids. @@ -231,6 +241,11 @@ async fn drive_turn( "turn/start", json!({ "threadId": thread_id, + // Codex treats cwd as sticky turn state. Send it even after + // `thread/resume`: otherwise a retained worktree exists only in + // Medulla's metadata while built-in tools default to the original + // directory recorded by the thread. + "cwd": cwd, "input": [{ "type": "text", "text": prompt }], "model": model, }), diff --git a/src/sdk/src/daemon/providers/codex_server/fold.rs b/src/sdk/src/daemon/providers/codex_server/fold.rs index 0b05eeb9a..4d9db7ec4 100644 --- a/src/sdk/src/daemon/providers/codex_server/fold.rs +++ b/src/sdk/src/daemon/providers/codex_server/fold.rs @@ -2,10 +2,11 @@ //! //! # Scope //! -//! Deliberately minimal: lifecycle status, the assistant's messages, and token -//! usage. The app-server reports far more than that — per-item reasoning deltas, -//! command output streams, patch previews — and the CLI transport's mappers turn -//! the equivalent into the rich agent-rail detail an operator watches. +//! Deliberately minimal: lifecycle status, the assistant's messages, token +//! usage, and repository moves that affect where the next turn executes. The +//! app-server reports far more than that — per-item reasoning deltas, command +//! output streams, patch previews — and the CLI transport's mappers turn the +//! equivalent into the rich agent-rail detail an operator watches. //! //! Reproducing that surface here would mean a second implementation of every //! mapper, tracking a wire format that is still marked experimental, for a @@ -22,10 +23,11 @@ use std::time::Instant; use serde_json::{json, Value}; use crate::codex_app_server::Notification; -use crate::daemon::mappers::HarnessSemanticEvent; +use crate::daemon::mappers::{worktree_checkout_from_output, HarnessSemanticEvent}; use crate::protocol::{HarnessEvent, TokenUsage}; +use crate::sessions::WorkspaceContext; -use super::super::types::OnEvent; +use super::super::types::{OnEvent, OnWorkspaceContext}; /// What a finished fold reports, without the callback it folded through. #[derive(Debug, Clone, Default)] @@ -58,6 +60,10 @@ pub(super) struct FoldState { pub(super) last_activity: Instant, /// Per-event status callback. on_event: Option, + /// Repository position retained across turns of this thread. + workspace_context: WorkspaceContext, + /// Persists a newly detected worktree for the next resumed turn. + on_workspace_context: Option, /// Line counter standing in for the CLI transport's transcript offsets. /// /// There is no transcript here, but `HarnessSemanticEvent::line` is the @@ -68,7 +74,17 @@ pub(super) struct FoldState { impl FoldState { /// A fold ready for one turn. + #[cfg(test)] pub(super) fn new(on_event: Option) -> Self { + Self::with_workspace(on_event, WorkspaceContext::default(), None) + } + + /// A fold seeded with repository position retained by a resumed thread. + pub(super) fn with_workspace( + on_event: Option, + workspace_context: WorkspaceContext, + on_workspace_context: Option, + ) -> Self { Self { reply: String::new(), items: 0, @@ -76,6 +92,8 @@ impl FoldState { error: None, last_activity: Instant::now(), on_event, + workspace_context, + on_workspace_context, line: 0, } } @@ -108,6 +126,7 @@ impl FoldState { self.emit("item/completed", "agent_message", json!({ "text": text })); } } + self.capture_worktree(item); false } "thread/tokenUsage/updated" => { @@ -188,6 +207,41 @@ impl FoldState { on_event(&event); } } + + /// Persist a stable worktree report carried by a completed command item. + fn capture_worktree(&mut self, item: Option<&Value>) { + let Some(item) = item + .filter(|item| item.get("type").and_then(Value::as_str) == Some("commandExecution")) + else { + return; + }; + let output = item + .get("aggregatedOutput") + .or_else(|| item.get("aggregated_output")) + .and_then(Value::as_str) + .unwrap_or(""); + let Some((cwd, branch)) = worktree_checkout_from_output(output) else { + return; + }; + if self.workspace_context.cwd.as_deref() != Some(&cwd) + || self.workspace_context.branch.as_deref() != Some(&branch) + { + self.workspace_context.pull_request = None; + } + self.workspace_context.cwd = Some(cwd); + self.workspace_context.branch = Some(branch); + self.emit( + "item/completed:workspace", + crate::harness_work::kinds::SESSION_INFO, + json!({ + "cwd": self.workspace_context.cwd, + "branch": self.workspace_context.branch, + }), + ); + if let Some(callback) = self.on_workspace_context.as_ref() { + callback(self.workspace_context.clone()); + } + } } /// A `status` payload saying the lane is working, with a one-line detail. diff --git a/src/sdk/src/daemon/providers/codex_server/tests.rs b/src/sdk/src/daemon/providers/codex_server/tests.rs index d88cbd1cd..614f53325 100644 --- a/src/sdk/src/daemon/providers/codex_server/tests.rs +++ b/src/sdk/src/daemon/providers/codex_server/tests.rs @@ -8,7 +8,7 @@ use serde_json::json; use crate::codex_app_server::Notification; use crate::daemon::mappers::HarnessSemanticEvent; use crate::protocol::{HarnessProvider, HarnessTransport}; -use crate::sessions::SessionClass; +use crate::sessions::{SessionClass, WorkspaceContext}; use super::super::types::{Abort, RunTaskOptions}; use super::execution::{child_env, uses_app_server, HARNESS_TRANSPORT_ENV}; @@ -287,3 +287,53 @@ fn advances_the_event_ordering_key() { .iter() .all(|event| event.record_type.starts_with("app_server:"))); } + +#[test] +fn completed_worktree_command_updates_the_app_server_workspace() { + let seen = Arc::new(Mutex::new(Vec::new())); + let sink = seen.clone(); + let events = Arc::new(Mutex::new(Vec::new())); + let event_sink = events.clone(); + let mut fold = FoldState::with_workspace( + Some(Box::new(move |event| { + event_sink.lock().unwrap().push(event.clone()) + })), + WorkspaceContext::default(), + Some(Box::new(move |context| sink.lock().unwrap().push(context))), + ); + fold.fold(¬ification( + "item/completed", + json!({ + "item": { + "type": "commandExecution", + "command": "worktree fix-context --json", + "aggregatedOutput": concat!( + "{\"status\":\"ready\",\"repository\":\"/repo\",", + "\"path\":\"/repo/worktrees/fix-context\",", + "\"branch\":\"fix-context\",\"head\":\"abc123456789\",", + "\"headShort\":\"abc1234\",\"created\":true,", + "\"submodules\":{\"state\":\"initialized_recursive\",\"count\":0},", + "\"nextCommand\":\"cd /repo/worktrees/fix-context\"}" + ), + "exitCode": 0 + } + }), + )); + + let contexts = seen.lock().unwrap(); + assert_eq!(contexts.len(), 1); + assert_eq!( + contexts[0].cwd.as_deref(), + Some("/repo/worktrees/fix-context") + ); + assert_eq!(contexts[0].branch.as_deref(), Some("fix-context")); + let events = events.lock().unwrap(); + let workspace = events + .iter() + .find(|event| event.event.kind == crate::harness_work::kinds::SESSION_INFO) + .expect("the active session is told about the move"); + assert_eq!( + workspace.event.payload["cwd"], + "/repo/worktrees/fix-context" + ); +} diff --git a/src/sdk/src/daemon/providers/execute.rs b/src/sdk/src/daemon/providers/execute.rs index 12f20ee0f..10ddef398 100644 --- a/src/sdk/src/daemon/providers/execute.rs +++ b/src/sdk/src/daemon/providers/execute.rs @@ -56,6 +56,12 @@ pub fn with_auth_hint(message: &str) -> String { /// Run one delegated task headlessly, retrying transient opencode SQLite-lock /// exits with jittered exponential backoff. pub async fn run_provider_task(mut options: RunTaskOptions) -> Result { + // A resumed conversation may have created and moved into a linked worktree + // during an earlier turn. The mapper persists that move separately from the + // daemon's configured launch checkout; make it the runtime cwd before any + // transport consumes the options, or Codex's next turn and all of its + // defaulted tool calls silently snap back to where the daemon started. + options.cwd = effective_cwd(&options.cwd, &options.workspace_context); // This has to precede every transport choice below. ACP and the pooled // app-server return before the CLI spawn seam, but each child is still an // external harness and must never inherit the embedded core's credential @@ -149,6 +155,24 @@ pub async fn run_provider_task(mut options: RunTaskOptions) -> Result String { + workspace_context + .cwd + .as_deref() + .filter(|cwd| std::path::Path::new(cwd).is_dir()) + .unwrap_or(configured) + .to_string() +} + /// A cheap uniform-ish `[0,1)` sample (no `rand` dep): folds the wall clock. pub(super) fn rand_unit() -> f64 { let nanos = std::time::SystemTime::now() diff --git a/src/sdk/src/daemon/providers/tests.rs b/src/sdk/src/daemon/providers/tests.rs index 9e9d1bff1..2cca0505d 100644 --- a/src/sdk/src/daemon/providers/tests.rs +++ b/src/sdk/src/daemon/providers/tests.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use crate::protocol::HarnessProvider; +use crate::sessions::WorkspaceContext; use super::detect::{ build_run_args, detect_providers, make_path_lookup, provider_bin, provider_name, @@ -136,6 +137,39 @@ fn build_run_args_per_provider() { ); } +#[test] +fn a_retained_worktree_becomes_the_resumed_runs_working_directory() { + let launch = tempfile::tempdir().unwrap(); + let worktree = tempfile::tempdir().unwrap(); + let context = WorkspaceContext { + cwd: Some(worktree.path().to_string_lossy().into_owned()), + branch: Some("feature".into()), + pull_request: None, + }; + + assert_eq!( + super::execute::effective_cwd(launch.path().to_str().unwrap(), &context), + worktree.path().to_string_lossy(), + ); +} + +#[test] +fn a_removed_retained_worktree_falls_back_to_the_configured_workspace() { + let launch = tempfile::tempdir().unwrap(); + let removed = tempfile::tempdir().unwrap(); + let context = WorkspaceContext { + cwd: Some(removed.path().to_string_lossy().into_owned()), + branch: Some("gone".into()), + pull_request: None, + }; + drop(removed); + + assert_eq!( + super::execute::effective_cwd(launch.path().to_str().unwrap(), &context), + launch.path().to_string_lossy(), + ); +} + #[test] fn build_run_args_neutralizes_dash_prompt() { let args = build_run_args(HarnessProvider::Codex, "-rf /", None, None, &[], false); diff --git a/src/sdk/tests/e2e_codex_app_server.rs b/src/sdk/tests/e2e_codex_app_server.rs index ea4b723d8..2437de3d4 100644 --- a/src/sdk/tests/e2e_codex_app_server.rs +++ b/src/sdk/tests/e2e_codex_app_server.rs @@ -108,6 +108,26 @@ async fn runs_a_turn_and_reports_the_reply() { assert!(kinds.contains(&"status"), "{kinds:?}"); } +#[tokio::test] +async fn resumed_turn_uses_the_worktree_as_its_runtime_cwd() { + let dir = TempDir::new(); + let worktree = dir.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let fake = fake_app_server(&dir, TurnScript::Reply("ok")); + let (mut options, _) = options(&fake, &home(&dir, "cwd"), "continue", 10_000); + options.cwd = dir.path().to_string_lossy().into_owned(); + options.workspace_context.cwd = Some(worktree.to_string_lossy().into_owned()); + + run_provider_task(options).await.expect("the turn runs"); + + let turn = fake + .requests() + .into_iter() + .find(|request| request["method"] == "turn/start") + .expect("turn/start request"); + assert_eq!(turn["params"]["cwd"], worktree.to_string_lossy().as_ref()); +} + /// The whole point: several tasks, one process. #[tokio::test] async fn shares_one_process_across_sequential_tasks() { From adb94ba2bb110d4c32427868cc85ebd32331f177 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 10:01:23 +0300 Subject: [PATCH 02/15] feat(codex_server): validate worktree checkout against registered git worktrees Add a `repository_cwd` field to `FoldState` and a new constructor `with_workspace_at` that accepts it, so the fold can verify that a reported worktree checkout corresponds to an actual worktree of the repository. The change filters out worktree events from failed commands and rejects checkouts not listed by `git worktree list --porcelain`, preventing the workspace context from being updated with stale or invalid paths. Auto-committed-on: macbook --- .../providers/codex_server/execution.rs | 4 ++ .../src/daemon/providers/codex_server/fold.rs | 71 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/sdk/src/daemon/providers/codex_server/execution.rs b/src/sdk/src/daemon/providers/codex_server/execution.rs index 86679216e..83d293a70 100644 --- a/src/sdk/src/daemon/providers/codex_server/execution.rs +++ b/src/sdk/src/daemon/providers/codex_server/execution.rs @@ -231,6 +231,10 @@ async fn drive_turn( on_event, workspace_context, on_workspace_context, + ))); + on_event, + workspace_context, + on_workspace_context, ))); let timeout = Duration::from_millis(timeout_ms); // Tracked from `turn/started` so an interrupt can name the turn it stops; diff --git a/src/sdk/src/daemon/providers/codex_server/fold.rs b/src/sdk/src/daemon/providers/codex_server/fold.rs index 4d9db7ec4..24474a522 100644 --- a/src/sdk/src/daemon/providers/codex_server/fold.rs +++ b/src/sdk/src/daemon/providers/codex_server/fold.rs @@ -18,6 +18,8 @@ //! notification counts as activity, including the ones that produce no event, or //! a long silent command would look like a dead process. +use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::Instant; use serde_json::{json, Value}; @@ -64,6 +66,8 @@ pub(super) struct FoldState { workspace_context: WorkspaceContext, /// Persists a newly detected worktree for the next resumed turn. on_workspace_context: Option, + /// Checkout from which Git may enumerate the repository's worktrees. + repository_cwd: Option, /// Line counter standing in for the CLI transport's transcript offsets. /// /// There is no transcript here, but `HarnessSemanticEvent::line` is the @@ -84,6 +88,16 @@ impl FoldState { on_event: Option, workspace_context: WorkspaceContext, on_workspace_context: Option, + ) -> Self { + Self::with_workspace_at(on_event, workspace_context, on_workspace_context, None) + } + + /// A fold seeded with workspace state and its configured checkout. + pub(super) fn with_workspace_at( + on_event: Option, + workspace_context: WorkspaceContext, + on_workspace_context: Option, + repository_cwd: Option, ) -> Self { Self { reply: String::new(), @@ -94,6 +108,7 @@ impl FoldState { on_event, workspace_context, on_workspace_context, + repository_cwd, line: 0, } } @@ -215,6 +230,9 @@ impl FoldState { else { return; }; + if !successful_worktree_command(item) { + return; + } let output = item .get("aggregatedOutput") .or_else(|| item.get("aggregated_output")) @@ -223,6 +241,9 @@ impl FoldState { let Some((cwd, branch)) = worktree_checkout_from_output(output) else { return; }; + if !self.is_registered_worktree(&cwd, &branch) { + return; + } if self.workspace_context.cwd.as_deref() != Some(&cwd) || self.workspace_context.branch.as_deref() != Some(&branch) { @@ -242,6 +263,56 @@ impl FoldState { callback(self.workspace_context.clone()); } } + + /// Accept only a report for a successful helper invocation that Git says is + /// an existing worktree of this repository on the reported branch. + fn is_registered_worktree(&self, cwd: &str, branch: &str) -> bool { + let Some(repository_cwd) = self.repository_cwd.as_deref() else { + return false; + }; + let Ok(cwd) = std::fs::canonicalize(cwd) else { + return false; + }; + let Ok(output) = Command::new("git") + .args(["-C"]) + .arg(repository_cwd) + .args(["worktree", "list", "--porcelain"]) + .output() + else { + return false; + }; + if !output.status.success() { + return false; + } + registered_worktrees(&String::from_utf8_lossy(&output.stdout)) + .into_iter() + .any(|(path, registered_branch)| path == cwd && registered_branch == branch) + } +} + +/// A command item may affect retained cwd only when the worktree helper itself +/// completed successfully. Generic shell output is untrusted text. +fn successful_worktree_command(item: &Value) -> bool { + item.get("exitCode").and_then(Value::as_i64) == Some(0) + && item + .get("command") + .and_then(Value::as_str) + .and_then(|command| command.split_whitespace().next()) + == Some("worktree") +} + +/// Parse Git's porcelain worktree listing into canonical checkout/branch pairs. +fn registered_worktrees(output: &str) -> Vec<(PathBuf, String)> { + output + .split("\n\n") + .filter_map(|entry| { + let path = entry.strip_prefix("worktree ")?.lines().next()?; + let branch = entry + .lines() + .find_map(|line| line.strip_prefix("branch refs/heads/"))?; + Some((std::fs::canonicalize(Path::new(path)).ok()?, branch.to_string())) + }) + .collect() } /// A `status` payload saying the lane is working, with a one-line detail. From 49f95f06f569af27443b228413baa0a804c76da7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 10:02:11 +0300 Subject: [PATCH 03/15] fix(daemon): validate worktree path in completed command reports The codex server now passes the repository's current working directory to the fold state so that completed worktree command reports are validated against the actual repository path. Previously, a forged report could inject an arbitrary path into the workspace context, and the e2e test for resumed turns now verifies that thread/resume precedes turn/start. Auto-committed-on: macbook --- .../providers/codex_server/execution.rs | 3 +- .../daemon/providers/codex_server/tests.rs | 82 +++++++++++++++++-- src/sdk/tests/e2e_codex_app_server.rs | 19 +++-- 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/src/sdk/src/daemon/providers/codex_server/execution.rs b/src/sdk/src/daemon/providers/codex_server/execution.rs index 83d293a70..aa3c5686e 100644 --- a/src/sdk/src/daemon/providers/codex_server/execution.rs +++ b/src/sdk/src/daemon/providers/codex_server/execution.rs @@ -227,10 +227,11 @@ async fn drive_turn( // Shared because the idle watchdog reads the fold's last-activity stamp // while the notification branch writes to it, and both live in one // `select!`. - let fold = Arc::new(Mutex::new(FoldState::with_workspace( + let fold = Arc::new(Mutex::new(FoldState::with_workspace_at( on_event, workspace_context, on_workspace_context, + Some(cwd.into()), ))); on_event, workspace_context, diff --git a/src/sdk/src/daemon/providers/codex_server/tests.rs b/src/sdk/src/daemon/providers/codex_server/tests.rs index 16cf43406..580e26dcb 100644 --- a/src/sdk/src/daemon/providers/codex_server/tests.rs +++ b/src/sdk/src/daemon/providers/codex_server/tests.rs @@ -1,6 +1,8 @@ //! Unit tests for transport selection and the notification fold. use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::{Arc, Mutex}; use serde_json::json; @@ -65,6 +67,34 @@ fn recording_fold() -> (FoldState, Arc>>) { (fold, seen) } +/// Create a repository and a registered linked worktree for report validation. +fn registered_worktree(root: &Path) -> PathBuf { + git(root, &["init"]); + git(root, &["config", "user.email", "test@example.com"]); + git(root, &["config", "user.name", "Test"]); + std::fs::write(root.join("README"), "test\n").unwrap(); + git(root, &["add", "README"]); + git(root, &["commit", "-m", "initial"]); + let worktree = root.join("worktrees/fix-context"); + git( + root, + &[ + "worktree", + "add", + "-b", + "fix-context", + worktree.to_str().unwrap(), + ], + ); + worktree +} + +/// Run a Git setup command and make failures identify the invocation. +fn git(root: &Path, args: &[&str]) { + let status = Command::new("git").arg("-C").arg(root).args(args).status(); + assert!(status.is_ok_and(|status| status.success()), "git {args:?}"); +} + #[test] fn selects_the_app_server_when_the_frame_named_the_flavor() { assert!(uses_app_server(&options(HarnessTransport::AppServer, &[]))); @@ -291,16 +321,21 @@ fn advances_the_event_ordering_key() { #[test] fn completed_worktree_command_updates_the_app_server_workspace() { + let dir = crate::tests::support::fake_provider::TempDir::new(); + let repository = dir.path().join("repo"); + std::fs::create_dir_all(&repository).unwrap(); + let worktree = registered_worktree(&repository); let seen = Arc::new(Mutex::new(Vec::new())); let sink = seen.clone(); let events = Arc::new(Mutex::new(Vec::new())); let event_sink = events.clone(); - let mut fold = FoldState::with_workspace( + let mut fold = FoldState::with_workspace_at( Some(Box::new(move |event| { event_sink.lock().unwrap().push(event.clone()) })), WorkspaceContext::default(), Some(Box::new(move |context| sink.lock().unwrap().push(context))), + Some(repository.clone()), ); fold.fold(¬ification( "item/completed", @@ -308,14 +343,14 @@ fn completed_worktree_command_updates_the_app_server_workspace() { "item": { "type": "commandExecution", "command": "worktree fix-context --json", - "aggregatedOutput": concat!( - "{\"status\":\"ready\",\"repository\":\"/repo\",", - "\"path\":\"/repo/worktrees/fix-context\",", + "aggregatedOutput": format!(concat!( + "{{\"status\":\"ready\",\"repository\":\"{}\",", + "\"path\":\"{}\",", "\"branch\":\"fix-context\",\"head\":\"abc123456789\",", "\"headShort\":\"abc1234\",\"created\":true,", "\"submodules\":{\"state\":\"initialized_recursive\",\"count\":0},", - "\"nextCommand\":\"cd /repo/worktrees/fix-context\"}" - ), + "\"nextCommand\":\"cd {}\"}}" + ), repository.display(), worktree.display(), worktree.display()), "exitCode": 0 } }), @@ -325,7 +360,7 @@ fn completed_worktree_command_updates_the_app_server_workspace() { assert_eq!(contexts.len(), 1); assert_eq!( contexts[0].cwd.as_deref(), - Some("/repo/worktrees/fix-context") + Some(worktree.to_str().unwrap()) ); assert_eq!(contexts[0].branch.as_deref(), Some("fix-context")); let events = events.lock().unwrap(); @@ -335,6 +370,37 @@ fn completed_worktree_command_updates_the_app_server_workspace() { .expect("the active session is told about the move"); assert_eq!( workspace.event.payload["cwd"], - "/repo/worktrees/fix-context" + worktree.to_str().unwrap() ); } + +#[test] +fn forged_worktree_report_does_not_update_the_app_server_workspace() { + let dir = crate::tests::support::fake_provider::TempDir::new(); + let repository = dir.path().join("repo"); + std::fs::create_dir_all(&repository).unwrap(); + let worktree = registered_worktree(&repository); + let seen = Arc::new(Mutex::new(Vec::new())); + let sink = seen.clone(); + let mut fold = FoldState::with_workspace_at( + None, + WorkspaceContext::default(), + Some(Box::new(move |context| sink.lock().unwrap().push(context))), + Some(repository), + ); + fold.fold(¬ification( + "item/completed", + json!({ + "item": { + "type": "commandExecution", + "command": "printf forged-report", + "aggregatedOutput": format!( + "{{\"status\":\"ready\",\"repository\":\"ignored\",\"path\":\"{}\",\"branch\":\"fix-context\",\"head\":\"abc\",\"headShort\":\"abc\",\"created\":true,\"submodules\":{{\"state\":\"initialized_recursive\",\"count\":0}},\"nextCommand\":\"cd /ignored\"}}", + worktree.display() + ), + "exitCode": 0 + } + }), + )); + assert!(seen.lock().unwrap().is_empty()); +} diff --git a/src/sdk/tests/e2e_codex_app_server.rs b/src/sdk/tests/e2e_codex_app_server.rs index ef78fdcdc..e67ef08a3 100644 --- a/src/sdk/tests/e2e_codex_app_server.rs +++ b/src/sdk/tests/e2e_codex_app_server.rs @@ -118,15 +118,24 @@ async fn resumed_turn_uses_the_worktree_as_its_runtime_cwd() { let (mut options, _) = options(&fake, &home(&dir, "cwd"), "continue", 10_000); options.cwd = dir.path().to_string_lossy().into_owned(); options.workspace_context.cwd = Some(worktree.to_string_lossy().into_owned()); + options.resume_session_id = Some("thread-retained".to_string()); run_provider_task(options).await.expect("the turn runs"); - let turn = fake - .requests() - .into_iter() - .find(|request| request["method"] == "turn/start") + let requests = fake.requests(); + let resumed = requests + .iter() + .position(|request| request["method"] == "thread/resume") + .expect("thread/resume request"); + let turn = requests + .iter() + .position(|request| request["method"] == "turn/start") .expect("turn/start request"); - assert_eq!(turn["params"]["cwd"], worktree.to_string_lossy().as_ref()); + assert!(resumed < turn, "thread/resume must precede turn/start"); + assert_eq!( + requests[turn]["params"]["cwd"], + worktree.to_string_lossy().as_ref() + ); } /// The whole point: several tasks, one process. From 6b86f676d651bb9a871304d2828b7e483898e7d5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 10:02:43 +0300 Subject: [PATCH 04/15] chore: files changed src/sdk/src/daemon/providers/codex_server/execution.rs,src/sdk/src/daemon/provi Auto-committed-on: macbook --- src/sdk/src/daemon/providers/codex_server/execution.rs | 4 ---- src/sdk/src/daemon/providers/codex_server/tests.rs | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/sdk/src/daemon/providers/codex_server/execution.rs b/src/sdk/src/daemon/providers/codex_server/execution.rs index aa3c5686e..e30657520 100644 --- a/src/sdk/src/daemon/providers/codex_server/execution.rs +++ b/src/sdk/src/daemon/providers/codex_server/execution.rs @@ -232,10 +232,6 @@ async fn drive_turn( workspace_context, on_workspace_context, Some(cwd.into()), - ))); - on_event, - workspace_context, - on_workspace_context, ))); let timeout = Duration::from_millis(timeout_ms); // Tracked from `turn/started` so an interrupt can name the turn it stops; diff --git a/src/sdk/src/daemon/providers/codex_server/tests.rs b/src/sdk/src/daemon/providers/codex_server/tests.rs index 580e26dcb..2c36da96f 100644 --- a/src/sdk/src/daemon/providers/codex_server/tests.rs +++ b/src/sdk/src/daemon/providers/codex_server/tests.rs @@ -321,7 +321,7 @@ fn advances_the_event_ordering_key() { #[test] fn completed_worktree_command_updates_the_app_server_workspace() { - let dir = crate::tests::support::fake_provider::TempDir::new(); + let dir = tempfile::tempdir().unwrap(); let repository = dir.path().join("repo"); std::fs::create_dir_all(&repository).unwrap(); let worktree = registered_worktree(&repository); @@ -376,7 +376,7 @@ fn completed_worktree_command_updates_the_app_server_workspace() { #[test] fn forged_worktree_report_does_not_update_the_app_server_workspace() { - let dir = crate::tests::support::fake_provider::TempDir::new(); + let dir = tempfile::tempdir().unwrap(); let repository = dir.path().join("repo"); std::fs::create_dir_all(&repository).unwrap(); let worktree = registered_worktree(&repository); From fd32d2df1b0b9bc56313822f417a1384c6700bd5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 10:03:33 +0300 Subject: [PATCH 05/15] fix: reformat code style in fold.rs and tests.rs Reformatted multiline assertions and tuple returns to single-line style for consistency with project conventions, removing unnecessary line breaks in the codebase. Auto-committed-on: macbook --- src/sdk/src/daemon/providers/codex_server/fold.rs | 5 ++++- src/sdk/src/daemon/providers/codex_server/tests.rs | 10 ++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/sdk/src/daemon/providers/codex_server/fold.rs b/src/sdk/src/daemon/providers/codex_server/fold.rs index 24474a522..18abe65c2 100644 --- a/src/sdk/src/daemon/providers/codex_server/fold.rs +++ b/src/sdk/src/daemon/providers/codex_server/fold.rs @@ -310,7 +310,10 @@ fn registered_worktrees(output: &str) -> Vec<(PathBuf, String)> { let branch = entry .lines() .find_map(|line| line.strip_prefix("branch refs/heads/"))?; - Some((std::fs::canonicalize(Path::new(path)).ok()?, branch.to_string())) + Some(( + std::fs::canonicalize(Path::new(path)).ok()?, + branch.to_string(), + )) }) .collect() } diff --git a/src/sdk/src/daemon/providers/codex_server/tests.rs b/src/sdk/src/daemon/providers/codex_server/tests.rs index 2c36da96f..4c35c3323 100644 --- a/src/sdk/src/daemon/providers/codex_server/tests.rs +++ b/src/sdk/src/daemon/providers/codex_server/tests.rs @@ -358,20 +358,14 @@ fn completed_worktree_command_updates_the_app_server_workspace() { let contexts = seen.lock().unwrap(); assert_eq!(contexts.len(), 1); - assert_eq!( - contexts[0].cwd.as_deref(), - Some(worktree.to_str().unwrap()) - ); + assert_eq!(contexts[0].cwd.as_deref(), Some(worktree.to_str().unwrap())); assert_eq!(contexts[0].branch.as_deref(), Some("fix-context")); let events = events.lock().unwrap(); let workspace = events .iter() .find(|event| event.event.kind == crate::harness_work::kinds::SESSION_INFO) .expect("the active session is told about the move"); - assert_eq!( - workspace.event.payload["cwd"], - worktree.to_str().unwrap() - ); + assert_eq!(workspace.event.payload["cwd"], worktree.to_str().unwrap()); } #[test] From 227aa6399b831d50c1ed5f202436364d0fbd7d8a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 10:06:54 +0300 Subject: [PATCH 06/15] fix(execution): clone cwd before passing to spawn The `cwd` value was being moved into the `spawn` call, preventing its use elsewhere. It is now cloned so the original reference remains available. The test expectation for submodule serialization is also corrected to use double braces for proper JSON escaping. Auto-committed-on: macbook --- src/sdk/src/daemon/providers/codex_server/execution.rs | 2 +- src/sdk/src/daemon/providers/codex_server/tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sdk/src/daemon/providers/codex_server/execution.rs b/src/sdk/src/daemon/providers/codex_server/execution.rs index e30657520..f9a85f40d 100644 --- a/src/sdk/src/daemon/providers/codex_server/execution.rs +++ b/src/sdk/src/daemon/providers/codex_server/execution.rs @@ -231,7 +231,7 @@ async fn drive_turn( on_event, workspace_context, on_workspace_context, - Some(cwd.into()), + Some(cwd.clone().into()), ))); let timeout = Duration::from_millis(timeout_ms); // Tracked from `turn/started` so an interrupt can name the turn it stops; diff --git a/src/sdk/src/daemon/providers/codex_server/tests.rs b/src/sdk/src/daemon/providers/codex_server/tests.rs index 4c35c3323..96ddb2269 100644 --- a/src/sdk/src/daemon/providers/codex_server/tests.rs +++ b/src/sdk/src/daemon/providers/codex_server/tests.rs @@ -348,7 +348,7 @@ fn completed_worktree_command_updates_the_app_server_workspace() { "\"path\":\"{}\",", "\"branch\":\"fix-context\",\"head\":\"abc123456789\",", "\"headShort\":\"abc1234\",\"created\":true,", - "\"submodules\":{\"state\":\"initialized_recursive\",\"count\":0},", + "\"submodules\":{{\"state\":\"initialized_recursive\",\"count\":0}},", "\"nextCommand\":\"cd {}\"}}" ), repository.display(), worktree.display(), worktree.display()), "exitCode": 0 From 734677ea635449007e2f1458d2a357a9ed18ffa1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 10:07:48 +0300 Subject: [PATCH 07/15] refactor(fold): simplify `new` by removing `with_workspace` indirection The `with_workspace` method was an unnecessary intermediate step that only forwarded to `with_workspace_at` with a default checkout position. Inlining its logic directly into `new` removes the dead code and makes the construction path clearer. Auto-committed-on: macbook --- src/sdk/src/daemon/providers/codex_server/fold.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/sdk/src/daemon/providers/codex_server/fold.rs b/src/sdk/src/daemon/providers/codex_server/fold.rs index 18abe65c2..79e887f67 100644 --- a/src/sdk/src/daemon/providers/codex_server/fold.rs +++ b/src/sdk/src/daemon/providers/codex_server/fold.rs @@ -80,16 +80,7 @@ impl FoldState { /// A fold ready for one turn. #[cfg(test)] pub(super) fn new(on_event: Option) -> Self { - Self::with_workspace(on_event, WorkspaceContext::default(), None) - } - - /// A fold seeded with repository position retained by a resumed thread. - pub(super) fn with_workspace( - on_event: Option, - workspace_context: WorkspaceContext, - on_workspace_context: Option, - ) -> Self { - Self::with_workspace_at(on_event, workspace_context, on_workspace_context, None) + Self::with_workspace_at(on_event, WorkspaceContext::default(), None, None) } /// A fold seeded with workspace state and its configured checkout. From 0cfcfdf46d2869907cad6cc03848d3e65aef59aa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 10:23:20 +0300 Subject: [PATCH 08/15] chore(loopback): remove unused import of `Command` in tests The `std::process::Command` import was no longer needed in the loopback test module, so it has been removed to keep the code clean and avoid compiler warnings about unused imports. Auto-committed-on: macbook --- src/sdk/src/auth/loopback/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sdk/src/auth/loopback/tests.rs b/src/sdk/src/auth/loopback/tests.rs index a82b81faf..033efd1a3 100644 --- a/src/sdk/src/auth/loopback/tests.rs +++ b/src/sdk/src/auth/loopback/tests.rs @@ -1,6 +1,5 @@ //! Unit tests for the loopback module's process-spawning helpers. -use std::process::Command; /// The browser opener must not inherit our stderr. `xdg-open` delegates to GIO, /// which prints warnings ("The peer-to-peer connection failed: ... gvfsd ...") From 0050b95316c0a4c8c3d5ed1552360bac5c6de13d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 10:23:43 +0300 Subject: [PATCH 09/15] chore(loopback): remove stray blank line in test file Removed an extra blank line that was left between the module-level doc comment and the first test function's doc comment in the loopback authentication tests. Auto-committed-on: macbook --- src/sdk/src/auth/loopback/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sdk/src/auth/loopback/tests.rs b/src/sdk/src/auth/loopback/tests.rs index 033efd1a3..0546e07e4 100644 --- a/src/sdk/src/auth/loopback/tests.rs +++ b/src/sdk/src/auth/loopback/tests.rs @@ -1,6 +1,5 @@ //! Unit tests for the loopback module's process-spawning helpers. - /// The browser opener must not inherit our stderr. `xdg-open` delegates to GIO, /// which prints warnings ("The peer-to-peer connection failed: ... gvfsd ...") /// that would otherwise be painted straight onto the TUI's frame. Assert the From 4bf166ae8b27e7100c46ad019a6f90d807fa680e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 12:56:27 +0300 Subject: [PATCH 10/15] refactor(daemon): replace git subprocess with injectable worktree registry Replace the direct git subprocess call for worktree enumeration with an injectable `WorktreeRegistry` enum that supports a static test registry alongside the existing git-based and disabled modes. This removes the need for real git repositories in unit tests, making them faster and more deterministic, and adds explicit test coverage for failed commands, unregistered worktrees, and branch mismatches. Auto-committed-on: dragonfly --- .../src/daemon/providers/codex_server/fold.rs | 64 +++++-- .../daemon/providers/codex_server/tests.rs | 157 ++++++++++-------- 2 files changed, 137 insertions(+), 84 deletions(-) diff --git a/src/sdk/src/daemon/providers/codex_server/fold.rs b/src/sdk/src/daemon/providers/codex_server/fold.rs index 79e887f67..285631ef7 100644 --- a/src/sdk/src/daemon/providers/codex_server/fold.rs +++ b/src/sdk/src/daemon/providers/codex_server/fold.rs @@ -66,8 +66,8 @@ pub(super) struct FoldState { workspace_context: WorkspaceContext, /// Persists a newly detected worktree for the next resumed turn. on_workspace_context: Option, - /// Checkout from which Git may enumerate the repository's worktrees. - repository_cwd: Option, + /// Source used to enumerate the repository's registered worktrees. + worktree_registry: WorktreeRegistry, /// Line counter standing in for the CLI transport's transcript offsets. /// /// There is no transcript here, but `HarnessSemanticEvent::line` is the @@ -99,11 +99,25 @@ impl FoldState { on_event, workspace_context, on_workspace_context, - repository_cwd, + worktree_registry: repository_cwd + .map(WorktreeRegistry::Git) + .unwrap_or(WorktreeRegistry::Disabled), line: 0, } } + /// A fold backed by a deterministic in-process worktree registry. + #[cfg(test)] + pub(super) fn with_registered_worktrees( + workspace_context: WorkspaceContext, + on_workspace_context: Option, + worktrees: Vec<(PathBuf, String)>, + ) -> Self { + let mut fold = Self::with_workspace_at(None, workspace_context, on_workspace_context, None); + fold.worktree_registry = WorktreeRegistry::Static(worktrees); + fold + } + /// Fold one notification, emitting whatever events it implies. /// /// Returns `true` once the turn is terminal, which is the caller's signal to @@ -258,29 +272,45 @@ impl FoldState { /// Accept only a report for a successful helper invocation that Git says is /// an existing worktree of this repository on the reported branch. fn is_registered_worktree(&self, cwd: &str, branch: &str) -> bool { - let Some(repository_cwd) = self.repository_cwd.as_deref() else { - return false; - }; let Ok(cwd) = std::fs::canonicalize(cwd) else { return false; }; - let Ok(output) = Command::new("git") - .args(["-C"]) - .arg(repository_cwd) - .args(["worktree", "list", "--porcelain"]) - .output() - else { - return false; + let worktrees = match &self.worktree_registry { + WorktreeRegistry::Disabled => return false, + WorktreeRegistry::Git(repository_cwd) => { + let Ok(output) = Command::new("git") + .args(["-C"]) + .arg(repository_cwd) + .args(["worktree", "list", "--porcelain"]) + .output() + else { + return false; + }; + if !output.status.success() { + return false; + } + registered_worktrees(&String::from_utf8_lossy(&output.stdout)) + } + #[cfg(test)] + WorktreeRegistry::Static(worktrees) => worktrees.clone(), }; - if !output.status.success() { - return false; - } - registered_worktrees(&String::from_utf8_lossy(&output.stdout)) + worktrees .into_iter() .any(|(path, registered_branch)| path == cwd && registered_branch == branch) } } +/// Where worktree membership is read from. +enum WorktreeRegistry { + /// No repository was configured, so no report may update the workspace. + Disabled, + /// Ask Git for the live registry rooted at this checkout. + Git(PathBuf), + /// Deterministic registry used by unit tests. + #[cfg(test)] + Static(Vec<(PathBuf, String)>), +} + /// A command item may affect retained cwd only when the worktree helper itself /// completed successfully. Generic shell output is untrusted text. fn successful_worktree_command(item: &Value) -> bool { diff --git a/src/sdk/src/daemon/providers/codex_server/tests.rs b/src/sdk/src/daemon/providers/codex_server/tests.rs index 96ddb2269..cdd315269 100644 --- a/src/sdk/src/daemon/providers/codex_server/tests.rs +++ b/src/sdk/src/daemon/providers/codex_server/tests.rs @@ -1,8 +1,7 @@ //! Unit tests for transport selection and the notification fold. use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use serde_json::json; @@ -67,32 +66,33 @@ fn recording_fold() -> (FoldState, Arc>>) { (fold, seen) } -/// Create a repository and a registered linked worktree for report validation. -fn registered_worktree(root: &Path) -> PathBuf { - git(root, &["init"]); - git(root, &["config", "user.email", "test@example.com"]); - git(root, &["config", "user.name", "Test"]); - std::fs::write(root.join("README"), "test\n").unwrap(); - git(root, &["add", "README"]); - git(root, &["commit", "-m", "initial"]); - let worktree = root.join("worktrees/fix-context"); - git( - root, - &[ - "worktree", - "add", - "-b", - "fix-context", - worktree.to_str().unwrap(), - ], - ); - worktree +/// A completed worktree helper item carrying the supplied checkout report. +fn worktree_notification(path: &str, branch: &str, exit_code: i64) -> Notification { + notification( + "item/completed", + json!({ + "item": { + "type": "commandExecution", + "command": "worktree fix-context --json", + "aggregatedOutput": format!( + "{{\"status\":\"ready\",\"repository\":\"/repo\",\"path\":\"{path}\",\"branch\":\"{branch}\",\"head\":\"abc\",\"headShort\":\"abc\",\"created\":true,\"submodules\":{{\"state\":\"initialized_recursive\",\"count\":0}},\"nextCommand\":\"cd {path}\"}}" + ), + "exitCode": exit_code + } + }), + ) } -/// Run a Git setup command and make failures identify the invocation. -fn git(root: &Path, args: &[&str]) { - let status = Command::new("git").arg("-C").arg(root).args(args).status(); - assert!(status.is_ok_and(|status| status.success()), "git {args:?}"); +/// A fold whose workspace callback records accepted reports. +fn workspace_fold(worktrees: Vec<(PathBuf, String)>) -> (FoldState, Arc>>) { + let seen = Arc::new(Mutex::new(Vec::new())); + let sink = seen.clone(); + let fold = FoldState::with_registered_worktrees( + WorkspaceContext::default(), + Some(Box::new(move |context| sink.lock().unwrap().push(context))), + worktrees, + ); + (fold, seen) } #[test] @@ -322,38 +322,19 @@ fn advances_the_event_ordering_key() { #[test] fn completed_worktree_command_updates_the_app_server_workspace() { let dir = tempfile::tempdir().unwrap(); - let repository = dir.path().join("repo"); - std::fs::create_dir_all(&repository).unwrap(); - let worktree = registered_worktree(&repository); - let seen = Arc::new(Mutex::new(Vec::new())); - let sink = seen.clone(); + let worktree = dir.path().join("worktrees/fix-context"); + std::fs::create_dir_all(&worktree).unwrap(); + let registered = std::fs::canonicalize(&worktree).unwrap(); + let (mut fold, seen) = workspace_fold(vec![(registered, "fix-context".to_string())]); let events = Arc::new(Mutex::new(Vec::new())); let event_sink = events.clone(); - let mut fold = FoldState::with_workspace_at( - Some(Box::new(move |event| { - event_sink.lock().unwrap().push(event.clone()) - })), - WorkspaceContext::default(), - Some(Box::new(move |context| sink.lock().unwrap().push(context))), - Some(repository.clone()), - ); - fold.fold(¬ification( - "item/completed", - json!({ - "item": { - "type": "commandExecution", - "command": "worktree fix-context --json", - "aggregatedOutput": format!(concat!( - "{{\"status\":\"ready\",\"repository\":\"{}\",", - "\"path\":\"{}\",", - "\"branch\":\"fix-context\",\"head\":\"abc123456789\",", - "\"headShort\":\"abc1234\",\"created\":true,", - "\"submodules\":{{\"state\":\"initialized_recursive\",\"count\":0}},", - "\"nextCommand\":\"cd {}\"}}" - ), repository.display(), worktree.display(), worktree.display()), - "exitCode": 0 - } - }), + fold.on_event = Some(Box::new(move |event| { + event_sink.lock().unwrap().push(event.clone()) + })); + fold.fold(&worktree_notification( + worktree.to_str().unwrap(), + "fix-context", + 0, )); let contexts = seen.lock().unwrap(); @@ -371,17 +352,9 @@ fn completed_worktree_command_updates_the_app_server_workspace() { #[test] fn forged_worktree_report_does_not_update_the_app_server_workspace() { let dir = tempfile::tempdir().unwrap(); - let repository = dir.path().join("repo"); - std::fs::create_dir_all(&repository).unwrap(); - let worktree = registered_worktree(&repository); - let seen = Arc::new(Mutex::new(Vec::new())); - let sink = seen.clone(); - let mut fold = FoldState::with_workspace_at( - None, - WorkspaceContext::default(), - Some(Box::new(move |context| sink.lock().unwrap().push(context))), - Some(repository), - ); + let worktree = dir.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let (mut fold, seen) = workspace_fold(vec![(worktree.clone(), "fix-context".to_string())]); fold.fold(¬ification( "item/completed", json!({ @@ -398,3 +371,53 @@ fn forged_worktree_report_does_not_update_the_app_server_workspace() { )); assert!(seen.lock().unwrap().is_empty()); } + +#[test] +fn failed_worktree_command_does_not_update_the_app_server_workspace() { + let dir = tempfile::tempdir().unwrap(); + let worktree = dir.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let (mut fold, seen) = workspace_fold(vec![(worktree.clone(), "fix-context".to_string())]); + + fold.fold(&worktree_notification( + worktree.to_str().unwrap(), + "fix-context", + 1, + )); + + assert!(seen.lock().unwrap().is_empty()); +} + +#[test] +fn unregistered_worktree_does_not_update_the_app_server_workspace() { + let dir = tempfile::tempdir().unwrap(); + let registered = dir.path().join("registered"); + let unregistered = dir.path().join("unregistered"); + std::fs::create_dir_all(®istered).unwrap(); + std::fs::create_dir_all(&unregistered).unwrap(); + let (mut fold, seen) = workspace_fold(vec![(registered, "fix-context".to_string())]); + + fold.fold(&worktree_notification( + unregistered.to_str().unwrap(), + "fix-context", + 0, + )); + + assert!(seen.lock().unwrap().is_empty()); +} + +#[test] +fn mismatched_worktree_branch_does_not_update_the_app_server_workspace() { + let dir = tempfile::tempdir().unwrap(); + let worktree = dir.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let (mut fold, seen) = workspace_fold(vec![(worktree.clone(), "other-branch".to_string())]); + + fold.fold(&worktree_notification( + worktree.to_str().unwrap(), + "fix-context", + 0, + )); + + assert!(seen.lock().unwrap().is_empty()); +} From 8cf7e0454c7aaded4b48e14a304d763e78d8ba36 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 12:56:53 +0300 Subject: [PATCH 11/15] refactor(tests): move event capture into workspace_fold helper The workspace_fold test helper now accepts an optional on_event callback and returns a shared event list alongside the existing workspace context list. This simplifies test setup by removing the need to manually attach an event handler after construction, and ensures all tests that use the helper can access captured events without additional boilerplate. Auto-committed-on: dragonfly --- .../src/daemon/providers/codex_server/fold.rs | 4 +- .../daemon/providers/codex_server/tests.rs | 39 ++++++++++++------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/sdk/src/daemon/providers/codex_server/fold.rs b/src/sdk/src/daemon/providers/codex_server/fold.rs index 285631ef7..97c72db28 100644 --- a/src/sdk/src/daemon/providers/codex_server/fold.rs +++ b/src/sdk/src/daemon/providers/codex_server/fold.rs @@ -109,11 +109,13 @@ impl FoldState { /// A fold backed by a deterministic in-process worktree registry. #[cfg(test)] pub(super) fn with_registered_worktrees( + on_event: Option, workspace_context: WorkspaceContext, on_workspace_context: Option, worktrees: Vec<(PathBuf, String)>, ) -> Self { - let mut fold = Self::with_workspace_at(None, workspace_context, on_workspace_context, None); + let mut fold = + Self::with_workspace_at(on_event, workspace_context, on_workspace_context, None); fold.worktree_registry = WorktreeRegistry::Static(worktrees); fold } diff --git a/src/sdk/src/daemon/providers/codex_server/tests.rs b/src/sdk/src/daemon/providers/codex_server/tests.rs index cdd315269..d7c3d360c 100644 --- a/src/sdk/src/daemon/providers/codex_server/tests.rs +++ b/src/sdk/src/daemon/providers/codex_server/tests.rs @@ -84,15 +84,30 @@ fn worktree_notification(path: &str, branch: &str, exit_code: i64) -> Notificati } /// A fold whose workspace callback records accepted reports. -fn workspace_fold(worktrees: Vec<(PathBuf, String)>) -> (FoldState, Arc>>) { +fn workspace_fold( + worktrees: Vec<(PathBuf, String)>, +) -> ( + FoldState, + Arc>>, + Arc>>, +) { let seen = Arc::new(Mutex::new(Vec::new())); let sink = seen.clone(); + let events = Arc::new(Mutex::new(Vec::new())); + let event_sink = events.clone(); + let worktrees = worktrees + .into_iter() + .map(|(path, branch)| (std::fs::canonicalize(path).unwrap(), branch)) + .collect(); let fold = FoldState::with_registered_worktrees( + Some(Box::new(move |event| { + event_sink.lock().unwrap().push(event.clone()) + })), WorkspaceContext::default(), Some(Box::new(move |context| sink.lock().unwrap().push(context))), worktrees, ); - (fold, seen) + (fold, seen, events) } #[test] @@ -324,13 +339,8 @@ fn completed_worktree_command_updates_the_app_server_workspace() { let dir = tempfile::tempdir().unwrap(); let worktree = dir.path().join("worktrees/fix-context"); std::fs::create_dir_all(&worktree).unwrap(); - let registered = std::fs::canonicalize(&worktree).unwrap(); - let (mut fold, seen) = workspace_fold(vec![(registered, "fix-context".to_string())]); - let events = Arc::new(Mutex::new(Vec::new())); - let event_sink = events.clone(); - fold.on_event = Some(Box::new(move |event| { - event_sink.lock().unwrap().push(event.clone()) - })); + let (mut fold, seen, events) = + workspace_fold(vec![(worktree.clone(), "fix-context".to_string())]); fold.fold(&worktree_notification( worktree.to_str().unwrap(), "fix-context", @@ -354,7 +364,8 @@ fn forged_worktree_report_does_not_update_the_app_server_workspace() { let dir = tempfile::tempdir().unwrap(); let worktree = dir.path().join("worktree"); std::fs::create_dir_all(&worktree).unwrap(); - let (mut fold, seen) = workspace_fold(vec![(worktree.clone(), "fix-context".to_string())]); + let (mut fold, seen, _) = + workspace_fold(vec![(worktree.clone(), "fix-context".to_string())]); fold.fold(¬ification( "item/completed", json!({ @@ -377,7 +388,8 @@ fn failed_worktree_command_does_not_update_the_app_server_workspace() { let dir = tempfile::tempdir().unwrap(); let worktree = dir.path().join("worktree"); std::fs::create_dir_all(&worktree).unwrap(); - let (mut fold, seen) = workspace_fold(vec![(worktree.clone(), "fix-context".to_string())]); + let (mut fold, seen, _) = + workspace_fold(vec![(worktree.clone(), "fix-context".to_string())]); fold.fold(&worktree_notification( worktree.to_str().unwrap(), @@ -395,7 +407,7 @@ fn unregistered_worktree_does_not_update_the_app_server_workspace() { let unregistered = dir.path().join("unregistered"); std::fs::create_dir_all(®istered).unwrap(); std::fs::create_dir_all(&unregistered).unwrap(); - let (mut fold, seen) = workspace_fold(vec![(registered, "fix-context".to_string())]); + let (mut fold, seen, _) = workspace_fold(vec![(registered, "fix-context".to_string())]); fold.fold(&worktree_notification( unregistered.to_str().unwrap(), @@ -411,7 +423,8 @@ fn mismatched_worktree_branch_does_not_update_the_app_server_workspace() { let dir = tempfile::tempdir().unwrap(); let worktree = dir.path().join("worktree"); std::fs::create_dir_all(&worktree).unwrap(); - let (mut fold, seen) = workspace_fold(vec![(worktree.clone(), "other-branch".to_string())]); + let (mut fold, seen, _) = + workspace_fold(vec![(worktree.clone(), "other-branch".to_string())]); fold.fold(&worktree_notification( worktree.to_str().unwrap(), From 44ff60c59e06b6267f7bf8ddcffd52b0be85483b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 12:58:13 +0300 Subject: [PATCH 12/15] chore(tests): reformat workspace_fold calls to single line Reformatted three test functions to place the workspace_fold call on a single line instead of splitting it across two lines, improving code consistency and readability without changing any behavior. Auto-committed-on: dragonfly --- src/sdk/src/daemon/providers/codex_server/tests.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/sdk/src/daemon/providers/codex_server/tests.rs b/src/sdk/src/daemon/providers/codex_server/tests.rs index d7c3d360c..b7ebaf7b7 100644 --- a/src/sdk/src/daemon/providers/codex_server/tests.rs +++ b/src/sdk/src/daemon/providers/codex_server/tests.rs @@ -364,8 +364,7 @@ fn forged_worktree_report_does_not_update_the_app_server_workspace() { let dir = tempfile::tempdir().unwrap(); let worktree = dir.path().join("worktree"); std::fs::create_dir_all(&worktree).unwrap(); - let (mut fold, seen, _) = - workspace_fold(vec![(worktree.clone(), "fix-context".to_string())]); + let (mut fold, seen, _) = workspace_fold(vec![(worktree.clone(), "fix-context".to_string())]); fold.fold(¬ification( "item/completed", json!({ @@ -388,8 +387,7 @@ fn failed_worktree_command_does_not_update_the_app_server_workspace() { let dir = tempfile::tempdir().unwrap(); let worktree = dir.path().join("worktree"); std::fs::create_dir_all(&worktree).unwrap(); - let (mut fold, seen, _) = - workspace_fold(vec![(worktree.clone(), "fix-context".to_string())]); + let (mut fold, seen, _) = workspace_fold(vec![(worktree.clone(), "fix-context".to_string())]); fold.fold(&worktree_notification( worktree.to_str().unwrap(), @@ -423,8 +421,7 @@ fn mismatched_worktree_branch_does_not_update_the_app_server_workspace() { let dir = tempfile::tempdir().unwrap(); let worktree = dir.path().join("worktree"); std::fs::create_dir_all(&worktree).unwrap(); - let (mut fold, seen, _) = - workspace_fold(vec![(worktree.clone(), "other-branch".to_string())]); + let (mut fold, seen, _) = workspace_fold(vec![(worktree.clone(), "other-branch".to_string())]); fold.fold(&worktree_notification( worktree.to_str().unwrap(), From 9165c71976f78d73a6a3d220aa6c7fdc542a732e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 12:58:23 +0300 Subject: [PATCH 13/15] test(loopback): add missing import for Command in tests The test file was using `std::process::Command` without importing it, which would cause a compilation error. This change adds the necessary use statement to resolve the missing import. Auto-committed-on: dragonfly --- src/sdk/src/auth/loopback/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sdk/src/auth/loopback/tests.rs b/src/sdk/src/auth/loopback/tests.rs index 0546e07e4..a82b81faf 100644 --- a/src/sdk/src/auth/loopback/tests.rs +++ b/src/sdk/src/auth/loopback/tests.rs @@ -1,5 +1,7 @@ //! Unit tests for the loopback module's process-spawning helpers. +use std::process::Command; + /// The browser opener must not inherit our stderr. `xdg-open` delegates to GIO, /// which prints warnings ("The peer-to-peer connection failed: ... gvfsd ...") /// that would otherwise be painted straight onto the TUI's frame. Assert the From 028e95d879cf7b0fd50b00be712629cc2076b0e5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 13:04:26 +0300 Subject: [PATCH 14/15] test(codex_server): replace inline return type with a type alias The workspace_fold function's verbose return type has been extracted into a WorkspaceRecording type alias, making the function signature more concise and improving readability of the test module. Auto-committed-on: dragonfly --- .../src/daemon/providers/codex_server/tests.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/sdk/src/daemon/providers/codex_server/tests.rs b/src/sdk/src/daemon/providers/codex_server/tests.rs index b7ebaf7b7..ed543652a 100644 --- a/src/sdk/src/daemon/providers/codex_server/tests.rs +++ b/src/sdk/src/daemon/providers/codex_server/tests.rs @@ -15,6 +15,13 @@ use super::super::types::{Abort, RunTaskOptions}; use super::execution::{child_env, uses_app_server, HARNESS_TRANSPORT_ENV}; use super::fold::FoldState; +/// Test fold plus its recorded workspace callbacks and semantic events. +type WorkspaceRecording = ( + FoldState, + Arc>>, + Arc>>, +); + /// Run options carrying a transport and an environment, and nothing else that /// matters to the two functions under test here. fn options(transport: HarnessTransport, env: &[(&str, &str)]) -> RunTaskOptions { @@ -84,13 +91,7 @@ fn worktree_notification(path: &str, branch: &str, exit_code: i64) -> Notificati } /// A fold whose workspace callback records accepted reports. -fn workspace_fold( - worktrees: Vec<(PathBuf, String)>, -) -> ( - FoldState, - Arc>>, - Arc>>, -) { +fn workspace_fold(worktrees: Vec<(PathBuf, String)>) -> WorkspaceRecording { let seen = Arc::new(Mutex::new(Vec::new())); let sink = seen.clone(); let events = Arc::new(Mutex::new(Vec::new())); From e4176c5f7135a3a35be62ea2dc06247eebf235ae Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 13 Aug 2026 13:41:37 +0300 Subject: [PATCH 15/15] test(codex_server): use json! macro for worktree notification payload Replaced the inline JSON string construction in the worktree notification helper with a structured json! macro call, making the test data easier to read and maintain without changing the resulting output. Auto-committed-on: dragonfly --- .../src/daemon/providers/codex_server/tests.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/sdk/src/daemon/providers/codex_server/tests.rs b/src/sdk/src/daemon/providers/codex_server/tests.rs index ed543652a..02b856458 100644 --- a/src/sdk/src/daemon/providers/codex_server/tests.rs +++ b/src/sdk/src/daemon/providers/codex_server/tests.rs @@ -75,15 +75,27 @@ fn recording_fold() -> (FoldState, Arc>>) { /// A completed worktree helper item carrying the supplied checkout report. fn worktree_notification(path: &str, branch: &str, exit_code: i64) -> Notification { + let report = json!({ + "status": "ready", + "repository": "/repo", + "path": path, + "branch": branch, + "head": "abc", + "headShort": "abc", + "created": true, + "submodules": { + "state": "initialized_recursive", + "count": 0, + }, + "nextCommand": format!("cd {path}"), + }); notification( "item/completed", json!({ "item": { "type": "commandExecution", "command": "worktree fix-context --json", - "aggregatedOutput": format!( - "{{\"status\":\"ready\",\"repository\":\"/repo\",\"path\":\"{path}\",\"branch\":\"{branch}\",\"head\":\"abc\",\"headShort\":\"abc\",\"created\":true,\"submodules\":{{\"state\":\"initialized_recursive\",\"count\":0}},\"nextCommand\":\"cd {path}\"}}" - ), + "aggregatedOutput": report.to_string(), "exitCode": exit_code } }),