mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why The standalone `/v1/alpha/search` request now requires a `model`, but the `web.run` extension currently omits it. Adds `model` to extension `ToolCall` invocation. Follow-up to #23823. ## What changed - Make `SearchRequest.model` required. - Expose the effective per-turn model on extension tool calls and pass it in standalone web-search requests. - Assert the model is forwarded in the app-server round-trip test. ## Testing - `just test -p codex-api -p codex-tools -p codex-web-search-extension -p codex-memories-extension -p codex-goal-extension` - `just test -p codex-core -E 'test(passes_turn_fields_and_scoped_turn_item_emitter_to_extension_call)'` - `just test -p codex-app-server -E 'test(standalone_web_search_round_trips_encrypted_output)'`
504 lines
16 KiB
Rust
504 lines
16 KiB
Rust
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use codex_extension_api::ContextContributor;
|
|
use codex_extension_api::ExtensionData;
|
|
use codex_extension_api::ExtensionRegistryBuilder;
|
|
use codex_extension_api::NoopTurnItemEmitter;
|
|
use codex_extension_api::PromptSlot;
|
|
use codex_extension_api::ToolCall;
|
|
use codex_extension_api::ToolContributor;
|
|
use codex_extension_api::ToolExecutor;
|
|
use codex_extension_api::ToolName;
|
|
use codex_extension_api::ToolPayload;
|
|
use codex_tools::ToolOutput;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_absolute_path::test_support::PathExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
use codex_utils_output_truncation::TruncationPolicy;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
|
|
use crate::extension::MemoriesExtension;
|
|
use crate::extension::MemoriesExtensionConfig;
|
|
use crate::local::LocalMemoriesBackend;
|
|
|
|
#[test]
|
|
fn memory_tool_namespace_matches_responses_api_identifier() {
|
|
assert!(!crate::MEMORY_TOOLS_NAMESPACE.is_empty());
|
|
assert!(
|
|
crate::MEMORY_TOOLS_NAMESPACE
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tools_are_not_contributed_without_thread_config() {
|
|
let extension = MemoriesExtension::default();
|
|
|
|
assert!(
|
|
extension
|
|
.tools(
|
|
&ExtensionData::new("session"),
|
|
&ExtensionData::new("thread")
|
|
)
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tools_are_not_contributed_when_disabled() {
|
|
let extension = MemoriesExtension::default();
|
|
let thread_store = ExtensionData::new("thread");
|
|
thread_store.insert(MemoriesExtensionConfig {
|
|
enabled: false,
|
|
dedicated_tools: true,
|
|
codex_home: test_path_buf("/tmp/codex-home").abs(),
|
|
});
|
|
|
|
assert!(
|
|
extension
|
|
.tools(&ExtensionData::new("session"), &thread_store)
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tools_are_not_contributed_when_dedicated_tools_disabled() {
|
|
let extension = MemoriesExtension::default();
|
|
let thread_store = ExtensionData::new("thread");
|
|
thread_store.insert(MemoriesExtensionConfig {
|
|
enabled: true,
|
|
dedicated_tools: false,
|
|
codex_home: test_path_buf("/tmp/codex-home").abs(),
|
|
});
|
|
|
|
assert!(
|
|
extension
|
|
.tools(&ExtensionData::new("session"), &thread_store)
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tools_are_contributed_when_enabled_with_dedicated_tools() {
|
|
let extension = MemoriesExtension::default();
|
|
let thread_store = ExtensionData::new("thread");
|
|
thread_store.insert(MemoriesExtensionConfig {
|
|
enabled: true,
|
|
dedicated_tools: true,
|
|
codex_home: test_path_buf("/tmp/codex-home").abs(),
|
|
});
|
|
|
|
let tool_names = extension
|
|
.tools(&ExtensionData::new("session"), &thread_store)
|
|
.into_iter()
|
|
.map(|tool| tool.tool_name())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
tool_names,
|
|
vec![
|
|
memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
|
|
memory_tool_name(crate::LIST_TOOL_NAME),
|
|
memory_tool_name(crate::READ_TOOL_NAME),
|
|
memory_tool_name(crate::SEARCH_TOOL_NAME),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_registers_dedicated_tool_contributor() {
|
|
let mut builder = ExtensionRegistryBuilder::<codex_core::config::Config>::new();
|
|
crate::install(&mut builder, /*metrics_client*/ None);
|
|
let registry = builder.build();
|
|
let thread_store = ExtensionData::new("thread");
|
|
thread_store.insert(MemoriesExtensionConfig {
|
|
enabled: true,
|
|
dedicated_tools: true,
|
|
codex_home: test_path_buf("/tmp/codex-home").abs(),
|
|
});
|
|
|
|
let tool_names = registry
|
|
.tool_contributors()
|
|
.iter()
|
|
.flat_map(|contributor| contributor.tools(&ExtensionData::new("session"), &thread_store))
|
|
.map(|tool| tool.tool_name())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
tool_names,
|
|
vec![
|
|
memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
|
|
memory_tool_name(crate::LIST_TOOL_NAME),
|
|
memory_tool_name(crate::READ_TOOL_NAME),
|
|
memory_tool_name(crate::SEARCH_TOOL_NAME),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ad_hoc_tool_definition_includes_filename_contract() {
|
|
let tool = memory_tool(
|
|
Path::new("/tmp/codex-home/memories"),
|
|
crate::ADD_AD_HOC_NOTE_TOOL_NAME,
|
|
);
|
|
let spec = serde_json::to_value(tool.spec()).expect("serialize tool spec");
|
|
|
|
let filename = spec
|
|
.pointer("/tools/0/parameters/properties/filename")
|
|
.expect("filename parameter should be in tool schema");
|
|
assert_eq!(filename.pointer("/type"), Some(&json!("string")));
|
|
assert!(
|
|
filename
|
|
.pointer("/description")
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|description| description.contains("YYYY-MM-DDTHH-MM-SS-<slug>.md"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_contribution_uses_memory_summary_when_enabled() {
|
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
|
let memories_dir = tempdir.path().join("memories");
|
|
tokio::fs::create_dir_all(&memories_dir)
|
|
.await
|
|
.expect("create memories dir");
|
|
tokio::fs::write(
|
|
memories_dir.join("memory_summary.md"),
|
|
"Remember repository-specific implementation preferences.",
|
|
)
|
|
.await
|
|
.expect("write memory summary");
|
|
|
|
let extension = MemoriesExtension::default();
|
|
let thread_store = ExtensionData::new("thread");
|
|
thread_store.insert(MemoriesExtensionConfig {
|
|
enabled: true,
|
|
dedicated_tools: false,
|
|
codex_home: tempdir.path().abs(),
|
|
});
|
|
|
|
let fragments = extension
|
|
.contribute(&ExtensionData::new("session"), &thread_store)
|
|
.await;
|
|
|
|
assert_eq!(fragments.len(), 1);
|
|
assert_eq!(fragments[0].slot(), PromptSlot::DeveloperPolicy);
|
|
assert!(
|
|
fragments[0]
|
|
.text()
|
|
.contains("Remember repository-specific implementation preferences.")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_ad_hoc_note_tool_creates_note_file() {
|
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
|
let memory_root = tempdir.path().join("memories");
|
|
let tool = memory_tool(&memory_root, crate::ADD_AD_HOC_NOTE_TOOL_NAME);
|
|
let payload = ToolPayload::Function {
|
|
arguments: json!({
|
|
"filename": "2026-05-26T13-42-08-remember-review-style.md",
|
|
"note": "Remember to keep PR review comments concise.",
|
|
})
|
|
.to_string(),
|
|
};
|
|
|
|
let output = tool
|
|
.handle(ToolCall {
|
|
turn_id: "turn-1".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
|
|
model: "gpt-test".to_string(),
|
|
truncation_policy: TruncationPolicy::Bytes(1024),
|
|
conversation_history: codex_extension_api::ConversationHistory::default(),
|
|
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
|
payload: payload.clone(),
|
|
})
|
|
.await
|
|
.expect("ad-hoc note should be written");
|
|
|
|
assert_eq!(
|
|
output.post_tool_use_response("call-1", &payload),
|
|
Some(json!({}))
|
|
);
|
|
assert_eq!(
|
|
tokio::fs::read_to_string(
|
|
memory_root
|
|
.join("extensions/ad_hoc/notes")
|
|
.join("2026-05-26T13-42-08-remember-review-style.md")
|
|
)
|
|
.await
|
|
.expect("read ad-hoc note"),
|
|
"Remember to keep PR review comments concise."
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_ad_hoc_note_tool_rejects_paths_as_filenames() {
|
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
|
let memory_root = tempdir.path().join("memories");
|
|
let tool = memory_tool(&memory_root, crate::ADD_AD_HOC_NOTE_TOOL_NAME);
|
|
let payload = ToolPayload::Function {
|
|
arguments: json!({
|
|
"filename": "../2026-05-26T13-42-08-remember-review-style.md",
|
|
"note": "Remember to keep PR review comments concise.",
|
|
})
|
|
.to_string(),
|
|
};
|
|
|
|
let result = tool
|
|
.handle(ToolCall {
|
|
turn_id: "turn-1".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME),
|
|
model: "gpt-test".to_string(),
|
|
truncation_policy: TruncationPolicy::Bytes(1024),
|
|
conversation_history: codex_extension_api::ConversationHistory::default(),
|
|
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
|
payload,
|
|
})
|
|
.await;
|
|
let err = match result {
|
|
Ok(_) => panic!("path-like filename should be rejected"),
|
|
Err(err) => err,
|
|
};
|
|
|
|
assert!(err.to_string().contains("filename"));
|
|
assert!(err.to_string().contains("YYYY-MM-DDTHH-MM-SS"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_tool_reads_memory_file() {
|
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
|
let memory_root = tempdir.path().join("memories");
|
|
tokio::fs::create_dir_all(&memory_root)
|
|
.await
|
|
.expect("create memories dir");
|
|
tokio::fs::write(
|
|
memory_root.join("MEMORY.md"),
|
|
"first line\nsecond needle line\nthird line\n",
|
|
)
|
|
.await
|
|
.expect("write memory");
|
|
let tool = memory_tool(&memory_root, crate::READ_TOOL_NAME);
|
|
let payload = ToolPayload::Function {
|
|
arguments: json!({
|
|
"path": "MEMORY.md",
|
|
"line_offset": 2,
|
|
"max_lines": 1
|
|
})
|
|
.to_string(),
|
|
};
|
|
|
|
let output = tool
|
|
.handle(ToolCall {
|
|
turn_id: "turn-1".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
tool_name: memory_tool_name(crate::READ_TOOL_NAME),
|
|
model: "gpt-test".to_string(),
|
|
truncation_policy: TruncationPolicy::Bytes(1024),
|
|
conversation_history: codex_extension_api::ConversationHistory::default(),
|
|
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
|
payload: payload.clone(),
|
|
})
|
|
.await
|
|
.expect("read should succeed");
|
|
|
|
assert_eq!(
|
|
output.post_tool_use_response("call-1", &payload),
|
|
Some(json!({
|
|
"path": "MEMORY.md",
|
|
"content": "second needle line\n",
|
|
"start_line_number": 2,
|
|
"truncated": true
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn search_tool_accepts_multiple_queries() {
|
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
|
let memory_root = tempdir.path().join("memories");
|
|
tokio::fs::create_dir_all(&memory_root)
|
|
.await
|
|
.expect("create memories dir");
|
|
tokio::fs::write(
|
|
memory_root.join("MEMORY.md"),
|
|
"alpha only\nneedle only\nalpha needle\n",
|
|
)
|
|
.await
|
|
.expect("write memory");
|
|
let tool = memory_tool(&memory_root, crate::SEARCH_TOOL_NAME);
|
|
let payload = ToolPayload::Function {
|
|
arguments: json!({
|
|
"queries": ["alpha", "needle"],
|
|
"case_sensitive": false
|
|
})
|
|
.to_string(),
|
|
};
|
|
|
|
let output = tool
|
|
.handle(ToolCall {
|
|
turn_id: "turn-1".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
|
|
model: "gpt-test".to_string(),
|
|
truncation_policy: TruncationPolicy::Bytes(1024),
|
|
conversation_history: codex_extension_api::ConversationHistory::default(),
|
|
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
|
payload: payload.clone(),
|
|
})
|
|
.await
|
|
.expect("search should succeed");
|
|
|
|
assert_eq!(
|
|
output.post_tool_use_response("call-1", &payload),
|
|
Some(json!({
|
|
"queries": ["alpha", "needle"],
|
|
"match_mode": {
|
|
"type": "any"
|
|
},
|
|
"path": null,
|
|
"matches": [
|
|
{
|
|
"path": "MEMORY.md",
|
|
"match_line_number": 1,
|
|
"content_start_line_number": 1,
|
|
"content": "alpha only",
|
|
"matched_queries": ["alpha"]
|
|
},
|
|
{
|
|
"path": "MEMORY.md",
|
|
"match_line_number": 2,
|
|
"content_start_line_number": 2,
|
|
"content": "needle only",
|
|
"matched_queries": ["needle"]
|
|
},
|
|
{
|
|
"path": "MEMORY.md",
|
|
"match_line_number": 3,
|
|
"content_start_line_number": 3,
|
|
"content": "alpha needle",
|
|
"matched_queries": ["alpha", "needle"]
|
|
}
|
|
],
|
|
"next_cursor": null,
|
|
"truncated": false
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn search_tool_accepts_windowed_all_match_mode() {
|
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
|
let memory_root = tempdir.path().join("memories");
|
|
tokio::fs::create_dir_all(&memory_root)
|
|
.await
|
|
.expect("create memories dir");
|
|
tokio::fs::write(memory_root.join("MEMORY.md"), "alpha\nmiddle\nneedle\n")
|
|
.await
|
|
.expect("write memory");
|
|
let tool = memory_tool(&memory_root, crate::SEARCH_TOOL_NAME);
|
|
let payload = ToolPayload::Function {
|
|
arguments: json!({
|
|
"queries": ["alpha", "needle"],
|
|
"match_mode": {
|
|
"type": "all_within_lines",
|
|
"line_count": 3
|
|
}
|
|
})
|
|
.to_string(),
|
|
};
|
|
|
|
let output = tool
|
|
.handle(ToolCall {
|
|
turn_id: "turn-1".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
|
|
model: "gpt-test".to_string(),
|
|
truncation_policy: TruncationPolicy::Bytes(1024),
|
|
conversation_history: codex_extension_api::ConversationHistory::default(),
|
|
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
|
payload: payload.clone(),
|
|
})
|
|
.await
|
|
.expect("search should succeed");
|
|
|
|
assert_eq!(
|
|
output.post_tool_use_response("call-1", &payload),
|
|
Some(json!({
|
|
"queries": ["alpha", "needle"],
|
|
"match_mode": {
|
|
"type": "all_within_lines",
|
|
"line_count": 3
|
|
},
|
|
"path": null,
|
|
"matches": [
|
|
{
|
|
"path": "MEMORY.md",
|
|
"match_line_number": 1,
|
|
"content_start_line_number": 1,
|
|
"content": "alpha\nmiddle\nneedle",
|
|
"matched_queries": ["alpha", "needle"]
|
|
}
|
|
],
|
|
"next_cursor": null,
|
|
"truncated": false
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn search_tool_rejects_legacy_single_query() {
|
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
|
let memory_root = tempdir.path().join("memories");
|
|
tokio::fs::create_dir_all(&memory_root)
|
|
.await
|
|
.expect("create memories dir");
|
|
let tool = memory_tool(&memory_root, crate::SEARCH_TOOL_NAME);
|
|
let payload = ToolPayload::Function {
|
|
arguments: json!({
|
|
"query": "needle",
|
|
})
|
|
.to_string(),
|
|
};
|
|
|
|
let result = tool
|
|
.handle(ToolCall {
|
|
turn_id: "turn-1".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME),
|
|
model: "gpt-test".to_string(),
|
|
truncation_policy: TruncationPolicy::Bytes(1024),
|
|
conversation_history: codex_extension_api::ConversationHistory::default(),
|
|
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
|
payload,
|
|
})
|
|
.await;
|
|
let err = match result {
|
|
Ok(_) => panic!("legacy query field should be rejected"),
|
|
Err(err) => err,
|
|
};
|
|
|
|
assert!(err.to_string().contains("unknown field"));
|
|
assert!(err.to_string().contains("query"));
|
|
}
|
|
|
|
fn memory_tool(memory_root: &Path, tool_name: &str) -> Arc<dyn ToolExecutor<ToolCall>> {
|
|
let expected_tool_name = memory_tool_name(tool_name);
|
|
crate::tools::memory_tools(
|
|
LocalMemoriesBackend::from_memory_root(memory_root),
|
|
/*metrics_client*/ None,
|
|
)
|
|
.into_iter()
|
|
.find(|tool| tool.tool_name() == expected_tool_name)
|
|
.unwrap_or_else(|| panic!("{tool_name} tool should be registered"))
|
|
}
|
|
|
|
fn memory_tool_name(tool_name: &str) -> ToolName {
|
|
ToolName::namespaced(crate::MEMORY_TOOLS_NAMESPACE, tool_name)
|
|
}
|