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.
945 lines
39 KiB
Rust
945 lines
39 KiB
Rust
//! The sandbox tool extension: queued generation, `assets.query`/`assets.schema`
|
|
//! and the `world.*` placement verbs. Parsing is the security boundary —
|
|
//! every wrong shape refuses — and the extension is only ADVERTISED when an
|
|
//! executor opts in via `ToolExecutor::tool_definitions`.
|
|
|
|
use makepad_asset_chat::provider::{ChatProvider, ProviderEvent, TurnInput};
|
|
use makepad_asset_chat::session::{CancelFlag, ExecCtx, Session, ToolExecutor};
|
|
use makepad_asset_chat::tools::{
|
|
canonical_from_api_name, definitions, encode_args, sandbox_definitions, ContentGenerateKind,
|
|
ContentToolCall, SpawnForm, SpawnScale, ToolDef, CSG_MODEL_TOOL_DOC,
|
|
};
|
|
use makepad_asset_chat::wire::{ProviderAvailability, ProviderKind, ToolOutcome};
|
|
use makepad_asset_client::json::{self, Value};
|
|
use makepad_asset_data::ScalePreset;
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
fn parse(name: &str, args: Value) -> Result<ContentToolCall, String> {
|
|
ContentToolCall::parse(name, &args)
|
|
}
|
|
|
|
fn obj(json_text: &str) -> Value {
|
|
json::parse(json_text.as_bytes()).expect("test json")
|
|
}
|
|
|
|
// ---------------------------------------------------------------- parsing
|
|
|
|
#[test]
|
|
fn world_api_is_a_bounded_read_only_game_tool_with_native_name_roundtrip() {
|
|
use makepad_asset_chat::context::ClientProfile;
|
|
use makepad_asset_chat::sandbox_effect::SandboxEffect;
|
|
for args in ["{}", r#"{"query":"game.ui","limit":20,"cursor":12}"#] {
|
|
let call = parse("world.api", obj(args)).unwrap();
|
|
assert_eq!(parse(call.name(), encode_args(&call)).unwrap(), call);
|
|
assert_eq!(call.sandbox_effect(), Some(SandboxEffect::Read));
|
|
assert!(ClientProfile::Game.client_executes(&call));
|
|
assert!(!ClientProfile::General.client_executes(&call));
|
|
}
|
|
assert_eq!(canonical_from_api_name("world_api"), Some("world.api"));
|
|
for bad in [r#"{"query":12}"#, r#"{"limit":0}"#, r#"{"limit":21}"#,
|
|
r#"{"cursor":-1}"#, r#"{"cursor":1000001}"#, r#"{"path":"/etc/passwd"}"#] {
|
|
assert!(parse("world.api", obj(bad)).is_err(), "{bad}");
|
|
}
|
|
assert!(parse("world.api", json::obj(vec![("query", json::s("x".repeat(161)))])).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn content_generate_roundtrips_and_refuses_unbounded_jobs() {
|
|
let call = parse(
|
|
"content.generate",
|
|
obj(r#"{"kind":"character","prompt":"a hopping clockwork bunny","dim_height":1.25}"#),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
call,
|
|
ContentToolCall::ContentGenerate {
|
|
kind: ContentGenerateKind::Character,
|
|
prompt: "a hopping clockwork bunny".into(),
|
|
dim_height: Some(1.25),
|
|
}
|
|
);
|
|
assert_eq!(ContentToolCall::parse(call.name(), &encode_args(&call)).unwrap(), call);
|
|
for bad in [
|
|
r#"{"kind":"video","prompt":"x"}"#,
|
|
r#"{"kind":"prop","prompt":""}"#,
|
|
r#"{"kind":"prop","prompt":"x","dim_height":0}"#,
|
|
r#"{"kind":"sound","prompt":"x","dim_height":101}"#,
|
|
r#"{"kind":"sound","prompt":"x","model":"expensive"}"#,
|
|
] {
|
|
assert!(parse("content.generate", obj(bad)).is_err(), "{bad}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn assets_query_roundtrips_and_bounds_sql() {
|
|
let call = parse("assets.query", obj(r#"{"sql": "SELECT 1"}"#)).unwrap();
|
|
assert_eq!(call, ContentToolCall::AssetsQuery { sql: "SELECT 1".into() });
|
|
assert_eq!(call.name(), "assets.query");
|
|
let re = ContentToolCall::parse("assets.query", &encode_args(&call)).unwrap();
|
|
assert_eq!(re, call);
|
|
|
|
let big = format!(r#"{{"sql": "{}"}}"#, "S".repeat(5000));
|
|
assert!(parse("assets.query", obj(&big)).is_err(), "oversized sql must refuse");
|
|
assert!(parse("assets.query", obj(r#"{"sql": ""}"#)).is_err());
|
|
assert!(parse("assets.query", obj(r#"{"sql": "SELECT 1", "x": 1}"#)).is_err());
|
|
assert!(parse("assets.query", obj(r#"{}"#)).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn assets_schema_takes_no_arguments() {
|
|
assert_eq!(parse("assets.schema", obj("{}")).unwrap(), ContentToolCall::AssetsSchema);
|
|
assert!(parse("assets.schema", obj(r#"{"table": "x"}"#)).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn world_place_parses_a_batch_and_refuses_bad_shapes() {
|
|
let call = parse(
|
|
"world.place",
|
|
obj(
|
|
r#"{"items": [
|
|
{"model": "kenney/props/fence", "pos": [4, 0, 2], "yaw_deg": 90, "scale": 1.5, "tag": "fence"},
|
|
{"model": "doom/doom/worlds/doom1/e1m1", "pos": [0, 0, 0]}
|
|
]}"#,
|
|
),
|
|
)
|
|
.unwrap();
|
|
let ContentToolCall::WorldPlace { items } = &call else {
|
|
panic!("wrong variant");
|
|
};
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(items[0].model, "kenney/props/fence");
|
|
assert_eq!(items[0].pos, [4.0, 0.0, 2.0]);
|
|
assert_eq!(items[0].yaw_deg, Some(90.0));
|
|
assert_eq!(items[0].scale, Some(1.5));
|
|
assert_eq!(items[0].tag.as_deref(), Some("fence"));
|
|
assert_eq!(items[1].yaw_deg, None);
|
|
let re = ContentToolCall::parse("world.place", &encode_args(&call)).unwrap();
|
|
assert_eq!(re, call);
|
|
|
|
// The model reference is spliced into game source as a string literal:
|
|
// quotes, backslashes and newlines refuse instead of being escaped.
|
|
for bad in [
|
|
r#"{"items": [{"model": "a\"b", "pos": [0,0,0]}]}"#,
|
|
r#"{"items": [{"model": "a\\b", "pos": [0,0,0]}]}"#,
|
|
r#"{"items": [{"model": "a\nb", "pos": [0,0,0]}]}"#,
|
|
] {
|
|
assert!(parse("world.place", obj(bad)).is_err(), "{bad}");
|
|
}
|
|
// Wrong positions.
|
|
for bad in [
|
|
r#"{"items": [{"model": "a", "pos": [0, 0]}]}"#,
|
|
r#"{"items": [{"model": "a", "pos": [0, 0, 0, 0]}]}"#,
|
|
r#"{"items": [{"model": "a", "pos": ["x", 0, 0]}]}"#,
|
|
r#"{"items": [{"model": "a", "pos": [1e7, 0, 0]}]}"#,
|
|
r#"{"items": [{"model": "a"}]}"#,
|
|
] {
|
|
assert!(parse("world.place", obj(bad)).is_err(), "{bad}");
|
|
}
|
|
// Bounds and unknown fields.
|
|
assert!(parse("world.place", obj(r#"{"items": []}"#)).is_err());
|
|
assert!(parse("world.place", obj(r#"{"items": [{"model": "a", "pos": [0,0,0], "rot": 1}]}"#)).is_err());
|
|
assert!(parse("world.place", obj(r#"{"items": [{"model": "a", "pos": [0,0,0], "scale": 0}]}"#)).is_err());
|
|
assert!(parse("world.place", obj(r#"{"items": [{"model": "a", "pos": [0,0,0], "tag": "NOT OK"}]}"#)).is_err());
|
|
let many: String = (0..65)
|
|
.map(|_| r#"{"model": "a", "pos": [0,0,0]}"#)
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
assert!(parse("world.place", obj(&format!(r#"{{"items": [{many}]}}"#))).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn world_remove_is_exactly_ids_or_tag() {
|
|
let by_ids = parse("world.remove", obj(r#"{"ids": [3, 4]}"#)).unwrap();
|
|
assert_eq!(by_ids, ContentToolCall::WorldRemove { ids: vec![3, 4], tag: None });
|
|
let by_tag = parse("world.remove", obj(r#"{"tag": "fence"}"#)).unwrap();
|
|
assert_eq!(by_tag, ContentToolCall::WorldRemove { ids: vec![], tag: Some("fence".into()) });
|
|
for call in [&by_ids, &by_tag] {
|
|
let re = ContentToolCall::parse("world.remove", &encode_args(call)).unwrap();
|
|
assert_eq!(&re, call);
|
|
}
|
|
assert!(parse("world.remove", obj(r#"{}"#)).is_err(), "neither is refused");
|
|
assert!(parse("world.remove", obj(r#"{"ids": [1], "tag": "x"}"#)).is_err(), "both is refused");
|
|
assert!(parse("world.remove", obj(r#"{"ids": []}"#)).is_err());
|
|
assert!(parse("world.remove", obj(r#"{"ids": [0]}"#)).is_err());
|
|
assert!(parse("world.remove", obj(r#"{"ids": ["3"]}"#)).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn world_move_needs_id_plus_at_least_one_change() {
|
|
let call = parse("world.move", obj(r#"{"id": 3, "pos": [6, 0, 2], "yaw_deg": 45}"#)).unwrap();
|
|
assert_eq!(
|
|
call,
|
|
ContentToolCall::WorldMove {
|
|
id: 3,
|
|
pos: Some([6.0, 0.0, 2.0]),
|
|
yaw_deg: Some(45.0),
|
|
scale: None
|
|
}
|
|
);
|
|
let re = ContentToolCall::parse("world.move", &encode_args(&call)).unwrap();
|
|
assert_eq!(re, call);
|
|
assert!(parse("world.move", obj(r#"{"id": 3}"#)).is_err());
|
|
assert!(parse("world.move", obj(r#"{"pos": [0,0,0]}"#)).is_err());
|
|
assert!(parse("world.move", obj(r#"{"id": 3, "scale": 2000}"#)).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn world_list_takes_no_arguments() {
|
|
assert_eq!(parse("world.list", obj("{}")).unwrap(), ContentToolCall::WorldList);
|
|
assert!(parse("world.list", obj(r#"{"tag": "x"}"#)).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn reviewed_model_tools_roundtrip_and_teaching_stays_small() {
|
|
let source = "let s=csg.sphere({r:0.1})\ncsg.part(\"ball\",s,{color:#4477aa})";
|
|
let call = parse(
|
|
"model.build",
|
|
json::obj(vec![("title", json::s("Blue Ball")), ("source", json::s(source))]),
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(&call, ContentToolCall::ModelBuild { title, source: got }
|
|
if title == "Blue Ball" && got == source));
|
|
assert_eq!(ContentToolCall::parse(call.name(), &encode_args(&call)).unwrap(), call);
|
|
|
|
let fetch = parse("model.fetch", obj(r#"{"alias":"gen/csg/blue-ball"}"#)).unwrap();
|
|
assert!(matches!(&fetch, ContentToolCall::ModelFetch { alias }
|
|
if alias.as_str() == "gen/csg/blue-ball"));
|
|
assert_eq!(ContentToolCall::parse(fetch.name(), &encode_args(&fetch)).unwrap(), fetch);
|
|
assert!(parse("model.build", obj(r#"{"title":"x"}"#)).is_err());
|
|
assert!(parse("model.fetch", obj(r#"{"alias":"public/not-csg"}"#)).is_err());
|
|
|
|
assert!(CSG_MODEL_TOOL_DOC.len() < 4_608, "{} bytes", CSG_MODEL_TOOL_DOC.len());
|
|
for verb in ["box", "sphere", "cylinder", "torus", "extrude", "lathe",
|
|
"union", "difference", "intersect", "move", "rotate", "scale", "mirror",
|
|
"implicit", "part", "anim"] {
|
|
assert!(CSG_MODEL_TOOL_DOC.contains(&format!("csg.{verb}")), "missing {verb}");
|
|
}
|
|
for forbidden in ["csg.shape", "csg.op", "csg.transform", "csg.gear", "csg.xor"] {
|
|
assert!(!CSG_MODEL_TOOL_DOC.contains(forbidden), "obsolete surface: {forbidden}");
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------ definitions
|
|
|
|
#[test]
|
|
fn world_source_tools_roundtrip_and_bound() {
|
|
let call = parse(
|
|
"world.set_source",
|
|
obj(r#"{"source": "game.sky({})\ngame.terrain({size: 100, cells: 65, smooth: true})", "note": "v1"}"#),
|
|
)
|
|
.unwrap();
|
|
let ContentToolCall::WorldSetSource { source, note } = &call else {
|
|
panic!("wrong variant");
|
|
};
|
|
assert!(source.starts_with("game.sky"));
|
|
assert_eq!(note.as_deref(), Some("v1"));
|
|
let re = ContentToolCall::parse("world.set_source", &encode_args(&call)).unwrap();
|
|
assert_eq!(re, call);
|
|
|
|
let big = format!(r#"{{"source": "{}"}}"#, "g".repeat(13_000));
|
|
assert!(parse("world.set_source", obj(&big)).is_err(), "oversized source refuses");
|
|
assert!(parse("world.set_source", obj(r#"{}"#)).is_err());
|
|
assert!(parse("world.set_source", obj(r#"{"source": "x", "mode": "y"}"#)).is_err());
|
|
|
|
assert_eq!(
|
|
parse("world.get_source", obj("{}")).unwrap(),
|
|
ContentToolCall::WorldGetSource
|
|
);
|
|
assert!(parse("world.get_source", obj(r#"{"x": 1}"#)).is_err());
|
|
|
|
// world.spawn: the content-add verb (§4.5 addon slice).
|
|
let call = parse(
|
|
"world.spawn",
|
|
obj(r#"{"model": "kenney/car-kit/ambulance"}"#),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
call,
|
|
ContentToolCall::WorldSpawn {
|
|
model: "kenney/car-kit/ambulance".into(),
|
|
pos: None,
|
|
form: None,
|
|
scale: None,
|
|
color: None,
|
|
hue: None,
|
|
tag: None,
|
|
}
|
|
);
|
|
let re = ContentToolCall::parse("world.spawn", &encode_args(&call)).unwrap();
|
|
assert_eq!(re, call);
|
|
let exact = parse(
|
|
"world.spawn",
|
|
obj(r#"{"model": "kenney/car-kit/ambulance", "scale": 0.5}"#),
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
&exact,
|
|
ContentToolCall::WorldSpawn { scale: Some(SpawnScale::Exact(0.5)), .. }
|
|
));
|
|
assert_eq!(ContentToolCall::parse("world.spawn", &encode_args(&exact)).unwrap(), exact);
|
|
let preset = parse(
|
|
"world.spawn",
|
|
obj(r#"{"model": "kenney/car-kit/ambulance", "scale": "small"}"#),
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
&preset,
|
|
ContentToolCall::WorldSpawn {
|
|
scale: Some(SpawnScale::Preset(ScalePreset::Small)),
|
|
..
|
|
}
|
|
));
|
|
assert_eq!(ContentToolCall::parse("world.spawn", &encode_args(&preset)).unwrap(), preset);
|
|
let recolored = parse(
|
|
"world.spawn",
|
|
obj(r##"{"model": "gen/csg/corgi-dog", "color": "#44aaff", "hue": -75}"##),
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
&recolored,
|
|
ContentToolCall::WorldSpawn {
|
|
color: Some(color),
|
|
hue: Some(-75.0),
|
|
..
|
|
} if color == "#44aaff"
|
|
));
|
|
assert_eq!(
|
|
ContentToolCall::parse("world.spawn", &encode_args(&recolored)).unwrap(),
|
|
recolored
|
|
);
|
|
assert!(parse(
|
|
"world.spawn",
|
|
obj(r##"{"model": "gen/csg/corgi-dog", "color": "blue"}"##),
|
|
)
|
|
.is_err());
|
|
assert!(parse(
|
|
"world.spawn",
|
|
obj(r#"{"model": "kenney/car-kit/ambulance", "scale": 0.1}"#),
|
|
)
|
|
.is_err());
|
|
assert!(parse(
|
|
"world.spawn",
|
|
obj(r#"{"model": "kenney/car-kit/ambulance", "scale": "tiny"}"#),
|
|
)
|
|
.is_err());
|
|
let call = parse(
|
|
"world.spawn",
|
|
obj(r#"{"model": "kenney/nature-kit/tree_oak", "pos": [4, 0, 2], "form": "prop", "tag": "forest"}"#),
|
|
)
|
|
.unwrap();
|
|
let re = ContentToolCall::parse("world.spawn", &encode_args(&call)).unwrap();
|
|
assert_eq!(re, call);
|
|
let follower = parse(
|
|
"world.spawn",
|
|
obj(r#"{"model": "gen/csg/corgi-dog", "form": "follower", "tag": "corgi"}"#),
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
&follower,
|
|
ContentToolCall::WorldSpawn {
|
|
form: Some(SpawnForm::Follower),
|
|
..
|
|
}
|
|
));
|
|
assert_eq!(
|
|
ContentToolCall::parse("world.spawn", &encode_args(&follower)).unwrap(),
|
|
follower
|
|
);
|
|
// world.tune: the world-knob verb (§4.5 tune slice). Both knobs are
|
|
// optional, at least one is required, and each has its own band.
|
|
let call = parse("world.tune", obj(r#"{"car_speed": 0.6}"#)).unwrap();
|
|
assert_eq!(
|
|
call,
|
|
ContentToolCall::WorldTune { time: None, car_speed: Some(0.6) }
|
|
);
|
|
let re = ContentToolCall::parse("world.tune", &encode_args(&call)).unwrap();
|
|
assert_eq!(re, call);
|
|
let call = parse("world.tune", obj(r#"{"time": 22, "car_speed": 2}"#)).unwrap();
|
|
assert_eq!(
|
|
call,
|
|
ContentToolCall::WorldTune { time: Some(22.0), car_speed: Some(2.0) }
|
|
);
|
|
let re = ContentToolCall::parse("world.tune", &encode_args(&call)).unwrap();
|
|
assert_eq!(re, call);
|
|
assert!(parse("world.tune", obj(r#"{}"#)).is_err(), "one knob required");
|
|
assert!(
|
|
parse("world.tune", obj(r#"{"car_speed": 0}"#)).is_err(),
|
|
"a frozen fleet is out of band"
|
|
);
|
|
assert!(
|
|
parse("world.tune", obj(r#"{"car_speed": 12}"#)).is_err(),
|
|
"an uncatchable fleet is out of band"
|
|
);
|
|
assert!(
|
|
parse("world.tune", obj(r#"{"speed": 2}"#)).is_err(),
|
|
"unknown knobs refuse rather than silently doing nothing"
|
|
);
|
|
|
|
assert!(parse("world.spawn", obj(r#"{}"#)).is_err(), "model required");
|
|
assert!(
|
|
parse("world.spawn", obj(r#"{"model": "x", "form": "banana"}"#)).is_err(),
|
|
"unknown form refuses"
|
|
);
|
|
assert!(
|
|
parse("world.spawn", obj(r#"{"model": "x", "near": "player"}"#)).is_err(),
|
|
"unknown keys refuse"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_definitions_are_consistent_and_disjoint_from_the_base() {
|
|
let base = definitions();
|
|
let extra = sandbox_definitions();
|
|
assert_eq!(extra.len(), 19);
|
|
assert!(extra.iter().any(|d| d.name == "world.get_plan"));
|
|
assert!(extra.iter().any(|d| d.name == "world.set_plan"));
|
|
for def in &extra {
|
|
assert_eq!(
|
|
canonical_from_api_name(def.api_name),
|
|
Some(def.name),
|
|
"{} must map back to {}",
|
|
def.api_name,
|
|
def.name
|
|
);
|
|
assert!(
|
|
!base.iter().any(|b| b.name == def.name || b.api_name == def.api_name),
|
|
"{} must not shadow a base tool",
|
|
def.name
|
|
);
|
|
// The documented example must parse: a wrong args_doc would teach
|
|
// the model a shape the parser refuses.
|
|
for example in def.args_doc.split(" or ") {
|
|
let args = obj(example.trim());
|
|
ContentToolCall::parse(def.name, &args)
|
|
.unwrap_or_else(|e| panic!("{} args_doc does not parse: {e}", def.name));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn base_args_docs_still_name_only_base_tools() {
|
|
// The broker prompt must not grow sandbox tools by accident.
|
|
let names: Vec<&str> = definitions().iter().map(|d| d.name).collect();
|
|
assert!(!names.contains(&"assets.query"));
|
|
assert!(!names.contains(&"world.place"));
|
|
assert!(!names.contains(&"content.generate"));
|
|
}
|
|
|
|
|
|
#[test]
|
|
fn plan_schema_guides_required_routes_and_rejects_unsupported_nested_input() {
|
|
let args = obj(r#"{"revision":0,"plan":{"v":1,"terrain":{"size":220,"relief":"flat"},"corridors":[{"id":"loop","kind":"rail","closed":true,"size":80}]}}"#);
|
|
let call = parse("world.set_plan", args).unwrap();
|
|
let ContentToolCall::WorldSetPlan { plan, .. } = &call else { panic!("plan variant") };
|
|
assert_eq!(plan.get("corridors").unwrap().as_arr().unwrap()[0].get("required").and_then(Value::as_bool), Some(true));
|
|
assert_eq!(parse(call.name(), encode_args(&call)).unwrap(), call);
|
|
let optional = parse("world.set_plan", obj(r#"{"revision":0,"plan":{"corridors":[{"id":"loop","kind":"rail","closed":true,"required":false}]}}"#)).unwrap();
|
|
let ContentToolCall::WorldSetPlan { plan, .. } = optional else { panic!("plan variant") };
|
|
assert_eq!(plan.get("corridors").unwrap().as_arr().unwrap()[0].get("required").and_then(Value::as_bool), Some(false));
|
|
for plan in [
|
|
r#"{"terrain":{"size":601}}"#, r#"{"terrain":{"amp":"flat"}}"#,
|
|
r#"{"corridors":[{"id":"x","kind":"rail","required":"yes"}]}"#,
|
|
r#"{"corridors":[{"id":"x","kind":"tunnel","closed":true}]}"#,
|
|
r#"{"corridors":[{"id":"x","kind":"road","path":"west"}]}"#,
|
|
r#"{"corridors":[{"id":"x","kind":"road","path":[[1,2]]}]}"#,
|
|
r#"{"corridors":[{"id":"x","kind":"road","from":"up","to":"east"}]}"#,
|
|
r#"{"corridors":[{"id":"x","kind":"road","through":["loop@1.2"]}]}"#,
|
|
r#"{"corridors":[{"id":"x","kind":"road","widht":9}]}"#,
|
|
r#"{"corridors":[{"id":"x","kind":"rail"},{"id":"x","kind":"road"}]}"#,
|
|
r#"{"corridors":[{"id":"bad:id","kind":"road"}]}"#,
|
|
r#"{"water":[{"id":"brook","kind":"river","depth":0}]}"#,
|
|
r#"{"dressing":{"forest":1.1}}"#, r#"{"biome":"volcanic"}"#,
|
|
] {
|
|
let args = json::obj(vec![("revision", Value::Int(0)), ("plan", obj(plan))]);
|
|
assert!(parse("world.set_plan", args).is_err(), "{plan}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn plan_schema_publishes_the_nested_contract() {
|
|
fn non_null(v: &Value) -> &Value {
|
|
v.get("anyOf").and_then(Value::as_arr).map_or(v, |v| &v[0])
|
|
}
|
|
let defs = sandbox_definitions();
|
|
let def = defs.iter().find(|d| d.name == "world.set_plan").unwrap();
|
|
let schema = def.parameters.get("properties").unwrap().get("plan").unwrap();
|
|
assert_eq!(schema.get("additionalProperties").and_then(Value::as_bool), Some(false));
|
|
let props = schema.get("properties").unwrap();
|
|
let corridors = non_null(props.get("corridors").unwrap()).get("items").unwrap();
|
|
assert_eq!(corridors.get("required").unwrap().as_arr().unwrap(), &[json::s("id")]);
|
|
let fields = corridors.get("properties").unwrap();
|
|
let requirement = non_null(fields.get("required").unwrap());
|
|
assert_eq!(requirement.get("type").and_then(Value::as_str), Some("boolean"));
|
|
assert_eq!(requirement.get("default").and_then(Value::as_bool), Some(true));
|
|
let kinds = non_null(fields.get("kind").unwrap()).get("enum").unwrap().as_arr().unwrap();
|
|
assert_eq!(kinds, &["road", "highway", "rail", "monorail", "path", "coaster"].map(json::s));
|
|
let radius = non_null(fields.get("radius").unwrap());
|
|
assert_eq!(radius.get("minimum"), Some(&Value::F64(4.0)));
|
|
assert_eq!(radius.get("maximum"), Some(&Value::F64(60.0)));
|
|
for key in ["biomes", "landforms", "water", "corridors", "places"] {
|
|
let item = non_null(props.get(key).unwrap()).get("items").unwrap();
|
|
assert_eq!(item.get("additionalProperties").and_then(Value::as_bool), Some(false));
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------- session advertisement
|
|
|
|
struct OneTurn {
|
|
turns: Rc<RefCell<Vec<TurnInput>>>,
|
|
}
|
|
|
|
impl ChatProvider for OneTurn {
|
|
fn kind(&self) -> ProviderKind {
|
|
ProviderKind::FleetQwen
|
|
}
|
|
fn availability(&mut self) -> ProviderAvailability {
|
|
ProviderAvailability::Available { model: "scripted".into(), detail: String::new() }
|
|
}
|
|
fn begin_turn(&mut self, input: &TurnInput) -> Result<(), String> {
|
|
self.turns.borrow_mut().push(input.clone());
|
|
Ok(())
|
|
}
|
|
fn poll(&mut self) -> Vec<ProviderEvent> {
|
|
vec![ProviderEvent::Done { text: "ok".into() }]
|
|
}
|
|
fn cancel(&mut self) {}
|
|
}
|
|
|
|
struct SandboxExec;
|
|
|
|
impl ToolExecutor for SandboxExec {
|
|
fn capability_doc(&mut self) -> String {
|
|
"sandbox capabilities".into()
|
|
}
|
|
fn tool_definitions(&mut self) -> Vec<ToolDef> {
|
|
let mut defs = definitions();
|
|
defs.extend(sandbox_definitions());
|
|
defs
|
|
}
|
|
fn execute(
|
|
&mut self,
|
|
_call: &ContentToolCall,
|
|
_ctx: &ExecCtx,
|
|
_progress: &mut dyn FnMut(u16, &str),
|
|
_cancel: &CancelFlag,
|
|
) -> ToolOutcome {
|
|
ToolOutcome::Ok { value: Value::Obj(Vec::new()) }
|
|
}
|
|
}
|
|
|
|
struct DefaultExec;
|
|
|
|
impl ToolExecutor for DefaultExec {
|
|
fn capability_doc(&mut self) -> String {
|
|
"broker capabilities".into()
|
|
}
|
|
fn execute(
|
|
&mut self,
|
|
_call: &ContentToolCall,
|
|
_ctx: &ExecCtx,
|
|
_progress: &mut dyn FnMut(u16, &str),
|
|
_cancel: &CancelFlag,
|
|
) -> ToolOutcome {
|
|
ToolOutcome::Ok { value: Value::Obj(Vec::new()) }
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------- native trained-format calls
|
|
|
|
use makepad_asset_chat::toolcall::{self, Extract};
|
|
|
|
#[test]
|
|
fn the_trained_qwen_tool_template_is_heard() {
|
|
// Verbatim from a live village run: the model reverted to its trained
|
|
// template and the turn silently died. Never again.
|
|
let text = "Let me find cars.\n</think>\n\nSearching now.\n<tool_call>\n<function=asset_search>\n<parameter=query>\ncar vehicle driveable\n</parameter>\n<parameter=limit>\n15\n</parameter>\n</function>\n</tool_call>";
|
|
let Extract::Call { clean, name, args } = toolcall::extract(text) else {
|
|
panic!("native template not heard: {:?}", toolcall::extract(text));
|
|
};
|
|
assert_eq!(clean, "Searching now.");
|
|
assert_eq!(name, "asset.search");
|
|
assert_eq!(args.get("query").and_then(Value::as_str), Some("car vehicle driveable"));
|
|
assert_eq!(args.get("limit").and_then(Value::as_i64), Some(15));
|
|
// And the typed parser accepts the coerced arguments.
|
|
ContentToolCall::parse(&name, &args).expect("typed parse of native-call args");
|
|
// The UI strip hides the block.
|
|
assert_eq!(toolcall::strip_marker(text), "Searching now.");
|
|
}
|
|
|
|
#[test]
|
|
fn native_template_carries_multiline_splash_source_intact() {
|
|
let source = "game.sky({})\ngame.terrain({size: 120, cells: 65, smooth: true})\nlet hero = game.character({pos: vec3(0, 2, 8), player: true})";
|
|
let text = format!(
|
|
"</think>\n<tool_call>\n<function=world_set_source>\n<parameter=source>\n{source}\n</parameter>\n<parameter=note>\nvillage v1\n</parameter>\n</function>\n</tool_call>"
|
|
);
|
|
let Extract::Call { name, args, .. } = toolcall::extract(&text) else {
|
|
panic!("not heard");
|
|
};
|
|
assert_eq!(name, "world.set_source");
|
|
assert_eq!(args.get("source").and_then(Value::as_str), Some(source));
|
|
let call = ContentToolCall::parse(&name, &args).unwrap();
|
|
let ContentToolCall::WorldSetSource { source: parsed, note } = call else {
|
|
panic!("wrong variant");
|
|
};
|
|
assert_eq!(parsed, source);
|
|
assert_eq!(note.as_deref(), Some("village v1"));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_native_function_names_fail_closed_as_readable_refusals() {
|
|
let text = "</think>\n<tool_call>\n<function=rm_rf>\n<parameter=path>\n/\n</parameter>\n</function>\n</tool_call>";
|
|
let Extract::Call { name, args, .. } = toolcall::extract(text) else {
|
|
panic!("expected a call shape");
|
|
};
|
|
assert_eq!(name, "rm_rf");
|
|
assert!(ContentToolCall::parse(&name, &args).is_err(), "unknown tools refuse");
|
|
}
|
|
|
|
// -------------------------------------------- client-executed tool round trip
|
|
|
|
use makepad_asset_chat::tools::game_definitions;
|
|
use makepad_asset_chat::wire::ChatEventBody;
|
|
|
|
/// A provider that replays scripted turn texts (each `begin_turn` consumes
|
|
/// one) — enough to drive a tool round.
|
|
struct Replay {
|
|
turns: Vec<String>,
|
|
pending: Vec<ProviderEvent>,
|
|
inputs: Rc<RefCell<Vec<TurnInput>>>,
|
|
}
|
|
|
|
impl ChatProvider for Replay {
|
|
fn kind(&self) -> ProviderKind {
|
|
ProviderKind::FleetQwen
|
|
}
|
|
fn availability(&mut self) -> ProviderAvailability {
|
|
ProviderAvailability::Available { model: "scripted".into(), detail: String::new() }
|
|
}
|
|
fn begin_turn(&mut self, input: &TurnInput) -> Result<(), String> {
|
|
self.inputs.borrow_mut().push(input.clone());
|
|
if self.turns.is_empty() {
|
|
return Err("script exhausted".into());
|
|
}
|
|
let text = self.turns.remove(0);
|
|
self.pending = vec![ProviderEvent::Done { text }];
|
|
Ok(())
|
|
}
|
|
fn poll(&mut self) -> Vec<ProviderEvent> {
|
|
std::mem::take(&mut self.pending)
|
|
}
|
|
fn cancel(&mut self) {}
|
|
}
|
|
|
|
/// A game-session executor: world tools are client-executed; everything
|
|
/// else answers Ok. Records what it actually executed.
|
|
struct GameExec {
|
|
executed: Rc<RefCell<Vec<String>>>,
|
|
}
|
|
|
|
impl ToolExecutor for GameExec {
|
|
fn capability_doc(&mut self) -> String {
|
|
"game capabilities".into()
|
|
}
|
|
fn tool_definitions(&mut self) -> Vec<ToolDef> {
|
|
game_definitions()
|
|
}
|
|
fn client_executes(&mut self, call: &ContentToolCall) -> bool {
|
|
call.name().starts_with("world.")
|
|
}
|
|
fn execute(
|
|
&mut self,
|
|
call: &ContentToolCall,
|
|
_ctx: &ExecCtx,
|
|
_progress: &mut dyn FnMut(u16, &str),
|
|
_cancel: &CancelFlag,
|
|
) -> ToolOutcome {
|
|
self.executed.borrow_mut().push(call.name().to_string());
|
|
ToolOutcome::Ok { value: Value::Obj(Vec::new()) }
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_world_call_parks_the_turn_until_the_client_answers() {
|
|
let inputs = Rc::new(RefCell::new(Vec::new()));
|
|
let provider = Replay {
|
|
turns: vec![
|
|
"I'll rebuild it.\n<<tool>>{\"name\":\"world.set_source\",\"args\":{\"source\":\"game.sky({})\"}}".into(),
|
|
"Done — the level is live.".into(),
|
|
],
|
|
pending: Vec::new(),
|
|
inputs: inputs.clone(),
|
|
};
|
|
let executed = Rc::new(RefCell::new(Vec::new()));
|
|
let mut exec = GameExec { executed: executed.clone() };
|
|
let mut session = Session::new("game", Box::new(provider));
|
|
session.send("make a level", &[], &mut exec).unwrap();
|
|
session.pump(&mut exec);
|
|
|
|
// Parked: the ToolCall event is out, nothing executed server-side,
|
|
// the session is busy but pumping does nothing.
|
|
assert_eq!(session.awaiting_client_tool(), Some("tc_1_1"));
|
|
assert!(!session.is_idle());
|
|
assert!(executed.borrow().is_empty(), "world tools must not execute in the broker");
|
|
let events = session.drain_events();
|
|
assert!(events.iter().any(|e| matches!(
|
|
&e.body,
|
|
ChatEventBody::ToolCall { name, .. } if name == "world.set_source"
|
|
)));
|
|
session.pump(&mut exec);
|
|
assert_eq!(session.awaiting_client_tool(), Some("tc_1_1"), "parked stays parked");
|
|
|
|
// A wrong call id is refused; the right one resumes the turn.
|
|
let ok = ToolOutcome::Ok {
|
|
value: json::obj(vec![("eval", json::s("game evaluated successfully"))]),
|
|
};
|
|
assert!(session.provide_client_outcome("tc_9_9", ok.clone(), &mut exec).is_err());
|
|
session.provide_client_outcome("tc_1_1", ok, &mut exec).unwrap();
|
|
while !session.is_idle() {
|
|
session.pump(&mut exec);
|
|
}
|
|
let events = session.drain_events();
|
|
assert!(events.iter().any(|e| matches!(&e.body, ChatEventBody::ToolResult { .. })));
|
|
assert!(events.iter().any(|e| matches!(&e.body, ChatEventBody::Done)));
|
|
// The follow-up provider turn carried the tool outcome in history.
|
|
let last_input = inputs.borrow().last().cloned().unwrap();
|
|
assert!(last_input
|
|
.messages
|
|
.iter()
|
|
.any(|m| m.text.contains("game evaluated successfully")));
|
|
// The game prompt advertised the world tools and dropped generation.
|
|
let system = inputs.borrow()[0].system.clone();
|
|
assert!(system.contains("world.set_source"));
|
|
assert!(!system.contains("- image.generate:"), "phase 1 hides generation tools");
|
|
}
|
|
|
|
/// `world.new_level` parses like `world.set_source` plus a title, is
|
|
/// advertised next to it on the game surface, and is client-executed.
|
|
#[test]
|
|
fn world_new_level_roundtrips_bounds_and_is_a_game_client_tool() {
|
|
let call = parse(
|
|
"world.new_level",
|
|
obj(r#"{"title": "Quarry Arena", "source": "game.sky({})\ngame.terrain({size: 120, cells: 65, smooth: true})", "note": "first cut"}"#),
|
|
)
|
|
.unwrap();
|
|
let ContentToolCall::WorldNewLevel { title, source, note } = &call else {
|
|
panic!("wrong variant");
|
|
};
|
|
assert_eq!(title, "Quarry Arena");
|
|
assert!(source.starts_with("game.sky"));
|
|
assert_eq!(note.as_deref(), Some("first cut"));
|
|
assert_eq!(call.name(), "world.new_level");
|
|
let re = ContentToolCall::parse("world.new_level", &encode_args(&call)).unwrap();
|
|
assert_eq!(re, call);
|
|
assert_eq!(canonical_from_api_name("world_new_level"), Some("world.new_level"));
|
|
|
|
// Same caps as set_source on the source; a title is display text.
|
|
let big = format!(r#"{{"title": "t", "source": "{}"}}"#, "g".repeat(13_000));
|
|
assert!(parse("world.new_level", obj(&big)).is_err(), "oversized source refuses");
|
|
assert!(parse("world.new_level", obj(r#"{"source": "x"}"#)).is_err(), "title required");
|
|
assert!(parse("world.new_level", obj(r#"{"title": "t"}"#)).is_err(), "source required");
|
|
assert!(parse("world.new_level", obj(r#"{"title": " ", "source": "x"}"#)).is_err());
|
|
assert!(parse("world.new_level", obj(r#"{"title": "ab", "source": "x"}"#)).is_ok());
|
|
assert!(parse("world.new_level", obj(r#"{"title": "a\u0007b", "source": "x"}"#)).is_err(), "control chars refuse");
|
|
let long_title = format!(r#"{{"title": "{}", "source": "x"}}"#, "t".repeat(81));
|
|
assert!(parse("world.new_level", obj(&long_title)).is_err());
|
|
assert!(parse("world.new_level", obj(r#"{"title": "t", "source": "x", "switch": true}"#)).is_err());
|
|
|
|
// Advertised on the game surface, right after set_source; never on the
|
|
// base list.
|
|
let names: Vec<&str> = game_definitions().iter().map(|d| d.name).collect();
|
|
let set = names.iter().position(|n| *n == "world.set_source").unwrap();
|
|
assert_eq!(names[set + 1], "world.new_level", "{names:?}");
|
|
assert!(!definitions().iter().any(|d| d.name == "world.new_level"));
|
|
assert!(makepad_asset_chat::context::ClientProfile::Game.client_executes(&call));
|
|
assert!(!makepad_asset_chat::context::ClientProfile::General.client_executes(&call));
|
|
}
|
|
|
|
#[test]
|
|
fn splash_source_tools_teach_if_else_and_range_loop_syntax() {
|
|
let defs = sandbox_definitions();
|
|
for name in ["world.add_addon", "world.set_source"] {
|
|
let description = defs
|
|
.iter()
|
|
.find(|def| def.name == name)
|
|
.unwrap_or_else(|| panic!("missing {name}"))
|
|
.description;
|
|
assert!(description.contains("NO ternary `?:` — use if/else"), "{name}: {description}");
|
|
assert!(description.contains("`for i in 0..n {}`"), "{name}: {description}");
|
|
}
|
|
}
|
|
|
|
/// The client's answer to `world.new_level` ENDS the turn: the round is in
|
|
/// the history, `done` is emitted, and NO follow-up model round runs — the
|
|
/// player is in another game, with its own chat, by then.
|
|
#[test]
|
|
fn a_new_level_answer_ends_the_turn_without_another_model_round() {
|
|
let inputs = Rc::new(RefCell::new(Vec::new()));
|
|
let provider = Replay {
|
|
turns: vec![
|
|
"New level coming.\n<<tool>>{\"name\":\"world.new_level\",\"args\":{\"title\":\"Quarry\",\"source\":\"game.sky({})\"}}".into(),
|
|
"THIS ROUND MUST NEVER RUN".into(),
|
|
],
|
|
pending: Vec::new(),
|
|
inputs: inputs.clone(),
|
|
};
|
|
let executed = Rc::new(RefCell::new(Vec::new()));
|
|
let mut exec = GameExec { executed: executed.clone() };
|
|
let mut session = Session::new("game", Box::new(provider));
|
|
session.send("make me a new level", &[], &mut exec).unwrap();
|
|
session.pump(&mut exec);
|
|
assert_eq!(session.awaiting_client_tool(), Some("tc_1_1"));
|
|
assert!(executed.borrow().is_empty());
|
|
|
|
let ok = ToolOutcome::Ok {
|
|
value: json::obj(vec![
|
|
("asset_id", json::s("ast_0123456789abcdef0123456789abcdef")),
|
|
("alias", json::s("games/quarry")),
|
|
("title", json::s("Quarry")),
|
|
]),
|
|
};
|
|
session.provide_client_outcome("tc_1_1", ok, &mut exec).unwrap();
|
|
assert!(session.is_idle(), "the turn ends on the answer");
|
|
let events = session.drain_events();
|
|
assert!(events.iter().any(|e| matches!(&e.body, ChatEventBody::ToolResult { .. })));
|
|
assert!(events.iter().any(|e| matches!(&e.body, ChatEventBody::Done)));
|
|
assert!(!events.iter().any(|e| matches!(&e.body, ChatEventBody::Error { .. })), "{events:?}");
|
|
assert_eq!(inputs.borrow().len(), 1, "no follow-up provider turn");
|
|
// The round is recorded for the transcript: user, assistant(+call), tool.
|
|
let roles: Vec<_> = session.history().iter().map(|m| m.role).collect();
|
|
assert_eq!(
|
|
roles,
|
|
vec![
|
|
makepad_asset_chat::wire::ChatRole::User,
|
|
makepad_asset_chat::wire::ChatRole::Assistant,
|
|
makepad_asset_chat::wire::ChatRole::Tool
|
|
]
|
|
);
|
|
let rows = makepad_asset_chat::transcript::render(session.history());
|
|
assert_eq!(rows.last().unwrap().text, "world.new_level · ok");
|
|
// The session is reusable afterwards (it is still this game's chat).
|
|
assert!(session.send("still here?", &[], &mut exec).is_ok());
|
|
}
|
|
|
|
/// A session rebuilt from its persisted history keeps its id, turn and the
|
|
/// conversation, and the next send replays that history to a fresh
|
|
/// provider. Over-long histories keep the newest half.
|
|
#[test]
|
|
fn a_resumed_session_replays_its_history_on_the_next_send() {
|
|
use makepad_asset_chat::session::SessionId;
|
|
use makepad_asset_chat::wire::{ChatMessage, ChatRole};
|
|
let inputs = Rc::new(RefCell::new(Vec::new()));
|
|
let provider = Replay {
|
|
turns: vec!["Welcome back.".into()],
|
|
pending: Vec::new(),
|
|
inputs: inputs.clone(),
|
|
};
|
|
let id = SessionId::parse("chat_00000000deadbeef").unwrap();
|
|
let history = vec![
|
|
ChatMessage::new(ChatRole::User, "make a level"),
|
|
ChatMessage::new(ChatRole::Assistant, "Built it."),
|
|
// An invalid (empty) row from a torn file is dropped, not fatal.
|
|
ChatMessage::new(ChatRole::Tool, ""),
|
|
];
|
|
let mut exec = GameExec { executed: Rc::new(RefCell::new(Vec::new())) };
|
|
let mut session = Session::resume(id.clone(), "game", Box::new(provider), history, 7, None);
|
|
assert_eq!(session.id(), &id);
|
|
assert_eq!(session.turn(), 7);
|
|
assert_eq!(session.history().len(), 2);
|
|
assert_eq!(session.send("and now?", &[], &mut exec).unwrap(), 8);
|
|
while !session.is_idle() {
|
|
session.pump(&mut exec);
|
|
}
|
|
let input = inputs.borrow()[0].clone();
|
|
assert_eq!(input.messages.len(), 3);
|
|
assert_eq!(input.messages[0].text, "make a level");
|
|
assert!(input.messages[2].text.starts_with("and now?"));
|
|
|
|
let many: Vec<ChatMessage> = (0..200)
|
|
.map(|i| ChatMessage::new(ChatRole::User, format!("m{i}")))
|
|
.collect();
|
|
let provider = Replay { turns: Vec::new(), pending: Vec::new(), inputs: inputs.clone() };
|
|
let session = Session::resume(id, "game", Box::new(provider), many, 1, None);
|
|
assert_eq!(session.history().len(), makepad_asset_chat::wire::MAX_MESSAGES / 2);
|
|
assert_eq!(session.history().last().unwrap().text, "m199");
|
|
}
|
|
|
|
#[test]
|
|
fn cancelling_a_parked_turn_frees_the_session() {
|
|
let provider = Replay {
|
|
turns: vec![
|
|
"<<tool>>{\"name\":\"world.list\",\"args\":{}}".into(),
|
|
],
|
|
pending: Vec::new(),
|
|
inputs: Rc::new(RefCell::new(Vec::new())),
|
|
};
|
|
let executed = Rc::new(RefCell::new(Vec::new()));
|
|
let mut exec = GameExec { executed };
|
|
let mut session = Session::new("game", Box::new(provider));
|
|
session.send("what is placed?", &[], &mut exec).unwrap();
|
|
session.pump(&mut exec);
|
|
assert!(session.awaiting_client_tool().is_some());
|
|
session.cancel();
|
|
assert!(session.is_idle());
|
|
assert!(session.awaiting_client_tool().is_none());
|
|
let events = session.drain_events();
|
|
assert!(events.iter().any(|e| matches!(&e.body, ChatEventBody::Cancelled)));
|
|
// A late tool result for the cancelled call is refused, not applied.
|
|
assert!(session
|
|
.provide_client_outcome(
|
|
"tc_1_1",
|
|
ToolOutcome::Ok { value: Value::Obj(Vec::new()) },
|
|
&mut exec
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn the_system_prompt_lists_exactly_what_the_executor_advertises() {
|
|
// Sandbox executor: the world tools are in the rendered system text.
|
|
let turns = Rc::new(RefCell::new(Vec::new()));
|
|
let mut session =
|
|
Session::new("test", Box::new(OneTurn { turns: turns.clone() }));
|
|
session.send("hello", &[], &mut SandboxExec).unwrap();
|
|
let system = turns.borrow()[0].system.clone();
|
|
assert!(system.contains("world.place"), "sandbox tools must be advertised");
|
|
assert!(system.contains("assets.query"));
|
|
assert!(system.contains("image.generate"), "base tools stay");
|
|
|
|
// Default executor: the broker prompt is unchanged — no world tools.
|
|
let turns2 = Rc::new(RefCell::new(Vec::new()));
|
|
let mut session2 =
|
|
Session::new("test", Box::new(OneTurn { turns: turns2.clone() }));
|
|
session2.send("hello", &[], &mut DefaultExec).unwrap();
|
|
let system2 = turns2.borrow()[0].system.clone();
|
|
assert!(!system2.contains("- world.place:"), "broker prompt must not advertise world tools");
|
|
assert!(!system2.contains("- assets.query:"), "a guidance example is not an advertised tool");
|
|
}
|
|
|
|
#[test]
|
|
fn world_tools_accept_and_roundtrip_an_explicit_sub_world() {
|
|
let call = parse("world.get_source", obj(r#"{"sub":"dogshop"}"#)).unwrap();
|
|
assert!(matches!(
|
|
call,
|
|
ContentToolCall::WorldInSub { ref sub, ref call }
|
|
if sub == "dogshop" && matches!(**call, ContentToolCall::WorldGetSource)
|
|
));
|
|
assert_eq!(encode_args(&call).get("sub").and_then(Value::as_str), Some("dogshop"));
|
|
assert!(parse("world.list", obj(r#"{"sub":"../escape"}"#)).is_err());
|
|
let def = sandbox_definitions().into_iter().find(|def| def.name == "world.list").unwrap();
|
|
assert!(def.parameters.to_json().contains("\"sub\""));
|
|
}
|
|
|
|
#[test]
|
|
fn per_turn_world_manifest_is_volatile_context_not_transcript_text() {
|
|
let turns = Rc::new(RefCell::new(Vec::new()));
|
|
let mut session = Session::new("test", Box::new(OneTurn { turns: turns.clone() }));
|
|
let context = "WORLD MANIFEST: asset ast_x; worlds: `main`, `dogshop`\nthe player is currently in: `dogshop`";
|
|
session
|
|
.send_with_context("add a chair", &[], context, &mut SandboxExec)
|
|
.unwrap();
|
|
assert_eq!(turns.borrow()[0].dynamic_context, context);
|
|
assert!(!turns.borrow()[0].system.contains(context), "the stable KV prefix must not churn each turn");
|
|
assert_eq!(session.history()[0].text, "add a chair");
|
|
assert!(!session.history()[0].text.contains("WORLD MANIFEST"));
|
|
}
|