diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 93aea6d4ac57..dc0f26e257bc 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index f25f03a8dbf6..f5c27687e63b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -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"), +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 9514cad4bbb8..85b36a2beb50 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -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 = 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, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt index df54617967d3..af03ecbac801 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt @@ -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, ) { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt new file mode 100644 index 000000000000..84ebe30e2bd4 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt @@ -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) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index dc578ff9fc35..7ef509d2e9fe 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -300,6 +300,46 @@ class GatewaySessionInvokeTest { } } + @Test + fun explicitNullPayloadsRemainPresentForResponsesAndEvents() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val eventPayload = CompletableDeferred() + 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 { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt index 69e3b640932c..b9e63af80ac8 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt @@ -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) } } diff --git a/package.json b/package.json index c918ffe7d7f6..b840e2030780 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-protocol-event-coverage.d.mts b/scripts/check-protocol-event-coverage.d.mts index 9c5f271308a6..c97bcad04a89 100644 --- a/scripts/check-protocol-event-coverage.d.mts +++ b/scripts/check-protocol-event-coverage.d.mts @@ -16,6 +16,8 @@ export function extractSwiftHandledEvents( source: unknown, constants?: Map, ): Set; +/** Extracts generated Kotlin enum entries whose constructor stores a wire string. */ +export function extractKotlinEnumStringConstants(source: unknown): Map; /** * 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; +export function extractKotlinHandledEvents( + source: unknown, + constants?: ReadonlyMap, +): Set; /** * Compares a client's handled events against the gateway catalog and its * allowlist. Returns human-readable error strings. Client-only names (e.g. the diff --git a/scripts/check-protocol-event-coverage.mjs b/scripts/check-protocol-event-coverage.mjs index fc0d4d5d4a5d..10eeb691c1f5 100644 --- a/scripts/check-protocol-event-coverage.mjs +++ b/scripts/check-protocol-event-coverage.mjs @@ -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, }), diff --git a/scripts/protocol-gen-kotlin.ts b/scripts/protocol-gen-kotlin.ts new file mode 100644 index 000000000000..e9c09d0eea28 --- /dev/null +++ b/scripts/protocol-gen-kotlin.ts @@ -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; + required?: string[]; + items?: JsonSchema; + enum?: Array; + patternProperties?: Record; + 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; + +const schemaNames = new Map([ + ["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; + 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(); + const selectedSignatures = new Map(); + 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(); + 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`; + } + 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(); + 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 { + 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); +}); diff --git a/test/scripts/check-protocol-event-coverage.test.ts b/test/scripts/check-protocol-event-coverage.test.ts index 4aadc9a44ea4..b577fb376fc7 100644 --- a/test/scripts/check-protocol-event-coverage.test.ts +++ b/test/scripts/check-protocol-event-coverage.test.ts @@ -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"], ]); });