Skip to content

Commit b2c1db4

Browse files
fix(ios): scope session mutations to selected agent (#103415)
* fix(ios): scope session mutations to selected agent * fix(ios): preserve agent ownership for session mutations --------- Co-authored-by: lin-hongkuan <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 1439646 commit b2c1db4

3 files changed

Lines changed: 112 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
3131
- **Chutes OAuth deadlines:** bound token exchange, profile lookup, and refresh requests, and keep issued tokens when optional userinfo enrichment stalls. (#102026) Thanks @Alix-007.
3232
- **Control UI workspace avatars:** inline validated agent avatar files in bootstrap and identity responses so Personal card images render without unauthenticated avatar-route requests, while preserving configured emoji precedence. (#102892, #97602) Thanks @LZY3538.
3333
- **Exec safe-bin flags:** auto-approve curated read-only boolean flags for default stdin-only filters while keeping unknown flags, tail follow/retry modes, file operands, and custom profiles fail-closed. (#88953) Thanks @yetval.
34+
- **iOS session mutations:** scope rename, archive, pin, delete, and fork requests to the selected agent, preserving the parent agent for forked sessions so multi-agent chat actions cannot mutate or create sessions under the wrong agent. (#103366, #103415) Thanks @lin-hongkuan and @harjothkhara.
3435
- **Model pin hot reload and fallback:** keep explicit `/model` selections authoritative across Telegram config reloads and model fallback, capture one live config snapshot per assembled turn, and leave fallback candidates turn-local instead of persisting them over the user's pin. (#103324, #103417) Thanks @obviyus.
3536
- **Swift protocol initializers:** default every schema-optional generated initializer parameter to `nil` so additive protocol fields no longer break SDK construction call sites.
3637
- **OpenCode Go MiMo catalog:** stop exposing the deprecated `mimo-v2-omni` and `mimo-v2-pro` aliases that reject agent requests, and keep release validation on the active MiMo V2.5 routes. (#103311, #103329) Thanks @krissding.

apps/ios/Sources/Chat/IOSGatewayChatTransport.swift

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
1111
private let gateway: GatewayNodeSession
1212
private let globalAgentId: String?
1313
private let outboxGatewayID: String?
14+
private let sessionMutationRequest: (@Sendable (String, String, Int) async throws -> Data)?
1415

1516
var outboxRequiresSessionRoutingContract: Bool {
1617
true
@@ -136,13 +137,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
136137
init(
137138
gateway: GatewayNodeSession,
138139
globalAgentId: String? = nil,
139-
outboxGatewayID: String? = nil)
140+
outboxGatewayID: String? = nil,
141+
sessionMutationRequest: (@Sendable (String, String, Int) async throws -> Data)? = nil)
140142
{
141143
self.gateway = gateway
142144
let normalized = globalAgentId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
143145
self.globalAgentId = normalized?.isEmpty == false ? normalized : nil
144146
let normalizedGatewayID = outboxGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines)
145147
self.outboxGatewayID = normalizedGatewayID?.isEmpty == false ? normalizedGatewayID : nil
148+
self.sessionMutationRequest = sessionMutationRequest
146149
}
147150

148151
func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult {
@@ -388,12 +391,6 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
388391
return json
389392
}
390393

391-
private func selectedGlobalAgentId(for sessionKey: String) -> String? {
392-
sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "global"
393-
? self.globalAgentId
394-
: nil
395-
}
396-
397394
struct SessionTarget: Equatable {
398395
var sessionKey: String
399396
var agentID: String?
@@ -436,6 +433,20 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
436433
overrideAgentID: overrideAgentID)
437434
}
438435

436+
private func requestSessionMutation(
437+
method: String,
438+
paramsJSON: String,
439+
timeoutSeconds: Int) async throws -> Data
440+
{
441+
if let sessionMutationRequest {
442+
return try await sessionMutationRequest(method, paramsJSON, timeoutSeconds)
443+
}
444+
return try await self.gateway.request(
445+
method: method,
446+
paramsJSON: paramsJSON,
447+
timeoutSeconds: timeoutSeconds)
448+
}
449+
439450
func createSession(
440451
key: String,
441452
label: String?,
@@ -450,7 +461,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
450461
label: label,
451462
parentSessionKey: parentTarget?.sessionKey,
452463
worktree: worktree)
453-
let res = try await self.gateway.request(method: "sessions.create", paramsJSON: json, timeoutSeconds: 15)
464+
let res = try await gateway.request(method: "sessions.create", paramsJSON: json, timeoutSeconds: 15)
454465
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: res)
455466
}
456467

@@ -469,12 +480,12 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
469480
archived: Bool) async throws -> OpenClawChatSessionsListResponse
470481
{
471482
let json = try Self.makeListSessionsParamsJSON(limit: limit, search: search, archived: archived)
472-
let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
483+
let res = try await gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
473484
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: res)
474485
}
475486

