use super::*;
#[tokio::test]
async fn session_config_change_hook_receives_context_window_updates() {
let observed = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let observed_hook = Arc::clone(&observed);
let plugin = Arc::new(RuntimeTestPluginFactory {
build: Arc::new(move |_| {
let observed = Arc::clone(&observed_hook);
Ok(Arc::new(RuntimeTestPlugin {
before_turn: None,
checkpoint: None,
tool_result_projector: None,
runtime_event: Some(Arc::new(move |event| {
let observed = Arc::clone(&observed);
Box::pin(async move {
if let crate::plugin::PluginRuntimeEvent::SessionConfigChanged(ctx) = event
{
observed.lock().await.push((ctx.previous, ctx.current));
}
Ok(())
})
})),
external_registrar: None,
}))
}),
});
let transport = mock_provider(Vec::new());
let mut runtime = runtime_with_plugins(vec![plugin], transport).await;
let alt_provider = TestProvider::builder()
.kind("alt")
.default_model("alt-model")
.complete_error("alt provider not wired")
.build();
runtime
.update_session_config(
Some(alt_provider.into_handle()),
Some("alt-model".to_string()),
Some(None),
Some(123_456),
None,
)
.await;
let changes = observed.lock().await;
assert_eq!(changes.len(), 1);
let (previous, current) = &changes[0];
assert_eq!(previous.provider.kind(), "mock");
assert_eq!(current.provider.kind(), "alt");
assert_eq!(current.model, "alt-model");
assert_ne!(previous.max_context_tokens, current.max_context_tokens);
}
#[tokio::test]
async fn plugin_before_turn_can_abort_and_inject_messages() {
let plugin = Arc::new(RuntimeTestPluginFactory {
build: Arc::new(|_| {
Ok(Arc::new(RuntimeTestPlugin {
before_turn: Some(Arc::new(|_| {
Box::pin(async {
Ok(vec![
crate::PluginDirective::EnqueueMessages {
messages: vec![crate::PluginMessage::text(
crate::MessageRole::System,
"plugin preface",
)],
},
crate::PluginDirective::AbortTurn {
code: "blocked".to_string(),
message: "plugin stopped the turn".to_string(),
},
])
})
})),
checkpoint: None,
tool_result_projector: None,
runtime_event: None,
external_registrar: None,
}))
}),
});
let transport = mock_provider(Vec::new());
let mut runtime = runtime_with_plugins(vec![plugin], transport).await;
let turn = runtime
.run_turn_assembled(
TurnInput {
items: vec![InputItem::Text {
text: "hello".to_string(),
}],
image_blobs: HashMap::new(),
mode_turn_options: None,
trace_turn_id: None,
mode_extension: None,
turn_context: crate::TurnContext::default(),
},
CancellationToken::new(),
)
.await
.expect("turn");
assert!(matches!(&turn.outcome, TurnOutcome::Stopped(_)));
assert!(matches!(
&turn.outcome,
TurnOutcome::Stopped(TurnStop::PluginAbort)
));
assert!(turn.errors.iter().any(|issue| issue.kind == "plugin"));
assert!(
active_conversation_messages(&turn.state)
.iter()
.any(|message| {
message
.parts
.iter()
.any(|part| part.content.contains("plugin preface"))
})
);
}
#[tokio::test]
async fn normal_turn_stores_effective_user_text_in_state() {
let transport = mock_provider(vec![MockCall {
stream_events: Vec::new(),
response: Ok(LlmResponse {
full_text: "Done".to_string(),
parts: vec![LlmOutputPart::Text {
text: "Done".to_string(),
response_meta: None,
}],
..LlmResponse::default()
}),
}]);
let mut runtime = runtime_with_plugins(Vec::new(), transport).await;
let turn = runtime
.run_turn_assembled(
TurnInput {
items: vec![InputItem::Text {
text: "/yolopush\n\n<skill>\nbody\n</skill>".to_string(),
}],
image_blobs: HashMap::new(),
mode_turn_options: None,
trace_turn_id: None,
mode_extension: None,
turn_context: crate::TurnContext::default(),
},
CancellationToken::new(),
)
.await
.expect("turn");
let read_model = turn.state.read_model();
let user_message = read_model
.messages
.iter()
.find(|message| message.role == MessageRole::User)
.expect("user message");
assert_eq!(
user_message.parts.first().map(|part| part.content.as_str()),
Some("/yolopush\n\n<skill>\nbody\n</skill>")
);
}
#[tokio::test]
async fn retryable_llm_failures_exhaust_and_fail_turn() {
let transport = mock_provider(vec![
MockCall {
stream_events: Vec::new(),
response: Err(
crate::llm::transport::LlmTransportError::new("provider unavailable")
.retryable(true)
.with_code("http_500"),
),
},
MockCall {
stream_events: Vec::new(),
response: Err(
crate::llm::transport::LlmTransportError::new("provider unavailable")
.retryable(true)
.with_code("http_500"),
),
},
MockCall {
stream_events: Vec::new(),
response: Err(
crate::llm::transport::LlmTransportError::new("provider unavailable")
.retryable(true)
.with_code("http_500"),
),
},
MockCall {
stream_events: Vec::new(),
response: Err(
crate::llm::transport::LlmTransportError::new("provider unavailable")
.retryable(true)
.with_code("http_500"),
),
},
]);
let mut runtime = runtime_with_plugins(Vec::new(), transport).await;
let turn = runtime
.run_turn_assembled(
TurnInput {
items: vec![InputItem::Text {
text: "hello".to_string(),
}],
image_blobs: HashMap::new(),
mode_turn_options: None,
trace_turn_id: None,
mode_extension: None,
turn_context: crate::TurnContext::default(),
},
CancellationToken::new(),
)
.await
.expect("turn");
assert!(matches!(&turn.outcome, TurnOutcome::Stopped(_)));
assert!(matches!(
&turn.outcome,
TurnOutcome::Stopped(TurnStop::ProviderError)
));
assert!(turn.errors.iter().any(|issue| issue.kind == "llm_provider"));
assert!(
turn.errors
.iter()
.any(|issue| issue.message.contains("provider unavailable"))
);
}
#[tokio::test]
async fn bridge_checkpoint_injection_continues_standard_turn() {
let bridge = crate::TurnInputInjectionBridge::new();
bridge
.enqueue(vec![crate::InjectedTurnInput {
id: None,
message: crate::PluginMessage::text(crate::MessageRole::User, "one more thing"),
}])
.expect("enqueue");
let transport = mock_provider(vec![
MockCall {
stream_events: Vec::new(),
response: Ok(LlmResponse {
full_text: "First answer.".to_string(),
parts: vec![LlmOutputPart::Text {
text: "First answer.".to_string(),
response_meta: None,
}],
..LlmResponse::default()
}),
},
MockCall {
stream_events: Vec::new(),
response: Ok(LlmResponse {
full_text: "Second answer.".to_string(),
parts: vec![LlmOutputPart::Text {
text: "Second answer.".to_string(),
response_meta: None,
}],
..LlmResponse::default()
}),
},
]);
let mut runtime = standard_runtime_with_input_bridge(transport, bridge).await;
let turn = runtime
.run_turn_assembled(
TurnInput {
items: vec![InputItem::Text {
text: "hello".to_string(),
}],
image_blobs: HashMap::new(),
mode_turn_options: None,
trace_turn_id: None,
mode_extension: None,
turn_context: crate::TurnContext::default(),
},
CancellationToken::new(),
)
.await
.expect("turn");
assert!(
active_conversation_messages(&turn.state)
.iter()
.any(|message| {
message.role == MessageRole::Assistant
&& message
.parts
.iter()
.any(|part| part.content.contains("Second answer."))
})
);
assert!(
active_conversation_messages(&turn.state)
.iter()
.all(|message| {
!(message.role == MessageRole::User
&& message
.parts
.iter()
.any(|part| part.content == "one more thing"))
})
);
}
#[tokio::test]
async fn bridge_checkpoint_injection_preserves_images() {
let bridge = crate::TurnInjectionBridge::new();
bridge
.enqueue(vec![crate::PluginMessage {
role: crate::MessageRole::User,
content: "see image".to_string(),
parts: vec![
crate::Part {
id: String::new(),
kind: crate::PartKind::Image,
content: String::new(),
attachment: Some(crate::session_model::message::PartAttachment {
reference: crate::AttachmentRef {
id: crate::AttachmentId::new("test-image"),
media_type: crate::MediaType::Image(crate::ImageMediaType::Png),
byte_len: 3,
width: None,
height: None,
label: None,
},
}),
tool_call_id: None,
tool_name: None,
tool_replay: None,
prune_state: crate::PruneState::Intact,
reasoning_meta: None,
response_meta: None,
},
crate::Part {
id: String::new(),
kind: crate::PartKind::Text,
content: "see image".to_string(),
attachment: None,
tool_call_id: None,
tool_name: None,
tool_replay: None,
prune_state: crate::PruneState::Intact,
reasoning_meta: None,
response_meta: None,
},
],
images: Vec::new(),
}])
.expect("enqueue");
let transport = mock_provider(vec![
MockCall {
stream_events: Vec::new(),
response: Ok(LlmResponse {
full_text: "First answer.".to_string(),
parts: vec![LlmOutputPart::Text {
text: "First answer.".to_string(),
response_meta: None,
}],
..LlmResponse::default()
}),
},
MockCall {
stream_events: Vec::new(),
response: Ok(LlmResponse {
full_text: "Second answer.".to_string(),
parts: vec![LlmOutputPart::Text {
text: "Second answer.".to_string(),
response_meta: None,
}],
..LlmResponse::default()
}),
},
]);
let mut runtime = standard_runtime_with_bridge(transport, bridge).await;
let turn = runtime
.run_turn_assembled(
TurnInput {
items: vec![InputItem::Text {
text: "hello".to_string(),
}],
image_blobs: HashMap::new(),
mode_turn_options: None,
trace_turn_id: None,
mode_extension: None,
turn_context: crate::TurnContext::default(),
},
CancellationToken::new(),
)
.await
.expect("turn");
assert!(
active_conversation_messages(&turn.state)
.iter()
.any(|message| {
message.role == MessageRole::User
&& message.parts.iter().any(|part| {
matches!(part.kind, PartKind::Image) && part.attachment.is_some()
})
})
);
}
#[tokio::test]
async fn checkpoint_hook_can_inject_messages() {
let plugin = Arc::new(RuntimeTestPluginFactory {
build: Arc::new(|_| {
Ok(Arc::new(RuntimeTestPlugin {
before_turn: None,
checkpoint: Some(Arc::new(|ctx| {
Box::pin(async move {
if ctx.checkpoint == crate::CheckpointKind::BeforeCompletion {
Ok(vec![crate::PluginDirective::EnqueueMessages {
messages: vec![crate::PluginMessage::text(
crate::MessageRole::System,
"checkpoint injected",
)],
}])
} else {
Ok(Vec::new())
}
})
})),
tool_result_projector: None,
runtime_event: None,
external_registrar: None,
}))
}),
});
let transport = mock_provider(vec![
MockCall {
stream_events: Vec::new(),
response: Ok(LlmResponse {
full_text: "First answer.".to_string(),
parts: vec![LlmOutputPart::Text {
text: "First answer.".to_string(),
response_meta: None,
}],
..LlmResponse::default()
}),
},
MockCall {
stream_events: Vec::new(),
response: Ok(LlmResponse {
full_text: "Second answer.".to_string(),
parts: vec![LlmOutputPart::Text {
text: "Second answer.".to_string(),
response_meta: None,
}],
..LlmResponse::default()
}),
},
]);
let mut runtime = runtime_with_plugins(vec![plugin], transport).await;
let turn = runtime
.run_turn_assembled(
TurnInput {
items: vec![InputItem::Text {
text: "hello".to_string(),
}],
image_blobs: HashMap::new(),
mode_turn_options: None,
trace_turn_id: None,
mode_extension: None,
turn_context: crate::TurnContext::default(),
},
CancellationToken::new(),
)
.await
.expect("turn");
assert!(
active_conversation_messages(&turn.state)
.iter()
.any(|message| {
message.role == MessageRole::System
&& message
.parts
.iter()
.any(|part| part.content == "checkpoint injected")
})
);
}
#[tokio::test]
async fn turn_injection_bridge_accepts_active_turn_input_without_persisting_duplicate_user_message()
{
let bridge = crate::TurnInputInjectionBridge::new();
bridge
.enqueue(vec![crate::InjectedTurnInput {
id: Some("follow-up-id".to_string()),
message: crate::PluginMessage {
role: crate::MessageRole::User,
content: "follow up".to_string(),
parts: Vec::new(),
images: Vec::new(),
},
}])
.expect("enqueue injected turn input");
let transport = mock_provider(vec![MockCall {
stream_events: Vec::new(),
response: Ok(LlmResponse {
full_text: "answer".to_string(),
parts: vec![LlmOutputPart::Text {
text: "answer".to_string(),
response_meta: None,
}],
..LlmResponse::default()
}),
}]);
let mut runtime = standard_runtime_with_input_bridge(transport, bridge).await;
let sink = RecordingSink::default();
let assembled = runtime
.stream_turn(
TurnInput {
items: vec![InputItem::Text {
text: "hello".to_string(),
}],
image_blobs: HashMap::new(),
mode_turn_options: None,
trace_turn_id: None,
mode_extension: None,
turn_context: crate::TurnContext::default(),
},
&sink,
CancellationToken::new(),
)
.await
.expect("turn");
let mut saw_injected_accept = false;
for event in sink.snapshot() {
if let crate::SessionEvent::InjectedTurnInputAccepted { inputs, .. } = event {
saw_injected_accept = inputs.iter().any(|input| {
input.id.as_deref() == Some("follow-up-id")
&& input.message.role == crate::MessageRole::User
&& input.message.content == "follow up"
});
}
}
assert!(
saw_injected_accept,
"expected injected turn input accepted event"
);
let projected = active_conversation_messages(&assembled.state);
let follow_up_count = projected
.iter()
.filter(|message| {
message.role == crate::MessageRole::User
&& message.parts.iter().any(|part| part.content == "follow up")
})
.count();
assert_eq!(
follow_up_count, 0,
"injected active-turn input must stay out of persisted history"
);
assert!(projected.iter().any(|message| {
message.role == crate::MessageRole::User
&& message.parts.iter().any(|part| part.content == "hello")
}));
}
#[tokio::test]
async fn external_invoke_can_create_session_from_current_snapshot() {
let plugin = Arc::new(RuntimeTestPluginFactory {
build: Arc::new(|_| {
Ok(Arc::new(RuntimeTestPlugin {
before_turn: None,
checkpoint: None,
tool_result_projector: None,
runtime_event: None,
external_registrar: Some(Arc::new(|reg| {
reg.actions().op(
crate::PluginActionDef {
name: "test.spawn".to_string(),
description: "spawn".to_string(),
kind: crate::PluginActionKind::Command,
session_param: crate::SessionParam::Optional,
input_schema: json!({}),
output_schema: json!({}),
},
Arc::new(|ctx, _args| {
Box::pin(async move {
let handle = ctx
.host
.create_session(crate::SessionCreateRequest {
session_id: Some("branched".to_string()),
relation: crate::SessionRelation::Root,
start: crate::SessionStartPoint::CurrentSession,
policy: None,
plugin_mode: crate::SessionPluginMode::InheritCurrent,
initial_nodes: vec![crate::SessionAppendNode::message(
crate::PluginMessage::text(
crate::MessageRole::User,
"branch seed",
),
)],
first_turn_input: None,
tool_access: crate::SessionToolAccess::default(),
subagent: None,
context_surface: crate::SessionContextSurface::default(),
mode_extras: crate::ModeExtras::default(),
usage_source: None,
})
.await
.map_err(|err| crate::ToolResult::err_fmt(err.to_string()));
match handle {
Ok(handle) => {
let snapshot = ctx
.host
.snapshot_session(&handle.session_id)
.await
.map_err(|err| {
crate::ToolResult::err_fmt(err.to_string())
});
match snapshot {
Ok(snapshot) => crate::ToolResult::ok(json!({
"session_id": handle.session_id,
"message_count": snapshot.read_model().messages.len(),
})),
Err(err) => err,
}
}
Err(err) => err,
}
})
}),
)
})),
}))
}),
});
let transport = mock_provider(Vec::new());
let mut runtime = runtime_with_plugins(vec![plugin], transport).await;
append_message(
&mut runtime.state,
Message {
id: "m0".to_string(),
role: MessageRole::User,
parts: vec![Part {
id: "m0.p0".to_string(),
kind: PartKind::Text,
content: "root msg".to_string(),
attachment: None,
tool_call_id: None,
tool_name: None,
tool_replay: None,
prune_state: PruneState::Intact,
reasoning_meta: None,
response_meta: None,
}]
.into(),
origin: None,
},
);
let result = runtime
.invoke_plugin_action("test.spawn", json!({}), None)
.await
.expect("invoke");
assert!(result.is_success());
assert_eq!(
result
.value_for_projection()
.get("session_id")
.and_then(|value| value.as_str()),
Some("branched")
);
assert_eq!(
result
.value_for_projection()
.get("message_count")
.and_then(|value| value.as_u64()),
Some(2)
);
}
#[tokio::test]
async fn session_manager_can_stream_and_await_child_session_turns() {
let transport = mock_provider(vec![MockCall {
stream_events: vec![
LlmStreamEvent::Delta("child ".to_string()),
LlmStreamEvent::Part(LlmOutputPart::Text {
text: "session".to_string(),
response_meta: None,
}),
LlmStreamEvent::Usage(LlmUsage {
input_tokens: 7,
output_tokens: 2,
cached_input_tokens: 0,
reasoning_tokens: 1,
}),
],
response: Ok(LlmResponse {
full_text: "child session".to_string(),
parts: vec![LlmOutputPart::Text {
text: "child session".to_string(),
response_meta: None,
}],
..LlmResponse::default()
}),
}]);
let runtime = runtime_with_plugins(Vec::new(), transport).await;
let manager = runtime.session_manager().expect("session manager");
let handle = manager
.create_session(crate::SessionCreateRequest {
session_id: Some("child".to_string()),
relation: crate::SessionRelation::Root,
start: crate::SessionStartPoint::Empty,
policy: None,
plugin_mode: crate::SessionPluginMode::InheritCurrent,
initial_nodes: Vec::new(),
first_turn_input: None,
tool_access: crate::SessionToolAccess::default(),
subagent: None,
context_surface: crate::SessionContextSurface::default(),
mode_extras: crate::ModeExtras::default(),
usage_source: None,
})
.await
.expect("child session");
let mut turn = manager
.start_turn_stream(
&handle.session_id,
TurnInput {
items: vec![InputItem::Text {
text: "hello".to_string(),
}],
image_blobs: HashMap::new(),
mode_turn_options: None,
trace_turn_id: None,
mode_extension: None,
turn_context: crate::TurnContext::default(),
},
)
.await
.expect("child turn");
let mut saw_stream_event = false;
while let Some(event) = turn.events.recv().await {
if matches!(
event,
SessionEvent::TextDelta { .. }
| SessionEvent::Message { .. }
| SessionEvent::TokenUsage { .. }
| SessionEvent::Done
) {
saw_stream_event = true;
}
}
let assembled = manager.await_turn(&turn.turn_id).await.expect("assembled");
assert_eq!(handle.session_id, "child");
assert_eq!(handle.policy.model, "mock-model");
assert_eq!(turn.session_id, "child");
assert_eq!(turn.policy.model, "mock-model");
assert!(saw_stream_event);
assert_eq!(assembled.state.session_id, "child");
}
#[tokio::test]
async fn session_manager_persists_child_sessions_in_separate_store() {
let factory = RecordingSessionStoreFactory::default();
let host = test_host_config().with_session_store_factory(Arc::new(factory.clone()));
let mut runtime = runtime_with_plugins_and_tools_and_host(
Vec::new(),
Arc::new(EmptyTools),
mock_provider(Vec::new()),
host,
)
.await;
append_message(
&mut runtime.state,
Message {
id: "u1".to_string(),
role: MessageRole::User,
parts: vec![Part {
id: "u1.p0".to_string(),
kind: PartKind::Text,
content: "parent hello".to_string(),
attachment: None,
tool_call_id: None,
tool_name: None,
tool_replay: None,
prune_state: PruneState::Intact,
reasoning_meta: None,
response_meta: None,
}]
.into(),
origin: None,
},
);
runtime.state.turn_index = 3;
let manager = runtime.session_manager().expect("session manager");
let handle = manager
.create_session(crate::SessionCreateRequest {
session_id: Some("child-store".to_string()),
relation: crate::SessionRelation::Child {
parent_session_id: "root".to_string(),
},
start: crate::SessionStartPoint::CurrentSession,
policy: None,
plugin_mode: crate::SessionPluginMode::InheritCurrent,
initial_nodes: Vec::new(),
first_turn_input: None,
tool_access: crate::SessionToolAccess::default(),
subagent: None,
context_surface: crate::SessionContextSurface::default(),
mode_extras: crate::ModeExtras::default(),
usage_source: None,
})
.await
.expect("child session");
assert_eq!(handle.session_id, "child-store");
let stores = factory.stores();
assert_eq!(stores.len(), 1);
let meta = crate::store::RuntimePersistence::load_session_meta(stores[0].as_ref())
.await
.expect("load session meta")
.expect("session meta");
assert_eq!(meta.session_id, "child-store");
assert_eq!(meta.parent_session_id.as_deref(), Some("root"));
let read = crate::store::RuntimePersistence::load_session(
stores[0].as_ref(),
crate::store::SessionReadScope::FullGraph,
)
.await
.expect("load session")
.expect("session read");
let graph = read.graph;
let read_model = graph.read_model();
let messages = read_model.messages.as_slice();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].parts[0].content, "parent hello");
let checkpoint = read.checkpoint.expect("checkpoint");
let turn_state = checkpoint.turn_state;
assert_eq!(turn_state.turn_index, 3);
}
#[tokio::test]
async fn child_relation_does_not_replace_active_session() {
let mut runtime = runtime_with_plugins(Vec::new(), mock_provider(Vec::new())).await;
let manager = runtime.session_manager().expect("session manager");
manager
.create_session(crate::SessionCreateRequest {
session_id: Some("ordinary-child".to_string()),
relation: crate::SessionRelation::Child {
parent_session_id: runtime.session_id().to_string(),
},
start: crate::SessionStartPoint::Empty,
policy: None,
plugin_mode: crate::SessionPluginMode::InheritCurrent,
initial_nodes: Vec::new(),
first_turn_input: None,
tool_access: crate::SessionToolAccess::default(),
subagent: None,
context_surface: crate::SessionContextSurface::default(),
mode_extras: crate::ModeExtras::default(),
usage_source: None,
})
.await
.expect("child session");
assert_eq!(runtime.session_id(), "root");
let assembled = runtime
.run_turn_assembled(
TurnInput {
items: vec![InputItem::Text {
text: "parent turn".to_string(),
}],
image_blobs: HashMap::new(),
mode_turn_options: None,
trace_turn_id: None,
mode_extension: None,
turn_context: crate::TurnContext::default(),
},
CancellationToken::new(),
)
.await
.expect("parent turn");
assert_eq!(assembled.state.session_id, "root");
assert_eq!(assembled.state.turn_index, 1);
}
#[tokio::test]
async fn handoff_relation_routes_original_session_to_successor() {
let transport = mock_provider(vec![MockCall {
stream_events: Vec::new(),
response: Ok(LlmResponse {
full_text: "successor response".to_string(),
parts: vec![LlmOutputPart::Text {
text: "successor response".to_string(),
response_meta: None,
}],
..LlmResponse::default()
}),
}]);
let mut runtime = runtime_with_plugins(Vec::new(), transport).await;
runtime.state.turn_index = 31;
let manager = runtime.session_manager().expect("session manager");
manager
.create_session(crate::SessionCreateRequest {
session_id: Some("handoff-child".to_string()),
relation: crate::SessionRelation::Handoff {
parent_session_id: runtime.session_id().to_string(),
reason: "test".to_string(),
metadata: serde_json::Map::new(),
},
start: crate::SessionStartPoint::Empty,
policy: None,
plugin_mode: crate::SessionPluginMode::InheritCurrent,
initial_nodes: Vec::new(),
first_turn_input: None,
tool_access: crate::SessionToolAccess::default(),
subagent: None,
context_surface: crate::SessionContextSurface::default(),
mode_extras: crate::ModeExtras::default(),
usage_source: None,
})
.await
.expect("handoff session");
let assembled = runtime
.run_turn_assembled(
TurnInput {
items: vec![InputItem::Text {
text: "next external turn".to_string(),
}],
image_blobs: HashMap::new(),
mode_turn_options: None,
trace_turn_id: None,
mode_extension: None,
turn_context: crate::TurnContext::default(),
},
CancellationToken::new(),
)
.await
.expect("routed turn");
assert_eq!(runtime.session_id(), "root");
assert_eq!(runtime.export_state().turn_index, 32);
assert_eq!(assembled.state.session_id, "handoff-child");
assert_eq!(assembled.state.turn_index, 32);
assert_eq!(
assembled.assistant_output.safe_text.trim(),
"successor response"
);
}
#[tokio::test]
async fn session_manager_rejects_duplicate_child_session_ids() {
let runtime = runtime_with_plugins(Vec::new(), mock_provider(Vec::new())).await;
let manager = runtime.session_manager().expect("session manager");
manager
.create_session(crate::SessionCreateRequest {
session_id: Some("child".to_string()),
relation: crate::SessionRelation::Root,
start: crate::SessionStartPoint::Empty,
policy: None,
plugin_mode: crate::SessionPluginMode::InheritCurrent,
initial_nodes: Vec::new(),
first_turn_input: None,
tool_access: crate::SessionToolAccess::default(),
subagent: None,
context_surface: crate::SessionContextSurface::default(),
mode_extras: crate::ModeExtras::default(),
usage_source: None,
})
.await
.expect("first child session");
let err = manager
.create_session(crate::SessionCreateRequest {
session_id: Some("child".to_string()),
relation: crate::SessionRelation::Root,
start: crate::SessionStartPoint::Empty,
policy: None,
plugin_mode: crate::SessionPluginMode::InheritCurrent,
initial_nodes: Vec::new(),
first_turn_input: None,
tool_access: crate::SessionToolAccess::default(),
subagent: None,
context_surface: crate::SessionContextSurface::default(),
mode_extras: crate::ModeExtras::default(),
usage_source: None,
})
.await
.expect_err("duplicate child session should fail");
assert!(err.to_string().contains("already exists"));
}
#[tokio::test]
async fn runtime_can_activate_managed_child_session() {
let mut runtime = runtime_with_plugins(Vec::new(), mock_provider(Vec::new())).await;
let manager = runtime.session_manager().expect("session manager");
manager
.create_session(crate::SessionCreateRequest {
session_id: Some("child".to_string()),
relation: crate::SessionRelation::Child {
parent_session_id: runtime.session_id().to_string(),
},
start: crate::SessionStartPoint::Empty,
policy: None,
plugin_mode: crate::SessionPluginMode::InheritCurrent,
initial_nodes: Vec::new(),
first_turn_input: Some(crate::PluginMessage::text(
crate::MessageRole::User,
"run child",
)),
tool_access: crate::SessionToolAccess::default(),
subagent: None,
context_surface: crate::SessionContextSurface::default(),
mode_extras: crate::ModeExtras::default(),
usage_source: Some("test".to_string()),
})
.await
.expect("child session");
runtime
.activate_managed_session("child")
.await
.expect("activate child");
assert_eq!(runtime.session_id(), "child");
let seed = manager
.take_first_turn_input("child")
.await
.expect("seed lookup")
.expect("seed");
assert_eq!(seed.content, "run child");
assert!(
manager
.start_turn_stream(
"child",
TurnInput {
items: vec![InputItem::Text {
text: "old manager should not own activated child".to_string(),
}],
image_blobs: HashMap::new(),
mode_turn_options: None,
trace_turn_id: None,
mode_extension: None,
turn_context: crate::TurnContext::default(),
},
)
.await
.is_err(),
"activated child runtime should leave the parent manager registry"
);
}