mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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
-15
@@ -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,
|
||||
) {
|
||||
|
||||
+59
@@ -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 {
|
||||
|
||||
+27
-96
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user