mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
horizon-ctl: describe the replies of hello and status (#8242)
The command table gains a reply field list per command. hello and status carry theirs; the generated reference gets a Replies section and the MCP tool list declares outputSchema (with structuredContent in the call result). A test requires every command in the group to name its reply fields. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
a700c4d819
commit
ad261e3ae4
4 files changed
+294
-3
No files matched your search
@@ -61,6 +61,122 @@ impl Group {
|
||||
}
|
||||
}
|
||||
|
||||
/// One field of a command's JSON reply. `ty` is a JSON type (`string`, `integer`, `number`,
|
||||
/// `boolean`, `array`, `object`); `a|null` marks a field that is `null` when it does not apply.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Field {
|
||||
pub name: &'static str,
|
||||
pub ty: &'static str,
|
||||
pub help: &'static str,
|
||||
}
|
||||
|
||||
const fn f(name: &'static str, ty: &'static str, help: &'static str) -> Field {
|
||||
Field { name, ty, help }
|
||||
}
|
||||
|
||||
/// `hello`: always these three fields.
|
||||
const HELLO_REPLY: &[Field] = &[
|
||||
f(
|
||||
"protocol",
|
||||
"string",
|
||||
"the control protocol, MAJOR.MINOR.PATCH (docs/api/stability.md)",
|
||||
),
|
||||
f(
|
||||
"release",
|
||||
"string",
|
||||
"the game's version (MCP serverInfo carries the same string)",
|
||||
),
|
||||
f(
|
||||
"features",
|
||||
"array",
|
||||
"the command groups the game answers, each a string",
|
||||
),
|
||||
];
|
||||
|
||||
/// `status`: the top-level fields the reply always carries. It holds more (sounds,
|
||||
/// projectiles, maps and the like), so a reader must ignore the keys it does not know.
|
||||
const STATUS_REPLY: &[Field] = &[
|
||||
f(
|
||||
"screen",
|
||||
"string",
|
||||
"the open screen (playing, inventory, paused...)",
|
||||
),
|
||||
f(
|
||||
"debug",
|
||||
"object",
|
||||
"the F3 overlay flags: overlay, hitboxes, chunk_borders, pause_menu, advanced_tooltips, pause_on_lost_focus, clipboard",
|
||||
),
|
||||
f("dimension", "string", "the dimension the player is in"),
|
||||
f("creative", "boolean", "creative mode"),
|
||||
f(
|
||||
"player",
|
||||
"object|null",
|
||||
"position, velocity (blocks), on_ground, health, max_health, food; null without a player",
|
||||
),
|
||||
f("yaw", "number", "view yaw, degrees"),
|
||||
f("pitch", "number", "view pitch, degrees"),
|
||||
f(
|
||||
"target",
|
||||
"object|null",
|
||||
"the block under the crosshair as pos and block; null when none",
|
||||
),
|
||||
f("selected", "integer", "the selected hotbar slot, 1 to 9"),
|
||||
f(
|
||||
"held",
|
||||
"object|null",
|
||||
"the held stack as item and count; null when empty",
|
||||
),
|
||||
f(
|
||||
"offhand",
|
||||
"object|null",
|
||||
"the offhand stack as item, count and damage; null when empty",
|
||||
),
|
||||
f(
|
||||
"keys",
|
||||
"array",
|
||||
"the keys the game holds down, each a string",
|
||||
),
|
||||
f("fps", "number", "frames per second"),
|
||||
f("frames", "integer", "frames drawn so far"),
|
||||
f("ticks", "integer", "game ticks so far"),
|
||||
f("day_time", "integer", "the world's time of day, ticks"),
|
||||
f("uptime_seconds", "number", "seconds since the game started"),
|
||||
f("render_distance", "integer", "chunks"),
|
||||
f("chunks", "integer", "chunks loaded"),
|
||||
f("mobs", "integer", "mobs this side knows"),
|
||||
f("block_entities", "integer", "block entities loaded"),
|
||||
f(
|
||||
"pending_chunks",
|
||||
"integer",
|
||||
"chunks still being generated, meshed, uploaded or received",
|
||||
),
|
||||
f(
|
||||
"streaming",
|
||||
"object",
|
||||
"chunk streaming stages and their counts",
|
||||
),
|
||||
f(
|
||||
"lod",
|
||||
"object",
|
||||
"far terrain: distances, tiles, quads and sampling progress",
|
||||
),
|
||||
f(
|
||||
"renderer",
|
||||
"object|null",
|
||||
"GPU, driver, backend, size, draw counts and GPU memory; null without a renderer",
|
||||
),
|
||||
f(
|
||||
"display",
|
||||
"object|null",
|
||||
"the monitor: width, height, scale, refresh_hz; null without a window",
|
||||
),
|
||||
f(
|
||||
"server",
|
||||
"object|null",
|
||||
"the connected server's address, name and state; null when not connected",
|
||||
),
|
||||
];
|
||||
|
||||
pub struct Cmd {
|
||||
pub name: &'static str,
|
||||
/// What the game receives instead of `name` (aliases such as look-at-block).
|
||||
@@ -75,6 +191,8 @@ pub struct Cmd {
|
||||
pub mcp: bool,
|
||||
/// Runs in horizon-ctl itself, not in the game.
|
||||
pub local: bool,
|
||||
/// The reply's fields; empty until the command's reply is described (`REPLY_GROUP`).
|
||||
pub reply: &'static [Field],
|
||||
}
|
||||
|
||||
const fn p(name: &'static str, ty: Ty, help: &'static str) -> Param {
|
||||
@@ -167,6 +285,12 @@ pub const DESTRUCTIVE: &[&str] = &["world-delete", "fill", "setblock"];
|
||||
pub const OPEN_WORLD: &[&str] = &["connect", "lan", "friends", "chat", "command"];
|
||||
|
||||
macro_rules! cmd {
|
||||
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr, reply: $reply:expr) => {
|
||||
Cmd {
|
||||
reply: $reply,
|
||||
..cmd!($name, $group, $usage, $summary, $details, [$($ex),*], $params, mcp: $mcp, local: $local)
|
||||
}
|
||||
};
|
||||
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
|
||||
Cmd {
|
||||
name: $name,
|
||||
@@ -179,6 +303,7 @@ macro_rules! cmd {
|
||||
params: $params,
|
||||
mcp: $mcp,
|
||||
local: $local,
|
||||
reply: &[],
|
||||
}
|
||||
};
|
||||
(@wire) => { None };
|
||||
@@ -216,10 +341,10 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[flag("lines", Ty::Int, "how many lines of latest.log (default 100)"), flag("crash", Ty::Bool, "the newest crash report instead")],
|
||||
mcp: true, local: true),
|
||||
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
|
||||
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
|
||||
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false, reply: STATUS_REPLY),
|
||||
cmd!("hello", Session, "hello", "the control protocol's version, the game's release and the command groups it serves",
|
||||
"Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).",
|
||||
["horizon-ctl hello"], &[], mcp: true, local: false),
|
||||
["horizon-ctl hello"], &[], mcp: true, local: false, reply: HELLO_REPLY),
|
||||
cmd!("memory", Session, "memory", "what holds the game's memory: resident set, allocator, client and server columns by part, renderer, GPU memory by resource",
|
||||
"Columns count blocks, biomes, light, heightmaps and records, each shared section once; server_columns are the single-player server thread's own levels. GPU memory by label needs Vulkan or DirectX 12. What the census doesn't reach is the resident set less its parts. A build with the alloc-census feature adds heap_by_tag_mb: the live heap by startup step, timed span, chunk job and thread.", ["horizon-ctl memory"], &[], mcp: true, local: false),
|
||||
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
@@ -533,6 +658,34 @@ pub fn detail(c: &Cmd) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The commands whose replies are described so far, one group at a time: the test
|
||||
/// requires each to name its fields.
|
||||
#[cfg(test)]
|
||||
pub const REPLY_GROUP: &[&str] = &["hello", "status"];
|
||||
|
||||
/// The JSON schema of a command's reply, or none while its reply is undescribed.
|
||||
pub fn reply_schema(c: &Cmd) -> Option<Value> {
|
||||
if c.reply.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut props = Map::new();
|
||||
let mut required = Vec::new();
|
||||
for fld in c.reply {
|
||||
let types: Vec<&str> = fld.ty.split('|').collect();
|
||||
let ty = if types.len() == 1 {
|
||||
json!(types[0])
|
||||
} else {
|
||||
json!(types)
|
||||
};
|
||||
props.insert(
|
||||
fld.name.to_owned(),
|
||||
json!({ "type": ty, "description": fld.help }),
|
||||
);
|
||||
required.push(fld.name);
|
||||
}
|
||||
Some(json!({ "type": "object", "properties": props, "required": required }))
|
||||
}
|
||||
|
||||
/// The generated reference, docs/horizon-ctl-commands.md (`horizon-ctl help --markdown`).
|
||||
pub fn markdown() -> String {
|
||||
let cell = |s: &str| s.replace('|', "\\|");
|
||||
@@ -597,6 +750,26 @@ pub fn markdown() -> String {
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push_str(
|
||||
"\n## Replies\n\nThe reply fields of the commands described so far (more follow, a group at a \
|
||||
time). Each reply is one JSON object; a type ending in `null` may be `null` when the field \
|
||||
does not apply. A reader ignores keys it does not know: replies may gain fields within a \
|
||||
protocol line. The MCP tool list declares the same fields as `outputSchema`.\n",
|
||||
);
|
||||
for c in COMMANDS.iter().filter(|c| !c.reply.is_empty()) {
|
||||
out.push_str(&format!(
|
||||
"\n### `{}`\n\n| Field | Type | Meaning |\n| --- | --- | --- |\n",
|
||||
c.name
|
||||
));
|
||||
for fld in c.reply {
|
||||
out.push_str(&format!(
|
||||
"| `{}` | {} | {} |\n",
|
||||
fld.name,
|
||||
fld.ty.replace('|', ", "),
|
||||
cell(fld.help)
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -838,6 +1011,52 @@ mod tests {
|
||||
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_in_the_reply_group_names_its_reply_fields() {
|
||||
for name in REPLY_GROUP {
|
||||
let c = find(name).unwrap_or_else(|| panic!("{name} is not a command"));
|
||||
assert!(!c.reply.is_empty(), "{name} names no reply fields");
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for fld in c.reply {
|
||||
assert!(seen.insert(fld.name), "{name}: {} twice", fld.name);
|
||||
assert!(!fld.help.is_empty(), "{name}.{} has no meaning", fld.name);
|
||||
for t in fld.ty.split('|') {
|
||||
assert!(
|
||||
[
|
||||
"string", "integer", "number", "boolean", "array", "object", "null"
|
||||
]
|
||||
.contains(&t),
|
||||
"{name}.{}: unknown type {t}",
|
||||
fld.name
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
reply_schema(c).unwrap()["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
c.reply.len()
|
||||
);
|
||||
}
|
||||
// Nothing outside the group claims a reply the group list does not cover.
|
||||
for c in COMMANDS.iter().filter(|c| !c.reply.is_empty()) {
|
||||
assert!(
|
||||
REPLY_GROUP.contains(&c.name),
|
||||
"{} has fields but is not in REPLY_GROUP",
|
||||
c.name
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
reply_schema(find("hello").unwrap()).unwrap()["properties"]["features"]["type"],
|
||||
"array"
|
||||
);
|
||||
assert_eq!(
|
||||
reply_schema(find("status").unwrap()).unwrap()["properties"]["player"]["type"],
|
||||
json!(["object", "null"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_generated_reference_is_up_to_date() {
|
||||
let path = concat!(
|
||||
|
||||
@@ -30,6 +30,9 @@ fn tools() -> Vec<Value> {
|
||||
description = "Capture the game window and return it as a PNG image.".into();
|
||||
}
|
||||
let mut tool = json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema });
|
||||
if let Some(out) = commands::reply_schema(c) {
|
||||
tool["outputSchema"] = out;
|
||||
}
|
||||
if commands::DESTRUCTIVE.contains(&c.name) {
|
||||
tool["annotations"] = json!({ "destructiveHint": true });
|
||||
} else if commands::OPEN_WORLD.contains(&c.name) {
|
||||
@@ -152,7 +155,20 @@ impl Session {
|
||||
}
|
||||
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
let (content, error) = self.call(name, &args);
|
||||
Ok(json!({ "content": content, "isError": error }))
|
||||
let mut result = json!({ "content": content, "isError": error });
|
||||
// A tool that declares an outputSchema answers with structuredContent too.
|
||||
if !error
|
||||
&& tools()
|
||||
.iter()
|
||||
.any(|t| t["name"] == name && t.get("outputSchema").is_some())
|
||||
&& let Some(v) = result["content"][0]["text"]
|
||||
.as_str()
|
||||
.and_then(|t| serde_json::from_str::<Value>(t).ok())
|
||||
.filter(Value::is_object)
|
||||
{
|
||||
result["structuredContent"] = v;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
_ => Err((-32601, format!("Method not found: {method}"))),
|
||||
}
|
||||
@@ -185,6 +201,16 @@ mod tests {
|
||||
assert!(names.insert(t["name"].as_str().unwrap().to_owned()));
|
||||
assert_eq!(t["inputSchema"]["type"], "object");
|
||||
}
|
||||
let by = |n: &str| list.iter().find(|t| t["name"] == n).unwrap();
|
||||
assert_eq!(
|
||||
by("hello")["outputSchema"]["required"],
|
||||
json!(["protocol", "release", "features"])
|
||||
);
|
||||
assert_eq!(
|
||||
by("status")["outputSchema"]["properties"]["screen"]["type"],
|
||||
"string"
|
||||
);
|
||||
assert!(by("state").get("outputSchema").is_none());
|
||||
for want in [
|
||||
"launch",
|
||||
"state",
|
||||
|
||||
@@ -44,6 +44,8 @@ API offers; the other commands the game's socket answers are test hooks and expe
|
||||
|
||||
- **Reply schemas.** Each command's reply described in the table (fields, types, units), so the reference documents
|
||||
replies as well as arguments and MCP tools declare an output schema.
|
||||
`hello` and `status` are described (the reference's "Replies" section; MCP `outputSchema` with `structuredContent`);
|
||||
the other groups follow one at a time.
|
||||
- **Events as a stream.** `events --since` polls today; a subscription that pushes events as they happen.
|
||||
- **Test hooks apart.** The hooks only harnesses use move under their own prefix, out of the agent's tool list.
|
||||
- **Compatibility tests.** A transcript per protocol version: commands and the reply keys each must keep.
|
||||
|
||||
@@ -149,3 +149,47 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
|
||||
| `record-list` | `record_list` | The recordings and repro bundles on disk | none |
|
||||
| `record-replay NAME [--stop] [--in-place] [--keep-world] [--timeout S]` | `record_replay` | Replay a recording, best effort | `name` (string): recording from record-list<br>`--stop` (boolean): stop the running replay<br>`--in-place` (boolean): feed the open world instead of opening the copy<br>`--keep-world` (boolean): leave the copied world in saves/<br>`--timeout` (integer): give up after this many seconds (default 300) |
|
||||
| `repro-export [NAME] [PATH] [--no-world]` | `repro_export` | Zip a recording with the logs, crash report and status | `name` (string): recording from record-list (default the newest)<br>`path` (string): where to write the zip<br>`--no-world` (boolean): leave the world out |
|
||||
|
||||
## Replies
|
||||
|
||||
The reply fields of the commands described so far (more follow, a group at a time). Each reply is one JSON object; a type ending in `null` may be `null` when the field does not apply. A reader ignores keys it does not know: replies may gain fields within a protocol line. The MCP tool list declares the same fields as `outputSchema`.
|
||||
|
||||
### `status`
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `screen` | string | the open screen (playing, inventory, paused...) |
|
||||
| `debug` | object | the F3 overlay flags: overlay, hitboxes, chunk_borders, pause_menu, advanced_tooltips, pause_on_lost_focus, clipboard |
|
||||
| `dimension` | string | the dimension the player is in |
|
||||
| `creative` | boolean | creative mode |
|
||||
| `player` | object, null | position, velocity (blocks), on_ground, health, max_health, food; null without a player |
|
||||
| `yaw` | number | view yaw, degrees |
|
||||
| `pitch` | number | view pitch, degrees |
|
||||
| `target` | object, null | the block under the crosshair as pos and block; null when none |
|
||||
| `selected` | integer | the selected hotbar slot, 1 to 9 |
|
||||
| `held` | object, null | the held stack as item and count; null when empty |
|
||||
| `offhand` | object, null | the offhand stack as item, count and damage; null when empty |
|
||||
| `keys` | array | the keys the game holds down, each a string |
|
||||
| `fps` | number | frames per second |
|
||||
| `frames` | integer | frames drawn so far |
|
||||
| `ticks` | integer | game ticks so far |
|
||||
| `day_time` | integer | the world's time of day, ticks |
|
||||
| `uptime_seconds` | number | seconds since the game started |
|
||||
| `render_distance` | integer | chunks |
|
||||
| `chunks` | integer | chunks loaded |
|
||||
| `mobs` | integer | mobs this side knows |
|
||||
| `block_entities` | integer | block entities loaded |
|
||||
| `pending_chunks` | integer | chunks still being generated, meshed, uploaded or received |
|
||||
| `streaming` | object | chunk streaming stages and their counts |
|
||||
| `lod` | object | far terrain: distances, tiles, quads and sampling progress |
|
||||
| `renderer` | object, null | GPU, driver, backend, size, draw counts and GPU memory; null without a renderer |
|
||||
| `display` | object, null | the monitor: width, height, scale, refresh_hz; null without a window |
|
||||
| `server` | object, null | the connected server's address, name and state; null when not connected |
|
||||
|
||||
### `hello`
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `protocol` | string | the control protocol, MAJOR.MINOR.PATCH (docs/api/stability.md) |
|
||||
| `release` | string | the game's version (MCP serverInfo carries the same string) |
|
||||
| `features` | array | the command groups the game answers, each a string |
|
||||
Reference in new issue
Block a user