From b48619c920f477743454840644e4ea2d197dc3ce Mon Sep 17 00:00:00 2001 From: Adam Cohen Hillel Date: Sun, 5 Jul 2026 17:54:06 -0700 Subject: [PATCH] Add version negotiation to the runtime protocol Replace the hard version===1 check with an inclusive supported-version range, advertise the range in the MCP initialize result and a new CLI info command, validate deprecated/superseded_by command metadata, and document the version-bump policy. Co-Authored-By: Claude Opus 4.7 --- docs/runtime/runtime-agent-protocol.md | 40 +++++ .../protocol/openphone-runtime-tools.mjs | 86 +++++++++- integrations/cli/src/index.mjs | 15 +- .../protocol/openphone-runtime-tools.mjs | 86 +++++++++- integrations/mcp-server/src/index.mjs | 8 +- runtime/protocol/openphone-runtime-tools.mjs | 86 +++++++++- .../protocol/validate-runtime-protocol.mjs | 36 +++- scripts/check-runtime-protocol.sh | 1 + scripts/check.sh | 1 + tests/README.md | 3 + tests/integrations/runtime-cli-contract.mjs | 15 ++ tests/integrations/runtime-mcp-contract.mjs | 9 + .../runtime-protocol-versioning-contract.mjs | 159 ++++++++++++++++++ 13 files changed, 529 insertions(+), 16 deletions(-) create mode 100644 tests/integrations/runtime-protocol-versioning-contract.mjs diff --git a/docs/runtime/runtime-agent-protocol.md b/docs/runtime/runtime-agent-protocol.md index 436f415..793257c 100644 --- a/docs/runtime/runtime-agent-protocol.md +++ b/docs/runtime/runtime-agent-protocol.md @@ -45,3 +45,43 @@ session's autonomy. If no session is available, it falls back to - Events: `runtime/protocol/openphone-events.json` - Capabilities: `runtime/protocol/openphone-capabilities.json` - Shape reference: `runtime/protocol/openphone-runtime.schema.json` + +## Versioning + +Every protocol manifest carries an integer `version`. Consumers load +manifests through `runtime/protocol/openphone-runtime-tools.mjs`, which +accepts an inclusive supported range (`RUNTIME_PROTOCOL_VERSION_RANGE`, +currently `min_version=1`, `max_version=1`) instead of a single pinned +version, so new manifest versions can roll out without breaking older +clients. + +### Handshake advertisement + +Integrations advertise the supported runtime-protocol range in a +structured way so clients can negotiate before invoking tools: + +- MCP server: the `initialize` result's `serverInfo.runtimeProtocol` + field carries `{ name, min_version, max_version }`. (This is separate + from the MCP `protocolVersion` date string, which versions the MCP + transport itself.) +- CLI: `openphone info --json` prints the same structure under + `runtime_protocol`. + +### Version-bump policy + +- Additive, backward-compatible changes (new commands, new optional + fields, new events/capabilities) do NOT bump the manifest version. +- Breaking changes (removing or renaming a command, changing a field's + meaning or required shape) require bumping the manifest `version` and + raising `max_version` in `RUNTIME_PROTOCOL_VERSION_RANGE`. Keep + `min_version` unchanged while old clients are still supported. +- Raise `min_version` only when support for an old manifest version is + deliberately dropped; announce it in release notes first. +- Instead of removing a command in place, mark it `deprecated: true` and + point at its replacement with `superseded_by: ""`. The + validator (`runtime/protocol/validate-runtime-protocol.mjs`, run by + `scripts/check.sh`) enforces that `superseded_by` references an + existing command and is only set alongside `deprecated: true`. + Deprecated commands must keep working for at least one released + version before removal (which is the breaking change that bumps the + manifest version). diff --git a/integrations/cli/runtime/protocol/openphone-runtime-tools.mjs b/integrations/cli/runtime/protocol/openphone-runtime-tools.mjs index eb02d3a..4817075 100644 --- a/integrations/cli/runtime/protocol/openphone-runtime-tools.mjs +++ b/integrations/cli/runtime/protocol/openphone-runtime-tools.mjs @@ -4,16 +4,94 @@ import path from "node:path"; const protocolRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../.."); const commandsPath = path.join(protocolRoot, "runtime/protocol/openphone-commands.json"); +// The inclusive range of runtime-protocol manifest versions this loader +// understands. Bump max_version when a new manifest version is introduced; +// bump min_version only when support for an old version is dropped. +export const RUNTIME_PROTOCOL_VERSION_RANGE = Object.freeze({ + min_version: 1, + max_version: 1, +}); + +export function isSupportedRuntimeProtocolVersion( + version, + range = RUNTIME_PROTOCOL_VERSION_RANGE, +) { + return Number.isInteger(version) + && version >= range.min_version + && version <= range.max_version; +} + +export function assertSupportedRuntimeProtocolVersion( + version, + range = RUNTIME_PROTOCOL_VERSION_RANGE, +) { + if (!isSupportedRuntimeProtocolVersion(version, range)) { + throw new Error( + `unsupported runtime protocol version: ${version} ` + + `(supported range: ${range.min_version}..${range.max_version})`, + ); + } +} + +// Structured advertisement of the runtime-protocol version range for +// initialize-style handshakes (MCP initialize result, CLI info output). +export function runtimeProtocolInfo(range = RUNTIME_PROTOCOL_VERSION_RANGE) { + return { + name: "openphone-runtime-protocol", + min_version: range.min_version, + max_version: range.max_version, + }; +} + +export function validateCommandDeprecations(commands) { + const known = new Set(); + for (const command of commands) { + known.add(command.name); + for (const alias of command.aliases ?? []) { + known.add(alias); + } + } + for (const command of commands) { + if (command.deprecated !== undefined && typeof command.deprecated !== "boolean") { + throw new Error(`command ${command.name}: deprecated must be a boolean`); + } + if (command.superseded_by !== undefined) { + if (typeof command.superseded_by !== "string" || !command.superseded_by) { + throw new Error(`command ${command.name}: superseded_by must be a non-empty string`); + } + if (command.deprecated !== true) { + throw new Error(`command ${command.name}: superseded_by requires deprecated=true`); + } + if (!known.has(command.superseded_by)) { + throw new Error( + `command ${command.name}: superseded_by references unknown command: ` + + command.superseded_by, + ); + } + if (command.superseded_by === command.name + || (command.aliases ?? []).includes(command.superseded_by)) { + throw new Error(`command ${command.name}: superseded_by must reference another command`); + } + } + } + return commands; +} + export function loadCommandManifest(file = commandsPath) { return JSON.parse(fs.readFileSync(file, "utf8")); } -export function loadCommands(file = commandsPath) { +export function loadCommands(file = commandsPath, options = {}) { + const range = options.versionRange ?? RUNTIME_PROTOCOL_VERSION_RANGE; const manifest = loadCommandManifest(file); - if (manifest.version !== 1 || !Array.isArray(manifest.commands)) { - throw new Error("openphone-commands.json must contain version=1 and commands[]"); + if (!isSupportedRuntimeProtocolVersion(manifest.version, range) + || !Array.isArray(manifest.commands)) { + throw new Error( + "openphone-commands.json must contain commands[] and a supported version " + + `(range: ${range.min_version}..${range.max_version}, got: ${manifest.version})`, + ); } - return manifest.commands; + return validateCommandDeprecations(manifest.commands); } export function commandMap(commands = loadCommands()) { diff --git a/integrations/cli/src/index.mjs b/integrations/cli/src/index.mjs index 0bcb9d0..7e1e243 100644 --- a/integrations/cli/src/index.mjs +++ b/integrations/cli/src/index.mjs @@ -6,6 +6,7 @@ import { mcpTools, parseJsonObject, resolveCommand, + runtimeProtocolInfo, } from "../runtime/protocol/openphone-runtime-tools.mjs"; const commands = loadCommands(); @@ -23,7 +24,9 @@ export async function main(argv = process.argv.slice(2), io = process) { io.stdout.write(usage()); return 0; } - if (group === "runtime") { + if (group === "info") { + result = infoCommand(); + } else if (group === "runtime") { result = await runtimeCommand(command, rest, transport); } else if (group === "screen" && command === "get") { result = await toolInvoke("openphone.screen.get", flagsToToolArgs(rest), transport); @@ -59,6 +62,15 @@ async function importRuntimeMcpServer() { } } +function infoCommand() { + return { + name: "openphone-runtime-cli", + version: "0.1.0", + runtime_protocol: runtimeProtocolInfo(), + commands: commands.length, + }; +} + function runtimeCommand(command, args, transport) { switch (command) { case "status": @@ -175,6 +187,7 @@ function usage() { return `Usage: openphone [options] Commands: + info runtime status runtime list runtime select --chat [--volume ] [--background ] diff --git a/integrations/mcp-server/runtime/protocol/openphone-runtime-tools.mjs b/integrations/mcp-server/runtime/protocol/openphone-runtime-tools.mjs index eb02d3a..4817075 100644 --- a/integrations/mcp-server/runtime/protocol/openphone-runtime-tools.mjs +++ b/integrations/mcp-server/runtime/protocol/openphone-runtime-tools.mjs @@ -4,16 +4,94 @@ import path from "node:path"; const protocolRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../.."); const commandsPath = path.join(protocolRoot, "runtime/protocol/openphone-commands.json"); +// The inclusive range of runtime-protocol manifest versions this loader +// understands. Bump max_version when a new manifest version is introduced; +// bump min_version only when support for an old version is dropped. +export const RUNTIME_PROTOCOL_VERSION_RANGE = Object.freeze({ + min_version: 1, + max_version: 1, +}); + +export function isSupportedRuntimeProtocolVersion( + version, + range = RUNTIME_PROTOCOL_VERSION_RANGE, +) { + return Number.isInteger(version) + && version >= range.min_version + && version <= range.max_version; +} + +export function assertSupportedRuntimeProtocolVersion( + version, + range = RUNTIME_PROTOCOL_VERSION_RANGE, +) { + if (!isSupportedRuntimeProtocolVersion(version, range)) { + throw new Error( + `unsupported runtime protocol version: ${version} ` + + `(supported range: ${range.min_version}..${range.max_version})`, + ); + } +} + +// Structured advertisement of the runtime-protocol version range for +// initialize-style handshakes (MCP initialize result, CLI info output). +export function runtimeProtocolInfo(range = RUNTIME_PROTOCOL_VERSION_RANGE) { + return { + name: "openphone-runtime-protocol", + min_version: range.min_version, + max_version: range.max_version, + }; +} + +export function validateCommandDeprecations(commands) { + const known = new Set(); + for (const command of commands) { + known.add(command.name); + for (const alias of command.aliases ?? []) { + known.add(alias); + } + } + for (const command of commands) { + if (command.deprecated !== undefined && typeof command.deprecated !== "boolean") { + throw new Error(`command ${command.name}: deprecated must be a boolean`); + } + if (command.superseded_by !== undefined) { + if (typeof command.superseded_by !== "string" || !command.superseded_by) { + throw new Error(`command ${command.name}: superseded_by must be a non-empty string`); + } + if (command.deprecated !== true) { + throw new Error(`command ${command.name}: superseded_by requires deprecated=true`); + } + if (!known.has(command.superseded_by)) { + throw new Error( + `command ${command.name}: superseded_by references unknown command: ` + + command.superseded_by, + ); + } + if (command.superseded_by === command.name + || (command.aliases ?? []).includes(command.superseded_by)) { + throw new Error(`command ${command.name}: superseded_by must reference another command`); + } + } + } + return commands; +} + export function loadCommandManifest(file = commandsPath) { return JSON.parse(fs.readFileSync(file, "utf8")); } -export function loadCommands(file = commandsPath) { +export function loadCommands(file = commandsPath, options = {}) { + const range = options.versionRange ?? RUNTIME_PROTOCOL_VERSION_RANGE; const manifest = loadCommandManifest(file); - if (manifest.version !== 1 || !Array.isArray(manifest.commands)) { - throw new Error("openphone-commands.json must contain version=1 and commands[]"); + if (!isSupportedRuntimeProtocolVersion(manifest.version, range) + || !Array.isArray(manifest.commands)) { + throw new Error( + "openphone-commands.json must contain commands[] and a supported version " + + `(range: ${range.min_version}..${range.max_version}, got: ${manifest.version})`, + ); } - return manifest.commands; + return validateCommandDeprecations(manifest.commands); } export function commandMap(commands = loadCommands()) { diff --git a/integrations/mcp-server/src/index.mjs b/integrations/mcp-server/src/index.mjs index cc6a94f..ef44d6b 100644 --- a/integrations/mcp-server/src/index.mjs +++ b/integrations/mcp-server/src/index.mjs @@ -5,12 +5,18 @@ import { loadCommands, mcpTools, resolveCommand, + runtimeProtocolInfo, textContent, } from "../runtime/protocol/openphone-runtime-tools.mjs"; export const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18"]; const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]; -const SERVER_INFO = { name: "openphone-runtime", version: "0.1.0" }; +export const RUNTIME_PROTOCOL_INFO = runtimeProtocolInfo(); +const SERVER_INFO = { + name: "openphone-runtime", + version: "0.1.0", + runtimeProtocol: RUNTIME_PROTOCOL_INFO, +}; const commands = loadCommands(); diff --git a/runtime/protocol/openphone-runtime-tools.mjs b/runtime/protocol/openphone-runtime-tools.mjs index eb02d3a..4817075 100644 --- a/runtime/protocol/openphone-runtime-tools.mjs +++ b/runtime/protocol/openphone-runtime-tools.mjs @@ -4,16 +4,94 @@ import path from "node:path"; const protocolRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../.."); const commandsPath = path.join(protocolRoot, "runtime/protocol/openphone-commands.json"); +// The inclusive range of runtime-protocol manifest versions this loader +// understands. Bump max_version when a new manifest version is introduced; +// bump min_version only when support for an old version is dropped. +export const RUNTIME_PROTOCOL_VERSION_RANGE = Object.freeze({ + min_version: 1, + max_version: 1, +}); + +export function isSupportedRuntimeProtocolVersion( + version, + range = RUNTIME_PROTOCOL_VERSION_RANGE, +) { + return Number.isInteger(version) + && version >= range.min_version + && version <= range.max_version; +} + +export function assertSupportedRuntimeProtocolVersion( + version, + range = RUNTIME_PROTOCOL_VERSION_RANGE, +) { + if (!isSupportedRuntimeProtocolVersion(version, range)) { + throw new Error( + `unsupported runtime protocol version: ${version} ` + + `(supported range: ${range.min_version}..${range.max_version})`, + ); + } +} + +// Structured advertisement of the runtime-protocol version range for +// initialize-style handshakes (MCP initialize result, CLI info output). +export function runtimeProtocolInfo(range = RUNTIME_PROTOCOL_VERSION_RANGE) { + return { + name: "openphone-runtime-protocol", + min_version: range.min_version, + max_version: range.max_version, + }; +} + +export function validateCommandDeprecations(commands) { + const known = new Set(); + for (const command of commands) { + known.add(command.name); + for (const alias of command.aliases ?? []) { + known.add(alias); + } + } + for (const command of commands) { + if (command.deprecated !== undefined && typeof command.deprecated !== "boolean") { + throw new Error(`command ${command.name}: deprecated must be a boolean`); + } + if (command.superseded_by !== undefined) { + if (typeof command.superseded_by !== "string" || !command.superseded_by) { + throw new Error(`command ${command.name}: superseded_by must be a non-empty string`); + } + if (command.deprecated !== true) { + throw new Error(`command ${command.name}: superseded_by requires deprecated=true`); + } + if (!known.has(command.superseded_by)) { + throw new Error( + `command ${command.name}: superseded_by references unknown command: ` + + command.superseded_by, + ); + } + if (command.superseded_by === command.name + || (command.aliases ?? []).includes(command.superseded_by)) { + throw new Error(`command ${command.name}: superseded_by must reference another command`); + } + } + } + return commands; +} + export function loadCommandManifest(file = commandsPath) { return JSON.parse(fs.readFileSync(file, "utf8")); } -export function loadCommands(file = commandsPath) { +export function loadCommands(file = commandsPath, options = {}) { + const range = options.versionRange ?? RUNTIME_PROTOCOL_VERSION_RANGE; const manifest = loadCommandManifest(file); - if (manifest.version !== 1 || !Array.isArray(manifest.commands)) { - throw new Error("openphone-commands.json must contain version=1 and commands[]"); + if (!isSupportedRuntimeProtocolVersion(manifest.version, range) + || !Array.isArray(manifest.commands)) { + throw new Error( + "openphone-commands.json must contain commands[] and a supported version " + + `(range: ${range.min_version}..${range.max_version}, got: ${manifest.version})`, + ); } - return manifest.commands; + return validateCommandDeprecations(manifest.commands); } export function commandMap(commands = loadCommands()) { diff --git a/runtime/protocol/validate-runtime-protocol.mjs b/runtime/protocol/validate-runtime-protocol.mjs index 63cf5fb..6798081 100755 --- a/runtime/protocol/validate-runtime-protocol.mjs +++ b/runtime/protocol/validate-runtime-protocol.mjs @@ -4,6 +4,11 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; +import { + RUNTIME_PROTOCOL_VERSION_RANGE, + isSupportedRuntimeProtocolVersion, +} from "./openphone-runtime-tools.mjs"; + const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../.."); const commandsPath = path.join(root, "runtime/protocol/openphone-commands.json"); const eventsPath = path.join(root, "runtime/protocol/openphone-events.json"); @@ -72,8 +77,12 @@ function extractPluginCommandBuckets(source, label) { } const manifest = readJson(commandsPath); -if (manifest.version !== 1 || !Array.isArray(manifest.commands)) { - fail("openphone-commands.json must contain version=1 and commands[]"); +if (!isSupportedRuntimeProtocolVersion(manifest.version) || !Array.isArray(manifest.commands)) { + fail( + "openphone-commands.json must contain commands[] and a supported version " + + `(range: ${RUNTIME_PROTOCOL_VERSION_RANGE.min_version}..` + + `${RUNTIME_PROTOCOL_VERSION_RANGE.max_version}, got: ${manifest.version})`, + ); } const runtimeSchema = readJson(schemaPath); @@ -120,6 +129,29 @@ for (const entry of manifestEntries) { } const manifestSet = unique(manifestCommands, "manifest command or alias"); +for (const entry of manifestEntries) { + if (entry.deprecated !== undefined && typeof entry.deprecated !== "boolean") { + fail(`manifest command has non-boolean deprecated field: ${entry.name}`); + } + if (entry.superseded_by !== undefined) { + if (typeof entry.superseded_by !== "string" || !entry.superseded_by) { + fail(`manifest command has invalid superseded_by field: ${entry.name}`); + } + if (entry.deprecated !== true) { + fail(`manifest command sets superseded_by without deprecated=true: ${entry.name}`); + } + if (!manifestSet.has(entry.superseded_by)) { + fail( + `manifest command superseded_by references missing command: ` + + `${entry.name} -> ${entry.superseded_by}`, + ); + } + if (entry.superseded_by === entry.name || (entry.aliases ?? []).includes(entry.superseded_by)) { + fail(`manifest command superseded_by must reference another command: ${entry.name}`); + } + } +} + const pluginBuckets = extractPluginCommandBuckets( fs.readFileSync(pluginSourcePath, "utf8"), "plugin source", diff --git a/scripts/check-runtime-protocol.sh b/scripts/check-runtime-protocol.sh index caea979..f9b123b 100755 --- a/scripts/check-runtime-protocol.sh +++ b/scripts/check-runtime-protocol.sh @@ -13,6 +13,7 @@ node "$root/tests/integrations/adb-stateful-gating-contract.mjs" node "$root/tests/integrations/openclaw-plugin-policy-contract.mjs" node "$root/tests/integrations/runtime-cli-contract.mjs" node "$root/tests/integrations/runtime-mcp-contract.mjs" +node "$root/tests/integrations/runtime-protocol-versioning-contract.mjs" node "$root/tests/integrations/runtime-package-contract.mjs" printf 'Runtime protocol checks passed.\n' diff --git a/scripts/check.sh b/scripts/check.sh index 99e8a2e..15471b5 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -126,6 +126,7 @@ required=( tests/integrations/adb-stateful-gating-contract.mjs tests/integrations/runtime-cli-contract.mjs tests/integrations/runtime-mcp-contract.mjs + tests/integrations/runtime-protocol-versioning-contract.mjs tests/integrations/openclaw-plugin-policy-contract.mjs tests/integrations/runtime-package-contract.mjs integrations/mcp-server/README.md diff --git a/tests/README.md b/tests/README.md index 29bf33a..a9da239 100644 --- a/tests/README.md +++ b/tests/README.md @@ -10,6 +10,9 @@ This directory contains repo-level tests that are reusable across packages. manifest-flagged stateful tools unless the operator opts in. - `runtime-cli-contract.mjs` checks the manifest-backed CLI surface. - `runtime-mcp-contract.mjs` checks the MCP JSON-RPC tool surface. +- `runtime-protocol-versioning-contract.mjs` checks manifest version-range + acceptance/rejection, the handshake version advertisement, and + `deprecated`/`superseded_by` metadata validation. - `openclaw-plugin-policy-contract.mjs` checks the OpenClaw plugin policy registration. diff --git a/tests/integrations/runtime-cli-contract.mjs b/tests/integrations/runtime-cli-contract.mjs index c0a5894..35be62b 100644 --- a/tests/integrations/runtime-cli-contract.mjs +++ b/tests/integrations/runtime-cli-contract.mjs @@ -20,6 +20,21 @@ function captureIo() { }; } +{ + // `info --json` advertises the supported runtime-protocol version range. + const capture = captureIo(); + const code = await main(["info", "--json"], capture.io); + assert.equal(code, 0); + const parsed = JSON.parse(capture.stdout()); + assert.equal(parsed.name, "openphone-runtime-cli"); + assert.equal(parsed.runtime_protocol.name, "openphone-runtime-protocol"); + assert.equal(parsed.runtime_protocol.min_version, 1); + assert.ok(Number.isInteger(parsed.runtime_protocol.max_version)); + assert.ok(parsed.runtime_protocol.min_version <= parsed.runtime_protocol.max_version); + assert.ok(parsed.commands > 10); + assert.equal(capture.stderr(), ""); +} + { const capture = captureIo(); const code = await main(["tool", "list", "--json"], capture.io); diff --git a/tests/integrations/runtime-mcp-contract.mjs b/tests/integrations/runtime-mcp-contract.mjs index 02d4097..7829912 100644 --- a/tests/integrations/runtime-mcp-contract.mjs +++ b/tests/integrations/runtime-mcp-contract.mjs @@ -31,6 +31,13 @@ const fakeTransport = { assert.equal(response.result.serverInfo.name, "openphone-runtime"); assert.ok(response.result.capabilities.tools); assert.equal(response.result.protocolVersion, SUPPORTED_PROTOCOL_VERSIONS[0]); + // The initialize handshake advertises the runtime-protocol version range. + const runtimeProtocol = response.result.serverInfo.runtimeProtocol; + assert.equal(runtimeProtocol.name, "openphone-runtime-protocol"); + assert.ok(Number.isInteger(runtimeProtocol.min_version)); + assert.ok(Number.isInteger(runtimeProtocol.max_version)); + assert.ok(runtimeProtocol.min_version <= runtimeProtocol.max_version); + assert.equal(runtimeProtocol.min_version, 1); } { @@ -208,6 +215,8 @@ const fakeTransport = { assert.equal(initialize.id, 1); assert.equal(initialize.result.protocolVersion, "2025-06-18"); assert.equal(initialize.result.serverInfo.name, "openphone-runtime"); + assert.equal(initialize.result.serverInfo.runtimeProtocol.name, "openphone-runtime-protocol"); + assert.equal(initialize.result.serverInfo.runtimeProtocol.min_version, 1); send({ jsonrpc: "2.0", method: "notifications/initialized" }); diff --git a/tests/integrations/runtime-protocol-versioning-contract.mjs b/tests/integrations/runtime-protocol-versioning-contract.mjs new file mode 100644 index 0000000..7d2ee82 --- /dev/null +++ b/tests/integrations/runtime-protocol-versioning-contract.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + RUNTIME_PROTOCOL_VERSION_RANGE, + assertSupportedRuntimeProtocolVersion, + isSupportedRuntimeProtocolVersion, + loadCommands, + runtimeProtocolInfo, + validateCommandDeprecations, +} from "../../runtime/protocol/openphone-runtime-tools.mjs"; + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openphone-protocol-versioning-")); + +function writeManifest(manifest) { + const file = path.join(tempDir, `manifest-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(file, JSON.stringify(manifest)); + return file; +} + +try { + // The published range is a well-formed inclusive integer range covering v1. + assert.ok(Number.isInteger(RUNTIME_PROTOCOL_VERSION_RANGE.min_version)); + assert.ok(Number.isInteger(RUNTIME_PROTOCOL_VERSION_RANGE.max_version)); + assert.ok( + RUNTIME_PROTOCOL_VERSION_RANGE.min_version <= RUNTIME_PROTOCOL_VERSION_RANGE.max_version, + ); + assert.ok(isSupportedRuntimeProtocolVersion(1)); + + // Range membership checks. + assert.equal(isSupportedRuntimeProtocolVersion(1, { min_version: 1, max_version: 2 }), true); + assert.equal(isSupportedRuntimeProtocolVersion(2, { min_version: 1, max_version: 2 }), true); + assert.equal(isSupportedRuntimeProtocolVersion(3, { min_version: 1, max_version: 2 }), false); + assert.equal(isSupportedRuntimeProtocolVersion(0, { min_version: 1, max_version: 2 }), false); + assert.equal(isSupportedRuntimeProtocolVersion("1", { min_version: 1, max_version: 2 }), false); + assert.equal(isSupportedRuntimeProtocolVersion(1.5, { min_version: 1, max_version: 2 }), false); + assert.equal( + isSupportedRuntimeProtocolVersion(undefined, { min_version: 1, max_version: 2 }), + false, + ); + + assert.doesNotThrow(() => assertSupportedRuntimeProtocolVersion(1)); + assert.throws( + () => assertSupportedRuntimeProtocolVersion(99), + /unsupported runtime protocol version: 99/u, + ); + + // The handshake advertisement mirrors the supported range. + const info = runtimeProtocolInfo(); + assert.equal(info.name, "openphone-runtime-protocol"); + assert.equal(info.min_version, RUNTIME_PROTOCOL_VERSION_RANGE.min_version); + assert.equal(info.max_version, RUNTIME_PROTOCOL_VERSION_RANGE.max_version); + + // The real manifest loads under the default range. + const realCommands = loadCommands(); + assert.ok(realCommands.length > 10); + + // No shipped command is deprecated yet. + assert.ok(realCommands.every((command) => command.deprecated !== true)); + + // A v1 manifest loads; an out-of-range version is rejected. + const command = { + name: "openphone.example.old", + description: "example", + input_schema: { type: "object" }, + output_schema: { type: "object" }, + }; + assert.ok(loadCommands(writeManifest({ version: 1, commands: [command] }))); + assert.throws( + () => loadCommands(writeManifest({ version: 99, commands: [command] })), + /supported version/u, + ); + assert.throws( + () => loadCommands(writeManifest({ version: 0, commands: [command] })), + /supported version/u, + ); + assert.throws( + () => loadCommands(writeManifest({ version: 1 })), + /commands\[\]/u, + ); + + // A wider caller-supplied range accepts newer manifest versions. + assert.ok(loadCommands( + writeManifest({ version: 2, commands: [command] }), + { versionRange: { min_version: 1, max_version: 2 } }, + )); + + // Deprecation metadata: valid shapes are accepted. + const supersededManifest = writeManifest({ + version: 1, + commands: [ + { ...command, deprecated: true, superseded_by: "openphone.example.new" }, + { ...command, name: "openphone.example.new" }, + ], + }); + const deprecatedCommands = loadCommands(supersededManifest); + assert.equal(deprecatedCommands[0].deprecated, true); + assert.equal(deprecatedCommands[0].superseded_by, "openphone.example.new"); + + // superseded_by may reference an alias of another command. + assert.ok(loadCommands(writeManifest({ + version: 1, + commands: [ + { ...command, deprecated: true, superseded_by: "example.new" }, + { ...command, name: "openphone.example.new", aliases: ["example.new"] }, + ], + }))); + + // superseded_by must reference an existing command. + assert.throws( + () => validateCommandDeprecations([ + { ...command, deprecated: true, superseded_by: "openphone.example.missing" }, + ]), + /superseded_by references unknown command/u, + ); + + // superseded_by requires deprecated=true. + assert.throws( + () => validateCommandDeprecations([ + { ...command, superseded_by: "openphone.example.new" }, + { ...command, name: "openphone.example.new" }, + ]), + /superseded_by requires deprecated=true/u, + ); + + // superseded_by must not point at the command itself (or its aliases). + assert.throws( + () => validateCommandDeprecations([ + { ...command, deprecated: true, superseded_by: "openphone.example.old" }, + ]), + /must reference another command/u, + ); + + // deprecated must be a boolean when present. + assert.throws( + () => validateCommandDeprecations([{ ...command, deprecated: "yes" }]), + /deprecated must be a boolean/u, + ); + + // superseded_by must be a non-empty string when present. + assert.throws( + () => validateCommandDeprecations([{ ...command, deprecated: true, superseded_by: "" }]), + /superseded_by must be a non-empty string/u, + ); + + // loadCommands enforces deprecation metadata too. + assert.throws( + () => loadCommands(writeManifest({ + version: 1, + commands: [{ ...command, deprecated: true, superseded_by: "openphone.example.missing" }], + })), + /superseded_by references unknown command/u, + ); +} finally { + fs.rmSync(tempDir, { recursive: true, force: true }); +}