Skip to content

Commit 1f93821

Browse files
authored
Merge branch 'main' into feat/issue-100162
2 parents 5b4c7f0 + c791e2b commit 1f93821

43 files changed

Lines changed: 845 additions & 169 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/.i18n/native-source.json

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,111 +1723,111 @@
17231723
},
17241724
{
17251725
"kind": "ui-call",
1726-
"line": 1035,
1726+
"line": 1036,
17271727
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
17281728
"source": "Wait for the current response to finish before starting a new chat.",
17291729
"surface": "android",
17301730
"id": "native.android.875e63350d705dec"
17311731
},
17321732
{
17331733
"kind": "ui-call",
1734-
"line": 1167,
1734+
"line": 1169,
17351735
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
17361736
"source": "Could not update model.",
17371737
"surface": "android",
17381738
"id": "native.android.2c46e20193e2ae90"
17391739
},
17401740
{
17411741
"kind": "ui-call",
1742-
"line": 1232,
1742+
"line": 1234,
17431743
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
17441744
"source": "Could not update thinking level.",
17451745
"surface": "android",
17461746
"id": "native.android.e4ab8aaa795ba79f"
17471747
},
17481748
{
17491749
"kind": "ui-call",
1750-
"line": 1725,
1750+
"line": 1727,
17511751
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
17521752
"source": "Chat failed before the run started; try again.",
17531753
"surface": "android",
17541754
"id": "native.android.956a394937480f8a"
17551755
},
17561756
{
17571757
"kind": "ui-call",
1758-
"line": 3007,
1758+
"line": 3009,
17591759
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
17601760
"source": "Could not stage an attachment for sending.",
17611761
"surface": "android",
17621762
"id": "native.android.2db6d419791ce6a7"
17631763
},
17641764
{
17651765
"kind": "ui-call",
1766-
"line": 3040,
1766+
"line": 3042,
17671767
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
17681768
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
17691769
"surface": "android",
17701770
"id": "native.android.c3899241029d152c"
17711771
},
17721772
{
17731773
"kind": "ui-call",
1774-
"line": 3046,
1774+
"line": 3048,
17751775
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
17761776
"source": "Attachments are too large to queue for one message; remove some and try again.",
17771777
"surface": "android",
17781778
"id": "native.android.4cf5774e212e307d"
17791779
},
17801780
{
17811781
"kind": "ui-call",
1782-
"line": 3052,
1782+
"line": 3054,
17831783
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
17841784
"source": "Offline attachment storage is full; delete queued items first.",
17851785
"surface": "android",
17861786
"id": "native.android.d8724124bd4a3719"
17871787
},
17881788
{
17891789
"kind": "ui-call",
1790-
"line": 3057,
1790+
"line": 3059,
17911791
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
17921792
"source": "Gateway health not OK; cannot send",
17931793
"surface": "android",
17941794
"id": "native.android.84af66e78ec02fa3"
17951795
},
17961796
{
17971797
"kind": "ui-call",
1798-
"line": 3064,
1798+
"line": 3066,
17991799
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
18001800
"source": "Could not queue message for later delivery.",
18011801
"surface": "android",
18021802
"id": "native.android.0b29275eefad9863"
18031803
},
18041804
{
18051805
"kind": "ui-call",
1806-
"line": 3781,
1806+
"line": 3783,
18071807
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
18081808
"source": "Chat failed",
18091809
"surface": "android",
18101810
"id": "native.android.8c2ff49af3e78aee"
18111811
},
18121812
{
18131813
"kind": "ui-call",
1814-
"line": 3919,
1814+
"line": 3921,
18151815
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
18161816
"source": "Event stream interrupted; try refreshing.",
18171817
"surface": "android",
18181818
"id": "native.android.e2f245e1995de5e8"
18191819
},
18201820
{
18211821
"kind": "ui-call",
1822-
"line": 4056,
1822+
"line": 4058,
18231823
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
18241824
"source": "Timed out waiting for a reply; try again or refresh.",
18251825
"surface": "android",
18261826
"id": "native.android.2274c6848b1c098b"
18271827
},
18281828
{
18291829
"kind": "ui-call",
1830-
"line": 4266,
1830+
"line": 4268,
18311831
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
18321832
"source": "Timed out confirming the sent message; refresh to check delivery.",
18331833
"surface": "android",

apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import ai.openclaw.app.GatewayModelSummary
44
import ai.openclaw.app.gateway.GatewayRequestDefinitiveFailure
55
import ai.openclaw.app.gateway.GatewayRequestNotEnqueued
66
import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown
7+
import ai.openclaw.app.gateway.GatewayRequestRejected
78
import ai.openclaw.app.gateway.GatewaySession
89
import ai.openclaw.app.gateway.QuestionListResult
910
import ai.openclaw.app.gateway.QuestionRecord
@@ -1050,11 +1051,12 @@ class ChatController internal constructor(
10501051
if (hasLoadedParentSession) {
10511052
put("parentSessionKey", JsonPrimitive(parentKey))
10521053
put("emitCommandHooks", JsonPrimitive(true))
1054+
put("succeedsParent", JsonPrimitive(false))
10531055
}
10541056
put("label", JsonPrimitive(label))
10551057
if (worktree) put("worktree", JsonPrimitive(true))
10561058
}
1057-
val res = requestGatewayBound(createGatewayId, "sessions.create", params.toString())
1059+
val res = requestSessionCreateWithDispositionFallback(createGatewayId, params)
10581060
if (!isCurrentHistoryLoad(parentKey, _sessionKey.value, requestGeneration, historyLoadGeneration.get())) {
10591061
return false
10601062
}
@@ -4745,6 +4747,31 @@ class ChatController internal constructor(
47454747
requestGatewayForGateway(gatewayId, method, paramsJson)
47464748
}
47474749

4750+
private suspend fun requestSessionCreateWithDispositionFallback(
4751+
gatewayId: String?,
4752+
params: JsonObject,
4753+
): String =
4754+
try {
4755+
requestGatewayBound(gatewayId, "sessions.create", params.toString())
4756+
} catch (err: GatewayRequestRejected) {
4757+
val message = err.gatewayError.message
4758+
val isOlderGateway =
4759+
err.gatewayError.code == "INVALID_REQUEST" &&
4760+
message.contains("invalid sessions.create params") &&
4761+
message.contains("succeedsParent")
4762+
if (!isOlderGateway || "succeedsParent" !in params) throw err
4763+
4764+
// Older Gateways cannot express a linked parallel child. Keep New Chat parallel by
4765+
// dropping the parent lifecycle fields instead of falling back to legacy rollover.
4766+
val legacyParams =
4767+
JsonObject(
4768+
params.filterKeys { key ->
4769+
key != "succeedsParent" && key != "parentSessionKey" && key != "emitCommandHooks"
4770+
},
4771+
)
4772+
requestGatewayBound(gatewayId, "sessions.create", legacyParams.toString())
4773+
}
4774+
47484775
private fun currentCacheScope(): ChatCacheScope? = normalizedChatCacheScope(cacheScope())
47494776

47504777
/** Keeps an unscoped chat bound to its verified agent only while the same gateway reconnects. */

apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package ai.openclaw.app.chat
22

3+
import ai.openclaw.app.gateway.GatewayRequestRejected
4+
import ai.openclaw.app.gateway.GatewaySession
35
import kotlinx.coroutines.CompletableDeferred
46
import kotlinx.coroutines.ExperimentalCoroutinesApi
57
import kotlinx.coroutines.async
@@ -249,13 +251,64 @@ class ChatControllerCommandControlsTest {
249251
assertTrue(create.second.orEmpty().contains("\"agentId\":\"main\""))
250252
assertTrue(create.second.orEmpty().contains("\"parentSessionKey\":\"main\""))
251253
assertTrue(create.second.orEmpty().contains("\"emitCommandHooks\":true"))
254+
assertTrue(create.second.orEmpty().contains("\"succeedsParent\":false"))
252255
assertTrue(create.second.orEmpty().contains("\"label\":\"New chat\""))
253256
assertEquals("agent:main:dashboard:fresh", controller.sessionKey.value)
254257
assertEquals("fresh-session", controller.sessionId.value)
255258
assertTrue(requests.any { it.first == "chat.history" })
256259
assertTrue(requests.any { it.first == "sessions.list" })
257260
}
258261

262+
@OptIn(ExperimentalCoroutinesApi::class)
263+
@Test
264+
fun startNewChatRetriesWithoutParentLifecycleAgainstOlderGateway() =
265+
runTest {
266+
val requests = mutableListOf<Pair<String, String?>>()
267+
var createCalls = 0
268+
val controller =
269+
ChatController(
270+
scope = this,
271+
json = json,
272+
requestGateway = { method, paramsJson ->
273+
requests += method to paramsJson
274+
when (method) {
275+
"sessions.create" -> {
276+
createCalls += 1
277+
if (createCalls == 1) {
278+
throw GatewayRequestRejected(
279+
GatewaySession.ErrorShape(
280+
code = "INVALID_REQUEST",
281+
message =
282+
"invalid sessions.create params: at root: unexpected property 'succeedsParent'",
283+
),
284+
)
285+
}
286+
"""{"ok":true,"key":"agent:main:dashboard:fresh"}"""
287+
}
288+
"chat.history" -> """{"sessionId":"fresh-session","messages":[]}"""
289+
"health" -> "{}"
290+
"sessions.list" -> """{"sessions":[]}"""
291+
else -> "{}"
292+
}
293+
},
294+
)
295+
controller.handleGatewayEvent("health", null)
296+
controller.load("main")
297+
advanceUntilIdle()
298+
299+
assertTrue(controller.startNewChatAwait())
300+
301+
val creates = requests.filter { it.first == "sessions.create" }
302+
assertEquals(2, creates.size)
303+
assertTrue(creates[0].second.orEmpty().contains("\"succeedsParent\":false"))
304+
assertEquals(false, creates[1].second.orEmpty().contains("\"succeedsParent\""))
305+
assertEquals(false, creates[1].second.orEmpty().contains("\"parentSessionKey\""))
306+
assertEquals(false, creates[1].second.orEmpty().contains("\"emitCommandHooks\""))
307+
assertTrue(creates[1].second.orEmpty().contains("\"agentId\":\"main\""))
308+
assertTrue(creates[1].second.orEmpty().contains("\"label\":\"New chat\""))
309+
assertEquals("agent:main:dashboard:fresh", controller.sessionKey.value)
310+
}
311+
259312
@OptIn(ExperimentalCoroutinesApi::class)
260313
@Test
261314
fun startNewChatInWorktreeIncludesWorktreeFlag() =

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5053,6 +5053,7 @@ public struct SessionsCreateParams: Codable, Sendable {
50535053
public let parentsessionkey: String?
50545054
public let fork: Bool?
50555055
public let emitcommandhooks: Bool?
5056+
public let succeedsparent: Bool?
50565057
public let task: String?
50575058
public let message: String?
50585059
public let attachments: [AnyCodable]?
@@ -5072,6 +5073,7 @@ public struct SessionsCreateParams: Codable, Sendable {
50725073
parentsessionkey: String? = nil,
50735074
fork: Bool? = nil,
50745075
emitcommandhooks: Bool? = nil,
5076+
succeedsparent: Bool? = nil,
50755077
task: String? = nil,
50765078
message: String? = nil,
50775079
attachments: [AnyCodable]? = nil,
@@ -5090,6 +5092,7 @@ public struct SessionsCreateParams: Codable, Sendable {
50905092
self.parentsessionkey = parentsessionkey
50915093
self.fork = fork
50925094
self.emitcommandhooks = emitcommandhooks
5095+
self.succeedsparent = succeedsparent
50935096
self.task = task
50945097
self.message = message
50955098
self.attachments = attachments
@@ -5110,6 +5113,7 @@ public struct SessionsCreateParams: Codable, Sendable {
51105113
case parentsessionkey = "parentSessionKey"
51115114
case fork
51125115
case emitcommandhooks = "emitCommandHooks"
5116+
case succeedsparent = "succeedsParent"
51135117
case task
51145118
case message
51155119
case attachments

docs/gateway/protocol.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ methods. Treat this as feature discovery, not a full enumeration of
527527
- `sessions.preview` returns bounded transcript previews for specific session keys.
528528
- `sessions.describe` returns one gateway session row for an exact session key.
529529
- `sessions.resolve` resolves or canonicalizes a session target.
530-
- `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key.
530+
- `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created.
531531
- `sessions.dispatch` (`operator.admin`) moves an existing local OpenClaw session with a session-owned managed worktree to a configured cloud-worker profile. Pass `{ key, profileId, agentId? }`. The method is absent when no worker profile is configured, closes local turn admission before draining active work, and returns only after placement reaches `active` worker ownership. Dispatch is one-way; worker-to-local pull-back is not part of this RPC.
532532
- `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). Membership stays on each session's `category` field; rename and delete update member sessions server-side.
533533
- `sessions.send` sends a message into an existing session.

docs/plugins/hooks.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ observation-only.
167167
| `before_compaction` / `after_compaction` | Observe or annotate compaction cycles |
168168
| `before_reset` | Observe session-reset events (`/reset`, programmatic resets) |
169169

170+
For `sessions.create` calls with `parentSessionKey` and `emitCommandHooks: true`, a distinct child always receives `session_start`. Callers declare whether the parent also receives terminal `session_end` with `succeedsParent`: `true` means successor, `false` means parallel child. Omission preserves the legacy parent-rollover behavior. The `command:new` and `before_reset` hooks still describe the requested `/new` action in both cases.
171+
170172
**Subagents**
171173

172174
- `subagent_spawned` / `subagent_ended` - observe subagent launch and completion.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "vitest";
2+
import { validateNodeInvokeProgressParams } from "../index.js";
3+
4+
describe("node protocol schemas", () => {
5+
it("accepts bounded progress chunks and rejects extra fields", () => {
6+
expect(
7+
validateNodeInvokeProgressParams({
8+
invokeId: "invoke-1",
9+
nodeId: "node-1",
10+
seq: 0,
11+
chunk: "stdout line",
12+
}),
13+
).toBe(true);
14+
15+
expect(
16+
validateNodeInvokeProgressParams({
17+
invokeId: "invoke-1",
18+
nodeId: "node-1",
19+
seq: 0,
20+
chunk: "x".repeat(16 * 1024 + 1),
21+
}),
22+
).toBe(false);
23+
24+
expect(
25+
validateNodeInvokeProgressParams({
26+
invokeId: "invoke-1",
27+
nodeId: "node-1",
28+
seq: 0,
29+
chunk: "stdout line",
30+
extra: "not allowed",
31+
}),
32+
).toBe(false);
33+
});
34+
});

packages/gateway-protocol/src/schema/nodes.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -164,16 +164,13 @@ export const NodeInvokeResultParamsSchema = closedObject({
164164
});
165165

166166
/** Ordered UTF-8 output emitted while a node command invocation is running. */
167-
export const NodeInvokeProgressParamsSchema = Type.Object(
168-
{
169-
invokeId: NonEmptyString,
170-
nodeId: NonEmptyString,
171-
seq: Type.Integer({ minimum: 0 }),
172-
// Empty chunks are liveness heartbeats for captured stderr or capped stdout.
173-
chunk: Type.String({ maxLength: 16 * 1024 }),
174-
},
175-
{ additionalProperties: false },
176-
);
167+
export const NodeInvokeProgressParamsSchema = closedObject({
168+
invokeId: NonEmptyString,
169+
nodeId: NonEmptyString,
170+
seq: Type.Integer({ minimum: 0 }),
171+
// Empty chunks are liveness heartbeats for captured stderr or capped stdout.
172+
chunk: Type.String({ maxLength: 16 * 1024 }),
173+
});
177174

178175
/** Generic node event envelope accepted by the gateway. */
179176
export const NodeEventParamsSchema = closedObject({

packages/gateway-protocol/src/schema/session-placement.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,4 +282,28 @@ describe("session dispatch protocol schemas", () => {
282282
}),
283283
).toBe(false);
284284
});
285+
286+
it("rejects extra fields in dispatch params and results", () => {
287+
const active = {
288+
state: "active" as const,
289+
...basePlacement,
290+
...workerOwnedFields,
291+
};
292+
expect(
293+
validateSessionsDispatchResult({
294+
ok: true,
295+
key: "agent:main:dispatch",
296+
sessionId: "session-1",
297+
placement: active,
298+
extra: true,
299+
}),
300+
).toBe(false);
301+
expect(
302+
validateSessionsDispatchParams({
303+
key: "agent:main:dispatch",
304+
profileId: "development",
305+
extra: true,
306+
}),
307+
).toBe(false);
308+
});
285309
});

0 commit comments

Comments
 (0)