Skip to content

Commit 150bebc

Browse files
committed
fix(gateway): require v4 chat deltas
1 parent 63724dd commit 150bebc

11 files changed

Lines changed: 298 additions & 58 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
package ai.openclaw.app.gateway
22

33
const val GATEWAY_PROTOCOL_VERSION = 4
4-
const val GATEWAY_MIN_PROTOCOL_VERSION = 3
4+
const val GATEWAY_MIN_PROTOCOL_VERSION = 4

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 167 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import Foundation
44

55
public let GATEWAY_PROTOCOL_VERSION = 4
6-
public let GATEWAY_MIN_PROTOCOL_VERSION = 3
6+
public let GATEWAY_MIN_PROTOCOL_VERSION = 4
77

88
private struct GatewayAnyCodingKey: CodingKey, Hashable {
99
let stringValue: String
@@ -6244,14 +6244,139 @@ public struct ChatInjectParams: Codable, Sendable {
62446244
}
62456245
}
62466246

6247-
public struct ChatEvent: Codable, Sendable {
6247+
public struct ChatDeltaEvent: Codable, Sendable {
62486248
public let runid: String
62496249
public let sessionkey: String
62506250
public let spawnedby: String?
62516251
public let seq: Int
6252-
public let state: AnyCodable
6252+
public let state: String
6253+
public let message: AnyCodable?
6254+
public let deltatext: String
6255+
public let replace: Bool?
6256+
public let usage: AnyCodable?
6257+
6258+
public init(
6259+
runid: String,
6260+
sessionkey: String,
6261+
spawnedby: String?,
6262+
seq: Int,
6263+
state: String,
6264+
message: AnyCodable?,
6265+
deltatext: String,
6266+
replace: Bool?,
6267+
usage: AnyCodable?)
6268+
{
6269+
self.runid = runid
6270+
self.sessionkey = sessionkey
6271+
self.spawnedby = spawnedby
6272+
self.seq = seq
6273+
self.state = state
6274+
self.message = message
6275+
self.deltatext = deltatext
6276+
self.replace = replace
6277+
self.usage = usage
6278+
}
6279+
6280+
private enum CodingKeys: String, CodingKey {
6281+
case runid = "runId"
6282+
case sessionkey = "sessionKey"
6283+
case spawnedby = "spawnedBy"
6284+
case seq
6285+
case state
6286+
case message
6287+
case deltatext = "deltaText"
6288+
case replace
6289+
case usage
6290+
}
6291+
}
6292+
6293+
public struct ChatFinalEvent: Codable, Sendable {
6294+
public let runid: String
6295+
public let sessionkey: String
6296+
public let spawnedby: String?
6297+
public let seq: Int
6298+
public let state: String
6299+
public let message: AnyCodable?
6300+
public let usage: AnyCodable?
6301+
public let stopreason: String?
6302+
6303+
public init(
6304+
runid: String,
6305+
sessionkey: String,
6306+
spawnedby: String?,
6307+
seq: Int,
6308+
state: String,
6309+
message: AnyCodable?,
6310+
usage: AnyCodable?,
6311+
stopreason: String?)
6312+
{
6313+
self.runid = runid
6314+
self.sessionkey = sessionkey
6315+
self.spawnedby = spawnedby
6316+
self.seq = seq
6317+
self.state = state
6318+
self.message = message
6319+
self.usage = usage
6320+
self.stopreason = stopreason
6321+
}
6322+
6323+
private enum CodingKeys: String, CodingKey {
6324+
case runid = "runId"
6325+
case sessionkey = "sessionKey"
6326+
case spawnedby = "spawnedBy"
6327+
case seq
6328+
case state
6329+
case message
6330+
case usage
6331+
case stopreason = "stopReason"
6332+
}
6333+
}
6334+
6335+
public struct ChatAbortedEvent: Codable, Sendable {
6336+
public let runid: String
6337+
public let sessionkey: String
6338+
public let spawnedby: String?
6339+
public let seq: Int
6340+
public let state: String
6341+
public let message: AnyCodable?
6342+
public let stopreason: String?
6343+
6344+
public init(
6345+
runid: String,
6346+
sessionkey: String,
6347+
spawnedby: String?,
6348+
seq: Int,
6349+
state: String,
6350+
message: AnyCodable?,
6351+
stopreason: String?)
6352+
{
6353+
self.runid = runid
6354+
self.sessionkey = sessionkey
6355+
self.spawnedby = spawnedby
6356+
self.seq = seq
6357+
self.state = state
6358+
self.message = message
6359+
self.stopreason = stopreason
6360+
}
6361+
6362+
private enum CodingKeys: String, CodingKey {
6363+
case runid = "runId"
6364+
case sessionkey = "sessionKey"
6365+
case spawnedby = "spawnedBy"
6366+
case seq
6367+
case state
6368+
case message
6369+
case stopreason = "stopReason"
6370+
}
6371+
}
6372+
6373+
public struct ChatErrorEvent: Codable, Sendable {
6374+
public let runid: String
6375+
public let sessionkey: String
6376+
public let spawnedby: String?
6377+
public let seq: Int
6378+
public let state: String
62536379
public let message: AnyCodable?
6254-
public let deltatext: String?
62556380
public let errormessage: String?
62566381
public let errorkind: AnyCodable?
62576382
public let usage: AnyCodable?
@@ -6262,9 +6387,8 @@ public struct ChatEvent: Codable, Sendable {
62626387
sessionkey: String,
62636388
spawnedby: String?,
62646389
seq: Int,
6265-
state: AnyCodable,
6390+
state: String,
62666391
message: AnyCodable?,
6267-
deltatext: String?,
62686392
errormessage: String?,
62696393
errorkind: AnyCodable?,
62706394
usage: AnyCodable?,
@@ -6276,7 +6400,6 @@ public struct ChatEvent: Codable, Sendable {
62766400
self.seq = seq
62776401
self.state = state
62786402
self.message = message
6279-
self.deltatext = deltatext
62806403
self.errormessage = errormessage
62816404
self.errorkind = errorkind
62826405
self.usage = usage
@@ -6290,7 +6413,6 @@ public struct ChatEvent: Codable, Sendable {
62906413
case seq
62916414
case state
62926415
case message
6293-
case deltatext = "deltaText"
62946416
case errormessage = "errorMessage"
62956417
case errorkind = "errorKind"
62966418
case usage
@@ -6397,6 +6519,43 @@ public enum PluginsSessionActionResult: Codable, Sendable {
63976519
}
63986520
}
63996521

6522+
public enum ChatEvent: Codable, Sendable {
6523+
case delta(ChatDeltaEvent)
6524+
case final(ChatFinalEvent)
6525+
case aborted(ChatAbortedEvent)
6526+
case error(ChatErrorEvent)
6527+
6528+
private enum CodingKeys: String, CodingKey {
6529+
case discriminator = "state"
6530+
}
6531+
6532+
public init(from decoder: Decoder) throws {
6533+
let container = try decoder.container(keyedBy: CodingKeys.self)
6534+
let discriminator = try container.decode(String.self, forKey: .discriminator)
6535+
switch discriminator {
6536+
case "delta": self = try .delta(ChatDeltaEvent(from: decoder))
6537+
case "final": self = try .final(ChatFinalEvent(from: decoder))
6538+
case "aborted": self = try .aborted(ChatAbortedEvent(from: decoder))
6539+
case "error": self = try .error(ChatErrorEvent(from: decoder))
6540+
default:
6541+
throw DecodingError.dataCorruptedError(
6542+
forKey: .discriminator,
6543+
in: container,
6544+
debugDescription: "Unknown ChatEvent discriminator value"
6545+
)
6546+
}
6547+
}
6548+
6549+
public func encode(to encoder: Encoder) throws {
6550+
switch self {
6551+
case .delta(let value): try value.encode(to: encoder)
6552+
case .final(let value): try value.encode(to: encoder)
6553+
case .aborted(let value): try value.encode(to: encoder)
6554+
case .error(let value): try value.encode(to: encoder)
6555+
}
6556+
}
6557+
}
6558+
64006559
public enum GatewayFrame: Codable, Sendable {
64016560
case req(RequestFrame)
64026561
case res(ResponseFrame)

docs/gateway/protocol.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -468,9 +468,9 @@ enumeration of `src/gateway/server-methods/*.ts`.
468468
### Common event families
469469

470470
- `chat`: UI chat updates such as `chat.inject` and other transcript-only chat
471-
events. Delta payloads keep `message` as the cumulative assistant snapshot for
472-
compatibility and may include `deltaText` when the Gateway can provide a safe
473-
additive text suffix.
471+
events. In protocol v4, delta payloads carry `deltaText`; `message` remains
472+
the cumulative assistant snapshot. Non-prefix replacements set `replace=true`
473+
and use `deltaText` as the replacement text.
474474
- `session.message` and `session.tool`: transcript/event-stream updates for a
475475
subscribed session.
476476
- `sessions.changed`: session index or metadata changed.
@@ -626,8 +626,8 @@ terminal summary, and sanitized error text.
626626

627627
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/version.ts`.
628628
- Clients send `minProtocol` + `maxProtocol`; the server rejects ranges that
629-
do not include its current protocol. Native clients use a v3 lower bound so
630-
additive v4 clients can still reach v3 gateways.
629+
do not include its current protocol. Current clients and servers require
630+
protocol v4.
631631
- Schemas + models are generated from TypeBox definitions:
632632
- `pnpm protocol:gen`
633633
- `pnpm protocol:gen:swift`
@@ -641,7 +641,7 @@ stable across protocol v4 and are the expected baseline for third-party clients.
641641
| Constant | Default | Source |
642642
| ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ |
643643
| `PROTOCOL_VERSION` | `4` | `src/gateway/protocol/version.ts` |
644-
| `MIN_CLIENT_PROTOCOL_VERSION` | `3` | `src/gateway/protocol/version.ts` |
644+
| `MIN_CLIENT_PROTOCOL_VERSION` | `4` | `src/gateway/protocol/version.ts` |
645645
| Request timeout (per RPC) | `30_000` ms | `src/gateway/client.ts` (`requestTimeoutMs`) |
646646
| Preauth / connect-challenge timeout | `15_000` ms | `src/gateway/handshake-timeouts.ts` (config/env can raise the paired server/client budget) |
647647
| Initial reconnect backoff | `1_000` ms | `src/gateway/client.ts` (`backoffMs`) |

packages/sdk/src/client.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ function readChatProjectionDeltaText(payload: Record<string, unknown>): string |
244244
return typeof payload.deltaText === "string" ? payload.deltaText : undefined;
245245
}
246246

247+
function readChatProjectionReplace(payload: Record<string, unknown>): boolean {
248+
return payload.replace === true;
249+
}
250+
247251
function isAssistantRunEvent(event: OpenClawEvent): boolean {
248252
return event.type === "assistant.delta" || event.type === "assistant.message";
249253
}
@@ -265,9 +269,7 @@ function normalizeChatProjectionEvent(
265269
const text = readChatProjectionText(projection.payload);
266270
const deltaText = readChatProjectionDeltaText(projection.payload);
267271
const hasPreviousText = previousText !== undefined;
268-
const isReplacement = Boolean(
269-
deltaText === undefined && previousText && text !== undefined && !text.startsWith(previousText),
270-
);
272+
const isReplacement = readChatProjectionReplace(projection.payload);
271273
return {
272274
...event,
273275
type: projection.state === "delta" ? "assistant.delta" : "run.completed",
@@ -276,12 +278,7 @@ function normalizeChatProjectionEvent(
276278
? text !== undefined
277279
? {
278280
text,
279-
delta:
280-
deltaText !== undefined && hasPreviousText
281-
? deltaText
282-
: isReplacement
283-
? text
284-
: text.slice(previousText?.length ?? 0),
281+
delta: hasPreviousText ? (deltaText ?? text) : text,
285282
...(isReplacement ? { replace: true } : {}),
286283
}
287284
: event.data

packages/sdk/src/index.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ describe("OpenClaw SDK", () => {
588588
runId: "run_chat_projection",
589589
sessionKey: "chat-projection",
590590
state: "delta",
591+
deltaText: "hello",
591592
message: {
592593
role: "assistant",
593594
content: [{ type: "text", text: "hello" }],
@@ -665,6 +666,7 @@ describe("OpenClaw SDK", () => {
665666
runId: "run_chat_only",
666667
sessionKey: "chat-only",
667668
state: "delta",
669+
deltaText: "hello",
668670
message: {
669671
role: "assistant",
670672
content: [{ type: "text", text: "hello" }],
@@ -679,6 +681,7 @@ describe("OpenClaw SDK", () => {
679681
runId: "run_chat_only",
680682
sessionKey: "chat-only",
681683
state: "delta",
684+
deltaText: " again",
682685
message: {
683686
role: "assistant",
684687
content: [{ type: "text", text: "hello again" }],
@@ -693,6 +696,8 @@ describe("OpenClaw SDK", () => {
693696
runId: "run_chat_only",
694697
sessionKey: "chat-only",
695698
state: "delta",
699+
deltaText: "reset",
700+
replace: true,
696701
message: {
697702
role: "assistant",
698703
content: [{ type: "text", text: "reset" }],

src/gateway/protocol/index.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ describe("validateWakeParams", () => {
451451
});
452452

453453
describe("validateChatEvent", () => {
454-
it("accepts additive chat delta text", () => {
454+
it("accepts v4 chat delta text and replacement markers", () => {
455455
expect(
456456
validateChatEvent({
457457
runId: "run-chat",
@@ -465,6 +465,35 @@ describe("validateChatEvent", () => {
465465
},
466466
}),
467467
).toBe(true);
468+
expect(
469+
validateChatEvent({
470+
runId: "run-chat",
471+
sessionKey: "agent:main:main",
472+
seq: 2,
473+
state: "delta",
474+
deltaText: "replacement",
475+
replace: true,
476+
message: {
477+
role: "assistant",
478+
content: [{ type: "text", text: "replacement" }],
479+
},
480+
}),
481+
).toBe(true);
482+
});
483+
484+
it("rejects v3-style chat deltas without deltaText", () => {
485+
expect(
486+
validateChatEvent({
487+
runId: "run-chat",
488+
sessionKey: "agent:main:main",
489+
seq: 1,
490+
state: "delta",
491+
message: {
492+
role: "assistant",
493+
content: [{ type: "text", text: "hello" }],
494+
},
495+
}),
496+
).toBe(false);
468497
});
469498
});
470499

0 commit comments

Comments
 (0)