Files
codex/codex-rs/ext/memories/src/tests.rs
T
pakrym-oai ce0323f2f2 Support typed content in contextual prompt fragments (#54776)
## Why

Contextual fragments and extension-owned world-state sections currently return strings, limiting them to text content.

## What changed

- Return `ContentItem` from fragment bodies and rendering, preserving classification and attribution. Apply text markers only to `InputText`; pass other content through unchanged.
- Allow extension prompt and world-state contributions to carry typed content, and preserve media in context-window hints.
- Hash non-text world-state fragments by their serialized content and role while retaining the existing text hashing behavior.
- Add shared content-to-tool-output conversion that preserves image references, image detail, and audio.

## Testing

Add coverage for typed world-state hashing and preservation of image content and attribution. Expand code-mode output tests to cover input text, output text, file-backed and inline images, and audio.

GitOrigin-RevId: 2ed00d466a3f169b151202f3eef71e667ea64be1
2026-10-17 04:02:21 +00:00

618 lines
20 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::ToolCallSource;
use codex_extension_api::ToolContributor;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolName;
use codex_extension_api::ToolPayload;
use codex_protocol::models::ContentItem;
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::backend::ListMemoriesRequest;
use crate::backend::ListMemoriesResponse;
use crate::backend::MemoriesBackend;
use crate::backend::MemoryEntry;
use crate::backend::MemoryEntryType;
use crate::backend::SearchMatchMode;
use crate::backend::SearchMemoriesRequest;
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 {
version: codex_protocol::MemoryVersion::V1,
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 {
version: codex_protocol::MemoryVersion::V1,
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 {
version: codex_protocol::MemoryVersion::V1,
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 {
version: codex_protocol::MemoryVersion::V1,
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 {
version: codex_protocol::MemoryVersion::V1,
enabled: true,
dedicated_tools: false,
codex_home: tempdir.path().abs(),
});
let fragments = extension
.contribute_thread_context(&ExtensionData::new("session"), &thread_store)
.await;
assert_eq!(fragments.len(), 1);
assert_eq!(fragments[0].slot(), PromptSlot::DeveloperPolicy);
assert!(matches!(
fragments[0].content(),
ContentItem::InputText { text }
if 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(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
source: ToolCallSource::Direct,
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
attachment_store: codex_core::passthrough_image_store(),
ephemeral: false,
environments: Vec::new(),
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(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
source: ToolCallSource::Direct,
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
attachment_store: codex_core::passthrough_image_store(),
ephemeral: false,
environments: Vec::new(),
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(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
source: ToolCallSource::Direct,
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
attachment_store: codex_core::passthrough_image_store(),
ephemeral: false,
environments: Vec::new(),
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 local_listing_and_search_ignore_symlinks() {
let tempdir = tempfile::tempdir().expect("tempdir");
let memory_root = tempdir.path().join("memories");
let outside_root = tempdir.path().join("outside");
std::fs::create_dir_all(memory_root.join("nested")).expect("create memories directory");
std::fs::create_dir_all(&outside_root).expect("create outside directory");
for (path, content) in [
(memory_root.join("a.md"), "visible needle"),
(memory_root.join("nested/z.md"), "nested needle"),
(outside_root.join("secret.md"), "outside needle"),
] {
std::fs::write(path, content).expect("write memory fixture");
}
#[cfg(unix)]
std::os::unix::fs::symlink(&outside_root, memory_root.join("linked-directory"))
.expect("create memory fixture symlink");
let backend = LocalMemoriesBackend::from_memory_root(&memory_root);
let listing = backend
.list(ListMemoriesRequest {
path: None,
cursor: None,
max_results: 10,
})
.await
.expect("list visible memories");
assert_eq!(
listing,
ListMemoriesResponse {
path: None,
entries: vec![
MemoryEntry {
path: "a.md".to_string(),
entry_type: MemoryEntryType::File,
},
MemoryEntry {
path: "nested".to_string(),
entry_type: MemoryEntryType::Directory,
},
],
next_cursor: None,
truncated: false,
}
);
let response = backend
.search(SearchMemoriesRequest {
queries: vec!["needle".to_string()],
match_mode: SearchMatchMode::Any,
path: None,
cursor: None,
context_lines: 0,
case_sensitive: false,
normalized: false,
max_results: 10,
})
.await
.expect("search visible memories");
let paths = response
.matches
.iter()
.map(|matched| matched.path.as_str())
.collect::<Vec<_>>();
assert_eq!(paths, vec!["a.md", "nested/z.md"]);
}
#[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(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
source: ToolCallSource::Direct,
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
attachment_store: codex_core::passthrough_image_store(),
ephemeral: false,
environments: Vec::new(),
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(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
source: ToolCallSource::Direct,
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
attachment_store: codex_core::passthrough_image_store(),
ephemeral: false,
environments: Vec::new(),
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(),
codex_turn_metadata: None,
truncation_policy: TruncationPolicy::Bytes(1024),
source: ToolCallSource::Direct,
conversation_history: codex_extension_api::ConversationHistory::default(),
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
attachment_store: codex_core::passthrough_image_store(),
ephemeral: false,
environments: Vec::new(),
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 for<'call> ToolExecutor<ToolCall<'call>>> {
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)
}