mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
## Why Multi-agent v2 `send_message` deliveries already reach the receiving model as typed `agent_message` items with encrypted content. Child-completion notifications are generated by Codex itself, so their content is plaintext and previously fell back to a serialized JSON envelope inside an assistant message. With plaintext `input_text` supported for `agent_message`, both delivery paths can use the same model-visible type while preserving explicit author and recipient metadata. ## What changed - add plaintext `input_text` support to `AgentMessageInputContent` and regenerate the affected app-server schemas - preserve `InterAgentCommunication` as structured mailbox input instead of converting it to assistant text - record delivered communications as typed `agent_message` history items - persist a dedicated rollout item so local delivery metadata such as `trigger_turn` remains available without leaking into the Responses request - reconstruct typed agent messages on resume and preserve fork-turn truncation behavior - remove request-time assistant-content parsing - preserve plaintext and encrypted inter-agent deliveries in stage-one memory inputs - normalize and link plaintext and encrypted agent messages in rollout traces without treating inbound messages as child results - cover the real MultiAgent V2 child-completion path end to end with deterministic mailbox synchronization ## Verification - `just test -p codex-core plaintext_multi_agent_v2_completion_sends_agent_message` - `just test -p codex-core input_queue_drains_mailbox_in_delivery_order record_initial_history_reconstructs_typed_inter_agent_message fork_turn_positions_use_inter_agent_delivery_metadata` - `just test -p codex-memories-write serializes_inter_agent_communications_for_memory` - `just test -p codex-rollout-trace agent_messages_preserve_routing_and_content sub_agent_started_activity_creates_spawn_edge` - `just test -p codex-rollout-trace agent_result_edge_falls_back_to_child_thread_without_result_message` - `just test -p codex-protocol -p codex-rollout -p codex-app-server-protocol`
1057 lines
34 KiB
Rust
1057 lines
34 KiB
Rust
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
|
|
use crate::model::AgentMessageMetadata;
|
|
use crate::model::ConversationBody;
|
|
use crate::model::ConversationChannel;
|
|
use crate::model::ConversationItemKind;
|
|
use crate::model::ConversationPart;
|
|
use crate::model::ConversationRole;
|
|
use crate::model::ExecutionStatus;
|
|
use crate::model::ProducerRef;
|
|
use crate::model::ToolCallKind;
|
|
use crate::model::ToolCallSummary;
|
|
use crate::payload::RawPayloadKind;
|
|
use crate::raw_event::RawTraceEventPayload;
|
|
use crate::reducer::test_support::append_inference_completion;
|
|
use crate::reducer::test_support::append_inference_start;
|
|
use crate::reducer::test_support::create_started_writer;
|
|
use crate::reducer::test_support::expect_replay_error;
|
|
use crate::reducer::test_support::message;
|
|
use crate::reducer::test_support::start_turn;
|
|
use crate::reducer::test_support::trace_context;
|
|
use crate::replay_bundle;
|
|
|
|
#[test]
|
|
fn request_snapshots_reuse_history_without_deduping_new_identical_items() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let first_request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [message("user", "ok")]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", first_request)?;
|
|
start_turn(&writer, "turn-2")?;
|
|
|
|
let second_request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [
|
|
message("user", "ok"),
|
|
message("assistant", "ack"),
|
|
message("user", "ok")
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-2", "turn-2", second_request)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let first = &rollout.inference_calls["inference-1"].request_item_ids;
|
|
let second = &rollout.inference_calls["inference-2"].request_item_ids;
|
|
|
|
assert_eq!(first.len(), 1);
|
|
assert_eq!(second.len(), 3);
|
|
assert_eq!(second[0], first[0]);
|
|
assert_ne!(second[2], first[0]);
|
|
assert_eq!(rollout.conversation_items.len(), 3);
|
|
assert_eq!(
|
|
rollout.threads["thread-root"].conversation_item_ids,
|
|
*second
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn response_outputs_enter_thread_conversation_on_completion() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [message("user", "run tests")]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
let response = writer.write_json_payload(
|
|
RawPayloadKind::InferenceResponse,
|
|
&json!({
|
|
"response_id": "resp-1",
|
|
"output_items": [
|
|
{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "tests passed"}]
|
|
}
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_completion(&writer, "inference-1", "resp-1", response)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let inference = &rollout.inference_calls["inference-1"];
|
|
let mut expected_thread_items = inference.request_item_ids.clone();
|
|
expected_thread_items.extend(inference.response_item_ids.clone());
|
|
|
|
assert_eq!(inference.response_item_ids.len(), 1);
|
|
assert_eq!(
|
|
rollout.threads["thread-root"].conversation_item_ids,
|
|
expected_thread_items,
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn agent_messages_preserve_routing_and_content() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [
|
|
{
|
|
"type": "agent_message",
|
|
"author": "/root/worker",
|
|
"recipient": "/root",
|
|
"content": [{"type": "input_text", "text": "done"}]
|
|
},
|
|
{
|
|
"type": "agent_message",
|
|
"author": "/root",
|
|
"recipient": "/root/worker",
|
|
"content": [{
|
|
"type": "encrypted_content",
|
|
"encrypted_content": "encrypted-task"
|
|
}]
|
|
}
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let actual = rollout.inference_calls["inference-1"]
|
|
.request_item_ids
|
|
.iter()
|
|
.map(|item_id| {
|
|
let item = &rollout.conversation_items[item_id];
|
|
(
|
|
item.role.clone(),
|
|
item.channel.clone(),
|
|
item.kind.clone(),
|
|
item.agent_message.clone(),
|
|
item.body.clone(),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
actual,
|
|
vec![
|
|
(
|
|
ConversationRole::Assistant,
|
|
Some(ConversationChannel::Analysis),
|
|
ConversationItemKind::Message,
|
|
Some(AgentMessageMetadata {
|
|
author: "/root/worker".to_string(),
|
|
recipient: "/root".to_string(),
|
|
}),
|
|
ConversationBody {
|
|
parts: vec![ConversationPart::Text {
|
|
text: "done".to_string(),
|
|
}],
|
|
},
|
|
),
|
|
(
|
|
ConversationRole::Assistant,
|
|
Some(ConversationChannel::Analysis),
|
|
ConversationItemKind::Message,
|
|
Some(AgentMessageMetadata {
|
|
author: "/root".to_string(),
|
|
recipient: "/root/worker".to_string(),
|
|
}),
|
|
ConversationBody {
|
|
parts: vec![ConversationPart::Encoded {
|
|
label: "encrypted_content".to_string(),
|
|
value: "encrypted-task".to_string(),
|
|
}],
|
|
},
|
|
),
|
|
]
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn later_full_request_reuses_prior_json_tool_call_by_position() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [message("user", "run tests")]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
let response = writer.write_json_payload(
|
|
RawPayloadKind::InferenceResponse,
|
|
&json!({
|
|
"response_id": "resp-1",
|
|
"output_items": [{
|
|
"type": "function_call",
|
|
"name": "shell",
|
|
"arguments": "{\"cmd\":\"cargo test\"}",
|
|
"call_id": "call-1"
|
|
}]
|
|
}),
|
|
)?;
|
|
append_inference_completion(&writer, "inference-1", "resp-1", response)?;
|
|
start_turn(&writer, "turn-2")?;
|
|
|
|
let next_request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [
|
|
message("user", "run tests"),
|
|
{
|
|
"type": "function_call",
|
|
"name": "shell",
|
|
"arguments": "{\"cmd\":\"cargo test\"}",
|
|
"call_id": "call-1"
|
|
}
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-2", "turn-2", next_request)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let first = &rollout.inference_calls["inference-1"];
|
|
let second = &rollout.inference_calls["inference-2"];
|
|
|
|
assert_eq!(
|
|
second.request_item_ids,
|
|
vec![
|
|
first.request_item_ids[0].clone(),
|
|
first.response_item_ids[0].clone(),
|
|
],
|
|
);
|
|
assert_eq!(rollout.conversation_items.len(), 2);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn incremental_request_carries_prior_request_and_response_items_forward() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [message("user", "run tests")]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
let response = writer.write_json_payload(
|
|
RawPayloadKind::InferenceResponse,
|
|
&json!({
|
|
"response_id": "resp-1",
|
|
"token_usage": {
|
|
"input_tokens": 10,
|
|
"cached_input_tokens": 1,
|
|
"output_tokens": 5,
|
|
"reasoning_output_tokens": 2,
|
|
"total_tokens": 15
|
|
},
|
|
"output_items": [
|
|
{
|
|
"type": "function_call",
|
|
"name": "shell",
|
|
"arguments": "{\"cmd\":\"cargo test\"}",
|
|
"call_id": "call-1"
|
|
}
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_completion(&writer, "inference-1", "resp-1", response)?;
|
|
start_turn(&writer, "turn-2")?;
|
|
|
|
let incremental_request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"type": "response.create",
|
|
"previous_response_id": "resp-1",
|
|
"input": [
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": "call-1",
|
|
"output": "tests passed"
|
|
}
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-2", "turn-2", incremental_request)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let first = &rollout.inference_calls["inference-1"];
|
|
let second = &rollout.inference_calls["inference-2"];
|
|
|
|
assert_eq!(first.response_item_ids.len(), 1);
|
|
assert_eq!(
|
|
second.request_item_ids,
|
|
vec![
|
|
first.request_item_ids[0].clone(),
|
|
first.response_item_ids[0].clone(),
|
|
rollout.threads["thread-root"].conversation_item_ids[2].clone(),
|
|
],
|
|
);
|
|
assert_eq!(
|
|
rollout.threads["thread-root"].conversation_item_ids,
|
|
second.request_item_ids,
|
|
);
|
|
assert_eq!(
|
|
first.usage.as_ref().map(|usage| usage.input_tokens),
|
|
Some(10),
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn full_request_snapshot_can_reorder_existing_items_and_insert_summary() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [
|
|
message("developer", "follow the repo rules"),
|
|
message("user", "count files")
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
start_turn(&writer, "turn-2")?;
|
|
|
|
let compacted_request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [
|
|
message("user", "count files"),
|
|
message("user", "summary from a compacted prior attempt"),
|
|
message("developer", "follow the repo rules")
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-2", "turn-2", compacted_request)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let first = &rollout.inference_calls["inference-1"].request_item_ids;
|
|
let second = &rollout.inference_calls["inference-2"].request_item_ids;
|
|
|
|
assert_eq!(second[0], first[1]);
|
|
assert_eq!(second[2], first[0]);
|
|
assert_ne!(second[1], first[0]);
|
|
assert_ne!(second[1], first[1]);
|
|
assert_eq!(rollout.conversation_items.len(), 3);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn reasoning_body_preserves_text_summary_and_encoded_content() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [message("user", "think visibly")]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
let response = writer.write_json_payload(
|
|
RawPayloadKind::InferenceResponse,
|
|
&json!({
|
|
"response_id": "resp-1",
|
|
"output_items": [{
|
|
"type": "reasoning",
|
|
"content": [{"type": "reasoning_text", "text": "raw reasoning"}],
|
|
"summary": [{"type": "summary_text", "text": "brief summary"}],
|
|
"encrypted_content": "encoded-reasoning"
|
|
}]
|
|
}),
|
|
)?;
|
|
append_inference_completion(&writer, "inference-1", "resp-1", response)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let reasoning_item_id = &rollout.inference_calls["inference-1"].response_item_ids[0];
|
|
|
|
assert_eq!(
|
|
rollout.conversation_items[reasoning_item_id].body.parts,
|
|
vec![
|
|
ConversationPart::Text {
|
|
text: "raw reasoning".to_string(),
|
|
},
|
|
ConversationPart::Summary {
|
|
text: "brief summary".to_string(),
|
|
},
|
|
ConversationPart::Encoded {
|
|
label: "encrypted_content".to_string(),
|
|
value: "encoded-reasoning".to_string(),
|
|
},
|
|
],
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn encrypted_reasoning_reuses_response_item_in_later_request() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let user = message("user", "count files");
|
|
let function_call = json!({
|
|
"type": "function_call",
|
|
"name": "shell",
|
|
"arguments": "{\"cmd\":\"find . -maxdepth 1 -type f | wc -l\"}",
|
|
"call_id": "call-1"
|
|
});
|
|
let encrypted_reasoning = json!({
|
|
"type": "reasoning",
|
|
"summary": [],
|
|
"encrypted_content": "encoded-reasoning"
|
|
});
|
|
let readable_reasoning = json!({
|
|
"type": "reasoning",
|
|
"content": [{"type": "text", "text": "need count"}],
|
|
"summary": [],
|
|
"encrypted_content": "encoded-reasoning"
|
|
});
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [user]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
let response = writer.write_json_payload(
|
|
RawPayloadKind::InferenceResponse,
|
|
&json!({
|
|
"response_id": "resp-1",
|
|
"output_items": [
|
|
readable_reasoning,
|
|
function_call
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_completion(&writer, "inference-1", "resp-1", response)?;
|
|
start_turn(&writer, "turn-2")?;
|
|
|
|
let followup = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [
|
|
user,
|
|
encrypted_reasoning,
|
|
function_call,
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": "call-1",
|
|
"output": "31\n"
|
|
}
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-2", "turn-2", followup)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let first = &rollout.inference_calls["inference-1"];
|
|
let second = &rollout.inference_calls["inference-2"];
|
|
let output_item_id = rollout.threads["thread-root"].conversation_item_ids[3].clone();
|
|
|
|
assert_eq!(
|
|
second.request_item_ids,
|
|
vec![
|
|
first.request_item_ids[0].clone(),
|
|
first.response_item_ids[0].clone(),
|
|
first.response_item_ids[1].clone(),
|
|
output_item_id,
|
|
],
|
|
);
|
|
assert_eq!(
|
|
rollout.conversation_items[&first.response_item_ids[0]]
|
|
.body
|
|
.parts,
|
|
vec![
|
|
ConversationPart::Text {
|
|
text: "need count".to_string(),
|
|
},
|
|
ConversationPart::Encoded {
|
|
label: "encrypted_content".to_string(),
|
|
value: "encoded-reasoning".to_string(),
|
|
},
|
|
],
|
|
);
|
|
assert_eq!(rollout.conversation_items.len(), 4);
|
|
assert_eq!(
|
|
rollout.threads["thread-root"].conversation_item_ids,
|
|
second.request_item_ids,
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn encrypted_reasoning_upgrades_when_later_sighting_has_more_readable_body() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let user = message("user", "count files");
|
|
// Both sightings carry the same encrypted identity, but each has a
|
|
// different kind of readable evidence. The reducer should keep both
|
|
// observations because text and summary are complementary.
|
|
let text_only_reasoning = json!({
|
|
"type": "reasoning",
|
|
"content": [{"type": "text", "text": "need count"}],
|
|
"summary": [],
|
|
"encrypted_content": "encoded-reasoning"
|
|
});
|
|
let summary_only_reasoning = json!({
|
|
"type": "reasoning",
|
|
"summary": [{"type": "summary_text", "text": "counting files"}],
|
|
"encrypted_content": "encoded-reasoning"
|
|
});
|
|
|
|
let first_request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [user, text_only_reasoning]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", first_request)?;
|
|
start_turn(&writer, "turn-2")?;
|
|
|
|
let second_request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [user, summary_only_reasoning]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-2", "turn-2", second_request)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let first = &rollout.inference_calls["inference-1"];
|
|
let second = &rollout.inference_calls["inference-2"];
|
|
let reasoning_item_id = &first.request_item_ids[1];
|
|
|
|
// The reducer should keep one conversation item and merge the missing
|
|
// readable kind without treating the second sighting as a conflict.
|
|
assert_eq!(&second.request_item_ids[1], reasoning_item_id);
|
|
assert_eq!(
|
|
rollout.conversation_items[reasoning_item_id].body.parts,
|
|
vec![
|
|
ConversationPart::Text {
|
|
text: "need count".to_string(),
|
|
},
|
|
ConversationPart::Summary {
|
|
text: "counting files".to_string(),
|
|
},
|
|
ConversationPart::Encoded {
|
|
label: "encrypted_content".to_string(),
|
|
value: "encoded-reasoning".to_string(),
|
|
},
|
|
],
|
|
);
|
|
assert_eq!(rollout.conversation_items.len(), 2);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn same_encrypted_reasoning_with_different_text_reuses_first_readable_body() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let user = message("user", "count files");
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [user]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
// The response is the first readable observation for this encrypted
|
|
// reasoning blob, so it is the body later conflicting sightings must not
|
|
// overwrite.
|
|
let response = writer.write_json_payload(
|
|
RawPayloadKind::InferenceResponse,
|
|
&json!({
|
|
"response_id": "resp-1",
|
|
"output_items": [{
|
|
"type": "reasoning",
|
|
"content": [{"type": "text", "text": "first text"}],
|
|
"summary": [],
|
|
"encrypted_content": "encoded-reasoning"
|
|
}]
|
|
}),
|
|
)?;
|
|
append_inference_completion(&writer, "inference-1", "resp-1", response)?;
|
|
start_turn(&writer, "turn-2")?;
|
|
|
|
let conflicting_request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [
|
|
user,
|
|
{
|
|
"type": "reasoning",
|
|
"content": [{"type": "text", "text": "different text"}],
|
|
"summary": [],
|
|
"encrypted_content": "encoded-reasoning"
|
|
}
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-2", "turn-2", conflicting_request)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let first = &rollout.inference_calls["inference-1"];
|
|
let second = &rollout.inference_calls["inference-2"];
|
|
let reasoning_item_id = &first.response_item_ids[0];
|
|
|
|
// Same encrypted identity still reuses the response item, but conflicting
|
|
// readable text is not a safe upgrade.
|
|
assert_eq!(
|
|
second.request_item_ids,
|
|
vec![first.request_item_ids[0].clone(), reasoning_item_id.clone(),],
|
|
);
|
|
assert_eq!(
|
|
rollout.conversation_items[reasoning_item_id].body.parts,
|
|
vec![
|
|
ConversationPart::Text {
|
|
text: "first text".to_string(),
|
|
},
|
|
ConversationPart::Encoded {
|
|
label: "encrypted_content".to_string(),
|
|
value: "encoded-reasoning".to_string(),
|
|
},
|
|
],
|
|
);
|
|
assert_eq!(rollout.conversation_items.len(), 2);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn model_visible_call_id_reuse_with_different_content_is_reducer_error() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [{
|
|
"type": "function_call",
|
|
"name": "shell",
|
|
"arguments": "{\"cmd\":\"cargo test\"}",
|
|
"call_id": "call-1"
|
|
}]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
start_turn(&writer, "turn-2")?;
|
|
|
|
let conflicting_request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [{
|
|
"type": "function_call",
|
|
"name": "shell",
|
|
"arguments": "{\"cmd\":\"cargo check\"}",
|
|
"call_id": "call-1"
|
|
}]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-2", "turn-2", conflicting_request)?;
|
|
|
|
expect_replay_error(
|
|
&temp,
|
|
"model-visible call id call-1 was reused with different content",
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_model_item_is_reducer_error() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [
|
|
{
|
|
"type": "new_unhandled_model_item",
|
|
"payload": "must not be silently skipped"
|
|
}
|
|
]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
expect_replay_error(
|
|
&temp,
|
|
"unsupported model item type new_unhandled_model_item",
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn missing_request_input_is_reducer_error() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"model": "gpt-test"
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
expect_replay_error(&temp, "did not contain input")
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_previous_response_id_is_reducer_error() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"previous_response_id": "resp-missing",
|
|
"input": [message("user", "still here")]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
expect_replay_error(&temp, "unknown previous_response_id resp-missing")
|
|
}
|
|
|
|
#[test]
|
|
fn compaction_boundary_repeats_prefix_and_reuses_replacement_items() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let developer = message("developer", "follow repo rules");
|
|
let user = message("user", "count files");
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [developer, user]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
let summary = message("user", "summary from compacted history");
|
|
let compaction_summary = json!({
|
|
"type": "compaction",
|
|
"encrypted_content": "encrypted-summary",
|
|
});
|
|
let checkpoint = writer.write_json_payload(
|
|
RawPayloadKind::CompactionCheckpoint,
|
|
&json!({
|
|
"input_history": [developer, user],
|
|
"replacement_history": [user, summary, compaction_summary]
|
|
}),
|
|
)?;
|
|
writer.append_with_context(
|
|
trace_context("turn-1"),
|
|
RawTraceEventPayload::CompactionInstalled {
|
|
compaction_id: "compaction-1".to_string(),
|
|
checkpoint_payload: checkpoint,
|
|
},
|
|
)?;
|
|
|
|
start_turn(&writer, "turn-2")?;
|
|
let post_compaction_request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [developer, user, summary, compaction_summary]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-2", "turn-2", post_compaction_request)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let first = &rollout.inference_calls["inference-1"];
|
|
let second = &rollout.inference_calls["inference-2"];
|
|
let compaction = &rollout.compactions["compaction-1"];
|
|
|
|
assert_eq!(compaction.input_item_ids, first.request_item_ids);
|
|
assert_eq!(second.request_item_ids.len(), 4);
|
|
assert_eq!(
|
|
&second.request_item_ids[1..],
|
|
compaction.replacement_item_ids.as_slice()
|
|
);
|
|
let marker = &rollout.conversation_items[&compaction.marker_item_id];
|
|
assert_eq!(marker.kind, ConversationItemKind::CompactionMarker);
|
|
assert_eq!(marker.body.parts, Vec::<ConversationPart>::new());
|
|
assert_eq!(
|
|
marker.produced_by,
|
|
vec![ProducerRef::Compaction {
|
|
compaction_id: "compaction-1".to_string()
|
|
}],
|
|
);
|
|
assert_ne!(second.request_item_ids[0], first.request_item_ids[0]);
|
|
assert_ne!(
|
|
compaction.replacement_item_ids[0],
|
|
first.request_item_ids[1]
|
|
);
|
|
assert_eq!(
|
|
rollout.conversation_items[&compaction.replacement_item_ids[0]].produced_by,
|
|
vec![ProducerRef::Compaction {
|
|
compaction_id: "compaction-1".to_string()
|
|
}],
|
|
);
|
|
assert_eq!(
|
|
rollout.conversation_items[&compaction.replacement_item_ids[1]].produced_by,
|
|
vec![ProducerRef::Compaction {
|
|
compaction_id: "compaction-1".to_string()
|
|
}],
|
|
);
|
|
assert_eq!(
|
|
rollout.conversation_items[&compaction.replacement_item_ids[2]].channel,
|
|
Some(ConversationChannel::Summary),
|
|
);
|
|
assert_eq!(
|
|
rollout.conversation_items[&compaction.replacement_item_ids[2]].kind,
|
|
ConversationItemKind::Message,
|
|
);
|
|
assert_eq!(
|
|
rollout.conversation_items[&compaction.replacement_item_ids[2]]
|
|
.body
|
|
.parts,
|
|
vec![ConversationPart::Encoded {
|
|
label: "encrypted_content".to_string(),
|
|
value: "encrypted-summary".to_string(),
|
|
}],
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn context_compaction_boundary_repeats_prefix_and_reuses_replacement_items() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let developer = message("developer", "follow repo rules");
|
|
let user = message("user", "count files");
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [developer, user]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
let summary = message("user", "summary from compacted history");
|
|
let compaction_summary = json!({
|
|
"type": "context_compaction",
|
|
"encrypted_content": "encrypted-summary",
|
|
});
|
|
let checkpoint = writer.write_json_payload(
|
|
RawPayloadKind::CompactionCheckpoint,
|
|
&json!({
|
|
"input_history": [developer, user],
|
|
"replacement_history": [user, summary, compaction_summary]
|
|
}),
|
|
)?;
|
|
writer.append_with_context(
|
|
trace_context("turn-1"),
|
|
RawTraceEventPayload::CompactionInstalled {
|
|
compaction_id: "compaction-1".to_string(),
|
|
checkpoint_payload: checkpoint,
|
|
},
|
|
)?;
|
|
|
|
start_turn(&writer, "turn-2")?;
|
|
let post_compaction_request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [developer, user, summary, compaction_summary]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-2", "turn-2", post_compaction_request)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let compaction = &rollout.compactions["compaction-1"];
|
|
|
|
assert_eq!(
|
|
rollout.conversation_items[&compaction.replacement_item_ids[2]].channel,
|
|
Some(ConversationChannel::Summary),
|
|
);
|
|
assert_eq!(
|
|
rollout.conversation_items[&compaction.replacement_item_ids[2]].kind,
|
|
ConversationItemKind::Message,
|
|
);
|
|
assert_eq!(
|
|
rollout.conversation_items[&compaction.replacement_item_ids[2]]
|
|
.body
|
|
.parts,
|
|
vec![ConversationPart::Encoded {
|
|
label: "encrypted_content".to_string(),
|
|
value: "encrypted-summary".to_string(),
|
|
}],
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn tool_call_links_model_call_and_followup_output_items() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
start_turn(&writer, "turn-1")?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [message("user", "run tests")]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-1", request)?;
|
|
|
|
let response = writer.write_json_payload(
|
|
RawPayloadKind::InferenceResponse,
|
|
&json!({
|
|
"response_id": "resp-1",
|
|
"output_items": [{
|
|
"type": "function_call",
|
|
"name": "exec_command",
|
|
"arguments": "{\"cmd\":\"cargo test\"}",
|
|
"call_id": "call-1"
|
|
}]
|
|
}),
|
|
)?;
|
|
append_inference_completion(&writer, "inference-1", "resp-1", response)?;
|
|
writer.append_with_context(
|
|
trace_context("turn-1"),
|
|
RawTraceEventPayload::ToolCallStarted {
|
|
tool_call_id: "tool-1".to_string(),
|
|
model_visible_call_id: Some("call-1".to_string()),
|
|
code_mode_runtime_tool_id: None,
|
|
requester: crate::raw_event::RawToolCallRequester::Model,
|
|
kind: ToolCallKind::ExecCommand,
|
|
summary: ToolCallSummary::Generic {
|
|
label: "exec_command".to_string(),
|
|
input_preview: Some("cargo test".to_string()),
|
|
output_preview: None,
|
|
},
|
|
invocation_payload: None,
|
|
},
|
|
)?;
|
|
writer.append_with_context(
|
|
trace_context("turn-1"),
|
|
RawTraceEventPayload::ToolCallEnded {
|
|
tool_call_id: "tool-1".to_string(),
|
|
status: ExecutionStatus::Completed,
|
|
result_payload: None,
|
|
},
|
|
)?;
|
|
|
|
start_turn(&writer, "turn-2")?;
|
|
let followup = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"previous_response_id": "resp-1",
|
|
"input": [{
|
|
"type": "function_call_output",
|
|
"call_id": "call-1",
|
|
"output": "tests passed"
|
|
}]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-2", "turn-2", followup)?;
|
|
|
|
let rollout = replay_bundle(temp.path())?;
|
|
let first_inference = &rollout.inference_calls["inference-1"];
|
|
let second_inference = &rollout.inference_calls["inference-2"];
|
|
let tool_call = &rollout.tool_calls["tool-1"];
|
|
let output_item_id = second_inference
|
|
.request_item_ids
|
|
.last()
|
|
.expect("follow-up output item");
|
|
|
|
assert_eq!(
|
|
first_inference.tool_call_ids_started_by_response,
|
|
vec!["tool-1".to_string()],
|
|
);
|
|
assert_eq!(
|
|
tool_call.model_visible_call_item_ids,
|
|
first_inference.response_item_ids,
|
|
);
|
|
assert_eq!(
|
|
tool_call.model_visible_output_item_ids,
|
|
vec![output_item_id.clone()],
|
|
);
|
|
assert_eq!(
|
|
rollout.conversation_items[output_item_id].produced_by,
|
|
vec![ProducerRef::Tool {
|
|
tool_call_id: "tool-1".to_string(),
|
|
}],
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn inference_start_rejects_unknown_codex_turn() -> anyhow::Result<()> {
|
|
let temp = TempDir::new()?;
|
|
let writer = create_started_writer(&temp)?;
|
|
|
|
let request = writer.write_json_payload(
|
|
RawPayloadKind::InferenceRequest,
|
|
&json!({
|
|
"input": [message("user", "hello")]
|
|
}),
|
|
)?;
|
|
append_inference_start(&writer, "inference-1", "turn-missing", request)?;
|
|
|
|
expect_replay_error(&temp, "referenced unknown codex turn turn-missing")
|
|
}
|