Squashed from work; the fine-grained history is under tag archive/work-2026-08-29: - mp* wave: mpwm window manager + the mp app family, WM API, theme bridge, PDF engine fix - work: land the sources the last commits reference - kenney: catalogue all 50 free 3D kits; Modal dismissed() never fired - asset store: central vision-annotation queue; Kenney donate prompt - asset-ai: vision domain — image + prompt -> text on every fleet node - annotation runs the fleet's vision services through the normal job queue - vj: responsive DJ mixer + Windows drag-and-drop, cherry-picked from PR #1199 (vjroger) - store search: WordNet synonym expansion, query-side; per-term seeks - video scheduling: a cold model pin never downloads past a warm one, and a dedicated box only serves its role - an expansion can never lose a run — and `expand: true` finally means something - fleet panel: a slow box is not a missing box - h3: first+last keyframe conditioning (the weights were always FL2VA) - registry: the three H3 FL2VA tiers name their real conditioning - fleet scheduling: spread before stacking, and a stuck job moves - a download never steals a job from a box that has the weights - the faster GPU takes the tie: 6000 > 5090 > 4090 - an evicted flux model gives the card its VRAM back - a job now records what each of its stages was handed - open a stage in RUNS and read what it sent - every expanded song was an instrumental: the lyrics had nowhere to go - the writer never overwrites words the person wrote - store: a dependent job's body is spliced from its deps' results at claim - a fleet box's job row says what that job was asked for - the store runs a whole pipeline, and one record says how far it got - a client can declare a whole run, watch it, and stop it - 100% now means published, and the expander is a job you can queue - one card says what a spawned task is doing, everywhere - a run that ends says so, instead of being noticed later - when every box holding the model is busy, buy another copy
1285 lines
47 KiB
Rust
1285 lines
47 KiB
Rust
//! Wire/schema contract tests. The sandbox will speak this exact schema
|
|
//! later, so representative encodings are pinned byte-for-byte: changing
|
|
//! them is a WIRE_VERSION event, not a refactor.
|
|
|
|
use makepad_asset_chat::toolcall::{self, Extract};
|
|
use makepad_asset_chat::tools::{self, ContentToolCall, GenerateThen};
|
|
use makepad_asset_chat::wire::*;
|
|
use makepad_asset_client::json::{self, Value};
|
|
use makepad_asset_data::AssetRevisionId;
|
|
|
|
fn rev(byte: u8) -> AssetRevisionId {
|
|
AssetRevisionId::from_bytes([byte; 32])
|
|
}
|
|
|
|
fn schema_required(schema: &Value) -> Vec<&str> {
|
|
schema
|
|
.get("required")
|
|
.and_then(Value::as_arr)
|
|
.map(|a| a.iter().filter_map(Value::as_str).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn schema_additional(schema: &Value) -> Option<bool> {
|
|
schema.get("additionalProperties").and_then(Value::as_bool)
|
|
}
|
|
|
|
fn schema_minmax(schema: &Value) -> (i64, i64) {
|
|
(
|
|
schema.get("minimum").and_then(Value::as_i64).unwrap(),
|
|
schema.get("maximum").and_then(Value::as_i64).unwrap(),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn delta_chunks_are_utf8_safe_and_bounded() {
|
|
let text = format!("{}{}", "x".repeat(MAX_DELTA_BYTES - 1), "éy");
|
|
let chunks = split_delta_text(&text);
|
|
assert!(chunks.len() >= 2);
|
|
assert!(chunks.iter().all(|c| c.len() <= MAX_DELTA_BYTES));
|
|
assert!(chunks.iter().all(|c| std::str::from_utf8(c.as_bytes()).is_ok()));
|
|
assert_eq!(chunks.concat(), text);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_kinds_are_explicit_and_closed() {
|
|
// Exactly three providers; slugs are stable wire identifiers.
|
|
assert_eq!(ProviderKind::FleetQwen.slug(), "fleet-qwen");
|
|
assert_eq!(ProviderKind::OpenAi.slug(), "openai");
|
|
assert_eq!(ProviderKind::Grok.slug(), "grok");
|
|
assert_eq!(ProviderKind::from_slug("fleet-qwen"), Some(ProviderKind::FleetQwen));
|
|
assert_eq!(ProviderKind::from_slug("openai"), Some(ProviderKind::OpenAi));
|
|
assert_eq!(ProviderKind::from_slug("grok"), Some(ProviderKind::Grok));
|
|
// No auto/fallback/Claude provider is representable on this wire.
|
|
assert_eq!(ProviderKind::from_slug("auto"), None);
|
|
assert_eq!(ProviderKind::from_slug("fallback"), None);
|
|
assert_eq!(ProviderKind::from_slug("server-claude"), None);
|
|
assert_eq!(ProviderKind::from_slug("claude"), None);
|
|
assert_eq!(ProviderKind::from_slug(""), None);
|
|
assert!(ProviderKind::OpenAi.uses_native_tools());
|
|
assert!(ProviderKind::Grok.uses_native_tools());
|
|
assert!(!ProviderKind::FleetQwen.uses_native_tools());
|
|
}
|
|
|
|
#[test]
|
|
fn availability_roundtrip_and_stable_encoding() {
|
|
let a = ProviderAvailability::Available {
|
|
model: "qwen3.8-27b".to_string(),
|
|
detail: "http://10.0.0.217:8765".to_string(),
|
|
};
|
|
assert_eq!(
|
|
a.encode().to_json(),
|
|
r#"{"state":"available","model":"qwen3.8-27b","detail":"http://10.0.0.217:8765"}"#
|
|
);
|
|
assert_eq!(ProviderAvailability::decode(&a.encode()).unwrap(), a);
|
|
|
|
let u = ProviderAvailability::Unavailable { reason: "no chat capability".to_string() };
|
|
assert_eq!(u.encode().to_json(), r#"{"state":"unavailable","reason":"no chat capability"}"#);
|
|
assert_eq!(ProviderAvailability::decode(&u.encode()).unwrap(), u);
|
|
|
|
assert!(ProviderAvailability::decode(&json::obj(vec![("state", json::s("maybe"))])).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn message_bounds_are_enforced() {
|
|
let ok = ChatMessage::new(ChatRole::User, "hello");
|
|
assert!(ok.validate().is_ok());
|
|
assert_eq!(ChatMessage::decode(&ok.encode()).unwrap(), ok);
|
|
|
|
let empty = ChatMessage::new(ChatRole::User, "");
|
|
assert!(empty.validate().is_err());
|
|
|
|
let big = ChatMessage::new(ChatRole::User, "x".repeat(MAX_MESSAGE_BYTES + 1));
|
|
assert!(big.validate().is_err());
|
|
assert!(ChatMessage::decode(&big.encode()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn attachment_binding_is_exact_revision_plus_role() {
|
|
let a = AttachmentBinding { revision: rev(0x11), role: "source".to_string() };
|
|
assert!(a.validate().is_ok());
|
|
let decoded = AttachmentBinding::decode(&a.encode()).unwrap();
|
|
assert_eq!(decoded, a);
|
|
|
|
// Roles are bounded identifiers, not free text or paths.
|
|
let bad = AttachmentBinding { revision: rev(0x11), role: "../etc".to_string() };
|
|
assert!(bad.validate().is_err());
|
|
let spacey = AttachmentBinding { revision: rev(0x11), role: "my role".to_string() };
|
|
assert!(spacey.validate().is_err());
|
|
|
|
// A malformed revision id is refused at decode.
|
|
let forged = json::obj(vec![
|
|
("revision", json::s("arev_zz")),
|
|
("role", json::s("source")),
|
|
]);
|
|
assert!(AttachmentBinding::decode(&forged).is_err());
|
|
}
|
|
|
|
/// The serving block is ADDITIVE on `delta`: a delta that knows nothing
|
|
/// encodes exactly the old two keys (so an old client sees the old event),
|
|
/// and a client that predates the block ignores it — pinned here by
|
|
/// decoding one WITH the block and checking the text survives untouched.
|
|
#[test]
|
|
fn serving_facts_are_additive_on_delta() {
|
|
let bare = ChatEvent { seq: 7, body: ChatEventBody::Delta { text: "hi".into(), serving: None } };
|
|
assert_eq!(bare.encode().to_json(), r#"{"seq":7,"type":"delta","text":"hi"}"#);
|
|
|
|
let full = ChatEvent {
|
|
seq: 8,
|
|
body: ChatEventBody::Delta {
|
|
text: "hi".into(),
|
|
serving: Some(ServingFacts {
|
|
gen_tokens: 128,
|
|
lanes_active: Some(2),
|
|
slots_total: Some(4), ..Default::default() }),
|
|
},
|
|
};
|
|
let encoded = full.encode();
|
|
assert_eq!(ChatEvent::decode(&encoded).unwrap(), full);
|
|
// The old fields are byte-identical; only a new key was added.
|
|
let json = encoded.to_json();
|
|
assert!(json.contains(r#""text":"hi""#), "{json}");
|
|
assert!(json.contains(r#""gen_tokens":128"#), "{json}");
|
|
|
|
// Lanes are optional inside the block (a single-lane box says nothing).
|
|
let no_lanes = ChatEvent {
|
|
seq: 9,
|
|
body: ChatEventBody::Delta {
|
|
text: "x".into(),
|
|
serving: Some(ServingFacts { gen_tokens: 1, lanes_active: None, slots_total: None, ..Default::default() }),
|
|
},
|
|
};
|
|
assert_eq!(ChatEvent::decode(&no_lanes.encode()).unwrap(), no_lanes);
|
|
}
|
|
|
|
/// A cosmetic counter must never kill a live turn: garbage in the block
|
|
/// decodes as "no facts", and the delta still arrives.
|
|
#[test]
|
|
fn a_malformed_serving_block_never_fails_the_delta() {
|
|
for junk in [
|
|
json::s("nonsense"),
|
|
Value::Obj(vec![]),
|
|
json::obj(vec![("gen_tokens", json::s("many"))]),
|
|
] {
|
|
let v = json::obj(vec![
|
|
("seq", Value::Int(1)),
|
|
("type", json::s("delta")),
|
|
("text", json::s("still here")),
|
|
("serving", junk),
|
|
]);
|
|
let decoded = ChatEvent::decode(&v).expect("delta survives a bad serving block");
|
|
match decoded.body {
|
|
ChatEventBody::Delta { text, serving } => {
|
|
assert_eq!(text, "still here");
|
|
assert!(serving.is_none());
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
}
|
|
// Implausible counters are clamped, not refused.
|
|
let v = json::obj(vec![
|
|
("seq", Value::Int(1)),
|
|
("type", json::s("delta")),
|
|
("text", json::s("t")),
|
|
(
|
|
"serving",
|
|
json::obj(vec![
|
|
("gen_tokens", Value::Int(i64::MAX)),
|
|
("lanes_active", Value::Int(9_000_000)),
|
|
("slots_total", Value::Int(9_000_000)),
|
|
]),
|
|
),
|
|
]);
|
|
match ChatEvent::decode(&v).unwrap().body {
|
|
ChatEventBody::Delta { serving: Some(s), .. } => {
|
|
assert_eq!(s.gen_tokens, 10_000_000);
|
|
assert_eq!(s.lanes_active, Some(1024));
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn event_roundtrip_all_variants() {
|
|
let events = vec![
|
|
ChatEvent { seq: 0, body: ChatEventBody::Delta { text: "hi".to_string(), serving: None } },
|
|
ChatEvent {
|
|
seq: 1,
|
|
body: ChatEventBody::ToolCall {
|
|
id: "tc_1_1".to_string(),
|
|
name: "asset.search".to_string(),
|
|
args: json::obj(vec![("query", json::s("neon"))]),
|
|
},
|
|
},
|
|
ChatEvent {
|
|
seq: 2,
|
|
body: ChatEventBody::ToolProgress {
|
|
id: "tc_1_1".to_string(),
|
|
permille: 500,
|
|
note: "halfway".to_string(),
|
|
},
|
|
},
|
|
ChatEvent {
|
|
seq: 3,
|
|
body: ChatEventBody::ToolResult {
|
|
id: "tc_1_1".to_string(),
|
|
outcome: ToolOutcome::Ok { value: json::obj(vec![("hits", Value::Arr(vec![]))]) },
|
|
},
|
|
},
|
|
ChatEvent { seq: 4, body: ChatEventBody::Done },
|
|
ChatEvent { seq: 5, body: ChatEventBody::Cancelled },
|
|
ChatEvent {
|
|
seq: 6,
|
|
body: ChatEventBody::Error { code: "provider".to_string(), message: "x".to_string() },
|
|
},
|
|
];
|
|
for e in events {
|
|
let round = ChatEvent::decode(&e.encode()).unwrap();
|
|
assert_eq!(round, e);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tool_outcomes_are_typed_including_unavailable() {
|
|
let outcomes = vec![
|
|
ToolOutcome::Ok { value: json::obj(vec![("job", json::s("job_00000000000000000000000000000000"))]) },
|
|
ToolOutcome::Unavailable { reason: "profile 'x' is not advertised".to_string() },
|
|
ToolOutcome::Denied { what: "the asset server denied this operation".to_string() },
|
|
ToolOutcome::Refused { what: "input not bound".to_string() },
|
|
ToolOutcome::Failed { message: "timeout".to_string() },
|
|
];
|
|
for o in outcomes {
|
|
assert_eq!(ToolOutcome::decode(&o.encode()).unwrap(), o);
|
|
}
|
|
// Unknown tags fail closed.
|
|
assert!(ToolOutcome::decode(&json::obj(vec![("outcome", json::s("shrug"))])).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn clamped_outcomes_always_decode() {
|
|
// The entry-22 regression: a long honest refusal must arrive truncated,
|
|
// never be refused wholesale as malformed by the receiving parser.
|
|
let long = "refused: buildings are CRAMMED — ".repeat(40);
|
|
assert!(long.len() > 512);
|
|
for o in [
|
|
ToolOutcome::Failed { message: long.clone() },
|
|
ToolOutcome::Refused { what: long.clone() },
|
|
ToolOutcome::Denied { what: long.clone() },
|
|
ToolOutcome::Unavailable { reason: long.clone() },
|
|
] {
|
|
// Unclamped, the receiver refuses it.
|
|
assert!(ToolOutcome::decode(&o.encode()).is_err());
|
|
let clamped = o.clamped();
|
|
let back = ToolOutcome::decode(&clamped.encode()).expect("clamped must decode");
|
|
assert_eq!(back, clamped);
|
|
// The guidance survives (truncated, ellipsis-terminated).
|
|
let msg = match &back {
|
|
ToolOutcome::Failed { message } => message,
|
|
ToolOutcome::Refused { what } | ToolOutcome::Denied { what } => what,
|
|
ToolOutcome::Unavailable { reason } => reason,
|
|
ToolOutcome::Ok { .. } => unreachable!(),
|
|
};
|
|
assert!(msg.starts_with("refused: buildings"));
|
|
assert!(msg.ends_with('…'));
|
|
}
|
|
// Multi-byte boundary: a wall of em-dashes truncates on a char boundary.
|
|
let dashes = "—".repeat(400);
|
|
let c = ToolOutcome::Failed { message: dashes }.clamped();
|
|
assert!(ToolOutcome::decode(&c.encode()).is_ok());
|
|
// Short outcomes pass through untouched.
|
|
let short = ToolOutcome::Failed { message: "timeout".into() };
|
|
assert_eq!(short.clone().clamped(), short);
|
|
// An oversized Ok value downgrades to the broker's own honest bound.
|
|
let big = ToolOutcome::Ok {
|
|
value: json::obj(vec![("text", json::s("x".repeat(17 * 1024)))]),
|
|
};
|
|
assert_eq!(
|
|
big.clamped(),
|
|
ToolOutcome::Failed { message: "tool result too large".to_string() }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permille_range_is_enforced_on_decode() {
|
|
let mut v = ChatEvent {
|
|
seq: 9,
|
|
body: ChatEventBody::ToolProgress { id: "t".into(), permille: 1000, note: "n".into() },
|
|
}
|
|
.encode();
|
|
// Force an out-of-range permille into the encoded form.
|
|
if let Value::Obj(pairs) = &mut v {
|
|
for (k, val) in pairs.iter_mut() {
|
|
if k == "permille" {
|
|
*val = Value::Int(1001);
|
|
}
|
|
}
|
|
}
|
|
assert!(ChatEvent::decode(&v).is_err());
|
|
}
|
|
|
|
/// No wire type can carry a credential: the schema has no such field, and
|
|
/// the encoded key sets are pinned here so one cannot appear unnoticed.
|
|
#[test]
|
|
fn wire_schema_has_no_credential_fields() {
|
|
let sample_events = vec![
|
|
ChatEvent { seq: 0, body: ChatEventBody::Delta { text: "t".into(), serving: None } },
|
|
ChatEvent {
|
|
seq: 1,
|
|
body: ChatEventBody::ToolCall {
|
|
id: "i".into(),
|
|
name: "operation.create".into(),
|
|
args: json::obj(vec![("prompt", json::s("p"))]),
|
|
},
|
|
},
|
|
ChatEvent {
|
|
seq: 2,
|
|
body: ChatEventBody::ToolResult {
|
|
id: "i".into(),
|
|
outcome: ToolOutcome::Ok { value: Value::Obj(vec![]) },
|
|
},
|
|
},
|
|
ChatEvent { seq: 3, body: ChatEventBody::Done },
|
|
];
|
|
let mut all = String::new();
|
|
for e in &sample_events {
|
|
all.push_str(&e.encode().to_json());
|
|
}
|
|
all.push_str(
|
|
&ProviderAvailability::Available { model: "m".into(), detail: "d".into() }
|
|
.encode()
|
|
.to_json(),
|
|
);
|
|
all.push_str(
|
|
&AttachmentBinding { revision: rev(1), role: "source".into() }.encode().to_json(),
|
|
);
|
|
for forbidden in ["token", "secret", "api_key", "bearer", "mpat_", "authorization"] {
|
|
assert!(
|
|
!all.to_lowercase().contains(forbidden),
|
|
"wire encoding contains credential-shaped key '{forbidden}': {all}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn origin_audit_fields_are_not_on_tool_or_chat_wire() {
|
|
// Origin.principal is local session metadata. It is not a tool argument
|
|
// and is not recorded by the Asset Server in this foundation.
|
|
let asset = makepad_asset_data::AssetId::from_bytes([5; 16]);
|
|
let call = ContentToolCall::parse(
|
|
"operation.create",
|
|
&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(rev(7).to_string())),
|
|
("role", json::s("texture")),
|
|
])]),
|
|
),
|
|
]),
|
|
)
|
|
.unwrap();
|
|
let encoded = tools::encode_args(&call).to_json();
|
|
for forbidden in ["principal", "audit", "enqueued_by"] {
|
|
assert!(
|
|
!encoded.contains(forbidden),
|
|
"create args must not carry {forbidden}: {encoded}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- tool calls
|
|
|
|
/// A song needs its words, and both music backends take them SEPARATELY:
|
|
/// an empty lyric field is what makes MiniMax generate an instrumental. The
|
|
/// chat path could not send them at all, so every song it asked for was a
|
|
/// hum.
|
|
#[test]
|
|
fn a_music_call_carries_its_lyrics_seed_and_steps() {
|
|
let lyrics = "[Verse]\nMorning settles on the floor\n\n[Chorus]\nWe can rise into the day";
|
|
let call = ContentToolCall::parse(
|
|
"music.generate",
|
|
&json::obj(vec![
|
|
("prompt", json::s("warm neo-soul, Rhodes, intimate male vocal")),
|
|
("lyrics", json::s(lyrics)),
|
|
("seconds", Value::Int(60)),
|
|
("steps", Value::Int(8)),
|
|
("seed", Value::Int(5)),
|
|
]),
|
|
)
|
|
.unwrap();
|
|
match &call {
|
|
ContentToolCall::MusicGenerate { prompt, lyrics: got, seconds, steps, seed, .. } => {
|
|
assert!(prompt.contains("neo-soul"));
|
|
assert_eq!(got.as_deref(), Some(lyrics), "tags and line breaks intact");
|
|
assert_eq!((*seconds, *steps, *seed), (Some(60), Some(8), Some(5)));
|
|
}
|
|
other => panic!("expected a music call, got {other:?}"),
|
|
}
|
|
// …and they survive the round trip back onto the wire.
|
|
let args = makepad_asset_chat::tools::encode_args(&call);
|
|
assert_eq!(args.get("lyrics").and_then(Value::as_str), Some(lyrics));
|
|
assert_eq!(args.get("steps").and_then(Value::as_i64), Some(8));
|
|
assert_eq!(args.get("seed").and_then(Value::as_i64), Some(5));
|
|
// An instrumental request simply says so; the field is never invented.
|
|
let instrumental = ContentToolCall::parse(
|
|
"music.generate",
|
|
&json::obj(vec![("prompt", json::s("taiko boss battle, instrumental"))]),
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
instrumental,
|
|
ContentToolCall::MusicGenerate { lyrics: None, .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn typed_tool_parse_accepts_the_allowlist() {
|
|
let asset = makepad_asset_data::AssetId::from_bytes([5; 16]);
|
|
let call = ContentToolCall::parse(
|
|
"operation.create",
|
|
&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(rev(7).to_string())),
|
|
("role", json::s("texture")),
|
|
])]),
|
|
),
|
|
("params", json::obj(vec![("seed", Value::Int(3))])),
|
|
]),
|
|
)
|
|
.unwrap();
|
|
match &call {
|
|
ContentToolCall::OperationCreate { kind, inputs, params, .. } => {
|
|
assert_eq!(kind, "mesh.from_image.v1");
|
|
assert_eq!(inputs.len(), 1);
|
|
assert_eq!(inputs[0].asset, asset);
|
|
assert_eq!(inputs[0].revision, rev(7));
|
|
assert_eq!(inputs[0].role, "texture");
|
|
assert_eq!(params.get("seed").and_then(Value::as_i64), Some(3));
|
|
}
|
|
other => panic!("unexpected parse: {other:?}"),
|
|
}
|
|
// encode_args -> parse roundtrip holds for the typed form.
|
|
let re = ContentToolCall::parse("operation.create", &tools::encode_args(&call)).unwrap();
|
|
assert_eq!(re, call);
|
|
}
|
|
|
|
#[test]
|
|
fn canonicalize_json_sorts_nested_objects() {
|
|
let a = json::obj(vec![
|
|
("z", json::s("1")),
|
|
("nested", json::obj(vec![("b", Value::Int(2)), ("a", Value::Int(1))])),
|
|
]);
|
|
let b = json::obj(vec![
|
|
("nested", json::obj(vec![("a", Value::Int(1)), ("b", Value::Int(2))])),
|
|
("z", json::s("1")),
|
|
]);
|
|
assert_eq!(
|
|
tools::canonicalize_json(&a).to_json(),
|
|
tools::canonicalize_json(&b).to_json()
|
|
);
|
|
}
|
|
|
|
/// The alias-publication default is the SAFE expectation: omitting `expect`
|
|
/// means "the alias must not exist yet". A model can only overwrite an
|
|
/// existing head by spelling out `any` (or a compare-and-set `head`).
|
|
#[test]
|
|
fn alias_publication_defaults_to_absent_expectation() {
|
|
use makepad_asset_chat::tools::{AliasExpectArg, PublicationArg};
|
|
let asset = makepad_asset_data::AssetId::from_bytes([5; 16]);
|
|
let create = |publication: Value| {
|
|
ContentToolCall::parse(
|
|
"operation.create",
|
|
&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(rev(7).to_string())),
|
|
("role", json::s("texture")),
|
|
])]),
|
|
),
|
|
("publication", publication),
|
|
]),
|
|
)
|
|
};
|
|
let implicit = create(json::obj(vec![
|
|
("mode", json::s("publish_and_alias")),
|
|
("alias", json::s("gen/hero")),
|
|
]))
|
|
.unwrap();
|
|
match implicit {
|
|
ContentToolCall::OperationCreate {
|
|
publication: PublicationArg::PublishAndAlias { expect, .. },
|
|
..
|
|
} => assert_eq!(expect, AliasExpectArg::Absent),
|
|
other => panic!("unexpected parse: {other:?}"),
|
|
}
|
|
// Unconditional overwrite stays expressible, but only explicitly.
|
|
let explicit = create(json::obj(vec![
|
|
("mode", json::s("publish_and_alias")),
|
|
("alias", json::s("gen/hero")),
|
|
("expect", json::s("any")),
|
|
]))
|
|
.unwrap();
|
|
match explicit {
|
|
ContentToolCall::OperationCreate {
|
|
publication: PublicationArg::PublishAndAlias { expect, .. },
|
|
..
|
|
} => assert_eq!(expect, AliasExpectArg::Any),
|
|
other => panic!("unexpected parse: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tool_parse_fails_closed() {
|
|
// Unknown tools are refusals — the allowlist is closed. Every removed
|
|
// privileged surface stays unknown: no raw jobs, publish, or aliases.
|
|
for gone in [
|
|
"run_command",
|
|
"write_file",
|
|
"generate",
|
|
"transform",
|
|
"job_status",
|
|
"await_job",
|
|
"cancel_job",
|
|
"choose_candidate",
|
|
"publish_alias",
|
|
"get_revision",
|
|
"catalog_search",
|
|
"asset_inspect",
|
|
] {
|
|
assert!(
|
|
ContentToolCall::parse(gone, &Value::Obj(vec![])).is_err(),
|
|
"'{gone}' must not parse"
|
|
);
|
|
}
|
|
|
|
let asset = makepad_asset_data::AssetId::from_bytes([5; 16]);
|
|
let input = |i: u8| {
|
|
json::obj(vec![
|
|
("slot", json::s("image")),
|
|
("asset", json::s(asset.to_string())),
|
|
("revision", json::s(rev(i).to_string())),
|
|
("role", json::s("texture")),
|
|
])
|
|
};
|
|
|
|
// Operation input bounds.
|
|
let too_many: Vec<Value> = (0..5).map(|i| input(i as u8)).collect();
|
|
assert!(ContentToolCall::parse(
|
|
"operation.create",
|
|
&json::obj(vec![
|
|
("kind", json::s("mesh.from_image.v1")),
|
|
("inputs", Value::Arr(too_many)),
|
|
]),
|
|
)
|
|
.is_err());
|
|
|
|
// UNKNOWN FIELDS refuse on the mutating surface — a model mistake can
|
|
// never become a silently different operation.
|
|
assert!(ContentToolCall::parse(
|
|
"operation.create",
|
|
&json::obj(vec![
|
|
("kind", json::s("mesh.from_image.v1")),
|
|
("inputs", Value::Arr(vec![input(1)])),
|
|
("owner", json::s("prin_someone")),
|
|
]),
|
|
)
|
|
.is_err());
|
|
let mut sneaky = input(1);
|
|
if let Value::Obj(pairs) = &mut sneaky {
|
|
pairs.push(("path".to_string(), json::s("/etc/passwd")));
|
|
}
|
|
assert!(ContentToolCall::parse(
|
|
"operation.create",
|
|
&json::obj(vec![
|
|
("kind", json::s("mesh.from_image.v1")),
|
|
("inputs", Value::Arr(vec![sneaky])),
|
|
]),
|
|
)
|
|
.is_err());
|
|
|
|
// Malformed ids never survive parsing.
|
|
assert!(ContentToolCall::parse(
|
|
"operation.get",
|
|
&json::obj(vec![("operation", json::s("op_not_hex"))]),
|
|
)
|
|
.is_err());
|
|
assert!(ContentToolCall::parse(
|
|
"operation.cancel",
|
|
&json::obj(vec![("operation", json::s("rm -rf /"))]),
|
|
)
|
|
.is_err());
|
|
|
|
// Limits are bounded.
|
|
assert!(ContentToolCall::parse(
|
|
"asset.search",
|
|
&json::obj(vec![("query", json::s("x")), ("limit", Value::Int(9999))]),
|
|
)
|
|
.is_err());
|
|
assert!(ContentToolCall::parse(
|
|
"operation.wait",
|
|
&json::obj(vec![
|
|
("operation", json::s("op_00000000000000000000000000000000")),
|
|
("timeout_ms", Value::Int(600000)),
|
|
]),
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn tool_parse_rejects_unknown_fields_and_wrong_types() {
|
|
let asset = makepad_asset_data::AssetId::from_bytes([5; 16]);
|
|
let input = json::obj(vec![
|
|
("slot", json::s("image")),
|
|
("asset", json::s(asset.to_string())),
|
|
("revision", json::s(rev(1).to_string())),
|
|
("role", json::s("texture")),
|
|
]);
|
|
let create = |extra: Vec<(&str, Value)>| {
|
|
let mut pairs = vec![
|
|
("kind", json::s("mesh.from_image.v1")),
|
|
("inputs", Value::Arr(vec![input.clone()])),
|
|
];
|
|
pairs.extend(extra);
|
|
ContentToolCall::parse("operation.create", &json::obj(pairs))
|
|
};
|
|
|
|
assert!(ContentToolCall::parse("asset.search", &Value::Arr(vec![])).is_err());
|
|
assert!(ContentToolCall::parse(
|
|
"asset.search",
|
|
&json::obj(vec![("query", json::s("x")), ("limit", json::s("10"))]),
|
|
)
|
|
.is_err());
|
|
assert!(ContentToolCall::parse(
|
|
"asset.search",
|
|
&json::obj(vec![("query", Value::Int(1))]),
|
|
)
|
|
.is_err());
|
|
assert!(ContentToolCall::parse(
|
|
"asset.search",
|
|
&json::obj(vec![("query", json::s("x")), ("extra", json::s("no"))]),
|
|
)
|
|
.is_err());
|
|
|
|
assert!(ContentToolCall::parse("asset.inspect", &Value::Obj(vec![])).is_err());
|
|
assert!(ContentToolCall::parse(
|
|
"asset.inspect",
|
|
&json::obj(vec![("asset", json::s(asset.to_string())), ("alias", json::s("gen/hero"))]),
|
|
)
|
|
.is_err());
|
|
assert!(ContentToolCall::parse(
|
|
"asset.inspect",
|
|
&json::obj(vec![("asset", Value::Int(1))]),
|
|
)
|
|
.is_err());
|
|
assert!(ContentToolCall::parse(
|
|
"asset.inspect",
|
|
&json::obj(vec![("asset", json::s(asset.to_string())), ("note", json::s("x"))]),
|
|
)
|
|
.is_err());
|
|
assert!(ContentToolCall::parse(
|
|
"asset.inspect",
|
|
&json::obj(vec![("asset", json::s(asset.to_string()))]),
|
|
)
|
|
.is_ok());
|
|
|
|
assert!(ContentToolCall::parse(
|
|
"operation.capabilities",
|
|
&json::obj(vec![("extra", json::s("x"))]),
|
|
)
|
|
.is_err());
|
|
|
|
assert!(create(vec![("publication", json::s("publish"))]).is_err());
|
|
assert!(create(vec![("publication", json::obj(vec![("mode", json::s("nope"))]))]).is_err());
|
|
assert!(create(vec![("publication", json::obj(vec![("alias", json::s("gen/hero"))]))]).is_err());
|
|
assert!(create(vec![("idempotency_key", Value::Int(1))]).is_err());
|
|
assert!(create(vec![("params", json::s("nope"))]).is_err());
|
|
|
|
let mut bad_slot = input.clone();
|
|
if let Value::Obj(pairs) = &mut bad_slot {
|
|
for (k, v) in pairs.iter_mut() {
|
|
if k == "slot" {
|
|
*v = Value::Int(1);
|
|
}
|
|
}
|
|
}
|
|
assert!(ContentToolCall::parse(
|
|
"operation.create",
|
|
&json::obj(vec![
|
|
("kind", json::s("mesh.from_image.v1")),
|
|
("inputs", Value::Arr(vec![bad_slot])),
|
|
]),
|
|
)
|
|
.is_err());
|
|
|
|
for (field, val) in [
|
|
("tier", Value::Int(1)),
|
|
("lod", json::s("1")),
|
|
("media", Value::Bool(true)),
|
|
] {
|
|
let mut item = input.clone();
|
|
if let Value::Obj(pairs) = &mut item {
|
|
pairs.push((field.to_string(), val));
|
|
}
|
|
assert!(
|
|
ContentToolCall::parse(
|
|
"operation.create",
|
|
&json::obj(vec![
|
|
("kind", json::s("mesh.from_image.v1")),
|
|
("inputs", Value::Arr(vec![item])),
|
|
]),
|
|
)
|
|
.is_err(),
|
|
"{field} wrong type must refuse"
|
|
);
|
|
}
|
|
|
|
assert!(ContentToolCall::parse(
|
|
"operation.wait",
|
|
&json::obj(vec![
|
|
("operation", json::s("op_00000000000000000000000000000000")),
|
|
("timeout_ms", json::s("60000")),
|
|
]),
|
|
)
|
|
.is_err());
|
|
assert!(ContentToolCall::parse(
|
|
"operation.wait",
|
|
&json::obj(vec![
|
|
("operation", json::s("op_00000000000000000000000000000000")),
|
|
("after", json::s("0")),
|
|
]),
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn publication_rejects_incompatible_field_combinations() {
|
|
let asset = makepad_asset_data::AssetId::from_bytes([5; 16]);
|
|
let input = json::obj(vec![
|
|
("slot", json::s("image")),
|
|
("asset", json::s(asset.to_string())),
|
|
("revision", json::s(rev(1).to_string())),
|
|
("role", json::s("texture")),
|
|
]);
|
|
let create = |publication: Value| {
|
|
ContentToolCall::parse(
|
|
"operation.create",
|
|
&json::obj(vec![
|
|
("kind", json::s("mesh.from_image.v1")),
|
|
("inputs", Value::Arr(vec![input.clone()])),
|
|
("publication", publication),
|
|
]),
|
|
)
|
|
};
|
|
assert!(create(json::obj(vec![
|
|
("mode", json::s("publish")),
|
|
("alias", json::s("gen/hero")),
|
|
]))
|
|
.is_err());
|
|
assert!(create(json::obj(vec![
|
|
("mode", json::s("publish")),
|
|
("expect", json::s("any")),
|
|
]))
|
|
.is_err());
|
|
assert!(create(json::obj(vec![
|
|
("mode", json::s("publish_and_alias")),
|
|
("alias", json::s("gen/hero")),
|
|
("expect", json::s("any")),
|
|
("expect_head", json::s(rev(2).to_string())),
|
|
]))
|
|
.is_err());
|
|
assert!(create(json::obj(vec![
|
|
("mode", json::s("publish_and_alias")),
|
|
("alias", json::s("gen/hero")),
|
|
("expect", json::s("absent")),
|
|
("expect_head", json::s(rev(2).to_string())),
|
|
]))
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn public_error_sanitize_redacts_keys_and_caps() {
|
|
assert_eq!(
|
|
sanitize_public_error("invalid api key sk-secret-ABC"),
|
|
"provider error"
|
|
);
|
|
assert!(!sanitize_public_error("Bearer leaked").contains("Bearer"));
|
|
let long = "e".repeat(MAX_PUBLIC_ERROR_BYTES + 40);
|
|
assert!(sanitize_public_error(&long).len() <= MAX_PUBLIC_ERROR_BYTES);
|
|
assert!(!sanitize_public_error("line\nwith\rCR").contains('\n'));
|
|
let almost = format!("{}é", "x".repeat(MAX_PUBLIC_ERROR_BYTES - 1));
|
|
let capped = sanitize_public_error(&almost);
|
|
assert!(capped.len() <= MAX_PUBLIC_ERROR_BYTES);
|
|
assert!(!capped.ends_with('é'));
|
|
let exact = format!("{}é", "x".repeat(MAX_PUBLIC_ERROR_BYTES - 2));
|
|
let kept = sanitize_public_error(&exact);
|
|
assert_eq!(kept.len(), MAX_PUBLIC_ERROR_BYTES);
|
|
assert!(kept.ends_with('é'));
|
|
}
|
|
|
|
#[test]
|
|
fn chat_event_error_decode_uses_public_error_cap() {
|
|
let ok = ChatEvent {
|
|
seq: 1,
|
|
body: ChatEventBody::Error {
|
|
code: "provider".into(),
|
|
message: "e".repeat(MAX_PUBLIC_ERROR_BYTES),
|
|
},
|
|
};
|
|
assert_eq!(ChatEvent::decode(&ok.encode()).unwrap(), ok);
|
|
let over = ChatEvent {
|
|
seq: 1,
|
|
body: ChatEventBody::Error {
|
|
code: "provider".into(),
|
|
message: "e".repeat(MAX_PUBLIC_ERROR_BYTES + 1),
|
|
},
|
|
};
|
|
assert!(ChatEvent::decode(&over.encode()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn tool_outcome_validate_matches_decode_field_caps() {
|
|
let ok512 = ToolOutcome::Failed { message: "m".repeat(512) };
|
|
assert!(ok512.validate().is_ok());
|
|
assert!(ToolOutcome::decode(&ok512.encode()).is_ok());
|
|
let over = ToolOutcome::Failed { message: "m".repeat(513) };
|
|
assert!(over.validate().is_err());
|
|
assert!(ToolOutcome::decode(&over.encode()).is_err());
|
|
let reason = ToolOutcome::Unavailable { reason: "r".repeat(513) };
|
|
assert!(reason.validate().is_err());
|
|
let what = ToolOutcome::Denied { what: "w".repeat(513) };
|
|
assert!(what.validate().is_err());
|
|
let refused = ToolOutcome::Refused { what: "w".repeat(512) };
|
|
assert!(refused.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn tool_outcome_and_event_enforce_object_shape_and_size() {
|
|
assert!(ToolOutcome::Ok { value: Value::Arr(vec![]) }.validate().is_err());
|
|
let ok = ToolOutcome::Ok { value: json::obj(vec![("hits", Value::Arr(vec![]))]) };
|
|
assert!(ok.validate().is_ok());
|
|
assert!(ToolOutcome::decode(&ok.encode()).is_ok());
|
|
|
|
let huge = ToolOutcome::Ok {
|
|
value: json::obj(vec![("blob", json::s("x".repeat(MAX_TOOL_JSON_BYTES)))]),
|
|
};
|
|
assert!(huge.validate().is_err());
|
|
assert!(ToolOutcome::decode(&huge.encode()).is_err());
|
|
|
|
let ev = ChatEvent {
|
|
seq: 1,
|
|
body: ChatEventBody::ToolCall {
|
|
id: "tc".into(),
|
|
name: "asset.search".into(),
|
|
args: Value::Arr(vec![]),
|
|
},
|
|
};
|
|
assert!(ChatEvent::decode(&ev.encode()).is_err());
|
|
|
|
let ev = ChatEvent {
|
|
seq: 1,
|
|
body: ChatEventBody::ToolCall {
|
|
id: "tc".into(),
|
|
name: "asset.search".into(),
|
|
args: json::obj(vec![("query", json::s("x".repeat(MAX_TOOL_JSON_BYTES)))]),
|
|
},
|
|
};
|
|
assert!(ChatEvent::decode(&ev.encode()).is_err());
|
|
}
|
|
|
|
// ---------------------------------------------------------- text contract
|
|
|
|
#[test]
|
|
fn toolcall_extraction_contract() {
|
|
// Plain text: no call.
|
|
assert_eq!(toolcall::extract("just chatting"), Extract::None);
|
|
|
|
// A call on its own line after visible text.
|
|
let text = "Searching now.\n<<tool>>{\"name\":\"catalog_search\",\"args\":{\"query\":\"neon\"}}\ntrailing chatter";
|
|
match toolcall::extract(text) {
|
|
Extract::Call { clean, name, args } => {
|
|
assert_eq!(clean, "Searching now.");
|
|
assert_eq!(name, "catalog_search");
|
|
assert_eq!(args.get("query").and_then(Value::as_str), Some("neon"));
|
|
}
|
|
other => panic!("expected call, got {other:?}"),
|
|
}
|
|
|
|
// Marker mid-line is prose, not a call.
|
|
assert_eq!(toolcall::extract("the marker <<tool>> is literal here"), Extract::None);
|
|
|
|
// A mid-line draft must not hide a later real line-start call.
|
|
match toolcall::extract(
|
|
"draft `<<tool>>{\"name\":\"image.generate\",\"args\":{\"prompt\":\"no\"}}`\n\
|
|
<<tool>>{\"name\":\"video.generate\",\"args\":{\"prompt\":\"yes\"}}",
|
|
) {
|
|
Extract::Call { clean, name, args } => {
|
|
assert!(clean.contains("draft"));
|
|
assert_eq!(name, "video.generate");
|
|
assert_eq!(args.get("prompt").and_then(Value::as_str), Some("yes"));
|
|
}
|
|
other => panic!("expected later line-start call, got {other:?}"),
|
|
}
|
|
|
|
// Thinking dump: execute the call AFTER </think>, not a draft inside.
|
|
let think_dump = "\
|
|
The user wants a video.\n\
|
|
` <<tool>>{\"name\":\"video.generate\",\"args\":{\"prompt\":\"draft\",\"frames\":58}} `\n\
|
|
</think>\n\
|
|
\n\
|
|
<<tool>>{\"name\":\"video.generate\",\"args\":{\"prompt\":\"a dancing unicorn\",\"frames\":58}}";
|
|
match toolcall::extract(think_dump) {
|
|
Extract::Call { clean, name, args } => {
|
|
assert!(!clean.contains("The user wants"), "thinking leaked into clean: {clean:?}");
|
|
assert_eq!(name, "video.generate");
|
|
assert_eq!(args.get("prompt").and_then(Value::as_str), Some("a dancing unicorn"));
|
|
assert_eq!(args.get("frames").and_then(Value::as_i64), Some(58));
|
|
}
|
|
other => panic!("expected post-think call, got {other:?}"),
|
|
}
|
|
let split = toolcall::split_thinking(think_dump);
|
|
assert!(split.think_closed);
|
|
assert!(split.thinking.contains("The user wants a video"));
|
|
assert!(split.visible.starts_with("<<tool>>"));
|
|
assert_eq!(toolcall::strip_marker(think_dump), "");
|
|
|
|
// Malformed JSON is a typed refusal the model can react to.
|
|
match toolcall::extract("<<tool>>{not json}") {
|
|
Extract::Malformed { reason, .. } => assert!(reason.contains("JSON")),
|
|
other => panic!("expected malformed, got {other:?}"),
|
|
}
|
|
match toolcall::extract("<<tool>>{\"args\":{}}") {
|
|
Extract::Malformed { reason, .. } => assert!(reason.contains("name")),
|
|
other => panic!("expected malformed, got {other:?}"),
|
|
}
|
|
|
|
// Model forgot the newline and kept talking after the JSON object.
|
|
match toolcall::extract(
|
|
"<<tool>>{\"name\":\"fleet.introspect\",\"args\":{\"domain\":\"image\"}}We have three",
|
|
) {
|
|
Extract::Call { clean, name, args } => {
|
|
assert!(clean.is_empty());
|
|
assert_eq!(name, "fleet.introspect");
|
|
assert_eq!(args.get("domain").and_then(Value::as_str), Some("image"));
|
|
}
|
|
other => panic!("expected call despite trailing prose, got {other:?}"),
|
|
}
|
|
assert_eq!(
|
|
toolcall::strip_marker("hello\n<<tool>>{\"name\":\"x\",\"args\":{}}\nmore"),
|
|
"hello"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn system_rendering_lists_allowlist_and_capabilities() {
|
|
let rendered =
|
|
toolcall::render_system(&tools::definitions(), "Registered operations: none");
|
|
for name in [
|
|
"image.generate",
|
|
"video.generate",
|
|
"audio.generate",
|
|
"speech.generate",
|
|
"music.generate",
|
|
"mesh.generate",
|
|
"world.generate",
|
|
"character.generate",
|
|
"defaults.get",
|
|
"defaults.set",
|
|
"fleet.introspect",
|
|
"asset.search",
|
|
"asset.inspect",
|
|
"operation.capabilities",
|
|
"operation.create",
|
|
"operation.get",
|
|
"operation.wait",
|
|
"operation.cancel",
|
|
"operation.retry",
|
|
"llm.consult",
|
|
] {
|
|
assert!(rendered.contains(name), "system prompt missing tool {name}");
|
|
}
|
|
// The privileged verbs never appear as tools in the prompt.
|
|
for gone in ["publish_alias", "choose_candidate", "job_status", "\n- generate:"] {
|
|
assert!(!rendered.contains(gone), "system prompt leaks removed tool {gone}");
|
|
}
|
|
assert!(rendered.contains("Registered operations: none"));
|
|
assert!(rendered.contains("Never invent asset or revision ids"));
|
|
|
|
let native = tools::render_native_system(&tools::definitions(), "Registered operations: none");
|
|
assert!(!native.contains("<<tool>>"), "native prompt must not teach the marker");
|
|
for api in [
|
|
"image_generate",
|
|
"video_generate",
|
|
"audio_generate",
|
|
"speech_generate",
|
|
"music_generate",
|
|
"mesh_generate",
|
|
"world_generate",
|
|
"character_generate",
|
|
"defaults_get",
|
|
"defaults_set",
|
|
"fleet_introspect",
|
|
"asset_search",
|
|
"asset_inspect",
|
|
"operation_capabilities",
|
|
"operation_create",
|
|
"operation_get",
|
|
"operation_wait",
|
|
"operation_cancel",
|
|
"operation_retry",
|
|
"llm_consult",
|
|
] {
|
|
assert!(native.contains(api), "native prompt missing {api}");
|
|
}
|
|
|
|
let atts = toolcall::render_attachments(&[AttachmentBinding {
|
|
revision: rev(0x22),
|
|
role: "source".into(),
|
|
}]);
|
|
assert!(atts.contains(&rev(0x22).to_string()));
|
|
assert!(atts.contains("source"));
|
|
}
|
|
|
|
#[test]
|
|
fn native_api_names_map_fail_closed_and_schemas_cover_allowlist() {
|
|
let defs = tools::definitions();
|
|
assert_eq!(defs.len(), 20);
|
|
for d in &defs {
|
|
assert_eq!(tools::canonical_from_api_name(d.api_name), Some(d.name));
|
|
assert!(d.api_name.bytes().all(|b| b != b'.'), "api name must be underscore form");
|
|
match &d.parameters {
|
|
Value::Obj(pairs) => {
|
|
assert!(pairs.iter().any(|(k, v)| k == "type" && v.as_str() == Some("object")));
|
|
assert!(pairs.iter().any(|(k, _)| k == "properties"));
|
|
}
|
|
other => panic!("parameters must be a schema object, got {other:?}"),
|
|
}
|
|
match d.name {
|
|
"asset.search" => {
|
|
assert_eq!(schema_required(&d.parameters), vec!["query"]);
|
|
assert_eq!(schema_additional(&d.parameters), Some(false));
|
|
assert_eq!(
|
|
schema_minmax(d.parameters.get("properties").unwrap().get("limit").unwrap()),
|
|
(1, 25)
|
|
);
|
|
}
|
|
"asset.inspect" => {
|
|
assert_eq!(schema_additional(&d.parameters), Some(false));
|
|
assert_eq!(d.parameters.get("minProperties").and_then(Value::as_i64), Some(1));
|
|
assert_eq!(d.parameters.get("maxProperties").and_then(Value::as_i64), Some(1));
|
|
}
|
|
"operation.create" => {
|
|
assert_eq!(schema_required(&d.parameters), vec!["kind", "inputs"]);
|
|
assert_eq!(schema_additional(&d.parameters), Some(false));
|
|
let inputs = d.parameters.get("properties").unwrap().get("inputs").unwrap();
|
|
assert_eq!(inputs.get("minItems").and_then(Value::as_i64), Some(1));
|
|
assert_eq!(
|
|
inputs.get("maxItems").and_then(Value::as_i64),
|
|
Some(MAX_TRANSFORM_INPUTS as i64)
|
|
);
|
|
let item = inputs.get("items").unwrap();
|
|
assert_eq!(schema_additional(item), Some(false));
|
|
assert_eq!(schema_required(item), vec!["asset", "revision", "role"]);
|
|
let item_props = item.get("properties").expect("input item properties");
|
|
for key in ["slot", "asset", "revision", "role", "tier", "lod", "media"] {
|
|
assert!(item_props.get(key).is_some(), "missing input property {key}");
|
|
}
|
|
for ident in ["slot", "role", "tier", "media"] {
|
|
assert_eq!(
|
|
item_props.get(ident).unwrap().get("pattern").and_then(Value::as_str),
|
|
Some(r"^[a-z0-9_-]{1,32}$"),
|
|
"{ident} schema must match ident_ok"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
item_props.get("asset").unwrap().get("maxLength").and_then(Value::as_i64),
|
|
Some(64)
|
|
);
|
|
assert_eq!(
|
|
item_props.get("revision").unwrap().get("maxLength").and_then(Value::as_i64),
|
|
Some(80)
|
|
);
|
|
let pub_schema = d.parameters.get("properties").unwrap().get("publication").unwrap();
|
|
assert_eq!(schema_required(pub_schema), vec!["mode"]);
|
|
assert_eq!(schema_additional(pub_schema), Some(false));
|
|
let pub_mode = pub_schema.get("properties").unwrap().get("mode").unwrap();
|
|
let modes: Vec<&str> = pub_mode
|
|
.get("enum")
|
|
.and_then(Value::as_arr)
|
|
.unwrap()
|
|
.iter()
|
|
.filter_map(Value::as_str)
|
|
.collect();
|
|
assert_eq!(modes, vec!["publish", "publish_and_alias"]);
|
|
assert!(d.parameters.get("properties").unwrap().get("params").is_some());
|
|
}
|
|
"operation.wait" => {
|
|
assert_eq!(schema_required(&d.parameters), vec!["operation"]);
|
|
assert_eq!(
|
|
schema_minmax(
|
|
d.parameters.get("properties").unwrap().get("timeout_ms").unwrap()
|
|
),
|
|
(1, 120_000)
|
|
);
|
|
}
|
|
"fleet.introspect" => {
|
|
assert_eq!(schema_additional(&d.parameters), Some(false));
|
|
assert!(d.parameters.get("properties").unwrap().get("domain").is_some());
|
|
}
|
|
"image.generate" => {
|
|
assert_eq!(schema_required(&d.parameters), vec!["prompt"]);
|
|
let then = d.parameters.get("properties").unwrap().get("then").unwrap();
|
|
let slugs: Vec<&str> = then
|
|
.get("enum")
|
|
.and_then(Value::as_arr)
|
|
.unwrap()
|
|
.iter()
|
|
.filter_map(Value::as_str)
|
|
.collect();
|
|
assert_eq!(slugs, GenerateThen::SLUGS);
|
|
}
|
|
"video.generate" | "audio.generate" | "speech.generate" | "music.generate"
|
|
| "mesh.generate" | "world.generate" | "character.generate" => {
|
|
assert_eq!(schema_required(&d.parameters), vec!["prompt"]);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
assert_eq!(
|
|
ContentToolCall::parse("fleet.introspect", &json::obj(vec![("domain", json::s("image"))]))
|
|
.unwrap(),
|
|
ContentToolCall::FleetIntrospect { domain: Some("image".into()) }
|
|
);
|
|
assert_eq!(
|
|
ContentToolCall::parse("defaults.get", &Value::Obj(vec![])).unwrap(),
|
|
ContentToolCall::DefaultsGet
|
|
);
|
|
assert_eq!(
|
|
ContentToolCall::parse(
|
|
"defaults.set",
|
|
&json::obj(vec![
|
|
("image_model", json::s("flux1-dev")),
|
|
("width", Value::Int(1024)),
|
|
("height", Value::Int(1024)),
|
|
("steps", Value::Int(20)),
|
|
("then", json::s("mesh")),
|
|
]),
|
|
)
|
|
.unwrap(),
|
|
ContentToolCall::DefaultsSet {
|
|
image_model: Some("flux1-dev".into()),
|
|
width: Some(1024),
|
|
height: Some(1024),
|
|
steps: Some(20),
|
|
then: Some(GenerateThen::Mesh),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
ContentToolCall::parse(
|
|
"video.generate",
|
|
&json::obj(vec![
|
|
("prompt", json::s("trawler cutting through fog")),
|
|
("frames", Value::Int(39)),
|
|
]),
|
|
)
|
|
.unwrap(),
|
|
ContentToolCall::VideoGenerate {
|
|
prompt: "trawler cutting through fog".into(),
|
|
model: None,
|
|
width: None,
|
|
height: None,
|
|
frames: Some(39),
|
|
steps: None,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
ContentToolCall::parse(
|
|
"image.generate",
|
|
&json::obj(vec![
|
|
("prompt", json::s("rusty trawler")),
|
|
("then", json::s("video")),
|
|
]),
|
|
)
|
|
.unwrap(),
|
|
ContentToolCall::ImageGenerate {
|
|
prompt: "rusty trawler".into(),
|
|
then: Some(GenerateThen::Video),
|
|
model: None,
|
|
width: None,
|
|
height: None,
|
|
steps: None,
|
|
}
|
|
);
|
|
for (name, kind) in [
|
|
("audio.generate", "AudioGenerate"),
|
|
("speech.generate", "SpeechGenerate"),
|
|
("music.generate", "MusicGenerate"),
|
|
("mesh.generate", "MeshGenerate"),
|
|
("world.generate", "WorldGenerate"),
|
|
("character.generate", "CharacterGenerate"),
|
|
] {
|
|
let call = ContentToolCall::parse(name, &json::obj(vec![("prompt", json::s("x"))])).unwrap();
|
|
assert_eq!(call.name(), name, "{kind}");
|
|
assert_eq!(
|
|
ContentToolCall::parse(name, &tools::encode_args(&call)).unwrap(),
|
|
call,
|
|
"{kind} encode/parse"
|
|
);
|
|
}
|
|
assert!(ContentToolCall::parse(
|
|
"image.generate",
|
|
&json::obj(vec![("prompt", json::s("x")), ("then", json::s("song"))]),
|
|
)
|
|
.is_err());
|
|
assert_eq!(tools::canonical_from_api_name("video_generate"), Some("video.generate"));
|
|
assert_eq!(tools::canonical_from_api_name("character_generate"), Some("character.generate"));
|
|
assert_eq!(tools::canonical_from_api_name("fleet_introspect"), Some("fleet.introspect"));
|
|
assert_eq!(tools::canonical_from_api_name("llm_consult"), Some("llm.consult"));
|
|
assert_eq!(
|
|
ContentToolCall::parse(
|
|
"llm.consult",
|
|
&json::obj(vec![
|
|
("task", json::s("level")),
|
|
("prompt", json::s("draft a quarry arena")),
|
|
("provider", json::s("grok")),
|
|
]),
|
|
)
|
|
.unwrap(),
|
|
ContentToolCall::LlmConsult {
|
|
task: makepad_asset_chat::ConsultTask::Level,
|
|
prompt: "draft a quarry arena".into(),
|
|
provider: Some(makepad_asset_chat::ProviderKind::Grok),
|
|
}
|
|
);
|
|
assert!(ContentToolCall::parse(
|
|
"llm.consult",
|
|
&json::obj(vec![
|
|
("task", json::s("level")),
|
|
("prompt", json::s("x")),
|
|
("provider", json::s("fleet-qwen")),
|
|
]),
|
|
)
|
|
.is_err());
|
|
assert_eq!(tools::canonical_from_api_name("asset.search"), None);
|
|
assert_eq!(tools::canonical_from_api_name("run_command"), None);
|
|
let payload = tools::native_tools_payload();
|
|
let tools_arr = payload.as_arr().expect("tools array");
|
|
assert_eq!(tools_arr.len(), 20);
|
|
for t in tools_arr {
|
|
assert_eq!(t.get("type").and_then(Value::as_str), Some("function"));
|
|
assert_eq!(t.get("strict").and_then(Value::as_bool), Some(false));
|
|
assert!(t.get("name").and_then(Value::as_str).is_some());
|
|
assert!(t.get("parameters").is_some());
|
|
}
|
|
}
|