Skip to content

[Bug]: iOS sessions.delete/patch/fork skip the agent-scoping applied to chat.send, targeting the wrong session #103366

Description

@harjothkhara

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

  1. Connect the iOS app to a gateway with more than one agent and select a non-default agent (so globalAgentId is set).
  2. 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).
  3. Send a message — it lands in agent:<id>:Matrix:Channel:!Room:example.org.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions