From 16b1bd2032fd9ebb2bfb14739e8d3863cd5da451 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 13 Jul 2026 02:29:16 -0700 Subject: [PATCH] fix(android): support gateway protocol v3 (#106205) --- apps/android/CHANGELOG.md | 1 + .../openclaw/app/gateway/GatewayProtocol.kt | 2 +- .../chat/ChatControllerStreamReplayTest.kt | 5 +- .../ai/openclaw/app/chat/ChatReplayHarness.kt | 6 +-- .../src/native-protocol-levels.guard.test.ts | 51 ++++++++++++------- scripts/protocol-gen-kotlin.ts | 5 +- 6 files changed, 43 insertions(+), 27 deletions(-) diff --git a/apps/android/CHANGELOG.md b/apps/android/CHANGELOG.md index e4ea78c478d9..8426f2bd1eca 100644 --- a/apps/android/CHANGELOG.md +++ b/apps/android/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +Allows Android to connect to Gateway protocol v3 or v4 while preserving the current v4 chat stream behavior. Creates or adopts Android's existing per-device chat session before loading connected history, preserving prior conversations while isolating each device. Thanks @snowzlmbot. Adds polished Installed/Browse skill management on Android with readiness filters, enable/disable controls, and readable Gateway-enforced ClawHub risk review. Thanks @snowzlmbot. Marks already installed ClawHub slugs in Browse and prevents fresh-install retries from overwriting or failing against existing skill directories. (#105741) 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 f5c27687e63b..5a5375f443a2 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 @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement const val GATEWAY_PROTOCOL_VERSION = 4 -const val GATEWAY_MIN_PROTOCOL_VERSION = 4 +const val GATEWAY_MIN_PROTOCOL_VERSION = 3 @Serializable data class GatewayProtocolError( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerStreamReplayTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerStreamReplayTest.kt index 3ffd35ea1762..caf165d14a2a 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerStreamReplayTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerStreamReplayTest.kt @@ -35,7 +35,7 @@ class ChatControllerStreamReplayTest { @Test @OptIn(ExperimentalCoroutinesApi::class) - fun cleanRunStreamsThenConvergesToHistoryWithoutDuplicates() = + fun cleanRunStreamsV3AndV4DeltasThenConvergesToHistoryWithoutDuplicates() = runTest { val gateway = ScriptedGateway(json) gateway.respondChatSend(status = "started") @@ -50,7 +50,8 @@ class ChatControllerStreamReplayTest { .single { it.role == "user" } .id - controller.handleGatewayEvent("chat", chatDeltaPayload("main", runId, 1, "Str", "Str")) + // V3 deltas carry the accumulated message without v4's deltaText field. + controller.handleGatewayEvent("chat", chatDeltaPayload("main", runId, 1, null, "Str")) assertEquals("Str", controller.streamingAssistantText.value) controller.handleGatewayEvent( "chat", diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt index 973657b918a1..d07fa4bbddc3 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt @@ -134,12 +134,12 @@ internal fun historyResponse( ) }.toString() -/** Protocol-valid gateway delta carrying both the incremental chunk and accumulated snapshot. */ +/** Gateway delta carrying the accumulated snapshot plus the v4 incremental chunk when present. */ internal fun chatDeltaPayload( sessionKey: String, runId: String, seq: Int, - deltaText: String, + deltaText: String?, accumulatedText: String, ): String = buildJsonObject { @@ -147,7 +147,7 @@ internal fun chatDeltaPayload( put("runId", JsonPrimitive(runId)) put("seq", JsonPrimitive(seq)) put("state", JsonPrimitive("delta")) - put("deltaText", JsonPrimitive(deltaText)) + if (deltaText != null) put("deltaText", JsonPrimitive(deltaText)) put( "message", buildJsonObject { diff --git a/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts b/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts index cf8f5aadd73c..993bbcd63200 100644 --- a/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts +++ b/packages/gateway-protocol/src/native-protocol-levels.guard.test.ts @@ -19,17 +19,22 @@ import { * and connect payloads aligned with the package source of truth. */ -/** Min/max protocol pair expected in every native client surface. */ +/** Min/max protocol pair expected in a native client surface. */ type ProtocolLevels = { min: number; max: number; }; -const expectedLevels: ProtocolLevels = { +const expectedClientLevels: ProtocolLevels = { min: MIN_CLIENT_PROTOCOL_VERSION, max: PROTOCOL_VERSION, }; +const expectedAndroidLevels: ProtocolLevels = { + min: MIN_NODE_PROTOCOL_VERSION, + max: PROTOCOL_VERSION, +}; + /** Reads a repo-relative source file used by a native protocol guard. */ async function readRepoFile(relativePath: string): Promise { return fs.readFile(path.join(process.cwd(), relativePath), "utf8"); @@ -52,12 +57,16 @@ function extractInteger( } /** Compares native min/max values to the TypeScript version constants. */ -function assertLevelsMatch(relativePath: string, actual: ProtocolLevels): void { - if (actual.min === expectedLevels.min && actual.max === expectedLevels.max) { +function assertLevelsMatch( + relativePath: string, + actual: ProtocolLevels, + expected: ProtocolLevels = expectedClientLevels, +): void { + if (actual.min === expected.min && actual.max === expected.max) { return; } throw new Error( - `${relativePath}: Gateway protocol level mismatch: expected min=${expectedLevels.min} max=${expectedLevels.max} from packages/gateway-protocol/src/version.ts, got min=${actual.min} max=${actual.max}. Update the native constants/generated artifacts before shipping.`, + `${relativePath}: Gateway protocol level mismatch: expected min=${expected.min} max=${expected.max} from packages/gateway-protocol/src/version.ts, got min=${actual.min} max=${actual.max}. Update the native constants/generated artifacts before shipping.`, ); } @@ -134,20 +143,24 @@ describe("native Gateway protocol levels", () => { const androidPath = "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt"; const android = await readRepoFile(androidPath); - assertLevelsMatch(androidPath, { - min: extractInteger( - android, - /const val GATEWAY_MIN_PROTOCOL_VERSION = (\d+)/, - androidPath, - "GATEWAY_MIN_PROTOCOL_VERSION", - ), - max: extractInteger( - android, - /const val GATEWAY_PROTOCOL_VERSION = (\d+)/, - androidPath, - "GATEWAY_PROTOCOL_VERSION", - ), - }); + assertLevelsMatch( + androidPath, + { + min: extractInteger( + android, + /const val GATEWAY_MIN_PROTOCOL_VERSION = (\d+)/, + androidPath, + "GATEWAY_MIN_PROTOCOL_VERSION", + ), + max: extractInteger( + android, + /const val GATEWAY_PROTOCOL_VERSION = (\d+)/, + androidPath, + "GATEWAY_PROTOCOL_VERSION", + ), + }, + expectedAndroidLevels, + ); }); it("uses the min constant for native connect compatibility ranges", async () => { diff --git a/scripts/protocol-gen-kotlin.ts b/scripts/protocol-gen-kotlin.ts index e9c09d0eea28..91f0f91df34d 100644 --- a/scripts/protocol-gen-kotlin.ts +++ b/scripts/protocol-gen-kotlin.ts @@ -3,7 +3,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { - MIN_CLIENT_PROTOCOL_VERSION, + MIN_NODE_PROTOCOL_VERSION, PROTOCOL_VERSION, ProtocolSchemas, } from "../packages/gateway-protocol/src/schema.js"; @@ -326,7 +326,8 @@ async function generate(): Promise { "import kotlinx.serialization.json.JsonElement", "", `const val GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}`, - `const val GATEWAY_MIN_PROTOCOL_VERSION = ${MIN_CLIENT_PROTOCOL_VERSION}`, + // Android consumes v3 message-only chat deltas and uses the N-1 node transport. + `const val GATEWAY_MIN_PROTOCOL_VERSION = ${MIN_NODE_PROTOCOL_VERSION}`, "", ...emitWireModels().flatMap((model) => [model, ""]), emitGatewayCatalogEnum("GatewayMethod", gatewayMethods),