Summary
On iOS, sessions.delete, sessions.patch, and sessions.create (fork) skip the agent-scoping normalization that chat.send and chat.history apply, so with a selected agent the app chats to agent:<id>:<key> but deletes, renames, archives, pins, and forks the unscoped <key> — a different session.
Bug type
Behavior bug (incorrect output/state without crash).
Evidence (source review)
IOSGatewayChatTransport has two different ways of deriving the target agent, and they disagree.
Path A — sessionTarget, used by send/history/reset/subscribe/create. It rewrites an unscoped key into an agent-scoped one:
apps/ios/Sources/Chat/IOSGatewayChatTransport.swift:390
let targetAgentID = override ?? (selected?.isEmpty == false ? selected : nil)
if sessionKey.lowercased() == "global" {
return SessionTarget(sessionKey: sessionKey, agentID: targetAgentID)
}
if let targetAgentID {
return SessionTarget(sessionKey: "agent:\(targetAgentID):\(sessionKey)", agentID: nil)
}
Confirmed by the colocated test at apps/ios/Tests/IOSGatewayChatTransportTests.swift:241:
#expect(IOSGatewayChatTransport.sessionTarget(
for: "main",
selectedAgentID: "main") == .init(sessionKey: "agent:main:main", agentID: nil))
Path B — selectedGlobalAgentId, used by delete/patch/fork. It returns the selected agent only for the literal key "global", and never rewrites the key:
apps/ios/Sources/Chat/IOSGatewayChatTransport.swift:379
private func selectedGlobalAgentId(for sessionKey: String) -> String? {
sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "global"
? self.globalAgentId
: nil
}
apps/ios/Sources/Chat/IOSGatewayChatTransport.swift:500
func deleteSession(key: String) async throws {
let json = try Self.encodeParams(DeleteSessionParams(
key: key, // <-- raw, unscoped
deleteTranscript: true,
agentId: self.selectedGlobalAgentId(for: key))) // <-- nil unless key == "global"
_ = try await self.gateway.request(method: "sessions.delete", paramsJSON: json, timeoutSeconds: 15)
}
patchSession (ShareViewController sibling at IOSGatewayChatTransport.swift:481) and forkSession (:508) have the same shape. Compare resetSession (:530) and requestHistory (:550), which both go through sessionTarget.
Reachability. The shared view model passes unscoped keys through verbatim — this is not hypothetical:
apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelSessionDeletionTests.swift:88
vm.deleteSession("scratch")
...
#expect(transport.deletedKeys == ["scratch"])
So with globalAgentId == "ops" and an unscoped session key scratch:
| Operation |
Key sent to gateway |
agentId |
chat.send |
agent:ops:scratch |
nil |
chat.history |
agent:ops:scratch |
nil |
sessions.reset |
agent:ops:scratch |
nil |
sessions.delete |
scratch |
nil |
sessions.patch |
scratch |
nil |
sessions.create (fork) |
scratch |
nil |
The bottom three address a different session than the one the user is looking at.
Why the tests miss it. Every existing test exercises the static param builders with an explicit agentId argument (IOSGatewayChatTransportTests.swift:108, :119, :129), never the instance methods' agent derivation. makeForkSessionParamsJSON(parentKey: "agent:reviewer:...", agentId: "reviewer") passes because the caller hand-supplied the agent that forkSession would have failed to derive.
Steps to reproduce
- Connect the iOS app to a gateway with more than one agent and select a non-default agent (so
globalAgentId is set).
- Open a session addressed by an unscoped key (e.g. a channel-derived key such as
Matrix:Channel:!Room:example.org, per the case in IOSGatewayChatTransportTests.swift:243).
- Send a message — it lands in
agent:<id>:Matrix:Channel:!Room:example.org.
- Delete, rename, archive, or pin that same session from the sessions list.
Expected behavior
All session operations address the same session the chat pane is bound to. sessions.delete should resolve the identical key that chat.send just wrote to.
Actual behavior
sessions.delete / sessions.patch / fork are dispatched against the unscoped key with agentId: nil. Depending on how the gateway resolves an unscoped key for a multi-agent config, the request either no-ops against a nonexistent session or mutates the wrong agent's session.
Impact and severity
- Affected: iOS users on a multi-agent gateway with a selected agent, operating on unscoped session keys.
- Severity: Potentially destructive —
deleteSession passes deleteTranscript: true (IOSGatewayChatTransport.swift:502). A delete aimed at the wrong session destroys a transcript.
- Frequency: Deterministic given a selected agent and an unscoped key; does not occur when the key is already
agent:-prefixed.
- Consequence: Rename/archive/pin appear to silently do nothing; delete may remove the wrong transcript.
Suggested direction
Route deleteSession, patchSession, and forkSession through the same sessionTarget(for:) normalization the send/history paths use, and delete selectedGlobalAgentId — it is a narrower duplicate of logic sessionTarget already implements correctly. Then add instance-level tests (not just param-builder tests) asserting the key actually dispatched for an unscoped key with a selected agent.
OpenClaw version
2026.6.11 (source review against main @ 6db586a388)
Operating system
iOS. Not reproduced on device — see below.
Additional information
Found by source review of apps/ios/Sources/Chat/IOSGatewayChatTransport.swift; not observed at runtime, so no gateway logs are attached. The code asymmetry and its reachability from OpenClawChatViewModel are both established from source and colocated tests. What I have not verified is how the gateway resolves an unscoped sessions.delete key under a multi-agent config — that determines whether the symptom is a silent no-op or a wrong-transcript deletion, and it is the thing worth checking first.
Reported with AI assistance (Claude Code); the analysis and line references were verified against the current source by the filer.
Summary
On iOS,
sessions.delete,sessions.patch, andsessions.create(fork) skip the agent-scoping normalization thatchat.sendandchat.historyapply, so with a selected agent the app chats toagent:<id>:<key>but deletes, renames, archives, pins, and forks the unscoped<key>— a different session.Bug type
Behavior bug (incorrect output/state without crash).
Evidence (source review)
IOSGatewayChatTransporthas two different ways of deriving the target agent, and they disagree.Path A —
sessionTarget, used by send/history/reset/subscribe/create. It rewrites an unscoped key into an agent-scoped one:apps/ios/Sources/Chat/IOSGatewayChatTransport.swift:390Confirmed by the colocated test at
apps/ios/Tests/IOSGatewayChatTransportTests.swift:241:Path B —
selectedGlobalAgentId, used by delete/patch/fork. It returns the selected agent only for the literal key"global", and never rewrites the key:apps/ios/Sources/Chat/IOSGatewayChatTransport.swift:379apps/ios/Sources/Chat/IOSGatewayChatTransport.swift:500patchSession(ShareViewControllersibling atIOSGatewayChatTransport.swift:481) andforkSession(:508) have the same shape. CompareresetSession(:530) andrequestHistory(:550), which both go throughsessionTarget.Reachability. The shared view model passes unscoped keys through verbatim — this is not hypothetical:
apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelSessionDeletionTests.swift:88So with
globalAgentId == "ops"and an unscoped session keyscratch:chat.sendagent:ops:scratchnilchat.historyagent:ops:scratchnilsessions.resetagent:ops:scratchnilsessions.deletescratchnilsessions.patchscratchnilsessions.create(fork)scratchnilThe bottom three address a different session than the one the user is looking at.
Why the tests miss it. Every existing test exercises the static param builders with an explicit
agentIdargument (IOSGatewayChatTransportTests.swift:108,:119,:129), never the instance methods' agent derivation.makeForkSessionParamsJSON(parentKey: "agent:reviewer:...", agentId: "reviewer")passes because the caller hand-supplied the agent thatforkSessionwould have failed to derive.Steps to reproduce
globalAgentIdis set).Matrix:Channel:!Room:example.org, per the case inIOSGatewayChatTransportTests.swift:243).agent:<id>:Matrix:Channel:!Room:example.org.Expected behavior
All session operations address the same session the chat pane is bound to.
sessions.deleteshould resolve the identical key thatchat.sendjust wrote to.Actual behavior
sessions.delete/sessions.patch/ fork are dispatched against the unscoped key withagentId: nil. Depending on how the gateway resolves an unscoped key for a multi-agent config, the request either no-ops against a nonexistent session or mutates the wrong agent's session.Impact and severity
deleteSessionpassesdeleteTranscript: true(IOSGatewayChatTransport.swift:502). A delete aimed at the wrong session destroys a transcript.agent:-prefixed.Suggested direction
Route
deleteSession,patchSession, andforkSessionthrough the samesessionTarget(for:)normalization the send/history paths use, and deleteselectedGlobalAgentId— it is a narrower duplicate of logicsessionTargetalready implements correctly. Then add instance-level tests (not just param-builder tests) asserting the key actually dispatched for an unscoped key with a selected agent.OpenClaw version
2026.6.11(source review againstmain@6db586a388)Operating system
iOS. Not reproduced on device — see below.
Additional information
Found by source review of
apps/ios/Sources/Chat/IOSGatewayChatTransport.swift; not observed at runtime, so no gateway logs are attached. The code asymmetry and its reachability fromOpenClawChatViewModelare both established from source and colocated tests. What I have not verified is how the gateway resolves an unscopedsessions.deletekey under a multi-agent config — that determines whether the symptom is a silent no-op or a wrong-transcript deletion, and it is the thing worth checking first.Reported with AI assistance (Claude Code); the analysis and line references were verified against the current source by the filer.