This commit is contained in:
jimmyfraiture
2025-10-06 16:39:47 +01:00
parent 4967cfbebd
commit ad8e6db038

View File

@@ -19,7 +19,9 @@ use core_test_support::wait_for_event;
use serde_json::Value;
use tokio::test;
use wiremock::matchers::any;
use wiremock::matchers::body_string_contains;
#[allow(clippy::expect_used)]
async fn collect_tool_names(model: &str) -> Result<Vec<String>> {
let server = start_mock_server().await;
let model_owned = model.to_string();
@@ -115,3 +117,182 @@ async fn gpt5_codex_models_do_not_expose_subsession_tools() -> Result<()> {
);
Ok(())
}
#[test(flavor = "multi_thread", worker_threads = 2)]
async fn subsession_can_apply_patch_to_workspace() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
// Ensure subsession tools are exposed and apply_patch is available in child.
config.model = "test-gpt-5-codex".to_string();
config.model_family =
find_family_for_model("test-gpt-5-codex").expect("model family available for test");
config.include_apply_patch_tool = true;
});
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder.build(&server).await?;
// Parent turn 1: ask to spawn a subsession to create a file.
// The parent model will call create_session with a prompt instructing the child.
let parent_first = sse(vec![
ev_response_created("resp-parent-1"),
responses::ev_function_call(
"create-session-1",
"create_session",
&serde_json::json!({
"session_type": "default",
"prompt": "Create a file named subsession.txt with the exact contents 'Hello from subsession'",
})
.to_string(),
),
ev_completed("resp-parent-1"),
]);
responses::mount_sse_once_match(&server, any(), parent_first).await;
// Child turn 1: upon spawn, the child will call apply_patch to create the file.
// Match on the subsession instructions to route this to the child conversation.
let child_first = sse(vec![
ev_response_created("resp-child-1"),
responses::ev_apply_patch_function_call(
"apply-patch-child-1",
r#"*** Begin Patch
*** Add File: subsession.txt
+Hello from subsession
*** End Patch"#,
),
ev_completed("resp-child-1"),
]);
responses::mount_sse_once_match(
&server,
body_string_contains("You are a compact subsession assistant"),
child_first,
)
.await;
// Parent follow-up: after the tool result is returned, the parent may send a
// subsequent request. Provide a simple assistant message to close the turn.
let parent_second = sse(vec![
ev_assistant_message("msg-parent-2", "subsession started"),
ev_completed("resp-parent-2"),
]);
responses::mount_sse_once_match(
&server,
body_string_contains("\"function_call_output\""),
parent_second,
)
.await;
// Child follow-up: after apply_patch executes, the child continues and then finishes.
let child_second = sse(vec![
ev_assistant_message("msg-child-2", "done"),
ev_completed("resp-child-2"),
]);
responses::mount_sse_once_match(
&server,
body_string_contains("You are a compact subsession assistant"),
child_second,
)
.await;
// Kick off the parent turn which should spawn the subsession.
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![InputItem::Text {
text: "please spawn a subsession to create a file".into(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
// Capture the child session id from the background event.
let mut child_id: Option<String> = None;
wait_for_event(&codex, |event| match event {
EventMsg::BackgroundEvent(ev) => {
if let Some((_, after)) = ev.message.split_once("spawned child session ")
&& let Some((id, _)) = after.split_once(' ')
{
child_id = Some(id.to_string());
return true;
}
false
}
_ => false,
})
.await;
// Wait for the child to complete and emit its final background event.
// This makes the file write deterministic before we assert.
let _ = wait_for_event(&codex, |event| match event {
EventMsg::BackgroundEvent(ev) => {
if let Some(id) = child_id.as_deref() {
return ev
.message
.contains(&format!("child session {id} completed"));
}
false
}
_ => false,
})
.await;
// Debug: inspect recorded requests to confirm routing during failures.
if let Some(requests) = server.received_requests().await {
for (i, req) in requests.iter().enumerate() {
if let Ok(body) = req.body_json::<Value>() {
let instr = body
.get("instructions")
.and_then(Value::as_str)
.unwrap_or("");
let has_apply = body
.get("tools")
.and_then(Value::as_array)
.map(|a| {
a.iter().any(|t| {
t.get("function")
.and_then(|f| f.get("name"))
.and_then(Value::as_str)
== Some("apply_patch")
|| t.get("name").and_then(Value::as_str) == Some("apply_patch")
})
})
.unwrap_or(false);
let has_fn_output = body
.get("input")
.and_then(Value::as_array)
.map(|a| {
a.iter().any(|it| {
it.get("type").and_then(Value::as_str) == Some("function_call_output")
})
})
.unwrap_or(false);
eprintln!(
"req#{i}: instr_has_subsession_prompt={} tools_include_apply_patch={} has_fn_output={}",
instr.contains("compact subsession assistant"),
has_apply,
has_fn_output,
);
}
}
}
// Verify the file created by the subsession exists with the expected contents.
let created_path = cwd.path().join("subsession.txt");
let contents = std::fs::read_to_string(&created_path)
.unwrap_or_else(|e| panic!("failed reading {}: {e}", created_path.display()));
assert_eq!(contents, "Hello from subsession\n");
Ok(())
}