mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 00:52:10 -06:00
db9bdb4a11
* fix(sessions): fence lifecycle mutations by identity * fix(sessions): finalize lifecycle identity fences
557 lines
22 KiB
Swift
557 lines
22 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
import OpenClawProtocol
|
|
import Testing
|
|
@testable import OpenClaw
|
|
@testable import OpenClawChatUI
|
|
|
|
struct IOSGatewayChatTransportTests {
|
|
private actor RequestRecorder {
|
|
private var requests: [OpenClawChatGatewayRequest] = []
|
|
|
|
func record(_ request: OpenClawChatGatewayRequest) -> Data {
|
|
self.requests.append(request)
|
|
if request.method == "sessions.create" {
|
|
return Data(#"{"key":"forked"}"#.utf8)
|
|
}
|
|
return Data(#"{"entry":{}}"#.utf8)
|
|
}
|
|
|
|
func record(_ request: OpenClawChatGatewayRequest, response: Data) -> Data {
|
|
self.requests.append(request)
|
|
return response
|
|
}
|
|
|
|
func all() -> [OpenClawChatGatewayRequest] {
|
|
self.requests
|
|
}
|
|
}
|
|
|
|
@Test func `model patch result decodes authoritative Luna thinking state`() throws {
|
|
let data = Data(
|
|
#"""
|
|
{
|
|
"entry":{"thinkingLevel":"ultra"},
|
|
"resolved":{
|
|
"modelProvider":"openai",
|
|
"model":"gpt-5.6-luna",
|
|
"thinkingLevel":"max",
|
|
"thinkingLevels":[{"id":"off","label":"off"},{"id":"max","label":"max"}]
|
|
}
|
|
}
|
|
"""#.utf8)
|
|
|
|
let result = try IOSGatewayChatTransport.decodeModelPatchResult(data)
|
|
|
|
#expect(result.modelProvider == "openai")
|
|
#expect(result.model == "gpt-5.6-luna")
|
|
#expect(result.thinkingLevel == "max")
|
|
#expect(result.thinkingLevels?.map(\.id) == ["off", "max"])
|
|
}
|
|
|
|
@Test func `live routing guard permits an identity still loading`() {
|
|
#expect(OpenClawChatSessionRoutingContract.expectedValue(
|
|
nil,
|
|
serverSupportsGuard: true) == nil)
|
|
#expect(OpenClawChatSessionRoutingContract.expectedValue(
|
|
" per-sender|main|reviewer ",
|
|
serverSupportsGuard: true) == "per-sender|main|reviewer")
|
|
#expect(OpenClawChatSessionRoutingContract.expectedValue(
|
|
"per-sender|main|reviewer",
|
|
serverSupportsGuard: false) == nil)
|
|
}
|
|
|
|
@Test func `routing contract round trips a delimited legacy main key`() throws {
|
|
let contract = try #require(OpenClawChatSessionRoutingContract.make(
|
|
scope: "per-sender",
|
|
mainKey: "team|primary",
|
|
defaultAgentID: "main"))
|
|
let components = try #require(OpenClawChatSessionRoutingContract.parse(contract))
|
|
#expect(components.scope == "per-sender")
|
|
#expect(components.mainKey == "team|primary")
|
|
#expect(components.defaultAgentID == "main")
|
|
}
|
|
|
|
@Test func `hello advertises guarded chat send capability`() throws {
|
|
let data = Data(
|
|
#"""
|
|
{
|
|
"type":"hello-ok",
|
|
"protocol":4,
|
|
"server":{"version":"test","connId":"test"},
|
|
"features":{"methods":[],"events":[],"capabilities":["chat-send-routing-contract"]},
|
|
"snapshot":{
|
|
"presence":[],
|
|
"health":{},
|
|
"stateVersion":{"presence":0,"health":0},
|
|
"uptimeMs":0
|
|
},
|
|
"auth":{},
|
|
"policy":{}
|
|
}
|
|
"""#.utf8)
|
|
let hello = try JSONDecoder().decode(HelloOk.self, from: data)
|
|
#expect(hello.supportsServerCapability(.chatSendRoutingContract))
|
|
}
|
|
|
|
@Test func `session mutations dispatch normalized selected agent targets`() async throws {
|
|
let recorder = RequestRecorder()
|
|
let transport = IOSGatewayChatTransport(
|
|
gateway: GatewayNodeSession(),
|
|
globalAgentId: " Reviewer ",
|
|
sessionMutationRequest: { request in
|
|
await recorder.record(request)
|
|
})
|
|
|
|
for key in ["Matrix:Channel:Room", "global", "agent:ops:main"] {
|
|
try await transport.patchSession(key: key, pinned: true)
|
|
try await transport.deleteSession(key: key)
|
|
_ = try await transport.forkSession(parentKey: key)
|
|
}
|
|
|
|
let requests = await recorder.all()
|
|
#expect(requests.map(\.method) == Array(
|
|
repeating: ["sessions.patch", "sessions.delete", "sessions.create"],
|
|
count: 3).flatMap(\.self))
|
|
#expect(requests.map(\.timeoutMs) == Array(repeating: 15000, count: 9))
|
|
|
|
for (offset, expectedKey, expectedMutationAgentID, expectedForkAgentID) in [
|
|
(0, "agent:reviewer:Matrix:Channel:Room", nil, "reviewer"),
|
|
(3, "global", "reviewer", "reviewer"),
|
|
(6, "agent:ops:main", nil, "ops"),
|
|
] as [(Int, String, String?, String?)] {
|
|
let patch = requests[offset].params
|
|
#expect(patch["key"]?.value as? String == expectedKey)
|
|
#expect(patch["agentId"]?.value as? String == expectedMutationAgentID)
|
|
#expect(patch["pinned"]?.value as? Bool == true)
|
|
|
|
let delete = requests[offset + 1].params
|
|
#expect(delete["key"]?.value as? String == expectedKey)
|
|
#expect(delete["agentId"]?.value as? String == expectedMutationAgentID)
|
|
#expect(delete["deleteTranscript"]?.value as? Bool == true)
|
|
|
|
let fork = requests[offset + 2].params
|
|
#expect(fork["parentSessionKey"]?.value as? String == expectedKey)
|
|
#expect(fork["agentId"]?.value as? String == expectedForkAgentID)
|
|
#expect(fork["fork"]?.value as? Bool == true)
|
|
}
|
|
}
|
|
|
|
@Test func `archive and restore carry the observed session identity`() async throws {
|
|
let recorder = RequestRecorder()
|
|
let transport = IOSGatewayChatTransport(
|
|
gateway: GatewayNodeSession(),
|
|
globalAgentId: " Reviewer ",
|
|
sessionMutationRequest: { request in
|
|
await recorder.record(request)
|
|
})
|
|
|
|
try await transport.patchSession(
|
|
key: "global",
|
|
expectedSessionID: " session-a ",
|
|
archived: true)
|
|
try await transport.patchSession(
|
|
key: "global",
|
|
expectedSessionID: "session-a",
|
|
archived: false)
|
|
|
|
let requests = await recorder.all()
|
|
#expect(requests.map(\.method) == ["sessions.patch", "sessions.patch"])
|
|
#expect(requests.map(\.timeoutMs) == [600_000, 15_000])
|
|
#expect(requests.allSatisfy { $0.params["key"]?.value as? String == "global" })
|
|
#expect(requests.allSatisfy { $0.params["agentId"]?.value as? String == "reviewer" })
|
|
#expect(requests.allSatisfy { $0.params["expectedSessionId"]?.value as? String == "session-a" })
|
|
#expect(requests[0].params["archived"]?.value as? Bool == true)
|
|
#expect(requests[1].params["archived"]?.value as? Bool == false)
|
|
}
|
|
|
|
@Test func `thinking changes dispatch through selected agent session target`() async throws {
|
|
let recorder = RequestRecorder()
|
|
let transport = IOSGatewayChatTransport(
|
|
gateway: GatewayNodeSession(),
|
|
globalAgentId: " Reviewer ",
|
|
sessionMutationRequest: { request in
|
|
await recorder.record(request)
|
|
})
|
|
|
|
try await transport.setSessionThinking(sessionKey: "global", thinkingLevel: "high")
|
|
|
|
let request = try #require(await recorder.all().first)
|
|
#expect(request.method == "sessions.patch")
|
|
#expect(request.params["key"]?.value as? String == "global")
|
|
#expect(request.params["agentId"]?.value as? String == "reviewer")
|
|
#expect(request.params["thinkingLevel"]?.value as? String == "high")
|
|
}
|
|
|
|
@Test func `advanced session creation forwards agent worktree and base ref`() async throws {
|
|
let recorder = RequestRecorder()
|
|
let transport = IOSGatewayChatTransport(
|
|
gateway: GatewayNodeSession(),
|
|
globalAgentId: " Reviewer ",
|
|
sessionMutationRequest: { request in
|
|
await recorder.record(request)
|
|
})
|
|
|
|
let created = try await transport.createSession(
|
|
key: "agent:builder:ios-new",
|
|
label: "Build",
|
|
agentID: " Builder ",
|
|
parentSessionKey: "agent:builder:main",
|
|
worktree: true,
|
|
worktreeBaseRef: " origin/release ")
|
|
|
|
#expect(created.key == "forked")
|
|
let request = try #require(await recorder.all().first)
|
|
#expect(request.method == "sessions.create")
|
|
#expect(request.params["key"]?.value as? String == "agent:builder:ios-new")
|
|
#expect(request.params["label"]?.value as? String == "Build")
|
|
#expect(request.params["agentId"]?.value as? String == "builder")
|
|
#expect(request.params["parentSessionKey"]?.value as? String == "agent:builder:main")
|
|
#expect(request.params["worktree"]?.value as? Bool == true)
|
|
#expect(request.params["worktreeBaseRef"]?.value as? String == "origin/release")
|
|
}
|
|
|
|
@Test func `verbosity patches preserve set and clear values`() async throws {
|
|
let recorder = RequestRecorder()
|
|
let transport = IOSGatewayChatTransport(
|
|
gateway: GatewayNodeSession(),
|
|
globalAgentId: " Reviewer ",
|
|
sessionMutationRequest: { request in
|
|
await recorder.record(request)
|
|
})
|
|
|
|
_ = try await transport.patchSessionSettings(
|
|
sessionKey: "global",
|
|
agentID: nil,
|
|
patch: OpenClawChatSessionSettingsPatch(verboseLevel: .some("full")))
|
|
_ = try await transport.patchSessionSettings(
|
|
sessionKey: "global",
|
|
agentID: nil,
|
|
patch: OpenClawChatSessionSettingsPatch(verboseLevel: .some(nil)))
|
|
|
|
let requests = await recorder.all()
|
|
#expect(requests.count == 2)
|
|
#expect(requests.allSatisfy { $0.method == "sessions.patch" })
|
|
#expect(requests.allSatisfy { $0.params["key"]?.value as? String == "global" })
|
|
#expect(requests.allSatisfy { $0.params["agentId"]?.value as? String == "reviewer" })
|
|
#expect(requests[0].params["verboseLevel"]?.value as? String == "full")
|
|
#expect(requests[1].params["verboseLevel"]?.value is NSNull)
|
|
#expect(requests.allSatisfy { $0.params["model"] == nil })
|
|
#expect(requests.allSatisfy { $0.params["thinkingLevel"] == nil })
|
|
}
|
|
|
|
@Test func `fast mode patches preserve boolean and explicit null`() async throws {
|
|
let recorder = RequestRecorder()
|
|
let transport = IOSGatewayChatTransport(
|
|
gateway: GatewayNodeSession(),
|
|
globalAgentId: " Reviewer ",
|
|
sessionMutationRequest: { request in
|
|
await recorder.record(request)
|
|
})
|
|
|
|
_ = try await transport.patchSessionSettings(
|
|
sessionKey: "global",
|
|
agentID: nil,
|
|
patch: OpenClawChatSessionSettingsPatch(fastMode: .some(.on)))
|
|
_ = try await transport.patchSessionSettings(
|
|
sessionKey: "global",
|
|
agentID: nil,
|
|
patch: OpenClawChatSessionSettingsPatch(fastMode: .some(nil)))
|
|
|
|
let requests = await recorder.all()
|
|
#expect(requests.count == 2)
|
|
#expect(requests[0].params["fastMode"]?.value as? Bool == true)
|
|
#expect(requests[1].params["fastMode"]?.value is NSNull)
|
|
#expect(requests.allSatisfy { $0.params["verboseLevel"] == nil })
|
|
}
|
|
|
|
@Test func `session groups lease uses the supplied pinned request path`() async throws {
|
|
let recorder = RequestRecorder()
|
|
let lease = IOSGatewayChatTransport.makeSessionGroupsRouteLease { request in
|
|
let response = if request.method == "sessions.groups.list" {
|
|
Data(#"{"groups":[{"name":"Work","position":0}]}"#.utf8)
|
|
} else {
|
|
Data(#"{"ok":true,"groups":[{"name":"Projects","position":0}]}"#.utf8)
|
|
}
|
|
return await recorder.record(request, response: response)
|
|
}
|
|
|
|
let listed = try await lease.listGroups()
|
|
let put = try await lease.putGroups(names: ["Work", "Personal"])
|
|
let renamed = try await lease.renameGroup(name: "Work", to: "Projects")
|
|
let deleted = try await lease.deleteGroup(name: "Personal")
|
|
|
|
#expect(listed?.groups.map(\.name) == ["Work"])
|
|
#expect(put.groups.map(\.name) == ["Projects"])
|
|
#expect(renamed.groups.map(\.name) == ["Projects"])
|
|
#expect(deleted.groups.map(\.name) == ["Projects"])
|
|
let requests = await recorder.all()
|
|
#expect(requests.map(\.method) == [
|
|
"sessions.groups.list",
|
|
"sessions.groups.put",
|
|
"sessions.groups.rename",
|
|
"sessions.groups.delete",
|
|
])
|
|
#expect(requests[1].params["names"]?.value as? [String] == ["Work", "Personal"])
|
|
#expect(requests[2].params["name"]?.value as? String == "Work")
|
|
#expect(requests[2].params["to"]?.value as? String == "Projects")
|
|
#expect(requests[3].params["name"]?.value as? String == "Personal")
|
|
}
|
|
|
|
@Test func `requests fail fast when gateway not connected`() async {
|
|
let gateway = GatewayNodeSession()
|
|
let transport = IOSGatewayChatTransport(gateway: gateway)
|
|
|
|
do {
|
|
_ = try await transport.requestHistory(sessionKey: "node-test")
|
|
Issue.record("Expected requestHistory to throw when gateway not connected")
|
|
} catch {}
|
|
|
|
do {
|
|
_ = try await transport.sendMessage(
|
|
sessionKey: "node-test",
|
|
message: "hello",
|
|
thinking: "low",
|
|
idempotencyKey: "idempotency",
|
|
attachments: [])
|
|
Issue.record("Expected sendMessage to throw when gateway not connected")
|
|
} catch {}
|
|
|
|
do {
|
|
_ = try await transport.sendMessage(
|
|
sessionKey: "node-test",
|
|
agentID: "main",
|
|
expectedSessionRoutingContract: "per-sender|main|main",
|
|
message: "hello",
|
|
thinking: "low",
|
|
idempotencyKey: "guarded-idempotency",
|
|
attachments: [])
|
|
Issue.record("Expected guarded sendMessage to fail before dispatch")
|
|
} catch is OpenClawChatTransportSendError {
|
|
// Expected: a missing route never reached chat.send.
|
|
} catch {
|
|
Issue.record("Expected a typed pre-dispatch failure, got \(error)")
|
|
}
|
|
|
|
do {
|
|
_ = try await transport.requestHealth(timeoutMs: 250)
|
|
Issue.record("Expected requestHealth to throw when gateway not connected")
|
|
} catch {}
|
|
|
|
do {
|
|
try await transport.resetSession(sessionKey: "node-test")
|
|
Issue.record("Expected resetSession to throw when gateway not connected")
|
|
} catch {}
|
|
|
|
do {
|
|
try await transport.setActiveSessionKey("node-test")
|
|
Issue.record("Expected setActiveSessionKey to throw when gateway not connected")
|
|
} catch {}
|
|
}
|
|
|
|
@Test func `maps session message event to session message`() {
|
|
let payload = AnyCodable([
|
|
"sessionKey": AnyCodable("agent:main:main"),
|
|
"agentId": AnyCodable("main"),
|
|
"messageId": AnyCodable("msg-1"),
|
|
"messageSeq": AnyCodable(7),
|
|
"message": AnyCodable([
|
|
"role": AnyCodable("assistant"),
|
|
"content": AnyCodable([
|
|
AnyCodable([
|
|
"type": AnyCodable("text"),
|
|
"text": AnyCodable("agent reply"),
|
|
]),
|
|
]),
|
|
"timestamp": AnyCodable(1234.5),
|
|
]),
|
|
])
|
|
let frame = EventFrame(
|
|
type: "event",
|
|
event: "session.message",
|
|
payload: payload,
|
|
seq: 1,
|
|
stateversion: nil)
|
|
let mapped = OpenClawChatGatewayPayloadCodec.event(from: frame)
|
|
|
|
switch mapped {
|
|
case let .sessionMessage(message):
|
|
#expect(message.sessionKey == "agent:main:main")
|
|
#expect(message.agentId == "main")
|
|
#expect(message.messageId == "msg-1")
|
|
#expect(message.messageSeq == 7)
|
|
#expect(message.message?.role == "assistant")
|
|
#expect(message.message?.content.first?.text == "agent reply")
|
|
#expect(message.message?.transcriptMessageID == "msg-1")
|
|
default:
|
|
Issue.record("expected .sessionMessage from session.message event, got \(String(describing: mapped))")
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `canonical transcript identity deduplicates replayed assistant messages`() {
|
|
let original = Self.canonicalAssistantMessage(timestamp: 1234.5)
|
|
let replay = Self.canonicalAssistantMessage(timestamp: 5678.5)
|
|
|
|
let messages = OpenClawChatViewModel.dedupeMessages([original, replay])
|
|
|
|
#expect(messages.count == 1)
|
|
#expect(messages.first?.transcriptMessageID == "canonical-assistant-1")
|
|
}
|
|
|
|
@Test @MainActor func `distinct transcript identities preserve identical assistant replies`() {
|
|
let first = Self.canonicalAssistantMessage(timestamp: 1234.5)
|
|
let second = Self.canonicalAssistantMessage(
|
|
timestamp: 1234.5,
|
|
transcriptMessageID: "canonical-assistant-2")
|
|
|
|
let messages = OpenClawChatViewModel.dedupeMessages([first, second])
|
|
|
|
#expect(messages.count == 2)
|
|
#expect(messages.map(\.transcriptMessageID) == ["canonical-assistant-1", "canonical-assistant-2"])
|
|
}
|
|
|
|
@Test @MainActor func `history reconciles a replay by its canonical transcript identity`() {
|
|
let original = Self.canonicalAssistantMessage(timestamp: 1234.5)
|
|
let replay = Self.canonicalAssistantMessage(timestamp: 5678.5)
|
|
|
|
let messages = OpenClawChatViewModel.reconcileMessageIDs(
|
|
previous: [original],
|
|
incoming: [replay])
|
|
|
|
#expect(messages.count == 1)
|
|
#expect(messages.first?.id == original.id)
|
|
#expect(messages.first?.timestamp == replay.timestamp)
|
|
#expect(messages.first?.transcriptMessageID == "canonical-assistant-1")
|
|
}
|
|
|
|
@Test @MainActor func `canonical adoption keeps the durable transcript identity`() {
|
|
let existing = OpenClawChatMessage(
|
|
role: "assistant",
|
|
content: [Self.assistantText],
|
|
timestamp: 1234.5)
|
|
let incoming = Self.canonicalAssistantMessage(timestamp: 5678.5)
|
|
|
|
let adopted = OpenClawChatViewModel.adoptingCanonicalMessage(incoming, over: existing)
|
|
|
|
#expect(adopted.id == existing.id)
|
|
#expect(adopted.timestamp == incoming.timestamp)
|
|
#expect(adopted.transcriptMessageID == "canonical-assistant-1")
|
|
}
|
|
|
|
@Test @MainActor func `user idempotency still reconciles an optimistic canonical echo`() {
|
|
let original = OpenClawChatMessage(
|
|
role: "user",
|
|
content: [Self.assistantText],
|
|
timestamp: 1234.5,
|
|
idempotencyKey: "run-1:user")
|
|
let echo = OpenClawChatMessage(
|
|
role: "user",
|
|
content: [Self.assistantText],
|
|
timestamp: 5678.5,
|
|
transcriptMessageID: "canonical-user-1",
|
|
idempotencyKey: "run-1:user")
|
|
|
|
let messages = OpenClawChatViewModel.reconcileMessageIDs(
|
|
previous: [original],
|
|
incoming: [echo])
|
|
|
|
#expect(messages.count == 1)
|
|
#expect(messages.first?.id == original.id)
|
|
#expect(messages.first?.transcriptMessageID == "canonical-user-1")
|
|
}
|
|
|
|
private static var assistantText: OpenClawChatMessageContent {
|
|
OpenClawChatMessageContent(
|
|
type: "text",
|
|
text: "agent reply",
|
|
mimeType: nil,
|
|
fileName: nil,
|
|
content: nil)
|
|
}
|
|
|
|
private static func canonicalAssistantMessage(
|
|
timestamp: Double,
|
|
transcriptMessageID: String = "canonical-assistant-1") -> OpenClawChatMessage
|
|
{
|
|
OpenClawChatMessage(
|
|
role: "assistant",
|
|
content: [self.assistantText],
|
|
timestamp: timestamp,
|
|
transcriptMessageID: transcriptMessageID)
|
|
}
|
|
|
|
@Test func `maps sessions changed event to authoritative refresh signal`() {
|
|
let payload = AnyCodable([
|
|
"sessionKey": AnyCodable("agent:main:main"),
|
|
"agentId": AnyCodable("main"),
|
|
"reason": AnyCodable("command-metadata"),
|
|
])
|
|
let frame = EventFrame(
|
|
type: "event",
|
|
event: "sessions.changed",
|
|
payload: payload,
|
|
seq: 1,
|
|
stateversion: nil)
|
|
|
|
let mapped = OpenClawChatGatewayPayloadCodec.event(from: frame)
|
|
guard case let .sessionsChanged(change) = mapped else {
|
|
Issue.record("expected .sessionsChanged, got \(String(describing: mapped))")
|
|
return
|
|
}
|
|
#expect(change == .init(
|
|
sessionKey: "agent:main:main",
|
|
agentId: "main",
|
|
reason: "command-metadata"))
|
|
}
|
|
|
|
@Test func `maps chat event to chat`() {
|
|
let payload = AnyCodable([
|
|
"runId": AnyCodable("run-1"),
|
|
"sessionKey": AnyCodable("main"),
|
|
"state": AnyCodable("final"),
|
|
])
|
|
let frame = EventFrame(type: "event", event: "chat", payload: payload, seq: 1, stateversion: nil)
|
|
let mapped = OpenClawChatGatewayPayloadCodec.event(from: frame)
|
|
|
|
switch mapped {
|
|
case let .chat(chat):
|
|
#expect(chat.runId == "run-1")
|
|
#expect(chat.sessionKey == "main")
|
|
#expect(chat.state == "final")
|
|
default:
|
|
Issue.record("expected .chat from chat event, got \(String(describing: mapped))")
|
|
}
|
|
}
|
|
|
|
@Test func `maps unknown event to nil`() {
|
|
let frame = EventFrame(
|
|
type: "event",
|
|
event: "unknown",
|
|
payload: AnyCodable(["a": AnyCodable(1)]),
|
|
seq: 1,
|
|
stateversion: nil)
|
|
let mapped = OpenClawChatGatewayPayloadCodec.event(from: frame)
|
|
#expect(mapped == nil)
|
|
}
|
|
}
|
|
|
|
struct LocalFixtureChatTransportTests {
|
|
@Test func `sent user turn carries gateway idempotency metadata`() async throws {
|
|
let transport = LocalFixtureChatTransport(fixture: .appleReviewDemo)
|
|
|
|
_ = try await transport.sendMessage(
|
|
sessionKey: "main",
|
|
message: "hello",
|
|
thinking: "auto",
|
|
idempotencyKey: "fixture-run",
|
|
attachments: [])
|
|
let history = try await transport.requestHistory(sessionKey: "main")
|
|
let decoded = try #require(history.messages).compactMap { payload -> OpenClawChatMessage? in
|
|
guard let data = try? JSONEncoder().encode(payload) else { return nil }
|
|
return try? JSONDecoder().decode(OpenClawChatMessage.self, from: data)
|
|
}
|
|
|
|
#expect(decoded.last(where: { $0.role == "user" })?.idempotencyKey == "fixture-run:user")
|
|
}
|
|
}
|