feat(android): generate gateway protocol models (#106011)

* feat(android): generate gateway protocol models

* fix(ci): recognize generated Kotlin event constants

* fix(android): normalize generated event catalog

* fix(ci): type Kotlin event coverage exports

* fix(ci): satisfy Kotlin codegen gates
This commit is contained in:
Peter Steinberger
2026-07-13 00:34:57 -07:00
committed by GitHub
parent 2237196871
commit 29781ab39e
12 changed files with 1243 additions and 198 deletions
+2 -2
View File
@@ -1811,7 +1811,7 @@
},
{
"kind": "conditional-branch",
"line": 1343,
"line": 1353,
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"source": "Connecting…",
"surface": "android",
@@ -1819,7 +1819,7 @@
},
{
"kind": "conditional-branch",
"line": 1343,
"line": 1353,
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"source": "Reconnecting…",
"surface": "android",
@@ -1,7 +1,407 @@
// Generated by scripts/protocol-gen-kotlin.ts — do not edit by hand.
package ai.openclaw.app.gateway
/** Gateway protocol version emitted by Android node clients. */
const val GATEWAY_PROTOCOL_VERSION = 4
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** Oldest gateway protocol version this Android client can speak safely. */
const val GATEWAY_PROTOCOL_VERSION = 4
const val GATEWAY_MIN_PROTOCOL_VERSION = 4
@Serializable
data class GatewayProtocolError(
val code: String,
val message: String,
val details: JsonElement? = null,
val retryable: Boolean? = null,
val retryAfterMs: Long? = null,
)
@Serializable
data class GatewayRequestFrame(
val type: String = "req",
val id: String,
val method: String,
val params: JsonElement? = null,
)
@Serializable
data class GatewayResponseFrame(
val type: String = "res",
val id: String,
val ok: Boolean,
val payload: JsonElement? = null,
val error: GatewayProtocolError? = null,
)
@Serializable
data class GatewayEventFrame(
val type: String = "event",
val event: String,
val payload: JsonElement? = null,
val seq: Long? = null,
val stateVersion: GatewayEventFrameStateVersion? = null,
)
@Serializable
data class GatewayNodeEventParams(
val event: String,
val payload: JsonElement? = null,
@SerialName("payloadJSON")
val payloadJson: String? = null,
)
@Serializable
data class GatewayNodeInvokeResultParams(
val id: String,
val nodeId: String,
val ok: Boolean,
val payload: JsonElement? = null,
@SerialName("payloadJSON")
val payloadJson: String? = null,
val error: GatewayNodeInvokeResultParamsError? = null,
)
@Serializable
data class GatewayNodeInvokeRequest(
val id: String,
val nodeId: String,
val command: String,
@SerialName("paramsJSON")
val paramsJson: String? = null,
val timeoutMs: Long? = null,
val idempotencyKey: String? = null,
)
@Serializable
data class GatewayEventFrameStateVersion(
val presence: Long,
val health: Long,
)
@Serializable
data class GatewayNodeInvokeResultParamsError(
val code: String? = null,
val message: String? = null,
)
enum class GatewayMethod(
val rawValue: String,
) {
Health("health"),
DiagnosticsStability("diagnostics.stability"),
DoctorMemoryStatus("doctor.memory.status"),
DoctorMemoryDreamDiary("doctor.memory.dreamDiary"),
DoctorMemoryBackfillDreamDiary("doctor.memory.backfillDreamDiary"),
DoctorMemoryResetDreamDiary("doctor.memory.resetDreamDiary"),
DoctorMemoryResetGroundedShortTerm("doctor.memory.resetGroundedShortTerm"),
DoctorMemoryRepairDreamingArtifacts("doctor.memory.repairDreamingArtifacts"),
DoctorMemoryDedupeDreamDiary("doctor.memory.dedupeDreamDiary"),
DoctorMemoryRemHarness("doctor.memory.remHarness"),
LogsTail("logs.tail"),
ChannelsStatus("channels.status"),
ChannelsStart("channels.start"),
ChannelsStop("channels.stop"),
ChannelsLogout("channels.logout"),
Status("status"),
UsageStatus("usage.status"),
UsageCost("usage.cost"),
TtsStatus("tts.status"),
TtsProviders("tts.providers"),
TtsPersonas("tts.personas"),
TtsEnable("tts.enable"),
TtsDisable("tts.disable"),
TtsConvert("tts.convert"),
TtsSetProvider("tts.setProvider"),
TtsSetPersona("tts.setPersona"),
ConfigGet("config.get"),
ConfigSet("config.set"),
ConfigApply("config.apply"),
ConfigPatch("config.patch"),
ConfigSchema("config.schema"),
ConfigSchemaLookup("config.schema.lookup"),
ExecApprovalsGet("exec.approvals.get"),
ExecApprovalsSet("exec.approvals.set"),
ExecApprovalsNodeGet("exec.approvals.node.get"),
ExecApprovalsNodeSet("exec.approvals.node.set"),
ExecApprovalGet("exec.approval.get"),
ExecApprovalList("exec.approval.list"),
ExecApprovalRequest("exec.approval.request"),
ExecApprovalWaitDecision("exec.approval.waitDecision"),
ExecApprovalResolve("exec.approval.resolve"),
PluginApprovalList("plugin.approval.list"),
PluginApprovalRequest("plugin.approval.request"),
PluginApprovalWaitDecision("plugin.approval.waitDecision"),
PluginApprovalResolve("plugin.approval.resolve"),
PluginsUiDescriptors("plugins.uiDescriptors"),
PluginsSessionAction("plugins.sessionAction"),
CrestodianChat("crestodian.chat"),
CrestodianSetupDetect("crestodian.setup.detect"),
CrestodianSetupActivate("crestodian.setup.activate"),
CrestodianSetupAuthStart("crestodian.setup.auth.start"),
WizardStart("wizard.start"),
WizardNext("wizard.next"),
WizardCancel("wizard.cancel"),
WizardStatus("wizard.status"),
TalkCatalog("talk.catalog"),
TalkConfig("talk.config"),
TalkClientCreate("talk.client.create"),
TalkClientToolCall("talk.client.toolCall"),
TalkClientSteer("talk.client.steer"),
TalkSessionCreate("talk.session.create"),
TalkSessionJoin("talk.session.join"),
TalkSessionAppendAudio("talk.session.appendAudio"),
TalkSessionStartTurn("talk.session.startTurn"),
TalkSessionEndTurn("talk.session.endTurn"),
TalkSessionCancelTurn("talk.session.cancelTurn"),
TalkSessionCancelOutput("talk.session.cancelOutput"),
TalkSessionSubmitToolResult("talk.session.submitToolResult"),
TalkSessionSteer("talk.session.steer"),
TalkSessionClose("talk.session.close"),
TalkSpeak("talk.speak"),
TalkMode("talk.mode"),
CommandsList("commands.list"),
ModelsList("models.list"),
ModelsAuthStatus("models.authStatus"),
ModelsAuthLogout("models.authLogout"),
ToolsCatalog("tools.catalog"),
ToolsEffective("tools.effective"),
ToolsInvoke("tools.invoke"),
McpAppView("mcp.app.view"),
McpAppListTools("mcp.app.listTools"),
McpAppListResources("mcp.app.listResources"),
McpAppListResourceTemplates("mcp.app.listResourceTemplates"),
McpAppReadResource("mcp.app.readResource"),
McpAppCallTool("mcp.app.callTool"),
AuditList("audit.list"),
AuditActivityList("audit.activity.list"),
TasksList("tasks.list"),
TasksGet("tasks.get"),
TasksCancel("tasks.cancel"),
TaskSuggestionsList("taskSuggestions.list"),
TaskSuggestionsCreate("taskSuggestions.create"),
TaskSuggestionsAccept("taskSuggestions.accept"),
TaskSuggestionsDismiss("taskSuggestions.dismiss"),
EnvironmentsList("environments.list"),
EnvironmentsStatus("environments.status"),
WorktreesList("worktrees.list"),
WorktreesBranches("worktrees.branches"),
FsListDir("fs.listDir"),
WorktreesCreate("worktrees.create"),
WorktreesRemove("worktrees.remove"),
WorktreesRestore("worktrees.restore"),
WorktreesGc("worktrees.gc"),
AgentsList("agents.list"),
AgentsCreate("agents.create"),
AgentsUpdate("agents.update"),
AgentsDelete("agents.delete"),
AgentsFilesList("agents.files.list"),
AgentsFilesGet("agents.files.get"),
AgentsFilesSet("agents.files.set"),
SessionsFilesList("sessions.files.list"),
SessionsFilesGet("sessions.files.get"),
SessionsFilesSet("sessions.files.set"),
ArtifactsList("artifacts.list"),
ArtifactsGet("artifacts.get"),
ArtifactsDownload("artifacts.download"),
SkillsStatus("skills.status"),
SkillsSearch("skills.search"),
SkillsDetail("skills.detail"),
SkillsSecurityVerdicts("skills.securityVerdicts"),
SkillsSkillCard("skills.skillCard"),
SkillsBins("skills.bins"),
SkillsUploadBegin("skills.upload.begin"),
SkillsUploadChunk("skills.upload.chunk"),
SkillsUploadCommit("skills.upload.commit"),
SkillsInstall("skills.install"),
SkillsUpdate("skills.update"),
SkillsCuratorStatus("skills.curator.status"),
SkillsCuratorPin("skills.curator.pin"),
SkillsCuratorUnpin("skills.curator.unpin"),
SkillsCuratorRestore("skills.curator.restore"),
SkillsProposalsList("skills.proposals.list"),
SkillsProposalsInspect("skills.proposals.inspect"),
SkillsProposalsCreate("skills.proposals.create"),
SkillsProposalsUpdate("skills.proposals.update"),
SkillsProposalsRevise("skills.proposals.revise"),
SkillsProposalsRequestRevision("skills.proposals.requestRevision"),
SkillsProposalsApply("skills.proposals.apply"),
SkillsProposalsReject("skills.proposals.reject"),
SkillsProposalsQuarantine("skills.proposals.quarantine"),
UpdateStatus("update.status"),
UpdateRun("update.run"),
VoicewakeGet("voicewake.get"),
VoicewakeSet("voicewake.set"),
SecretsReload("secrets.reload"),
SecretsResolve("secrets.resolve"),
VoicewakeRoutingGet("voicewake.routing.get"),
VoicewakeRoutingSet("voicewake.routing.set"),
SessionsList("sessions.list"),
SessionsSubscribe("sessions.subscribe"),
SessionsUnsubscribe("sessions.unsubscribe"),
SessionsMessagesSubscribe("sessions.messages.subscribe"),
SessionsMessagesUnsubscribe("sessions.messages.unsubscribe"),
SessionsPreview("sessions.preview"),
SessionsDescribe("sessions.describe"),
SessionsCompactionList("sessions.compaction.list"),
SessionsCompactionGet("sessions.compaction.get"),
SessionsCompactionBranch("sessions.compaction.branch"),
SessionsCompactionRestore("sessions.compaction.restore"),
SessionsCreate("sessions.create"),
SessionsSend("sessions.send"),
SessionsAbort("sessions.abort"),
SessionsPatch("sessions.patch"),
SessionsPluginPatch("sessions.pluginPatch"),
SessionsCleanup("sessions.cleanup"),
SessionsReset("sessions.reset"),
SessionsDelete("sessions.delete"),
SessionsCompact("sessions.compact"),
SessionsGroupsList("sessions.groups.list"),
SessionsGroupsPut("sessions.groups.put"),
SessionsGroupsRename("sessions.groups.rename"),
SessionsGroupsDelete("sessions.groups.delete"),
LastHeartbeat("last-heartbeat"),
SetHeartbeats("set-heartbeats"),
Wake("wake"),
NodePairList("node.pair.list"),
NodePairApprove("node.pair.approve"),
NodePairReject("node.pair.reject"),
NodePairRemove("node.pair.remove"),
DevicePairList("device.pair.list"),
DevicePairApprove("device.pair.approve"),
DevicePairReject("device.pair.reject"),
DevicePairRemove("device.pair.remove"),
DevicePairRename("device.pair.rename"),
DeviceTokenRotate("device.token.rotate"),
DeviceTokenRevoke("device.token.revoke"),
DevicePairSetupCode("device.pair.setupCode"),
NodeRename("node.rename"),
NodeList("node.list"),
NodeDescribe("node.describe"),
NodePluginSurfaceRefresh("node.pluginSurface.refresh"),
NodePluginToolsUpdate("node.pluginTools.update"),
NodeSkillsUpdate("node.skills.update"),
NodePendingDrain("node.pending.drain"),
NodePendingEnqueue("node.pending.enqueue"),
NodeInvoke("node.invoke"),
NodePendingPull("node.pending.pull"),
NodePendingAck("node.pending.ack"),
NodeInvokeResult("node.invoke.result"),
NodeEvent("node.event"),
CronGet("cron.get"),
CronList("cron.list"),
CronStatus("cron.status"),
CronAdd("cron.add"),
CronUpdate("cron.update"),
CronRemove("cron.remove"),
CronRun("cron.run"),
CronRuns("cron.runs"),
GatewayIdentityGet("gateway.identity.get"),
GatewayRestartPreflight("gateway.restart.preflight"),
GatewayRestartRequest("gateway.restart.request"),
SystemPresence("system-presence"),
SystemEvent("system-event"),
MessageAction("message.action"),
Send("send"),
Agent("agent"),
AgentIdentityGet("agent.identity.get"),
AgentWait("agent.wait"),
ChatHistory("chat.history"),
ChatStartup("chat.startup"),
ChatMetadata("chat.metadata"),
ChatMessageGet("chat.message.get"),
ChatAbort("chat.abort"),
ChatSend("chat.send"),
TerminalOpen("terminal.open"),
TerminalInput("terminal.input"),
TerminalResize("terminal.resize"),
TerminalClose("terminal.close"),
AssistantMediaGet("assistant.media.get"),
SessionsGet("sessions.get"),
SessionsResolve("sessions.resolve"),
SessionsUsage("sessions.usage"),
SessionsUsageTimeseries("sessions.usage.timeseries"),
SessionsUsageLogs("sessions.usage.logs"),
Poll("poll"),
SessionsSteer("sessions.steer"),
PushTest("push.test"),
AttachGrant("attach.grant"),
AttachRevoke("attach.revoke"),
PushWebVapidPublicKey("push.web.vapidPublicKey"),
PushWebSubscribe("push.web.subscribe"),
PushWebUnsubscribe("push.web.unsubscribe"),
PushWebTest("push.web.test"),
ConfigOpenFile("config.openFile"),
Connect("connect"),
ChatInject("chat.inject"),
NativeHookInvoke("nativeHook.invoke"),
WebLoginStart("web.login.start"),
WebLoginWait("web.login.wait"),
TerminalAttach("terminal.attach"),
TerminalList("terminal.list"),
TerminalText("terminal.text"),
ControlUiGithubPreview("controlUi.githubPreview"),
SystemInfo("system.info"),
AgentsWorkspaceList("agents.workspace.list"),
AgentsWorkspaceGet("agents.workspace.get"),
TtsSpeak("tts.speak"),
PluginsList("plugins.list"),
PluginsSearch("plugins.search"),
PluginsInstall("plugins.install"),
PluginsSetEnabled("plugins.setEnabled"),
PluginsUninstall("plugins.uninstall"),
ControlUiSessionPullRequests("controlUi.sessionPullRequests"),
GatewaySuspendPrepare("gateway.suspend.prepare"),
GatewaySuspendStatus("gateway.suspend.status"),
GatewaySuspendResume("gateway.suspend.resume"),
ChatToolTitles("chat.toolTitles"),
SessionsDiff("sessions.diff"),
CrestodianSetupVerify("crestodian.setup.verify"),
EnvironmentsCreate("environments.create"),
EnvironmentsDestroy("environments.destroy"),
SessionsCatalogList("sessions.catalog.list"),
SessionsCatalogRead("sessions.catalog.read"),
SessionsCatalogContinue("sessions.catalog.continue"),
SessionsCatalogArchive("sessions.catalog.archive"),
ApprovalGet("approval.get"),
ApprovalResolve("approval.resolve"),
SessionsSearch("sessions.search"),
}
enum class GatewayEvent(
val rawValue: String,
) {
ConnectChallenge("connect.challenge"),
Agent("agent"),
Chat("chat"),
SessionApproval("session.approval"),
SessionMessage("session.message"),
SessionOperation("session.operation"),
SessionTool("session.tool"),
SessionsChanged("sessions.changed"),
Presence("presence"),
Tick("tick"),
TalkMode("talk.mode"),
TalkEvent("talk.event"),
Shutdown("shutdown"),
Health("health"),
Heartbeat("heartbeat"),
Cron("cron"),
Task("task"),
TaskSuggestion("task.suggestion"),
NodePairRequested("node.pair.requested"),
NodePairResolved("node.pair.resolved"),
NodePresence("node.presence"),
NodeInvokeRequest("node.invoke.request"),
DevicePairRequested("device.pair.requested"),
DevicePairResolved("device.pair.resolved"),
VoicewakeChanged("voicewake.changed"),
VoicewakeRoutingChanged("voicewake.routing.changed"),
ExecApprovalRequested("exec.approval.requested"),
ExecApprovalResolved("exec.approval.resolved"),
PluginApprovalRequested("plugin.approval.requested"),
PluginApprovalResolved("plugin.approval.resolved"),
TerminalData("terminal.data"),
TerminalExit("terminal.exit"),
UpdateAvailable("update.available"),
}
@@ -221,7 +221,12 @@ class GatewaySession(
val error: ErrorShape?,
)
private val json = Json { ignoreUnknownKeys = true }
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
private val writeLock = Mutex()
@Volatile private var pluginSurfaceUrls: Map<String, String> = emptyMap()
@@ -387,7 +392,7 @@ class GatewaySession(
val conn = readyConnection(expectedEndpointStableId) ?: return NodeEventSendOutcome.DISCONNECTED
return try {
conn.request(
"node.event",
GatewayMethod.NodeEvent.rawValue,
buildNodeEventParams(event = event, payloadJson = payloadJson),
timeoutMs = 8_000,
)
@@ -422,7 +427,7 @@ class GatewaySession(
)
val params = buildNodeEventParams(event = event, payloadJson = payloadJson)
try {
val res = conn.request("node.event", params, timeoutMs = timeoutMs)
val res = conn.request(GatewayMethod.NodeEvent.rawValue, params, timeoutMs = timeoutMs)
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err::class.java.simpleName}")
@@ -438,11 +443,11 @@ class GatewaySession(
event: String,
payloadJson: String?,
): JsonObject =
buildJsonObject {
put("event", JsonPrimitive(event))
// Gateway node events carry payloadJSON as a string for compatibility with non-JSON payload producers.
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
json
.encodeToJsonElement(
GatewayNodeEventParams.serializer(),
GatewayNodeEventParams(event = event, payloadJson = payloadJson ?: "{}"),
).asObjectOrNull() ?: error("GatewayNodeEventParams must encode as an object")
/** Sends an RPC request and throws a code-prefixed exception when the gateway returns an error. */
suspend fun request(
@@ -689,12 +694,11 @@ class GatewaySession(
method: String,
params: JsonElement?,
): JsonObject =
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (params != null) put("params", params)
}
json
.encodeToJsonElement(
GatewayRequestFrame.serializer(),
GatewayRequestFrame(id = id, method = method, params = params),
).asObjectOrNull() ?: error("GatewayRequestFrame must encode as an object")
suspend fun awaitClose() = closedDeferred.await()
@@ -857,7 +861,7 @@ class GatewaySession(
connectNonce = connectNonce,
selectedAuth = selectedAuth,
)
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
val res = request(GatewayMethod.Connect.rawValue, payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
if (!res.ok) {
val error = res.error ?: ErrorShape("UNAVAILABLE", "connect failed")
val shouldRetryWithDeviceToken =
@@ -1154,15 +1158,17 @@ class GatewaySession(
}
private fun handleResponse(frame: JsonObject) {
val id = frame["id"].asStringOrNull() ?: return
val response =
runCatching {
json.decodeFromJsonElement(GatewayResponseFrame.serializer(), frame)
}.getOrNull() ?: return
val id = response.id
if (id == connectRequestId) connectResponseAccepted.set(true)
val ok = frame["ok"].asBooleanOrNull() ?: false
val payloadJson = frame["payload"]?.let { payload -> payload.toString() }
// Read the raw element so an explicit JSON null remains distinguishable from an omitted payload.
val payloadJson = frame["payload"]?.toString()
val error =
frame["error"]?.asObjectOrNull()?.let { obj ->
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = obj["message"].asStringOrNull() ?: "request failed"
val detailObj = obj["details"].asObjectOrNull()
response.error?.let { wireError ->
val detailObj = wireError.details.asObjectOrNull()
val details =
detailObj?.let {
GatewayConnectErrorDetails(
@@ -1172,7 +1178,7 @@ class GatewaySession(
pauseReconnect = it["pauseReconnect"].asBooleanOrNull(),
reason = it["reason"].asStringOrNull(),
requestId = normalizeGatewayApprovalRequestId(it["requestId"].asStringOrNull()),
retryable = it["retryable"].asBooleanOrNull() == true,
retryable = it["retryable"].asBooleanOrNull() == true || wireError.retryable == true,
clientMinProtocol = it["clientMinProtocol"].asIntOrNull(),
clientMaxProtocol = it["clientMaxProtocol"].asIntOrNull(),
expectedProtocol = it["expectedProtocol"].asIntOrNull(),
@@ -1182,23 +1188,27 @@ class GatewaySession(
clawhubVersion = it["version"].asStringOrNull(),
)
}
ErrorShape(code, msg, details)
ErrorShape(wireError.code, wireError.message, details)
}
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
pending.remove(id)?.complete(RpcResponse(id, response.ok, payloadJson, error))
}
private fun handleEvent(frame: JsonObject) {
val event = frame["event"].asStringOrNull() ?: return
val gatewayEvent =
runCatching {
json.decodeFromJsonElement(GatewayEventFrame.serializer(), frame)
}.getOrNull() ?: return
val event = gatewayEvent.event
val payloadJson =
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "connect.challenge") {
frame["payload"]?.toString() ?: frame["payloadJSON"].asStringOrNull()
if (event == GatewayEvent.ConnectChallenge.rawValue) {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) {
connectNonceDeferred.complete(nonce.trim())
}
return
}
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
if (event == GatewayEvent.NodeInvokeRequest.rawValue && payloadJson != null && onInvoke != null) {
handleInvokeEvent(payloadJson)
return
}
@@ -1220,22 +1230,27 @@ class GatewaySession(
private fun handleInvokeEvent(payloadJson: String) {
val payload =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return
val id = payload["id"].asStringOrNull() ?: return
val nodeId = payload["nodeId"].asStringOrNull() ?: return
val command = payload["command"].asStringOrNull() ?: return
val params =
payload["paramsJSON"].asStringOrNull()
?: payload["params"]?.let { value -> if (value is JsonNull) null else value.toString() }
val timeoutMs = payload["timeoutMs"].asLongOrNull()
runCatching {
json.decodeFromString(GatewayNodeInvokeRequest.serializer(), payloadJson)
}.getOrNull() ?: return
// Older gateways sent structured `params`; keep accepting that shipped wire shape while
// generated models follow the canonical `paramsJSON` schema.
val paramsJson =
payload.paramsJson
?: runCatching {
json.parseToJsonElement(payloadJson).asObjectOrNull()?.get("params")
}.getOrNull()?.let { value -> if (value is JsonNull) null else value.toString() }
connectionScope.launch {
val request = InvokeRequest(id, nodeId, command, params, timeoutMs)
val request =
InvokeRequest(
id = payload.id,
nodeId = payload.nodeId,
command = payload.command,
paramsJson = paramsJson,
timeoutMs = payload.timeoutMs,
)
val result = executeInvokeRequest(request)
sendInvokeResult(id, nodeId, result, timeoutMs)
sendInvokeResult(payload.id, payload.nodeId, result, payload.timeoutMs)
}
}
@@ -1277,29 +1292,24 @@ class GatewaySession(
) {
val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) }
val params =
buildJsonObject {
put("id", JsonPrimitive(id))
put("nodeId", JsonPrimitive(nodeId))
put("ok", JsonPrimitive(result.ok))
if (parsedPayload != null) {
put("payload", parsedPayload)
} else if (result.payloadJson != null) {
// Preserve malformed/non-object payloads as payloadJSON so the gateway can report handler output.
put("payloadJSON", JsonPrimitive(result.payloadJson))
}
result.error?.let { err ->
put(
"error",
buildJsonObject {
put("code", JsonPrimitive(err.code))
put("message", JsonPrimitive(err.message))
},
)
}
}
json
.encodeToJsonElement(
GatewayNodeInvokeResultParams.serializer(),
GatewayNodeInvokeResultParams(
id = id,
nodeId = nodeId,
ok = result.ok,
payload = parsedPayload,
payloadJson = if (parsedPayload == null) result.payloadJson else null,
error =
result.error?.let { err ->
GatewayNodeInvokeResultParamsError(code = err.code, message = err.message)
},
),
).asObjectOrNull() ?: error("GatewayNodeInvokeResultParams must encode as an object")
val ackTimeoutMs = resolveInvokeResultAckTimeoutMs(invokeTimeoutMs)
try {
request("node.invoke.result", params, timeoutMs = ackTimeoutMs)
request(GatewayMethod.NodeInvokeResult.rawValue, params, timeoutMs = ackTimeoutMs)
} catch (err: Throwable) {
Log.w(
loggerTag,
@@ -1,6 +1,6 @@
// Generated by scripts/protocol-gen-kotlin.ts — do not edit by hand.
package ai.openclaw.app.protocol
/** Capability ids advertised by the Android node to the OpenClaw gateway. */
enum class OpenClawCapability(
val rawValue: String,
) {
@@ -19,7 +19,6 @@ enum class OpenClawCapability(
CallLog("callLog"),
}
/** Canvas command ids mirrored from the gateway tool namespace. */
enum class OpenClawCanvasCommand(
val rawValue: String,
) {
@@ -35,7 +34,6 @@ enum class OpenClawCanvasCommand(
}
}
/** Streaming canvas commands sent from agents back into the Android UI. */
enum class OpenClawCanvasA2UICommand(
val rawValue: String,
) {
@@ -49,7 +47,6 @@ enum class OpenClawCanvasA2UICommand(
}
}
/** Camera command ids accepted by the Android node. */
enum class OpenClawCameraCommand(
val rawValue: String,
) {
@@ -63,7 +60,6 @@ enum class OpenClawCameraCommand(
}
}
/** SMS command ids accepted by the Android node. */
enum class OpenClawSmsCommand(
val rawValue: String,
) {
@@ -76,7 +72,6 @@ enum class OpenClawSmsCommand(
}
}
/** Push-to-talk command ids accepted by the Android node. */
enum class OpenClawTalkCommand(
val rawValue: String,
) {
@@ -91,7 +86,6 @@ enum class OpenClawTalkCommand(
}
}
/** Location command ids accepted by the Android node. */
enum class OpenClawLocationCommand(
val rawValue: String,
) {
@@ -103,7 +97,6 @@ enum class OpenClawLocationCommand(
}
}
/** Device status and metadata command ids accepted by the Android node. */
enum class OpenClawDeviceCommand(
val rawValue: String,
) {
@@ -119,7 +112,6 @@ enum class OpenClawDeviceCommand(
}
}
/** Notification command ids accepted by the Android node. */
enum class OpenClawNotificationsCommand(
val rawValue: String,
) {
@@ -132,7 +124,6 @@ enum class OpenClawNotificationsCommand(
}
}
/** System command ids accepted by the Android node. */
enum class OpenClawSystemCommand(
val rawValue: String,
) {
@@ -144,7 +135,6 @@ enum class OpenClawSystemCommand(
}
}
/** Photos command ids accepted by the Android node. */
enum class OpenClawPhotosCommand(
val rawValue: String,
) {
@@ -156,7 +146,6 @@ enum class OpenClawPhotosCommand(
}
}
/** Contacts command ids accepted by the Android node. */
enum class OpenClawContactsCommand(
val rawValue: String,
) {
@@ -169,7 +158,6 @@ enum class OpenClawContactsCommand(
}
}
/** Calendar command ids accepted by the Android node. */
enum class OpenClawCalendarCommand(
val rawValue: String,
) {
@@ -182,7 +170,6 @@ enum class OpenClawCalendarCommand(
}
}
/** Motion sensor command ids accepted by the Android node. */
enum class OpenClawMotionCommand(
val rawValue: String,
) {
@@ -195,7 +182,6 @@ enum class OpenClawMotionCommand(
}
}
/** Call-log command ids accepted by the Android node. */
enum class OpenClawCallLogCommand(
val rawValue: String,
) {
@@ -0,0 +1,59 @@
package ai.openclaw.app.gateway
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayProtocolGeneratedTest {
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
@Test
fun requestFrameEncodingIncludesTheDiscriminatorAndOmitsNullParams() {
val encoded =
json
.encodeToJsonElement(
GatewayRequestFrame.serializer(),
GatewayRequestFrame(id = "request-1", method = GatewayMethod.Health.rawValue),
).jsonObject
assertEquals("req", encoded.getValue("type").jsonPrimitive.content)
assertEquals("request-1", encoded.getValue("id").jsonPrimitive.content)
assertEquals(GatewayMethod.Health.rawValue, encoded.getValue("method").jsonPrimitive.content)
assertNull(encoded["params"])
}
@Test
fun nodeInvokeRequestUsesTheSchemaWireNames() {
val decoded =
json.decodeFromString(
GatewayNodeInvokeRequest.serializer(),
"""{"id":"invoke-1","nodeId":"node-1","command":"device.info","paramsJSON":"{}","timeoutMs":5000}""",
)
assertEquals("invoke-1", decoded.id)
assertEquals("node-1", decoded.nodeId)
assertEquals("device.info", decoded.command)
assertEquals("{}", decoded.paramsJson)
assertEquals(5_000L, decoded.timeoutMs)
}
@Test
fun generatedGatewayCatalogsAreCompleteAndUnique() {
val methods = GatewayMethod.entries.map { it.rawValue }
val events = GatewayEvent.entries.map { it.rawValue }
assertTrue(methods.size > 200)
assertTrue(events.size > 20)
assertEquals(methods.size, methods.toSet().size)
assertEquals(events.size, events.toSet().size)
}
}
@@ -300,6 +300,46 @@ class GatewaySessionInvokeTest {
}
}
@Test
fun explicitNullPayloadsRemainPresentForResponsesAndEvents() =
runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val eventPayload = CompletableDeferred<String?>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, _ ->
when (method) {
"connect" -> {
webSocket.send(connectResponseFrame(id))
webSocket.send("""{"type":"event","event":"health","payload":null}""")
}
"test.null-payload" ->
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":null}""")
}
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
onEvent = { event, payload ->
if (event == GatewayEvent.Health.rawValue) eventPayload.complete(payload)
},
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val response = harness.session.requestDetailed("test.null-payload", null)
assertEquals("null", response.payloadJson)
assertEquals("null", withTimeout(TEST_TIMEOUT_MS) { eventPayload.await() })
} finally {
shutdownHarness(harness, server)
}
}
@Test
fun connect_usesBootstrapTokenWhenSharedAndDeviceTokensAreAbsent() =
runBlocking {
@@ -1,108 +1,39 @@
package ai.openclaw.app.protocol
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class OpenClawProtocolConstantsTest {
@Test
fun canvasCommandsUseStableStrings() {
assertEquals("canvas.present", OpenClawCanvasCommand.Present.rawValue)
assertEquals("canvas.hide", OpenClawCanvasCommand.Hide.rawValue)
assertEquals("canvas.navigate", OpenClawCanvasCommand.Navigate.rawValue)
assertEquals("canvas.eval", OpenClawCanvasCommand.Eval.rawValue)
assertEquals("canvas.snapshot", OpenClawCanvasCommand.Snapshot.rawValue)
fun generatedCapabilitiesAreUniqueProtocolIds() {
val values = OpenClawCapability.entries.map { it.rawValue }
assertTrue(values.isNotEmpty())
assertTrue(values.all { it.isNotBlank() && "." !in it })
assertTrue(values.size == values.toSet().size)
}
@Test
fun a2uiCommandsUseStableStrings() {
assertEquals("canvas.a2ui.push", OpenClawCanvasA2UICommand.Push.rawValue)
assertEquals("canvas.a2ui.pushJSONL", OpenClawCanvasA2UICommand.PushJSONL.rawValue)
assertEquals("canvas.a2ui.reset", OpenClawCanvasA2UICommand.Reset.rawValue)
}
fun generatedCommandGroupsMatchTheirNamespaces() {
val groups =
listOf(
OpenClawCanvasCommand.NamespacePrefix to OpenClawCanvasCommand.entries.map { it.rawValue },
OpenClawCanvasA2UICommand.NamespacePrefix to OpenClawCanvasA2UICommand.entries.map { it.rawValue },
OpenClawCameraCommand.NamespacePrefix to OpenClawCameraCommand.entries.map { it.rawValue },
OpenClawSmsCommand.NamespacePrefix to OpenClawSmsCommand.entries.map { it.rawValue },
OpenClawTalkCommand.NamespacePrefix to OpenClawTalkCommand.entries.map { it.rawValue },
OpenClawLocationCommand.NamespacePrefix to OpenClawLocationCommand.entries.map { it.rawValue },
OpenClawDeviceCommand.NamespacePrefix to OpenClawDeviceCommand.entries.map { it.rawValue },
OpenClawNotificationsCommand.NamespacePrefix to OpenClawNotificationsCommand.entries.map { it.rawValue },
OpenClawSystemCommand.NamespacePrefix to OpenClawSystemCommand.entries.map { it.rawValue },
OpenClawPhotosCommand.NamespacePrefix to OpenClawPhotosCommand.entries.map { it.rawValue },
OpenClawContactsCommand.NamespacePrefix to OpenClawContactsCommand.entries.map { it.rawValue },
OpenClawCalendarCommand.NamespacePrefix to OpenClawCalendarCommand.entries.map { it.rawValue },
OpenClawMotionCommand.NamespacePrefix to OpenClawMotionCommand.entries.map { it.rawValue },
OpenClawCallLogCommand.NamespacePrefix to OpenClawCallLogCommand.entries.map { it.rawValue },
)
@Test
fun capabilitiesUseStableStrings() {
assertEquals("canvas", OpenClawCapability.Canvas.rawValue)
assertEquals("camera", OpenClawCapability.Camera.rawValue)
assertEquals("talk", OpenClawCapability.Talk.rawValue)
assertEquals("location", OpenClawCapability.Location.rawValue)
assertEquals("sms", OpenClawCapability.Sms.rawValue)
assertEquals("device", OpenClawCapability.Device.rawValue)
assertEquals("notifications", OpenClawCapability.Notifications.rawValue)
assertEquals("system", OpenClawCapability.System.rawValue)
assertEquals("photos", OpenClawCapability.Photos.rawValue)
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
assertEquals("motion", OpenClawCapability.Motion.rawValue)
assertEquals("callLog", OpenClawCapability.CallLog.rawValue)
}
@Test
fun cameraCommandsUseStableStrings() {
assertEquals("camera.list", OpenClawCameraCommand.List.rawValue)
assertEquals("camera.snap", OpenClawCameraCommand.Snap.rawValue)
assertEquals("camera.clip", OpenClawCameraCommand.Clip.rawValue)
}
@Test
fun notificationsCommandsUseStableStrings() {
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)
assertEquals("notifications.actions", OpenClawNotificationsCommand.Actions.rawValue)
}
@Test
fun deviceCommandsUseStableStrings() {
assertEquals("device.status", OpenClawDeviceCommand.Status.rawValue)
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
assertEquals("device.permissions", OpenClawDeviceCommand.Permissions.rawValue)
assertEquals("device.health", OpenClawDeviceCommand.Health.rawValue)
assertEquals("device.apps", OpenClawDeviceCommand.Apps.rawValue)
}
@Test
fun systemCommandsUseStableStrings() {
assertEquals("system.notify", OpenClawSystemCommand.Notify.rawValue)
}
@Test
fun photosCommandsUseStableStrings() {
assertEquals("photos.latest", OpenClawPhotosCommand.Latest.rawValue)
}
@Test
fun contactsCommandsUseStableStrings() {
assertEquals("contacts.search", OpenClawContactsCommand.Search.rawValue)
assertEquals("contacts.add", OpenClawContactsCommand.Add.rawValue)
}
@Test
fun calendarCommandsUseStableStrings() {
assertEquals("calendar.events", OpenClawCalendarCommand.Events.rawValue)
assertEquals("calendar.add", OpenClawCalendarCommand.Add.rawValue)
}
@Test
fun motionCommandsUseStableStrings() {
assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
}
@Test
fun smsCommandsUseStableStrings() {
assertEquals("sms.send", OpenClawSmsCommand.Send.rawValue)
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
}
@Test
fun talkCommandsUseStableStrings() {
assertEquals("talk.ptt.start", OpenClawTalkCommand.PttStart.rawValue)
assertEquals("talk.ptt.stop", OpenClawTalkCommand.PttStop.rawValue)
assertEquals("talk.ptt.cancel", OpenClawTalkCommand.PttCancel.rawValue)
assertEquals("talk.ptt.once", OpenClawTalkCommand.PttOnce.rawValue)
}
@Test
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
val commands = groups.flatMap { (prefix, values) -> values.onEach { assertTrue(it.startsWith(prefix)) } }
assertTrue(commands.size == commands.toSet().size)
}
}
+3 -1
View File
@@ -1744,8 +1744,10 @@
"prompt:snapshots:check": "node --import tsx scripts/generate-prompt-snapshots.ts --check",
"prompt:snapshots:gen": "node --import tsx scripts/generate-prompt-snapshots.ts --write",
"prompt:snapshots:sync-codex-model": "node --import tsx scripts/sync-codex-model-prompt-fixture.ts",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && pnpm protocol:gen:kotlin && git diff --exit-code -- dist/protocol.schema.json apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt",
"protocol:check:kotlin": "pnpm protocol:gen:kotlin && git diff --exit-code -- apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:kotlin": "node --import tsx scripts/protocol-gen-kotlin.ts",
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"proxy:coverage": "node scripts/run-node.mjs proxy coverage",
"proxy:gateway": "node scripts/run-node.mjs proxy run -- node scripts/run-node.mjs gateway",
+6 -1
View File
@@ -16,6 +16,8 @@ export function extractSwiftHandledEvents(
source: unknown,
constants?: Map<unknown, unknown>,
): Set<unknown>;
/** Extracts generated Kotlin enum entries whose constructor stores a wire string. */
export function extractKotlinEnumStringConstants(source: unknown): Map<string, string>;
/**
* Extracts event names a Kotlin source handles: string-literal case labels of
* `when (event)` blocks plus `event == "..."` comparisons, both scoped to
@@ -26,7 +28,10 @@ export function extractSwiftHandledEvents(
* stays tree-wide because Swift consumption always reads `.event` off a
* received EventFrame, which does not have that false-positive shape.
*/
export function extractKotlinHandledEvents(source: unknown): Set<unknown>;
export function extractKotlinHandledEvents(
source: unknown,
constants?: ReadonlyMap<string, string>,
): Set<string>;
/**
* Compares a client's handled events against the gateway catalog and its
* allowlist. Returns human-readable error strings. Client-only names (e.g. the
+214 -13
View File
@@ -13,9 +13,9 @@
// `.event == "..."` comparisons, and Kotlin `when (event) { "..." -> }` blocks
// plus `event == "..."` comparisons scoped to `fun handle*Event(...)` bodies.
// Swift case labels may use qualified static string constants; those are
// resolved across the scanned source tree so deleting the real handler cannot
// hide behind an allowlist entry. Events a client intentionally does not
// consume live in scripts/protocol-event-coverage.allowlist.json.
// resolved across the scanned source tree. Kotlin labels and comparisons may
// likewise use generated enum `rawValue` constants. Events a client
// intentionally does not consume live in scripts/protocol-event-coverage.allowlist.json.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -52,9 +52,15 @@ const KOTLIN_EVENT_WHEN_RE = /\bwhen\s*\(\s*event\s*\)\s*\{/u;
// Handlers named differently surface as loud "unhandled" failures, which is
// the safe direction for a coverage gate.
const KOTLIN_HANDLER_FUN_RE = /\bfun\s+handle\w*Event\s*\(/u;
const KOTLIN_CASE_LABEL_RE = /^\s*((?:"[^"]+"\s*,\s*)*"[^"]+")\s*->/u;
const KOTLIN_CASE_SEGMENT_RE = /^\s*(.+?)\s*->/u;
const KOTLIN_ENUM_DECLARATION_RE = /^\s*enum\s+class\s+([A-Za-z_]\w*)\s*\(/u;
const KOTLIN_ENUM_STRING_ENTRY_RE = /^\s*([A-Za-z_]\w*)\s*\(\s*"([^"]+)"\s*\)\s*,?/u;
const KOTLIN_STRING_CASE_EXPRESSION_RE = /^"([^"]+)"$/u;
const KOTLIN_RAW_VALUE_EXPRESSION_RE = /^([A-Za-z_]\w*\.[A-Za-z_]\w*)\.rawValue$/u;
const SWIFT_EVENT_COMPARISON_RE = /\.event\s*==\s*"([^"]+)"/gu;
const KOTLIN_EVENT_COMPARISON_RE = /\bevent\s*==\s*"([^"]+)"/gu;
const KOTLIN_EVENT_COMPARISON_RE = /\bevent\s*==\s*"([^"]+)"(?!\s*(?:\.|\?\.|\+|\[|\())/gu;
const KOTLIN_EVENT_CONSTANT_COMPARISON_RE =
/\bevent\s*==\s*([A-Za-z_]\w*\.[A-Za-z_]\w*)\.rawValue\b(?!\s*(?:\.|\?\.|\+|\[|\())/gu;
const STRING_LITERAL_RE = /"([^"]+)"/gu;
function isMainModule() {
@@ -151,6 +157,129 @@ function pushStringLiterals(segment, names) {
}
}
function stripKotlinComments(source) {
let output = "";
let index = 0;
let blockDepth = 0;
let lineComment = false;
/** @type {Array<{type: "code" | "raw" | "quoted" | "char", templateDepth: number | null}>} */
const contexts = [{ type: "code", templateDepth: null }];
while (index < source.length) {
const char = source[index];
const pair = source.slice(index, index + 2);
const triple = source.slice(index, index + 3);
const context = contexts.at(-1);
if (lineComment) {
output += char === "\n" ? "\n" : " ";
lineComment = char !== "\n";
index += 1;
continue;
}
if (blockDepth > 0) {
if (pair === "/*") {
output += " ";
blockDepth += 1;
index += 2;
continue;
}
if (pair === "*/") {
output += " ";
blockDepth -= 1;
index += 2;
continue;
}
output += char === "\n" ? "\n" : " ";
index += 1;
continue;
}
if (context.type === "raw") {
if (triple === '"""') {
output += triple;
contexts.pop();
index += 3;
} else if (pair === "${") {
output += pair;
contexts.push({ type: "code", templateDepth: 1 });
index += 2;
} else {
output += char;
index += 1;
}
continue;
}
if (context.type === "quoted" || context.type === "char") {
output += char;
if (char === "\\" && index + 1 < source.length) {
output += source[index + 1];
index += 2;
continue;
}
if (context.type === "quoted" && pair === "${") {
output += source[index + 1];
contexts.push({ type: "code", templateDepth: 1 });
index += 2;
continue;
}
const delimiter = context.type === "quoted" ? '"' : "'";
if (char === delimiter) {
contexts.pop();
}
index += 1;
continue;
}
if (pair === "//") {
output += " ";
lineComment = true;
index += 2;
continue;
}
if (pair === "/*") {
output += " ";
blockDepth = 1;
index += 2;
continue;
}
if (triple === '"""') {
output += triple;
contexts.push({ type: "raw" });
index += 3;
continue;
}
if (char === '"') {
output += char;
contexts.push({ type: "quoted" });
index += 1;
continue;
}
if (char === "'") {
output += char;
contexts.push({ type: "char" });
index += 1;
continue;
}
if (context.templateDepth !== null) {
if (char === "{") {
context.templateDepth += 1;
} else if (char === "}") {
context.templateDepth -= 1;
if (context.templateDepth === 0) {
contexts.pop();
}
}
}
output += char;
index += 1;
}
return output;
}
/**
* Extracts qualified static string constants declared at Swift type scope.
* Type qualification avoids resolving unrelated constants that share a short
@@ -190,6 +319,43 @@ export function extractSwiftStaticStringConstants(source) {
return constants;
}
/** Extracts generated Kotlin enum entries whose constructor stores a wire string. */
export function extractKotlinEnumStringConstants(source) {
const constants = new Map();
const lines = stripKotlinComments(source).split("\n");
for (let i = 0; i < lines.length; i += 1) {
const declaration = KOTLIN_ENUM_DECLARATION_RE.exec(lines[i]);
if (!declaration) {
continue;
}
const typeName = declaration[1];
let depth = 0;
let opened = false;
for (let j = i; j < lines.length; j += 1) {
const line = lines[j];
if (opened && depth === 1) {
const entry = KOTLIN_ENUM_STRING_ENTRY_RE.exec(line);
if (entry) {
constants.set(`${typeName}.${entry[1]}`, entry[2]);
}
}
const sanitized = sanitizeLineForBraces(line);
for (const char of sanitized) {
if (char === "{") {
depth += 1;
opened = true;
} else if (char === "}") {
depth -= 1;
}
}
if (opened && depth <= 0) {
break;
}
}
}
return constants;
}
/** Extracts Swift gateway-event case labels, including qualified constants. */
export function extractSwiftHandledEvents(source, constants = new Map()) {
const names = collectBlockCaseLabels(source, SWIFT_EVENT_SWITCH_RE, (line, sink) => {
@@ -217,15 +383,16 @@ export function extractSwiftHandledEvents(source, constants = new Map()) {
function extractKotlinHandlerBodies(source) {
const bodies = [];
const lines = source.split("\n");
const uncommentedLines = stripKotlinComments(source).split("\n");
for (let i = 0; i < lines.length; i += 1) {
if (!KOTLIN_HANDLER_FUN_RE.test(lines[i])) {
if (!KOTLIN_HANDLER_FUN_RE.test(uncommentedLines[i])) {
continue;
}
let depth = 0;
let opened = false;
const body = [];
for (let j = i; j < lines.length; j += 1) {
const sanitized = sanitizeLineForBraces(lines[j]);
const sanitized = sanitizeLineForBraces(uncommentedLines[j]);
if (opened) {
body.push(lines[j]);
}
@@ -258,20 +425,39 @@ function extractKotlinHandlerBodies(source) {
* stays tree-wide because Swift consumption always reads `.event` off a
* received EventFrame, which does not have that false-positive shape.
*/
export function extractKotlinHandledEvents(source) {
export function extractKotlinHandledEvents(source, constants = new Map()) {
const names = [];
for (const body of extractKotlinHandlerBodies(source)) {
const uncommentedBody = stripKotlinComments(body);
names.push(
...collectBlockCaseLabels(body, KOTLIN_EVENT_WHEN_RE, (line, sink) => {
const label = KOTLIN_CASE_LABEL_RE.exec(line);
if (label) {
pushStringLiterals(label[1], sink);
...collectBlockCaseLabels(uncommentedBody, KOTLIN_EVENT_WHEN_RE, (line, sink) => {
const segment = KOTLIN_CASE_SEGMENT_RE.exec(line)?.[1];
if (!segment) {
return;
}
for (const expression of segment.split(",").map((value) => value.trim())) {
const literal = KOTLIN_STRING_CASE_EXPRESSION_RE.exec(expression);
if (literal) {
sink.push(literal[1]);
continue;
}
const reference = KOTLIN_RAW_VALUE_EXPRESSION_RE.exec(expression)?.[1];
const value = reference ? constants.get(reference) : undefined;
if (value) {
sink.push(value);
}
}
}),
);
for (const comparison of body.matchAll(KOTLIN_EVENT_COMPARISON_RE)) {
for (const comparison of uncommentedBody.matchAll(KOTLIN_EVENT_COMPARISON_RE)) {
names.push(comparison[1]);
}
for (const comparison of uncommentedBody.matchAll(KOTLIN_EVENT_CONSTANT_COMPARISON_RE)) {
const value = constants.get(comparison[1]);
if (value) {
names.push(value);
}
}
}
return new Set(names);
}
@@ -416,6 +602,20 @@ function collectSwiftStaticStringConstants(sources) {
return constants;
}
function collectKotlinEnumStringConstants(sources) {
const constants = new Map();
for (const source of sources) {
for (const [name, value] of extractKotlinEnumStringConstants(source)) {
const existing = constants.get(name);
if (existing !== undefined && existing !== value) {
throw new Error(`Conflicting Kotlin enum string values for ${name}.`);
}
constants.set(name, value);
}
}
return constants;
}
/**
* Runs the full coverage check against a repo checkout and returns error
* strings plus a summary for logging.
@@ -449,6 +649,7 @@ function collectProtocolEventCoverageErrors(params = {}) {
roots: [ANDROID_SCAN_ROOT],
extension: ".kt",
extract: extractKotlinHandledEvents,
buildExtractContext: collectKotlinEnumStringConstants,
sentinels: ANDROID_SENTINEL_FILES,
fsImpl,
}),
+353
View File
@@ -0,0 +1,353 @@
// Protocol Gen Kotlin script supports OpenClaw repository automation.
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
ProtocolSchemas,
} from "../packages/gateway-protocol/src/schema.js";
import { CORE_GATEWAY_METHOD_SPECS } from "../src/gateway/methods/core-descriptors.js";
import { extractGatewayEventNames } from "./check-protocol-event-coverage.mjs";
type JsonSchema = {
type?: string | string[];
const?: boolean | number | string | null;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: Array<boolean | number | string | null>;
patternProperties?: Record<string, JsonSchema>;
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
};
type EnumSpec = {
name: string;
values: Array<{ name: string; rawValue: string }>;
namespacePrefix?: string;
};
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const gatewayOutputPath = path.join(
repoRoot,
"apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt",
);
const constantsOutputPath = path.join(
repoRoot,
"apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt",
);
const protocolSchemas = ProtocolSchemas as unknown as Record<string, JsonSchema>;
const schemaNames = new Map<string, string>([
["ErrorShape", "GatewayProtocolError"],
["RequestFrame", "GatewayRequestFrame"],
["ResponseFrame", "GatewayResponseFrame"],
["EventFrame", "GatewayEventFrame"],
["NodeEventParams", "GatewayNodeEventParams"],
["NodeInvokeResultParams", "GatewayNodeInvokeResultParams"],
["NodeInvokeRequestEvent", "GatewayNodeInvokeRequest"],
]);
const androidEnums: EnumSpec[] = [
enumSpec("OpenClawCapability", "", [
["Canvas", "canvas"],
["Camera", "camera"],
["Sms", "sms"],
["Talk", "talk"],
["Location", "location"],
["Device", "device"],
["Notifications", "notifications"],
["System", "system"],
["Photos", "photos"],
["Contacts", "contacts"],
["Calendar", "calendar"],
["Motion", "motion"],
["CallLog", "callLog"],
]),
enumSpec("OpenClawCanvasCommand", "canvas.", [
["Present", "present"],
["Hide", "hide"],
["Navigate", "navigate"],
["Eval", "eval"],
["Snapshot", "snapshot"],
]),
enumSpec("OpenClawCanvasA2UICommand", "canvas.a2ui.", [
["Push", "push"],
["PushJSONL", "pushJSONL"],
["Reset", "reset"],
]),
enumSpec("OpenClawCameraCommand", "camera.", [
["List", "list"],
["Snap", "snap"],
["Clip", "clip"],
]),
enumSpec("OpenClawSmsCommand", "sms.", [
["Send", "send"],
["Search", "search"],
]),
enumSpec("OpenClawTalkCommand", "talk.", [
["PttStart", "ptt.start"],
["PttStop", "ptt.stop"],
["PttCancel", "ptt.cancel"],
["PttOnce", "ptt.once"],
]),
enumSpec("OpenClawLocationCommand", "location.", [["Get", "get"]]),
enumSpec("OpenClawDeviceCommand", "device.", [
["Status", "status"],
["Info", "info"],
["Permissions", "permissions"],
["Health", "health"],
["Apps", "apps"],
]),
enumSpec("OpenClawNotificationsCommand", "notifications.", [
["List", "list"],
["Actions", "actions"],
]),
enumSpec("OpenClawSystemCommand", "system.", [["Notify", "notify"]]),
enumSpec("OpenClawPhotosCommand", "photos.", [["Latest", "latest"]]),
enumSpec("OpenClawContactsCommand", "contacts.", [
["Search", "search"],
["Add", "add"],
]),
enumSpec("OpenClawCalendarCommand", "calendar.", [
["Events", "events"],
["Add", "add"],
]),
enumSpec("OpenClawMotionCommand", "motion.", [
["Activity", "activity"],
["Pedometer", "pedometer"],
]),
enumSpec("OpenClawCallLogCommand", "callLog.", [["Search", "search"]]),
];
function enumSpec(
name: string,
namespacePrefix: string,
values: Array<[name: string, suffix: string]>,
): EnumSpec {
return {
name,
namespacePrefix,
values: values.map(([caseName, suffix]) => ({
name: caseName,
rawValue: namespacePrefix + suffix,
})),
};
}
function words(value: string): string[] {
return (
value.match(/[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|\d+/g)?.map((part) => part.toLowerCase()) ??
[]
);
}
function upperCamel(value: string): string {
const parts = words(value);
if (parts.length === 0) {
throw new Error(`Cannot create Kotlin identifier from ${JSON.stringify(value)}`);
}
return parts.map((part) => part[0]!.toUpperCase() + part.slice(1)).join("");
}
function lowerCamel(value: string): string {
const name = upperCamel(value);
return name[0]!.toLowerCase() + name.slice(1);
}
function stableJson(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(stableJson);
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.toSorted()
.map((key) => [key, stableJson(record[key])]),
);
}
return value;
}
function schemaSignature(schema: JsonSchema): string {
return JSON.stringify(stableJson(schema));
}
function literalValue(schema: JsonSchema): boolean | number | string | null | undefined {
if ("const" in schema) {
return schema.const;
}
return schema.enum?.length === 1 ? schema.enum[0] : undefined;
}
function kotlinLiteral(value: boolean | number | string | null): string {
if (typeof value === "string") {
return JSON.stringify(value);
}
if (value === null) {
return "null";
}
return String(value);
}
function emitEnum(spec: EnumSpec): string {
const body = spec.values.map((value) => ` ${value.name}(${JSON.stringify(value.rawValue)}),`);
if (spec.namespacePrefix) {
body.push(
" ;",
"",
" companion object {",
` const val NamespacePrefix: String = ${JSON.stringify(spec.namespacePrefix)}`,
" }",
);
}
return [`enum class ${spec.name}(`, " val rawValue: String,", ") {", ...body, "}"].join("\n");
}
function emitWireModels(): string[] {
const selectedSchemas = new Map<JsonSchema, string>();
const selectedSignatures = new Map<string, string>();
for (const [schemaName, kotlinName] of schemaNames) {
const schema = protocolSchemas[schemaName];
if (!schema) {
throw new Error(`Missing ProtocolSchemas.${schemaName}`);
}
selectedSchemas.set(schema, kotlinName);
selectedSignatures.set(schemaSignature(schema), kotlinName);
}
const nestedModels = new Map<string, JsonSchema>();
const kotlinType = (schema: JsonSchema, nestedName: string): string => {
const selected = selectedSchemas.get(schema) ?? selectedSignatures.get(schemaSignature(schema));
if (selected) {
return selected;
}
if (schema.type === "string" || typeof schema.const === "string") {
return "String";
}
if (schema.type === "integer") {
return "Long";
}
if (schema.type === "number") {
return "Double";
}
if (schema.type === "boolean") {
return "Boolean";
}
if (schema.type === "array") {
return `List<${kotlinType(schema.items ?? {}, `${nestedName}Item`)}>`;
}
if (schema.patternProperties) {
const valueSchema = Object.values(schema.patternProperties)[0] ?? {};
return `Map<String, ${kotlinType(valueSchema, `${nestedName}Value`)}>`;
}
if (schema.type === "object") {
nestedModels.set(nestedName, schema);
return nestedName;
}
return "JsonElement";
};
const emitModel = (name: string, schema: JsonSchema): string => {
if (schema.type !== "object" || !schema.properties) {
throw new Error(`${name} must remain an object schema for Kotlin generation`);
}
const required = new Set(schema.required ?? []);
const properties = Object.entries(schema.properties).map(([wireName, propertySchema]) => {
const propertyName = lowerCamel(wireName);
const type = kotlinType(propertySchema, `${name}${upperCamel(wireName)}`);
const literal = literalValue(propertySchema);
const optional = !required.has(wireName);
return {
annotation: propertyName === wireName ? [] : [` @SerialName(${JSON.stringify(wireName)})`],
declaration: ` val ${propertyName}: ${type}${optional ? "?" : ""}${
literal !== undefined ? ` = ${kotlinLiteral(literal)}` : optional ? " = null" : ""
},`,
};
});
const fields: string[] = [];
for (const property of properties) {
fields.push(...property.annotation, property.declaration);
}
return ["@Serializable", `data class ${name}(`, ...fields, ")"].join("\n");
};
const output: string[] = [];
for (const [schemaName, kotlinName] of schemaNames) {
output.push(emitModel(kotlinName, protocolSchemas[schemaName]!));
}
for (const [nestedName, schema] of nestedModels) {
if (!output.some((model) => model.startsWith(`@Serializable\ndata class ${nestedName}(`))) {
output.push(emitModel(nestedName, schema));
}
}
return output;
}
function emitGatewayCatalogEnum(name: string, values: readonly string[]): string {
const seenNames = new Map<string, string>();
const entries = values.map((rawValue) => {
const caseName = upperCamel(rawValue);
const previous = seenNames.get(caseName);
if (previous) {
throw new Error(
`${name} case collision: ${previous} and ${rawValue} both map to ${caseName}`,
);
}
seenNames.set(caseName, rawValue);
return { name: caseName, rawValue };
});
return emitEnum({ name, values: entries });
}
async function generate(): Promise<void> {
const gatewayEventSource = await fs.readFile(
path.join(repoRoot, "src/gateway/server-methods-list.ts"),
"utf8",
);
const gatewayEventConstants = await fs.readFile(
path.join(repoRoot, "src/gateway/events.ts"),
"utf8",
);
const gatewayEvents = Array.from(
extractGatewayEventNames(gatewayEventSource, gatewayEventConstants),
);
const gatewayMethods = CORE_GATEWAY_METHOD_SPECS.map((spec) => spec.name);
const gatewayContent = [
"// Generated by scripts/protocol-gen-kotlin.ts — do not edit by hand.",
"package ai.openclaw.app.gateway",
"",
"import kotlinx.serialization.SerialName",
"import kotlinx.serialization.Serializable",
"import kotlinx.serialization.json.JsonElement",
"",
`const val GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}`,
`const val GATEWAY_MIN_PROTOCOL_VERSION = ${MIN_CLIENT_PROTOCOL_VERSION}`,
"",
...emitWireModels().flatMap((model) => [model, ""]),
emitGatewayCatalogEnum("GatewayMethod", gatewayMethods),
"",
emitGatewayCatalogEnum("GatewayEvent", gatewayEvents),
"",
].join("\n");
const constantsContent = [
"// Generated by scripts/protocol-gen-kotlin.ts — do not edit by hand.",
"package ai.openclaw.app.protocol",
"",
...androidEnums.flatMap((spec) => [emitEnum(spec), ""]),
].join("\n");
await fs.writeFile(gatewayOutputPath, gatewayContent);
await fs.writeFile(constantsOutputPath, constantsContent);
console.log(`wrote ${path.relative(repoRoot, gatewayOutputPath)}`);
console.log(`wrote ${path.relative(repoRoot, constantsOutputPath)}`);
}
generate().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
compareEventCoverage,
extractGatewayEventNames,
extractKotlinEnumStringConstants,
extractKotlinHandledEvents,
extractSwiftHandledEvents,
extractSwiftStaticStringConstants,
@@ -133,7 +134,20 @@ describe("extractSwiftHandledEvents", () => {
});
describe("extractKotlinHandledEvents", () => {
it("collects when-block case literals and comparisons inside handler functions only", () => {
it("collects literals and generated enum constants inside handler functions only", () => {
const constants = extractKotlinEnumStringConstants(`
enum class GatewayEvent(
val rawValue: String,
) {
ConnectChallenge("connect.challenge"),
Health("health"),
Other("other"),
Modified("modified"),
Commented("commented"),
BlockCommented("block-commented"),
NestedCommented("nested-commented"),
}
`);
const source = `
fun handleGatewayEvent(event: String, payloadJson: String?) {
when (event) {
@@ -147,23 +161,67 @@ describe("extractKotlinHandledEvents", () => {
}
}
"sessions.changed", "session.message" -> refresh()
GatewayEvent.Health.rawValue, GatewayEvent.Other.rawValue -> refreshHealth()
GatewayEvent.Modified.rawValue.uppercase() -> ignoreModifiedValue()
// GatewayEvent.Commented.rawValue -> ignoreComment()
/*
GatewayEvent.BlockCommented.rawValue -> ignoreBlockComment()
*/
/* outer
/* inner */
GatewayEvent.NestedCommented.rawValue -> ignoreNestedComment()
*/
"slash//event", "block/*event*/" -> refreshCommentMarkers()
}
}
private fun handleEvent(
frame: JsonObject,
) {
val event = frame["event"].asStringOrNull() ?: return
if (event == "connect.challenge") { return }
val rawMarker = """/* raw string */ // raw string"""
val templateMarker = "\${if (rawMarker.isEmpty()) "/* text */" else "// text"}"
val slashMarker = '/'
if (event == GatewayEvent.ConnectChallenge.rawValue) { return }
if (event == GatewayEvent.Modified.rawValue.trim()) { ignoreModifiedValue() }
// if (event == GatewayEvent.Commented.rawValue) { ignoreComment() }
when {
event == "when-condition" -> refreshCondition()
}
val isAssignment = event == "assignment"
if (!isAssignment) { return }
val other = keyEvent == "not.a.gateway.event"
}
`;
const handled = extractKotlinHandledEvents(source);
const handled = extractKotlinHandledEvents(source, constants);
expect([...handled].toSorted()).toEqual([
"assignment",
"block/*event*/",
"chat",
"connect.challenge",
"health",
"other",
"session.message",
"sessions.changed",
"slash//event",
"tick",
"when-condition",
]);
});
it("extracts only enum-scoped constructor string values", () => {
const constants = extractKotlinEnumStringConstants(`
enum class GatewayEvent(
val rawValue: String,
) {
Tick("tick"),
Chat("chat"),
}
val Tick = "wrong.global.value"
`);
expect([...constants]).toEqual([
["GatewayEvent.Tick", "tick"],
["GatewayEvent.Chat", "chat"],
]);
});