476487
func listModels() async throws -> [OpenClawChatModelChoice] {
477-
let response = try await self.gateway.request(
488+
let response = try await gateway.request(
478489
method: "models.list",
479490
paramsJSON: nil,
480491
timeoutSeconds: 15)
@@ -498,30 +509,37 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
498509
archived: Bool? = nil,
499510
unread: Bool? = nil) async throws
500511
{
512+
let target = self.sessionTarget(for: key)
501513
let json = try Self.makePatchSessionParamsJSON(
502-
key: key,
503-
agentId: self.selectedGlobalAgentId(for: key),
514+
key: target.sessionKey,
515+
agentId: target.agentID,
504516
label: label,
505517
category: category,
506518
pinned: pinned,
507519
archived: archived,
508520
unread: unread)
509-
_ = try await self.gateway.request(method: "sessions.patch", paramsJSON: json, timeoutSeconds: 15)
521+
_ = try await self.requestSessionMutation(method: "sessions.patch", paramsJSON: json, timeoutSeconds: 15)
510522
}
511523

512524
func deleteSession(key: String) async throws {
525+
let target = self.sessionTarget(for: key)
513526
let json = try Self.encodeParams(DeleteSessionParams(
514-
key: key,
527+
key: target.sessionKey,
515528
deleteTranscript: true,
516-
agentId: self.selectedGlobalAgentId(for: key)))
517-
_ = try await self.gateway.request(method: "sessions.delete", paramsJSON: json, timeoutSeconds: 15)
529+
agentId: target.agentID))
530+
_ = try await self.requestSessionMutation(method: "sessions.delete", paramsJSON: json, timeoutSeconds: 15)
518531
}
519532

520533
func forkSession(parentKey: String) async throws -> String {
534+
let target = self.sessionTarget(for: parentKey)
535+
let childAgentID = target.agentID ?? Self.agentID(fromSessionKey: target.sessionKey)
521536
let json = try Self.makeForkSessionParamsJSON(
522-
parentKey: parentKey,
523-
agentId: Self.agentID(fromSessionKey: parentKey) ?? self.selectedGlobalAgentId(for: parentKey))
524-
let response = try await self.gateway.request(method: "sessions.create", paramsJSON: json, timeoutSeconds: 15)
537+
parentKey: target.sessionKey,
538+
agentId: childAgentID)
539+
let response = try await requestSessionMutation(
540+
method: "sessions.create",
541+
paramsJSON: json,
542+
timeoutSeconds: 15)
525543
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: response).key
526544
}
527545

apps/ios/Tests/IOSGatewayChatTransportTests.swift

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,28 @@ import Testing
66
@testable import OpenClaw
77

