fix(android): support gateway protocol v3 (#106205)

This commit is contained in:
Peter Steinberger
2026-07-13 02:29:16 -07:00
committed by GitHub
parent c43c960aad
commit 16b1bd2032
6 changed files with 43 additions and 27 deletions

View File

@@ -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)

View File

@@ -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(

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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<string> {
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 () => {

View File

@@ -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<void> {
"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),