makepad/libs/asset/chat/tests/session_engine.rs
Admin 88048aa784 assets: extend authoring pipelines and typed asset search
Add sandbox authoring effects and character/composite creation, improve creator submission handling, and extend publishing/search metadata. Update importer conversions and the asset UI integration.

Validation: 31 store search tests, ten composite tests and two author-policy tests passed in release mode.
2026-09-05 01:42:39 +02:00

1023 lines
39 KiB
Rust

//! Session engine tests with a scripted provider and a deterministic tool
//! executor. These mocks exist ONLY here: the engine under test cannot
//! construct a provider itself (no fallback is even expressible — it holds
//! one `Box<dyn ChatProvider>` for its whole life).
use makepad_asset_chat::provider::{ChatProvider, ProviderEvent, TurnInput};
use makepad_asset_chat::session::{CancelFlag, ExecCtx, SendRefusal, Session, ToolExecutor};
use makepad_asset_chat::tools::ContentToolCall;
use makepad_asset_chat::wire::{
AttachmentBinding, ChatEventBody, ProviderAvailability, ProviderKind, ServingFacts,
ToolOutcome, MAX_DELTA_BYTES, MAX_MESSAGE_BYTES, MAX_PROGRESS_EVENTS, MAX_TOOL_JSON_BYTES,
MAX_TOOL_ROUNDS,
};
use makepad_asset_client::json::{self, Value};
use makepad_asset_data::AssetRevisionId;
use std::cell::RefCell;
use std::rc::Rc;
fn rev(byte: u8) -> AssetRevisionId {
AssetRevisionId::from_bytes([byte; 32])
}
/// Scripted provider: each `begin_turn` shifts the next event script;
/// records every turn input for assertions.
struct Scripted {
kind: ProviderKind,
available: ProviderAvailability,
scripts: Vec<Vec<ProviderEvent>>,
pending: Vec<ProviderEvent>,
pub turns: Rc<RefCell<Vec<TurnInput>>>,
pub continuations: Rc<RefCell<Vec<(String, String)>>>,
cancelled: Rc<RefCell<u32>>,
begin_fails: u32,
}
impl Scripted {
fn new(scripts: Vec<Vec<ProviderEvent>>) -> Scripted {
Scripted {
kind: ProviderKind::FleetQwen,
available: ProviderAvailability::Available {
model: "scripted".into(),
detail: "test".into(),
},
scripts,
pending: Vec::new(),
turns: Rc::new(RefCell::new(Vec::new())),
continuations: Rc::new(RefCell::new(Vec::new())),
cancelled: Rc::new(RefCell::new(0)),
begin_fails: 0,
}
}
}
impl ChatProvider for Scripted {
fn kind(&self) -> ProviderKind {
self.kind
}
fn availability(&mut self) -> ProviderAvailability {
self.available.clone()
}
fn begin_turn(&mut self, input: &TurnInput) -> Result<(), String> {
self.turns.borrow_mut().push(input.clone());
if self.begin_fails > 0 {
self.begin_fails -= 1;
return Err("provider start failed".to_string());
}
if self.scripts.is_empty() {
return Err("script exhausted".to_string());
}
self.pending = self.scripts.remove(0);
Ok(())
}
fn poll(&mut self) -> Vec<ProviderEvent> {
std::mem::take(&mut self.pending)
}
fn cancel(&mut self) {
*self.cancelled.borrow_mut() += 1;
self.pending.clear();
}
fn continue_function(&mut self, call_id: &str, output: &str) -> Result<(), String> {
self.continuations.borrow_mut().push((call_id.to_string(), output.to_string()));
if self.scripts.is_empty() {
return Err("script exhausted".to_string());
}
self.pending = self.scripts.remove(0);
Ok(())
}
}
/// Deterministic executor: programmed outcome per tool name, records calls.
struct Recorder {
outcome: ToolOutcome,
calls: Rc<RefCell<Vec<ContentToolCall>>>,
seen_known: Rc<RefCell<Vec<std::collections::HashSet<AssetRevisionId>>>>,
progress_ticks: u16,
}
impl Recorder {
fn new(outcome: ToolOutcome) -> Recorder {
Recorder {
outcome,
calls: Rc::new(RefCell::new(Vec::new())),
seen_known: Rc::new(RefCell::new(Vec::new())),
progress_ticks: 0,
}
}
}
impl ToolExecutor for Recorder {
fn capability_doc(&mut self) -> String {
"Registered operations (test): mesh.from_image.v1".to_string()
}
fn execute(
&mut self,
call: &ContentToolCall,
ctx: &ExecCtx,
progress: &mut dyn FnMut(u16, &str),
_cancel: &CancelFlag,
) -> ToolOutcome {
self.calls.borrow_mut().push(call.clone());
self.seen_known.borrow_mut().push(ctx.known.clone());
for i in 0..self.progress_ticks {
progress(((i + 1) as u32 * 1000 / self.progress_ticks.max(1) as u32) as u16, "working");
}
self.outcome.clone()
}
}
fn tool_line(name: &str, args: Value) -> String {
format!(
"<<tool>>{}",
json::obj(vec![("name", json::s(name)), ("args", args)]).to_json()
)
}
/// Serving facts ride out on the delta they describe — and on the LAST
/// chunk of a split, because they describe the END of that text.
#[test]
fn serving_facts_ride_on_the_delta_they_describe() {
let facts = ServingFacts { gen_tokens: 64, lanes_active: Some(1), slots_total: Some(4), ..Default::default() };
let big = "a".repeat(MAX_DELTA_BYTES + 16);
let provider = Scripted::new(vec![vec![
ProviderEvent::Delta("before".into()),
ProviderEvent::Serving(facts),
ProviderEvent::Delta(big.clone()),
ProviderEvent::Done { text: format!("before{big}") },
]]);
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_test", Box::new(provider));
session.send("hi", &[], &mut exec).unwrap();
session.pump(&mut exec);
let carried: Vec<Option<ServingFacts>> = session
.drain_events()
.iter()
.filter_map(|e| match &e.body {
ChatEventBody::Delta { serving, .. } => Some(*serving),
_ => None,
})
.collect();
assert_eq!(carried.len(), 3, "one delta, then a split one: {carried:?}");
assert_eq!(carried[0], None, "facts that had not arrived yet are not invented");
assert_eq!(carried[1], None, "the middle of a split says nothing");
assert_eq!(carried[2], Some(facts));
}
#[test]
fn plain_turn_streams_and_completes_in_order() {
let provider = Scripted::new(vec![vec![
ProviderEvent::Delta("Hel".into()),
ProviderEvent::Delta("lo".into()),
ProviderEvent::Done { text: "Hello".into() },
]]);
let turns = provider.turns.clone();
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_test", Box::new(provider));
session.send("hi", &[], &mut exec).unwrap();
session.pump(&mut exec);
let events = session.drain_events();
// seq is monotonic from 0 and the order is delta, delta, done.
let seqs: Vec<u64> = events.iter().map(|e| e.seq).collect();
assert_eq!(seqs, vec![0, 1, 2]);
assert!(matches!(&events[0].body, ChatEventBody::Delta { text, .. } if text == "Hel"));
assert!(matches!(&events[1].body, ChatEventBody::Delta { text, .. } if text == "lo"));
assert!(matches!(events[2].body, ChatEventBody::Done));
assert!(session.is_idle());
// The system prompt carried the executor's live capability text.
assert!(turns.borrow()[0].system.contains("mesh.from_image.v1"));
}
#[test]
fn unavailable_provider_refuses_send_no_fallback_no_events() {
let mut provider = Scripted::new(vec![]);
provider.available =
ProviderAvailability::Unavailable { reason: "no chat capability on fleet".into() };
let turns = provider.turns.clone();
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_test", Box::new(provider));
let refusal = session.send("hi", &[], &mut exec).unwrap_err();
assert_eq!(
refusal,
SendRefusal::ProviderUnavailable { reason: "no chat capability on fleet".into() }
);
// Honest refusal: nothing streamed, nothing started, nothing rerouted.
assert!(session.drain_events().is_empty());
assert!(turns.borrow().is_empty());
assert_eq!(session.provider_kind(), ProviderKind::FleetQwen);
}
#[test]
fn busy_session_refuses_second_send() {
let provider = Scripted::new(vec![vec![ProviderEvent::Delta("...".into())]]);
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_test", Box::new(provider));
session.send("first", &[], &mut exec).unwrap();
assert_eq!(session.send("second", &[], &mut exec).unwrap_err(), SendRefusal::Busy);
}
#[test]
fn tool_round_trip_events_history_and_followup_turn() {
let call_args = json::obj(vec![("query", json::s("neon")), ("limit", Value::Int(3))]);
let provider = Scripted::new(vec![
vec![ProviderEvent::Done {
text: format!("Let me look.\n{}", tool_line("asset.search", call_args)),
}],
vec![ProviderEvent::Done { text: "Found nothing interesting.".into() }],
]);
let turns = provider.turns.clone();
let mut exec = Recorder::new(ToolOutcome::Ok {
value: json::obj(vec![("hits", Value::Arr(vec![]))]),
});
exec.progress_ticks = 2;
let calls = exec.calls.clone();
let mut session = Session::new("prin_test", Box::new(provider));
session.send("find neon stuff", &[], &mut exec).unwrap();
session.pump(&mut exec); // turn 1: tool call + execution + follow-up begins
session.pump(&mut exec); // turn 2: final text
let events = session.drain_events();
let kinds: Vec<&'static str> = events
.iter()
.map(|e| match &e.body {
ChatEventBody::Delta { .. } => "delta",
ChatEventBody::ToolCall { .. } => "tool_call",
ChatEventBody::ToolProgress { .. } => "tool_progress",
ChatEventBody::ToolResult { .. } => "tool_result",
ChatEventBody::Done => "done",
ChatEventBody::Cancelled => "cancelled",
ChatEventBody::Error { .. } => "error",
})
.collect();
assert_eq!(kinds, vec!["tool_call", "tool_progress", "tool_progress", "tool_result", "done"]);
// The executor received the typed call.
assert!(matches!(
&calls.borrow()[0],
ContentToolCall::AssetSearch { query, limit } if query == "neon" && *limit == 3
));
// The follow-up turn carried the tool result as a Tool-role message.
let followup = &turns.borrow()[1];
let tool_msg = followup
.messages
.iter()
.find(|m| m.role == makepad_asset_chat::wire::ChatRole::Tool)
.expect("tool message in follow-up");
assert!(tool_msg.text.contains("\"outcome\":\"ok\""));
assert!(session.is_idle());
}
#[test]
fn malformed_tool_line_is_refused_back_to_model() {
let provider = Scripted::new(vec![
vec![ProviderEvent::Done { text: "<<tool>>{broken".into() }],
vec![ProviderEvent::Done { text: "Sorry, retrying properly.".into() }],
]);
let turns = provider.turns.clone();
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_test", Box::new(provider));
session.send("go", &[], &mut exec).unwrap();
session.pump(&mut exec);
session.pump(&mut exec);
// No executor call happened; the refusal went back as the tool message.
assert!(exec.calls.borrow().is_empty());
let followup = &turns.borrow()[1];
let tool_msg = followup
.messages
.iter()
.find(|m| m.role == makepad_asset_chat::wire::ChatRole::Tool)
.unwrap();
assert!(tool_msg.text.contains("refused"));
}
#[test]
fn leaked_level_source_is_refused_back_not_final() {
// 2026-08-27 dog-shop regression: the model hit the token cap while
// printing a whole interior as PLAIN TEXT (no tool call). That must
// spend a corrective round, not end the turn as a final answer.
let leak = "Here is the shop:
game.sky({top: #111})
game.box({pos: vec3(0,0,0), size: vec3(1,1,1)})
game.box({pos: vec3(1,0,0), size: vec3(1,1,1)})
game.box({pos: vec3(2,0,0), size: vec3(1,1,1)})
game.box({pos: vec3(3,0.9,-2.0), size: vec3(0.25,0.3,0.2), body:";
let provider = Scripted::new(vec![
vec![ProviderEvent::Done { text: leak.into() }],
vec![ProviderEvent::Done { text: "Calling the tool properly now.".into() }],
]);
let turns = provider.turns.clone();
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_test", Box::new(provider));
session.send("furnish the shop", &[], &mut exec).unwrap();
session.pump(&mut exec);
session.pump(&mut exec);
// Nothing executed; the model got a corrective tool message naming the fix.
assert!(exec.calls.borrow().is_empty());
let followup = &turns.borrow()[1];
let tool_msg = followup
.messages
.iter()
.find(|m| m.role == makepad_asset_chat::wire::ChatRole::Tool)
.unwrap();
assert!(tool_msg.text.contains("plain text"), "{}", tool_msg.text);
assert!(tool_msg.text.contains("add_addon"), "{}", tool_msg.text);
}
#[test]
fn attachments_bind_known_revisions_and_tool_results_extend_them() {
let input = rev(0x33);
let derived = rev(0x44);
let provider = Scripted::new(vec![
vec![ProviderEvent::Done {
text: tool_line("operation.get", json::obj(vec![("operation", json::s("op_00000000000000000000000000000000"))])),
}],
vec![ProviderEvent::Done { text: "done".into() }],
]);
let mut exec = Recorder::new(ToolOutcome::Ok {
value: json::obj(vec![("result_revision", json::s(derived.to_string()))]),
});
let mut session = Session::new("prin_test", Box::new(provider));
session
.send(
"derive from this",
&[AttachmentBinding { revision: input, role: "source".into() }],
&mut exec,
)
.unwrap();
assert!(session.known_revisions().contains(&input));
assert!(!session.known_revisions().contains(&derived));
session.pump(&mut exec);
// The tool result's revision became chainable.
assert!(session.known_revisions().contains(&derived));
}
#[test]
fn refused_oversized_attachments_do_not_authorize_revisions() {
let leaked = rev(0xAB);
let provider = Scripted::new(vec![vec![ProviderEvent::Done { text: "ok".into() }]]);
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_test", Box::new(provider));
let refusal = session
.send(
&"x".repeat(MAX_MESSAGE_BYTES),
&[AttachmentBinding { revision: leaked, role: "source".into() }],
&mut exec,
)
.unwrap_err();
assert_eq!(refusal, SendRefusal::TooLarge { what: "message" });
assert!(!session.known_revisions().contains(&leaked));
assert!(session.is_idle());
}
#[test]
fn refused_provider_start_does_not_authorize_revisions() {
let leaked = rev(0xCD);
let ok_rev = rev(0x11);
let mut provider = Scripted::new(vec![
vec![ProviderEvent::Done {
text: tool_line(
"operation.get",
json::obj(vec![("operation", json::s("op_00000000000000000000000000000000"))]),
),
}],
vec![ProviderEvent::Done { text: "done".into() }],
]);
provider.begin_fails = 1;
let mut exec = Recorder::new(ToolOutcome::Ok { value: json::obj(vec![("ok", json::s("1"))]) });
let seen = exec.seen_known.clone();
let mut session = Session::new("prin_test", Box::new(provider));
let refusal = session
.send(
"go",
&[AttachmentBinding { revision: leaked, role: "source".into() }],
&mut exec,
)
.unwrap_err();
assert!(matches!(refusal, SendRefusal::ProviderError { .. }), "{refusal:?}");
assert!(!session.known_revisions().contains(&leaked));
assert!(session.is_idle());
session
.send(
"retry without the leaked attach",
&[AttachmentBinding { revision: ok_rev, role: "source".into() }],
&mut exec,
)
.unwrap();
session.pump(&mut exec);
assert!(session.known_revisions().contains(&ok_rev));
assert!(!session.known_revisions().contains(&leaked));
let last_known = seen.borrow().last().cloned().expect("tool saw known");
assert!(last_known.contains(&ok_rev));
assert!(!last_known.contains(&leaked), "refused revision must not be a transform input");
}
#[test]
fn cancel_mid_stream_emits_cancelled_and_idles() {
let provider = Scripted::new(vec![vec![ProviderEvent::Delta("stream".into())]]);
let cancelled = provider.cancelled.clone();
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_test", Box::new(provider));
session.send("go", &[], &mut exec).unwrap();
session.pump(&mut exec);
session.cancel();
let events = session.drain_events();
assert!(matches!(events.last().unwrap().body, ChatEventBody::Cancelled));
assert!(session.is_idle());
assert_eq!(*cancelled.borrow(), 1);
// Cancel when idle is a no-op, not an event.
session.cancel();
assert!(session.drain_events().is_empty());
}
#[test]
fn tool_round_budget_degrades_gracefully_on_the_textual_lane() {
// A provider that answers EVERY turn with another tool call. The
// textual lane must NOT hard-kill the turn at the budget: the model
// gets one final completion round (with a nudge in history) and any
// tool line it emits there is cut off, not executed — the turn ends
// in Done, never a dead session.
let scripts: Vec<Vec<ProviderEvent>> = (0..MAX_TOOL_ROUNDS + 2)
.map(|_| {
vec![ProviderEvent::Done {
text: tool_line("operation.get", json::obj(vec![("operation", json::s("op_00000000000000000000000000000000"))])),
}]
})
.collect();
let provider = Scripted::new(scripts);
let turns = provider.turns.clone();
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_test", Box::new(provider));
session.send("loop forever", &[], &mut exec).unwrap();
for _ in 0..MAX_TOOL_ROUNDS + 4 {
session.pump(&mut exec);
}
let events = session.drain_events();
let last = events.last().unwrap();
assert!(
matches!(&last.body, ChatEventBody::Done),
"the budget must end the turn gracefully, got {:?}",
last.body
);
assert!(session.is_idle());
assert!(!session.is_sealed(), "a budgeted turn is not a dead session");
// Exactly the budget executed; the final round's tool line did not.
assert_eq!(exec.calls.borrow().len(), MAX_TOOL_ROUNDS as usize);
// The final provider turn saw the nudge in its history.
let final_input = turns.borrow().last().cloned().unwrap();
assert!(
final_input.messages.iter().any(|m| m.text.contains("tool budget reached")),
"the final round must carry the budget nudge"
);
}
/// Qwen keeps the textual marker contract; native providers do not.
/// Validated tool DTOs stay byte-equivalent when the same call is executed.
#[test]
fn qwen_marker_and_native_prompt_split_with_equivalent_tool_dtos() {
let asset = makepad_asset_data::AssetId::from_bytes([5; 16]);
let input_rev = rev(0x66);
let call_args = json::obj(vec![
("kind", json::s("mesh.from_image.v1")),
(
"inputs",
Value::Arr(vec![json::obj(vec![
("slot", json::s("image")),
("asset", json::s(asset.to_string())),
("revision", json::s(input_rev.to_string())),
("role", json::s("texture")),
])]),
),
("params", json::obj(vec![("seed", Value::Int(3))])),
]);
let mut qwen = Scripted::new(vec![
vec![ProviderEvent::Done { text: tool_line("operation.create", call_args.clone()) }],
vec![ProviderEvent::Done { text: "done".into() }],
]);
qwen.kind = ProviderKind::FleetQwen;
let qwen_turns = qwen.turns.clone();
let mut qwen_exec = Recorder::new(ToolOutcome::Ok {
value: json::obj(vec![("operation", json::s("op_00000000000000000000000000000000"))]),
});
let qwen_calls = qwen_exec.calls.clone();
let mut qwen_session = Session::new("prin_parity", Box::new(qwen));
qwen_session
.send(
"make a mesh",
&[AttachmentBinding { revision: input_rev, role: "image".into() }],
&mut qwen_exec,
)
.unwrap();
qwen_session.pump(&mut qwen_exec);
qwen_session.pump(&mut qwen_exec);
let mut native = Scripted::new(vec![
vec![
ProviderEvent::Delta("working".into()),
ProviderEvent::FunctionCall {
call_id: "call_create_1".into(),
name: "operation_create".into(),
arguments: call_args.to_json(),
},
],
vec![ProviderEvent::Delta("done".into()), ProviderEvent::Done { text: "done".into() }],
]);
native.kind = ProviderKind::OpenAi;
let native_turns = native.turns.clone();
let native_conts = native.continuations.clone();
let mut native_exec = Recorder::new(ToolOutcome::Ok {
value: json::obj(vec![("operation", json::s("op_00000000000000000000000000000000"))]),
});
let native_calls = native_exec.calls.clone();
let mut native_session = Session::new("prin_parity", Box::new(native));
native_session
.send(
"make a mesh",
&[AttachmentBinding { revision: input_rev, role: "image".into() }],
&mut native_exec,
)
.unwrap();
native_session.pump(&mut native_exec);
native_session.pump(&mut native_exec);
let qwen_system = qwen_turns.borrow()[0].system.clone();
let native_system = native_turns.borrow()[0].system.clone();
assert!(qwen_system.contains("<<tool>>"), "qwen must keep the marker contract");
assert!(!native_system.contains("<<tool>>"), "native prompt must not mention the marker");
assert!(native_system.contains("asset_search"));
assert_eq!(
encode_calls(&qwen_calls.borrow()),
encode_calls(&native_calls.borrow())
);
assert_eq!(native_conts.borrow().len(), 1);
assert_eq!(qwen_session.provider_kind().slug(), "fleet-qwen");
assert_eq!(native_session.provider_kind().slug(), "openai");
}
fn encode_calls(calls: &[ContentToolCall]) -> Vec<String> {
calls
.iter()
.map(|c| {
format!(
"{}:{}",
c.name(),
makepad_asset_chat::tools::encode_args(c).to_json()
)
})
.collect()
}
#[test]
fn native_tool_executes_and_continues_exactly_once() {
let args = json::obj(vec![("query", json::s("neon")), ("limit", Value::Int(3))]);
let mut provider = Scripted::new(vec![
vec![
ProviderEvent::Delta("Let me look.".into()),
ProviderEvent::FunctionCall {
call_id: "call_search_1".into(),
name: "asset_search".into(),
arguments: args.to_json(),
},
],
vec![
ProviderEvent::Delta("Found nothing interesting.".into()),
ProviderEvent::Done { text: "Found nothing interesting.".into() },
],
]);
provider.kind = ProviderKind::Grok;
let conts = provider.continuations.clone();
let mut exec = Recorder::new(ToolOutcome::Ok {
value: json::obj(vec![("hits", Value::Arr(vec![]))]),
});
exec.progress_ticks = 1;
let calls = exec.calls.clone();
let mut session = Session::new("prin_native", Box::new(provider));
session.send("find neon", &[], &mut exec).unwrap();
session.pump(&mut exec);
session.pump(&mut exec);
let events = session.drain_events();
let kinds: Vec<&'static str> = events
.iter()
.map(|e| match &e.body {
ChatEventBody::Delta { .. } => "delta",
ChatEventBody::ToolCall { .. } => "tool_call",
ChatEventBody::ToolProgress { .. } => "tool_progress",
ChatEventBody::ToolResult { .. } => "tool_result",
ChatEventBody::Done => "done",
ChatEventBody::Cancelled => "cancelled",
ChatEventBody::Error { .. } => "error",
})
.collect();
assert_eq!(
kinds,
vec!["delta", "tool_call", "tool_progress", "tool_result", "delta", "done"]
);
let tool_calls: Vec<_> = events
.iter()
.filter(|e| matches!(e.body, ChatEventBody::ToolCall { .. }))
.collect();
let tool_results: Vec<_> = events
.iter()
.filter(|e| matches!(e.body, ChatEventBody::ToolResult { .. }))
.collect();
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_results.len(), 1);
assert!(matches!(
&events[1].body,
ChatEventBody::ToolCall { id, name, .. } if id == "call_search_1" && name == "asset.search"
));
assert_eq!(calls.borrow().len(), 1);
assert!(matches!(
&calls.borrow()[0],
ContentToolCall::AssetSearch { query, limit } if query == "neon" && *limit == 3
));
assert_eq!(conts.borrow().len(), 1);
assert_eq!(conts.borrow()[0].0, "call_search_1");
assert!(conts.borrow()[0].1.contains("\"outcome\":\"ok\""));
assert_eq!(session.provider_kind().slug(), "grok");
assert!(session.is_idle());
}
#[test]
fn native_malformed_args_are_refused_continuation() {
let mut provider = Scripted::new(vec![
vec![ProviderEvent::FunctionCall {
call_id: "call_bad".into(),
name: "asset_search".into(),
arguments: "not-json".into(),
}],
vec![ProviderEvent::Done { text: "ok".into() }],
]);
provider.kind = ProviderKind::OpenAi;
let conts = provider.continuations.clone();
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_native", Box::new(provider));
session.send("go", &[], &mut exec).unwrap();
session.pump(&mut exec);
session.pump(&mut exec);
assert!(exec.calls.borrow().is_empty());
assert_eq!(conts.borrow().len(), 1);
assert_eq!(conts.borrow()[0].0, "call_bad");
assert!(conts.borrow()[0].1.contains("refused"));
let events = session.drain_events();
assert!(events.iter().any(|e| matches!(
&e.body,
ChatEventBody::ToolResult { outcome: ToolOutcome::Refused { .. }, .. }
)));
}
#[test]
fn native_tool_round_budget_terminates() {
let scripts: Vec<Vec<ProviderEvent>> = (0..MAX_TOOL_ROUNDS + 2)
.map(|i| {
vec![ProviderEvent::FunctionCall {
call_id: format!("call_{i}"),
name: "operation_get".into(),
arguments: r#"{"operation":"op_00000000000000000000000000000000"}"#.into(),
}]
})
.collect();
let mut provider = Scripted::new(scripts);
provider.kind = ProviderKind::OpenAi;
let conts = provider.continuations.clone();
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_native", Box::new(provider));
session.send("loop forever", &[], &mut exec).unwrap();
for _ in 0..MAX_TOOL_ROUNDS + 2 {
session.pump(&mut exec);
}
let events = session.drain_events();
let last = events.last().unwrap();
assert!(
matches!(&last.body, ChatEventBody::Error { code, .. } if code == "tool_budget"),
"expected tool_budget, got {:?}",
last.body
);
assert!(session.is_idle());
assert_eq!(exec.calls.borrow().len(), MAX_TOOL_ROUNDS as usize);
assert_eq!(conts.borrow().len(), (MAX_TOOL_ROUNDS - 1) as usize);
}
#[test]
fn native_continue_error_keeps_session_idle_and_history_intact() {
let mut provider = Scripted::new(vec![
vec![ProviderEvent::FunctionCall {
call_id: "call_1".into(),
name: "asset_search".into(),
arguments: r#"{"query":"x"}"#.into(),
}],
vec![ProviderEvent::Error("boom".into())],
vec![ProviderEvent::Done { text: "ok".into() }],
]);
provider.kind = ProviderKind::OpenAi;
let turns = provider.turns.clone();
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_native", Box::new(provider));
session.send("go", &[], &mut exec).unwrap();
session.pump(&mut exec);
session.pump(&mut exec);
assert!(session.is_idle());
let events = session.drain_events();
assert!(events.iter().any(|e| matches!(e.body, ChatEventBody::Error { .. })));
let refusal = session.send("again", &[], &mut exec).unwrap_err();
assert!(
matches!(refusal, SendRefusal::Sealed { .. }),
"expected sealed session, got {refusal:?}"
);
assert_eq!(turns.borrow().len(), 1);
}
#[test]
fn executed_mutation_plus_continuation_error_never_runs_again() {
let asset = makepad_asset_data::AssetId::from_bytes([5; 16]);
let input_rev = rev(0x66);
let call_args = json::obj(vec![
("kind", json::s("mesh.from_image.v1")),
(
"inputs",
Value::Arr(vec![json::obj(vec![
("slot", json::s("image")),
("asset", json::s(asset.to_string())),
("revision", json::s(input_rev.to_string())),
("role", json::s("texture")),
])]),
),
]);
let mut provider = Scripted::new(vec![
vec![ProviderEvent::FunctionCall {
call_id: "call_create_1".into(),
name: "operation_create".into(),
arguments: call_args.to_json(),
}],
vec![ProviderEvent::Error("continuation failed".into())],
vec![ProviderEvent::Done { text: "should never run".into() }],
]);
provider.kind = ProviderKind::OpenAi;
let turns = provider.turns.clone();
let mut exec = Recorder::new(ToolOutcome::Ok {
value: json::obj(vec![("operation", json::s("op_00000000000000000000000000000000"))]),
});
let calls = exec.calls.clone();
let mut session = Session::new("prin_mut", Box::new(provider));
session
.send(
"make a mesh",
&[AttachmentBinding { revision: input_rev, role: "image".into() }],
&mut exec,
)
.unwrap();
session.pump(&mut exec);
session.pump(&mut exec);
assert!(session.is_idle());
assert_eq!(calls.borrow().len(), 1);
assert!(matches!(calls.borrow()[0], ContentToolCall::OperationCreate { .. }));
let refusal = session.send("try again", &[], &mut exec).unwrap_err();
assert!(matches!(refusal, SendRefusal::Sealed { .. }), "{refusal:?}");
session.pump(&mut exec);
assert_eq!(calls.borrow().len(), 1);
assert_eq!(turns.borrow().len(), 1);
}
#[test]
fn executed_mutation_plus_continuation_cancel_never_runs_again() {
let asset = makepad_asset_data::AssetId::from_bytes([9; 16]);
let input_rev = rev(0x77);
let call_args = json::obj(vec![
("kind", json::s("mesh.from_image.v1")),
(
"inputs",
Value::Arr(vec![json::obj(vec![
("slot", json::s("image")),
("asset", json::s(asset.to_string())),
("revision", json::s(input_rev.to_string())),
("role", json::s("texture")),
])]),
),
]);
let mut provider = Scripted::new(vec![
vec![ProviderEvent::FunctionCall {
call_id: "call_create_2".into(),
name: "operation_create".into(),
arguments: call_args.to_json(),
}],
vec![ProviderEvent::Delta("continuing".into())],
vec![ProviderEvent::Done { text: "should never run".into() }],
]);
provider.kind = ProviderKind::Grok;
let mut exec = Recorder::new(ToolOutcome::Ok {
value: json::obj(vec![("operation", json::s("op_00000000000000000000000000000000"))]),
});
let calls = exec.calls.clone();
let mut session = Session::new("prin_mut", Box::new(provider));
session
.send(
"make a mesh",
&[AttachmentBinding { revision: input_rev, role: "image".into() }],
&mut exec,
)
.unwrap();
session.pump(&mut exec);
assert_eq!(calls.borrow().len(), 1);
session.cancel();
assert!(session.is_idle());
let refusal = session.send("retry", &[], &mut exec).unwrap_err();
assert!(matches!(refusal, SendRefusal::Sealed { .. }), "{refusal:?}");
session.pump(&mut exec);
assert_eq!(calls.borrow().len(), 1);
}
#[test]
fn provider_slugs_are_stable() {
for (kind, slug) in [
(ProviderKind::FleetQwen, "fleet-qwen"),
(ProviderKind::OpenAi, "openai"),
(ProviderKind::Grok, "grok"),
(ProviderKind::ClaudeCli, "claude-cli"),
(ProviderKind::CodexCli, "codex-cli"),
(ProviderKind::GrokCli, "grok-cli"),
] {
let mut provider = Scripted::new(vec![]);
provider.kind = kind;
let session = Session::new("p", Box::new(provider));
assert_eq!(session.provider_kind().slug(), slug);
}
}
#[test]
fn codex_json_fixture_runs_world_tool_continuation_and_stays_map_scoped() {
struct GameRecorder(Recorder);
impl ToolExecutor for GameRecorder {
fn capability_doc(&mut self) -> String { "Village world tools".into() }
fn tool_definitions(&mut self) -> Vec<makepad_asset_chat::tools::ToolDef> {
makepad_asset_chat::tools::sandbox_definitions()
}
fn client_executes(&mut self, call: &ContentToolCall) -> bool {
matches!(call, ContentToolCall::WorldGetPlan)
}
fn execute(&mut self, _: &ContentToolCall, _: &ExecCtx,
_: &mut dyn FnMut(u16, &str), _: &CancelFlag) -> ToolOutcome {
panic!("world tools belong to the game client");
}
}
// Real Codex JSON parser -> ordinary Session -> ordinary typed world
// tool execution. Only the external model and game result are fixtures.
fn reply(text: &str) -> Vec<ProviderEvent> {
use makepad_asset_chat::codex_cli::{parse_line, ParseState};
let item = json::obj(vec![("type", json::s("item.completed")),
("item", json::obj(vec![("type", json::s("agent_message")), ("text", json::s(text))]))]);
let mut state = ParseState::default();
let (mut events, _) = parse_line(&item, &mut state);
events.extend(parse_line(&json::obj(vec![("type", json::s("turn.completed"))]), &mut state).0);
events
}
let mut provider = Scripted::new(vec![
reply(&tool_line("world.get_plan", Value::Obj(vec![]))),
reply("The village plan is revision 17."),
vec![ProviderEvent::Delta("working".into())],
]);
provider.kind = ProviderKind::CodexCli;
let turns = provider.turns.clone();
let cancelled = provider.cancelled.clone();
let mut exec = GameRecorder(Recorder::new(ToolOutcome::Ok {
value: json::obj(vec![("revision", Value::Int(17)), ("title", json::s("Village"))]),
}));
let mut session = Session::new("village", Box::new(provider));
session.send("inspect Village", &[], &mut exec).unwrap();
session.pump(&mut exec);
assert!(session.drain_events().iter().any(|event| matches!(
&event.body, ChatEventBody::ToolCall { name, .. } if name == "world.get_plan")));
session.provide_client_outcome("tc_1_1", exec.0.outcome.clone(), &mut exec).unwrap();
session.pump(&mut exec);
assert!(session.is_idle());
assert!(exec.0.calls.borrow().is_empty());
assert_eq!(turns.borrow().len(), 2);
assert!(turns.borrow()[1].messages.iter().any(|message|
message.text.contains("revision") && message.text.contains("17")));
session.send("continue Village", &[], &mut exec).unwrap();
session.cancel();
assert!(session.is_idle());
assert_eq!(*cancelled.borrow(), 1);
let mut next = Scripted::new(vec![reply("Desert is a new map.")]);
next.kind = ProviderKind::CodexCli;
let next_turns = next.turns.clone();
let mut next_session = Session::new("desert", Box::new(next));
next_session.send("inspect Desert", &[], &mut exec).unwrap();
next_session.pump(&mut exec);
assert!(next_turns.borrow()[0].messages.iter().all(|message| !message.text.contains("Village")));
}
#[test]
fn progress_callbacks_are_bounded() {
let provider = Scripted::new(vec![
vec![ProviderEvent::Done {
text: tool_line(
"operation.get",
json::obj(vec![("operation", json::s("op_00000000000000000000000000000000"))]),
),
}],
vec![ProviderEvent::Done { text: "done".into() }],
]);
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
exec.progress_ticks = (MAX_PROGRESS_EVENTS as u16).saturating_add(40);
let mut session = Session::new("prin_test", Box::new(provider));
session.send("go", &[], &mut exec).unwrap();
session.pump(&mut exec);
let progress = session
.drain_events()
.into_iter()
.filter(|e| matches!(e.body, ChatEventBody::ToolProgress { .. }))
.count();
assert_eq!(progress, MAX_PROGRESS_EVENTS);
}
#[test]
fn native_oversize_arguments_are_refused_before_json_parse() {
let mut provider = Scripted::new(vec![
vec![ProviderEvent::FunctionCall {
call_id: "call_big".into(),
name: "asset_search".into(),
arguments: format!("{{\"query\":\"{}\"}}", "x".repeat(MAX_TOOL_JSON_BYTES)),
}],
vec![ProviderEvent::Done { text: "ok".into() }],
]);
provider.kind = ProviderKind::OpenAi;
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_native", Box::new(provider));
session.send("go", &[], &mut exec).unwrap();
session.pump(&mut exec);
assert!(exec.calls.borrow().is_empty());
let events = session.drain_events();
assert!(events.iter().any(|e| matches!(
&e.body,
ChatEventBody::ToolResult { outcome: ToolOutcome::Refused { what }, .. } if what.contains("too large")
)));
}
#[test]
fn session_ids_are_unique_and_parseable() {
let a = Session::new("p", Box::new(Scripted::new(vec![])));
let b = Session::new("p", Box::new(Scripted::new(vec![])));
assert_ne!(a.id().as_str(), b.id().as_str());
assert!(makepad_asset_chat::session::SessionId::parse(a.id().as_str()).is_some());
assert!(makepad_asset_chat::session::SessionId::parse("chat_zz").is_none());
// Origin keeps principal locally; session id is the dispatch scope.
assert_eq!(a.origin().principal, "p");
assert_eq!(a.origin().session.as_str(), a.id().as_str());
}
/// A turn spends its opening inside the model's think block. If that reasoning
/// is not streamed as text there is no delta for the serving facts to ride on
/// — and the client would see nothing during precisely the wait it most wants
/// explained, with its rate readout frozen at whatever the last text carried.
#[test]
fn serving_facts_reach_the_client_even_when_no_text_does() {
let facts = ServingFacts {
gen_tokens: 24,
think_tokens: Some(24),
..Default::default()
};
// A poll that reports progress and NO text: the box is generating, the
// user can read none of it yet.
let provider = Scripted::new(vec![vec![ProviderEvent::Serving(facts)]]);
let mut exec = Recorder::new(ToolOutcome::Ok { value: Value::Obj(vec![]) });
let mut session = Session::new("prin_test", Box::new(provider));
session.send("hi", &[], &mut exec).unwrap();
session.pump(&mut exec);
let deltas: Vec<(String, Option<ServingFacts>)> = session
.drain_events()
.iter()
.filter_map(|e| match &e.body {
ChatEventBody::Delta { text, serving } => Some((text.clone(), *serving)),
_ => None,
})
.collect();
let (text, serving) = deltas
.last()
.expect("a silent phase must still report the facts");
assert_eq!(text, "", "carried on an EMPTY delta, which appends nothing");
let serving = serving.expect("the facts are the whole point of the event");
assert_eq!(serving.gen_tokens, 24);
assert_eq!(serving.think_tokens, Some(24));
}