88
struct IOSGatewayChatTransportTests {
9+
private actor RequestRecorder {
10+
struct Request: Sendable {
11+
var method: String
12+
var paramsJSON: String
13+
var timeoutSeconds: Int
14+
}
15+
16+
private var requests: [Request] = []
17+
18+
func record(method: String, paramsJSON: String, timeoutSeconds: Int) -> Data {
19+
self.requests.append(Request(
20+
method: method,
21+
paramsJSON: paramsJSON,
22+
timeoutSeconds: timeoutSeconds))
23+
return Data(#"{"key":"forked"}"#.utf8)
24+
}
25+
26+
func all() -> [Request] {
27+
self.requests
28+
}
29+
}
30+
931
private func object(from json: String) throws -> [String: Any] {
1032
let data = try #require(json.data(using: .utf8))
1133
let value = try JSONSerialization.jsonObject(with: data)
@@ -84,13 +106,13 @@ struct IOSGatewayChatTransportTests {
84106
}
85107

86108
@Test func `list sessions params request archived sessions explicitly`() throws {
87-
let params = try self.object(
109+
let params = try object(
88110
from: IOSGatewayChatTransport.makeListSessionsParamsJSON(limit: 12, archived: true))
89111
#expect(params["archived"] as? Bool == true)
90112
}
91113

92114
@Test func `patch session params preserve explicit null clearing`() throws {
93-
let params = try self.object(
115+
let params = try object(
94116
from: IOSGatewayChatTransport.makePatchSessionParamsJSON(
95117
key: "session-1",
96118
label: .some(nil),
@@ -106,7 +128,7 @@ struct IOSGatewayChatTransportTests {
106128
}
107129

108130
@Test func `patch session params include selected global agent`() throws {
109-
let params = try self.object(
131+
let params = try object(
110132
from: IOSGatewayChatTransport.makePatchSessionParamsJSON(
111133
key: "global",
112134
agentId: "reviewer",
@@ -117,7 +139,7 @@ struct IOSGatewayChatTransportTests {
117139
}
118140

119141
@Test func `fork session params preserve parent agent`() throws {
120-
let params = try self.object(
142+
let params = try object(
121143
from: IOSGatewayChatTransport.makeForkSessionParamsJSON(
122144
parentKey: "agent:reviewer:telegram:group:1",
123145
agentId: "reviewer"))
@@ -127,7 +149,7 @@ struct IOSGatewayChatTransportTests {
127149
}
128150

129151
@Test func `session model patch params include model and selected agent`() throws {
130-
let params = try self.object(
152+
let params = try object(
131153
from: IOSGatewayChatTransport.makeSessionPatchModelParamsJSON(
132154
sessionKey: "global",
133155
agentId: "reviewer",
@@ -138,7 +160,7 @@ struct IOSGatewayChatTransportTests {
138160
}
139161

140162
@Test func `session model patch params encode default model as null`() throws {
141-
let params = try self.object(
163+
let params = try object(
142164
from: IOSGatewayChatTransport.makeSessionPatchModelParamsJSON(
143165
sessionKey: "agent:main:main",
144166
model: nil))
@@ -261,6 +283,52 @@ struct IOSGatewayChatTransportTests {
261283
selectedAgentID: "reviewer") == .init(sessionKey: "agent::main", agentID: nil))
262284
}
263285

286+
@Test func `session mutations dispatch normalized selected agent targets`() async throws {
287+
let recorder = RequestRecorder()
288+
let transport = IOSGatewayChatTransport(
289+
gateway: GatewayNodeSession(),
290+
globalAgentId: " Reviewer ",
291+
sessionMutationRequest: { method, paramsJSON, timeoutSeconds in
292+
await recorder.record(
293+
method: method,
294+
paramsJSON: paramsJSON,
295+
timeoutSeconds: timeoutSeconds)
296+
})
297+
298+
for key in ["Matrix:Channel:Room", "global", "agent:ops:main"] {
299+
try await transport.patchSession(key: key, pinned: true)
300+
try await transport.deleteSession(key: key)
301+
_ = try await transport.forkSession(parentKey: key)
302+
}
303+
304+
let requests = await recorder.all()
305+
#expect(requests.map(\.method) == Array(
306+
repeating: ["sessions.patch", "sessions.delete", "sessions.create"],
307+
count: 3).flatMap(\.self))
308+
#expect(requests.map(\.timeoutSeconds) == Array(repeating: 15, count: 9))
309+
310+
for (offset, expectedKey, expectedMutationAgentID, expectedForkAgentID) in [
311+
(0, "agent:reviewer:Matrix:Channel:Room", nil, "reviewer"),
312+
(3, "global", "reviewer", "reviewer"),
313+
(6, "agent:ops:main", nil, "ops"),
314+
] as [(Int, String, String?, String?)] {
315+
let patch = try object(from: requests[offset].paramsJSON)
316+
#expect(patch["key"] as? String == expectedKey)
317+
#expect(patch["agentId"] as? String == expectedMutationAgentID)
318+
#expect(patch["pinned"] as? Bool == true)
319+
320+
let delete = try object(from: requests[offset + 1].paramsJSON)
321+
#expect(delete["key"] as? String == expectedKey)
322+
#expect(delete["agentId"] as? String == expectedMutationAgentID)
323+
#expect(delete["deleteTranscript"] as? Bool == true)
324+
325+
let fork = try object(from: requests[offset + 2].paramsJSON)
326+
#expect(fork["parentSessionKey"] as? String == expectedKey)
327+
#expect(fork["agentId"] as? String == expectedForkAgentID)
328+
#expect(fork["fork"] as? Bool == true)
329+
}
330+
}
331+
264332
@Test func `requests fail fast when gateway not connected`() async {
265333
let gateway = GatewayNodeSession()
266334
let transport = IOSGatewayChatTransport(gateway: gateway)

0 commit comments

Comments
 (0)