Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/Chat/ChatInteractorTests.swift
T
weishuandClaude Opus 5.5 5942b427a9 feat(ios): rename, delete and reopen sessions
The API wrappers existed but iOS had no UI for these actions, while
web and Android did. The session row's context menu and the chat's …
menu now offer Rename for every session. Active sessions keep Archive.
Inactive sessions get Reopen and a confirmed Delete instead of
Archive, which the hub rejects with 409 for them.

The store applies renames and deletes optimistically. A failed rename
refetches server state; a failed delete restores the row and the
cached detail. Reopen marks the returned session active right away.
When reopen returns a superseding id, the chat seeds the new window,
moves the draft and navigates to it, as send-resume already does. A
reopen from the list also moves the draft and opens the session. A
422 shows the hub's message with the missing fields.

On iPhone, an authoritative removal (delete, or archive elsewhere) now
pops that chat, matching the iPad split, which already clears the
selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:00:00 +08:00

1193 lines
53 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking // URLSession types live here on Linux
#endif
import HapiClient
import HapiProtocol
import Testing
// Transcription of the Android reference suite
// (`ChatViewModelInteractionTest.kt`) against the iOS `ChatInteractor`. The
// interactor runs over the REAL `APIClient`/`AuthManager`/`SessionListStore`/
// `MessageWindowControllers`, with only HTTP scripted (route-based recording
// performer) — so wire bodies are asserted as the canonical JSON the app
// actually sends (`HapiJSON.encoder` sorts keys deterministically).
private let chatSessionID = "sess-1"
// MARK: - Routing performer
/// Answers by (method, exact path) — scripted FIFOs first, then persistent
/// exact routes, then persistent suffix handlers, then `200 {}` — and records
/// every exchange for body/path assertions.
private actor ChatRoutingPerformer: HTTPPerforming {
struct Exchange: Sendable {
let method: String
let path: String
let body: String?
}
private(set) var exchanges: [Exchange] = []
private var scripted: [String: [(status: Int, json: String)]] = [:]
private var served: [String: (status: Int, json: String)] = [:]
private var suffixRoutes: [(method: String, suffix: String, handler: @Sendable (URLRequest) -> (Int, String))] = []
private var gatedRoutes: [(method: String, suffix: String)] = []
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
/// One-shot response for an exact `path`, consumed in FIFO order.
func script(_ method: String, _ path: String, status: Int = 200, json: String = "{}") {
scripted["\(method) \(path)", default: []].append((status, json))
}
/// Persistent response for an exact `path` (later calls replace it).
func serve(_ method: String, _ path: String, status: Int = 200, json: String) {
served["\(method) \(path)"] = (status, json)
}
/// Persistent computed response for any path ending in `suffix`.
func serveSuffix(
_ method: String,
_ suffix: String,
handler: @escaping @Sendable (URLRequest) -> (Int, String)
) {
suffixRoutes.append((method, suffix, handler))
}
/// Parks matching requests (after recording them) until ``openGates()``
/// — in-flight-state tests.
func gate(_ method: String, pathSuffix: String) {
gatedRoutes.append((method, pathSuffix))
}
func openGates() {
gatedRoutes = []
let waiters = gateWaiters
gateWaiters = []
for waiter in waiters {
waiter.resume()
}
}
func bodies(_ method: String, pathSuffix: String) -> [String] {
exchanges
.filter { $0.method == method && $0.path.hasSuffix(pathSuffix) }
.compactMap(\.body)
}
func count(_ method: String, pathSuffix: String) -> Int {
exchanges.filter { $0.method == method && $0.path.hasSuffix(pathSuffix) }.count
}
func paths(_ method: String, pathSuffix: String) -> [String] {
exchanges.filter { $0.method == method && $0.path.hasSuffix(pathSuffix) }.map(\.path)
}
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let method = request.httpMethod ?? "GET"
let path = request.url?.path ?? ""
exchanges.append(Exchange(
method: method,
path: path,
body: request.httpBody.map { String(decoding: $0, as: UTF8.self) }
))
if gatedRoutes.contains(where: { $0.method == method && path.hasSuffix($0.suffix) }) {
await withCheckedContinuation { continuation in
gateWaiters.append(continuation)
}
}
let key = "\(method) \(path)"
let status: Int
let json: String
if var queue = scripted[key], !queue.isEmpty {
(status, json) = queue.removeFirst()
scripted[key] = queue
} else if let exact = served[key] {
(status, json) = exact
} else if let route = suffixRoutes.first(where: { $0.method == method && path.hasSuffix($0.suffix) }) {
(status, json) = route.handler(request)
} else {
(status, json) = (200, "{}")
}
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
) else {
throw URLError(.badURL)
}
return (Data(json.utf8), response)
}
}
// MARK: - Fakes & builders
@MainActor
private final class InMemoryChatDrafts: ChatDrafts {
var storage: [String: String] = [:]
func load(sessionId: String) -> String? { storage[sessionId] }
func save(sessionId: String, text: String) {
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
storage[sessionId] = nil
} else {
storage[sessionId] = text
}
}
func clear(sessionId: String) { storage[sessionId] = nil }
func move(fromSessionId: String, toSessionId: String) {
guard let draft = storage.removeValue(forKey: fromSessionId) else { return }
if storage[toSessionId]?.isEmpty != false {
storage[toSessionId] = draft
}
}
}
private func chatDetail(
id: String = chatSessionID,
flavor: String = "claude",
active: Bool = true,
thinking: Bool = false,
permissionMode: PermissionMode? = .default,
model: String? = nil,
effort: String? = nil,
agentState: AgentState? = nil
) -> Session {
Session(
id: id,
namespace: "default",
seq: 1,
createdAt: 1,
updatedAt: 1,
active: active,
activeAt: 0,
metadata: SessionMetadata(path: "/repo/app", host: "devbox", flavor: flavor),
metadataVersion: 1,
agentState: agentState,
agentStateVersion: 1,
thinking: thinking,
thinkingAt: 0,
model: model,
effort: effort,
permissionMode: permissionMode
)
}
private func bashRequest(_ command: String = "rm -rf build") -> AgentStateRequest {
AgentStateRequest(tool: "Bash", arguments: .object(["command": .string(command)]))
}
/// Server-echoed queued row (`id != localId`, explicit `invokedAt: null` via
/// the wire collapse in `WindowMessage(wire:)`).
private func queuedServerRow(id: String, localId: String, text: String) -> DecryptedMessage {
DecryptedMessage(
id: id,
seq: 7,
localId: localId,
content: .object([
"role": .string("user"),
"content": .object([
"type": .string("text"),
"text": .string(text),
]),
]),
createdAt: 500,
invokedAt: nil
)
}
private func messageJSON(id: String, localId: String, text: String, invokedAt: Int) -> String {
"""
{"id":"\(id)","seq":7,"localId":"\(localId)","createdAt":500,"invokedAt":\(invokedAt),\
"content":{"role":"user","content":{"type":"text","text":"\(text)"}}}
"""
}
// MARK: - Harness
@MainActor
private final class ChatInteractionHarness {
let performer: ChatRoutingPerformer
let api: APIClient
let store: SessionListStore
let windows: MessageWindowControllers
let drafts: InMemoryChatDrafts
let interactor: ChatInteractor
private(set) var events: [ChatInteractionEvent] = []
let sessionID: String
init(detail: Session = chatDetail(), activate: Bool = true) async throws {
sessionID = detail.id
let performer = ChatRoutingPerformer()
self.performer = performer
// Empty pages for every window sync; direction mirrors the query so
// an after-catch-up never masquerades as a reset (Android fake note).
await performer.serveSuffix("GET", "/messages") { request in
let items = request.url
.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems } ?? []
let names = Set(items.map(\.name))
let direction = names.contains("afterAt")
? "after"
: names.contains("beforeAt") ? "before" : "latest"
let json = """
{"messages":[],"page":{"direction":"\(direction)","limit":200,"epoch":1,\
"reset":false,"hasMore":false}}
"""
return (200, json)
}
// Queued-state reconcile: everything asked about is still queued.
await performer.serveSuffix("POST", "/messages/queued-state") { request in
struct Probe: Decodable { let localIds: [String] }
var queued = "[]"
if let body = request.httpBody,
let probe = try? JSONDecoder().decode(Probe.self, from: body) {
queued = "[" + probe.localIds.map { "\"\($0)\"" }.joined(separator: ",") + "]"
}
return (200, "{\"queuedLocalIds\":\(queued),\"invokedLocalMessages\":[]}")
}
await performer.serve("GET", "/api/sessions", json: "{\"sessions\":[]}")
// Uploads succeed with a filename-derived path (Android fake parity).
await performer.serveSuffix("POST", "/upload") { request in
struct UploadBody: Decodable { let filename: String }
let filename = request.httpBody
.flatMap { try? JSONDecoder().decode(UploadBody.self, from: $0) }?
.filename ?? "file"
return (200, "{\"success\":true,\"path\":\"/uploads/\(filename)\"}")
}
await performer.serveSuffix("POST", "/upload/delete") { _ in
(200, "{\"success\":true}")
}
let baseURL = try #require(URL(string: testHubURLString))
let credentials = InMemoryCredentialStore()
try credentials.store(HubCredentials(
hubUrl: testHubURLString,
accessToken: "access-token",
jwt: freshJWT()
))
let auth = AuthManager(
baseURL: baseURL,
credentialStore: credentials,
performer: performer,
now: { testNow }
)
let api = APIClient(baseURL: baseURL, authManager: auth, performer: performer)
self.api = api
store = SessionListStore(api: api)
windows = MessageWindowControllers(provider: api)
drafts = InMemoryChatDrafts()
var localIdCounter = 0
interactor = ChatInteractor(
sessionId: detail.id,
api: api,
sessionStore: store,
windows: windows,
drafts: drafts,
draftSaveDebounce: .milliseconds(10),
now: { 1_000 },
makeLocalId: {
localIdCounter += 1
return "local-\(localIdCounter)"
}
)
interactor.onEvent = { [weak self] event in
self?.events.append(event)
}
try await serveDetail(detail)
_ = try await store.loadSessionDetail(detail.id)
if activate {
interactor.activate()
}
}
/// Sets the detail `GET /api/sessions/:id` will answer from now on (the
/// "server truth" for rollbacks and settle-patches), without loading it.
func serveDetail(_ detail: Session) async throws {
let data = try HapiJSON.encoder.encode(SessionResponse(session: detail))
await performer.serve(
"GET",
"/api/sessions/\(detail.id)",
json: String(decoding: data, as: UTF8.self)
)
}
func window(_ sessionId: String? = nil) async -> MessageWindowController {
await windows.open(sessionId: sessionId ?? sessionID)
}
func row(localId: String, in sessionId: String? = nil) async -> WindowMessage? {
await window(sessionId).state.messages.first { $0.localId == localId }
}
}
/// Polls `condition` (10 ms cadence) until true or `timeout`; returns the
/// final verdict for `#expect`.
@MainActor
private func eventually(
timeout: Duration = .seconds(5),
_ condition: @MainActor () async -> Bool
) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while clock.now < deadline {
if await condition() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return await condition()
}
// MARK: - Tests
@Suite("ChatInteractor")
@MainActor
struct ChatInteractorTests {
// MARK: Send
@Test func optimisticSendHappyPathPostsQueueDeliveryAndSettlesToSent() async throws {
let harness = try await ChatInteractionHarness()
harness.interactor.setComposerText("hello agent")
harness.interactor.sendMessage()
#expect(await eventually {
await harness.row(localId: "local-1")?.status == .sent
})
let sends = await harness.performer.bodies("POST", pathSuffix: "/api/sessions/sess-1/messages")
#expect(sends == [#"{"deliveryMode":"queue","localId":"local-1","text":"hello agent"}"#])
// Optimistic until the SSE echo replaces it.
#expect(await harness.row(localId: "local-1")?.id == "local-1")
#expect(harness.interactor.composerText.isEmpty)
#expect(harness.drafts.storage[chatSessionID] == nil)
}
@Test func sendWhileThinkingSettlesToQueuedAndSteerIntentRidesTheWire() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(thinking: true))
harness.interactor.setComposerText("steer this")
harness.interactor.sendMessage(steer: true)
#expect(await eventually {
await harness.row(localId: "local-1")?.status == .queued
})
let sends = await harness.performer.bodies("POST", pathSuffix: "/api/sessions/sess-1/messages")
#expect(sends == [#"{"deliveryMode":"steer","localId":"local-1","text":"steer this"}"#])
}
@Test func failedSendMarksTheRowFailedAndRetryRefiresWithTheSameLocalId() async throws {
let harness = try await ChatInteractionHarness()
await harness.performer.script(
"POST", "/api/sessions/sess-1/messages",
status: 500, json: #"{"error":"boom"}"#
)
harness.interactor.setComposerText("try me")
harness.interactor.sendMessage()
#expect(await eventually {
await harness.row(localId: "local-1")?.status == .failed
})
harness.interactor.retryFailedMessage(localId: "local-1")
#expect(await eventually {
await harness.row(localId: "local-1")?.status == .sent
})
let sends = await harness.performer.bodies("POST", pathSuffix: "/api/sessions/sess-1/messages")
#expect(sends.count == 2)
// Retry never re-binds a steer intent; durable queue only.
#expect(sends[1] == #"{"deliveryMode":"queue","localId":"local-1","text":"try me"}"#)
}
@Test func sessionInactiveResumesOnceAndRetriesAgainstTheSameId() async throws {
let harness = try await ChatInteractionHarness(
detail: chatDetail(active: false, permissionMode: .acceptEdits)
)
await harness.performer.script(
"POST", "/api/sessions/sess-1/messages",
status: 409, json: #"{"error":"Session is inactive","code":"session_inactive"}"#
)
await harness.performer.serve(
"POST", "/api/sessions/sess-1/resume",
json: #"{"sessionId":"sess-1"}"#
)
harness.interactor.setComposerText("wake up")
harness.interactor.sendMessage()
#expect(await eventually {
await harness.row(localId: "local-1")?.status == .sent
})
let resumes = await harness.performer.bodies("POST", pathSuffix: "/resume")
#expect(resumes == [#"{"permissionMode":"acceptEdits"}"#])
let sendCount = await harness.performer.count("POST", pathSuffix: "/api/sessions/sess-1/messages")
#expect(sendCount == 2)
// Resume success reflects activity locally.
#expect(harness.store.detail(for: chatSessionID)?.active == true)
}
@Test func sessionInactiveResumeWithASupersedingIdMigratesTheSendAndEmitsTheEvent() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(active: false))
await harness.performer.script(
"POST", "/api/sessions/sess-1/messages",
status: 409, json: #"{"error":"Session is inactive","code":"session_inactive"}"#
)
await harness.performer.serve(
"POST", "/api/sessions/sess-1/resume",
json: #"{"sessionId":"sess-2"}"#
)
harness.interactor.setComposerText("follow me")
harness.interactor.sendMessage()
#expect(await eventually {
harness.events.contains(.sessionSuperseded(sessionId: "sess-2"))
})
// The retry targeted the superseding session…
let targetSends = await harness.performer.count("POST", pathSuffix: "/api/sessions/sess-2/messages")
#expect(targetSends == 1)
// …and the optimistic row lives (settled) in the new window only.
#expect(await eventually {
await harness.row(localId: "local-1", in: "sess-2")?.status == .sent
})
#expect(await harness.row(localId: "local-1") == nil)
// The draft was cleared before the send; migration must not resurrect it.
#expect(harness.drafts.storage["sess-2"] == nil)
}
// MARK: Attachments (A-M3f, transcribed from the Android VM suite)
private func preparedShot(id: String = "att-1", filename: String = "shot.jpg") -> PreparedAttachment {
PreparedAttachment(
id: id,
filename: filename,
mimeType: "image/jpeg",
bytes: Data([1, 2, 3]),
previewBytes: Data([7, 7])
)
}
/// The canonical send body for one `preparedShot` upload.
private func attachmentSendBody(text: String, filename: String = "shot.jpg") -> String {
let preview = AttachmentPolicy.dataUrl(mimeType: "image/jpeg", bytes: Data([7, 7]))
return "{\"attachments\":[{\"filename\":\"\(filename)\",\"id\":\"att-1\","
+ "\"mimeType\":\"image/jpeg\",\"path\":\"/uploads/\(filename)\","
+ "\"previewUrl\":\"\(preview)\",\"size\":3}],"
+ "\"deliveryMode\":\"queue\",\"localId\":\"local-1\",\"text\":\"\(text)\"}"
}
@Test func sendRidesReadyAttachmentsAsWireMetadataAndTheOptimisticRowCarriesThem() async throws {
let harness = try await ChatInteractionHarness()
harness.interactor.attachments.add(preparedShot())
#expect(await eventually { harness.interactor.attachments.allReady })
harness.interactor.setComposerText("see the screenshot")
harness.interactor.sendMessage()
#expect(await eventually {
await harness.row(localId: "local-1")?.status == .sent
})
let sends = await harness.performer.bodies("POST", pathSuffix: "/api/sessions/sess-1/messages")
#expect(sends == [attachmentSendBody(text: "see the screenshot")])
// The optimistic row carries the attachments — thumbnails render
// before the SSE echo replaces it.
let row = try #require(await harness.row(localId: "local-1"))
let normalized = try #require(normalizeDecryptedMessage(row.asDecryptedMessage))
guard case .user(_, let attachments) = normalized.content else {
Issue.record("expected a user row")
return
}
#expect(attachments?.map(\.filename) == ["shot.jpg"])
// The tray was consumed by the send.
#expect(harness.interactor.attachments.items.isEmpty)
}
@Test func attachmentsOnlySendPostsEmptyText() async throws {
let harness = try await ChatInteractionHarness()
harness.interactor.attachments.add(preparedShot())
#expect(await eventually { harness.interactor.attachments.allReady })
harness.interactor.sendMessage()
#expect(await eventually {
await harness.performer.count("POST", pathSuffix: "/api/sessions/sess-1/messages") == 1
})
// Wire allows text OR attachments — empty text rides along.
let sends = await harness.performer.bodies("POST", pathSuffix: "/api/sessions/sess-1/messages")
#expect(sends == [attachmentSendBody(text: "")])
}
@Test func sendRefusesWhileAnAttachmentUploadIsUnsettled() async throws {
let harness = try await ChatInteractionHarness()
await harness.performer.gate("POST", pathSuffix: "/upload")
harness.interactor.attachments.add(preparedShot())
// Upload started and parked on the gate.
#expect(await eventually {
await harness.performer.count("POST", pathSuffix: "/upload") == 1
})
harness.interactor.setComposerText("hold on")
harness.interactor.sendMessage()
#expect(await eventually {
harness.events.contains { event in
if case .notice(let message) = event { return message.contains("uploading") }
return false
}
})
let refusedSends = await harness.performer.count("POST", pathSuffix: "/api/sessions/sess-1/messages")
#expect(refusedSends == 0)
// The draft text and the chip both survive the refused send.
#expect(harness.interactor.composerText == "hold on")
#expect(harness.interactor.attachments.items.count == 1)
// Once the upload settles, the same send goes through with metadata.
await harness.performer.openGates()
#expect(await eventually { harness.interactor.attachments.allReady })
harness.interactor.sendMessage()
#expect(await eventually {
await harness.performer.count("POST", pathSuffix: "/api/sessions/sess-1/messages") == 1
})
let sends = await harness.performer.bodies("POST", pathSuffix: "/api/sessions/sess-1/messages")
#expect(sends == [attachmentSendBody(text: "hold on")])
}
@Test func failedSendRetryReSendsTheSameAttachmentsFromTheWireRow() async throws {
let harness = try await ChatInteractionHarness()
await harness.performer.script(
"POST", "/api/sessions/sess-1/messages",
status: 500, json: #"{"error":"boom"}"#
)
harness.interactor.attachments.add(preparedShot())
#expect(await eventually { harness.interactor.attachments.allReady })
harness.interactor.setComposerText("try again")
harness.interactor.sendMessage()
#expect(await eventually {
await harness.row(localId: "local-1")?.status == .failed
})
harness.interactor.retryFailedMessage(localId: "local-1")
#expect(await eventually {
await harness.row(localId: "local-1")?.status == .sent
})
let sends = await harness.performer.bodies("POST", pathSuffix: "/api/sessions/sess-1/messages")
#expect(sends.count == 2)
// Attachments round-tripped through the optimistic row's wire JSON —
// the retry body is byte-identical (canonical sorted keys).
#expect(sends[0] == sends[1])
#expect(sends[1].contains("/uploads/shot.jpg"))
}
@Test func removingAReadyChipDeletesTheHubUploadAndDiscardDropsTheRest() async throws {
let harness = try await ChatInteractionHarness()
harness.interactor.attachments.add(preparedShot(id: "att-1", filename: "a.jpg"))
#expect(await eventually { harness.interactor.attachments.allReady })
harness.interactor.attachments.remove("att-1")
#expect(await eventually {
await harness.performer.bodies("POST", pathSuffix: "/upload/delete")
== [#"{"path":"/uploads/a.jpg"}"#]
})
#expect(harness.interactor.attachments.items.isEmpty)
// Un-sent leftovers are discarded when the screen goes away for good.
harness.interactor.attachments.add(preparedShot(id: "att-2", filename: "b.jpg"))
#expect(await eventually { harness.interactor.attachments.allReady })
harness.interactor.discardAttachments()
#expect(await eventually {
await harness.performer.count("POST", pathSuffix: "/upload/delete") == 2
})
#expect(harness.interactor.attachments.items.isEmpty)
}
// MARK: Queued bar
@Test func queuedCancelInvokedRaceIngestsTheAuthoritativeRowAsSent() async throws {
let harness = try await ChatInteractionHarness()
await harness.window().ingestSSEMessages([
WindowMessage(wire: queuedServerRow(id: "srv-1", localId: "l-1", text: "queued text"))
])
#expect(await eventually {
harness.interactor.queuedRows.contains { $0.id == "srv-1" && $0.canAct }
})
await harness.performer.script(
"DELETE", "/api/sessions/sess-1/messages/srv-1",
json: #"{"status":"invoked","message":\#(messageJSON(id: "srv-1", localId: "l-1", text: "queued text", invokedAt: 900))}"#
)
harness.interactor.cancelQueuedMessage("srv-1")
#expect(await eventually { harness.interactor.queuedRows.isEmpty })
#expect(await eventually {
let row = await harness.row(localId: "l-1")
return row?.status == .sent && row?.invokedAtNumber == 900
})
let cancels = await harness.performer.count("DELETE", pathSuffix: "/messages/srv-1")
#expect(cancels == 1)
}
@Test func steerPostsAndAnInvokedAnswerReconcilesAMissedConsume() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(thinking: true))
await harness.window().ingestSSEMessages([
WindowMessage(wire: queuedServerRow(id: "srv-2", localId: "l-2", text: "steer me"))
])
#expect(await eventually {
harness.interactor.queuedRows.contains { $0.id == "srv-2" && $0.canSteer }
})
await harness.performer.script(
"POST", "/api/sessions/sess-1/messages/srv-2/steer",
json: #"{"status":"invoked","message":\#(messageJSON(id: "srv-2", localId: "l-2", text: "steer me", invokedAt: 950))}"#
)
harness.interactor.steerQueuedMessage("srv-2")
#expect(await eventually {
await harness.row(localId: "l-2")?.invokedAtNumber == 950
})
let steers = await harness.performer.count("POST", pathSuffix: "/messages/srv-2/steer")
#expect(steers == 1)
}
@Test func editCancelsAndPrefillsTheComposer() async throws {
let harness = try await ChatInteractionHarness()
await harness.window().ingestSSEMessages([
WindowMessage(wire: queuedServerRow(id: "srv-3", localId: "l-3", text: "edit me"))
])
#expect(await eventually {
harness.interactor.queuedRows.contains { $0.id == "srv-3" && $0.canAct }
})
await harness.performer.script(
"DELETE", "/api/sessions/sess-1/messages/srv-3",
json: #"{"status":"cancelled","localId":"l-3"}"#
)
harness.interactor.editQueuedMessage("srv-3")
#expect(await eventually { harness.interactor.composerText == "edit me" })
let cancels = await harness.performer.count("DELETE", pathSuffix: "/messages/srv-3")
#expect(cancels == 1)
}
// MARK: Permissions
@Test func claudeApproveBodiesMatchTheWebPermissionFooterExactly() async throws {
let requests: [String: AgentStateRequest] = [
"r-allow": bashRequest(),
"r-session": bashRequest("git push"),
"r-edits": AgentStateRequest(tool: "Edit", arguments: .object(["file_path": .string("/a")])),
"r-deny": bashRequest(),
]
let harness = try await ChatInteractionHarness(
detail: chatDetail(flavor: "claude", agentState: AgentState(requests: requests))
)
harness.interactor.resolvePermission(requestId: "r-allow", action: .allow)
#expect(await eventually { await harness.performer.count("POST", pathSuffix: "/approve") == 1 })
harness.interactor.resolvePermission(requestId: "r-session", action: .allowForSession)
#expect(await eventually { await harness.performer.count("POST", pathSuffix: "/approve") == 2 })
harness.interactor.resolvePermission(requestId: "r-edits", action: .allowAllEdits)
#expect(await eventually { await harness.performer.count("POST", pathSuffix: "/approve") == 3 })
harness.interactor.resolvePermission(requestId: "r-deny", action: .deny)
#expect(await eventually { await harness.performer.count("POST", pathSuffix: "/deny") == 1 })
let approveBodies = await harness.performer.bodies("POST", pathSuffix: "/approve")
#expect(approveBodies == [
#"{}"#,
#"{"allowTools":["Bash(git push)"]}"#,
#"{"mode":"acceptEdits"}"#,
])
let approvePaths = await harness.performer.paths("POST", pathSuffix: "/approve")
#expect(approvePaths == [
"/api/sessions/sess-1/permissions/r-allow/approve",
"/api/sessions/sess-1/permissions/r-session/approve",
"/api/sessions/sess-1/permissions/r-edits/approve",
])
let denyBodies = await harness.performer.bodies("POST", pathSuffix: "/deny")
#expect(denyBodies == [#"{}"#])
}
@Test func codexFamilyApproveAndAbortBodiesUseDecisions() async throws {
let requests: [String: AgentStateRequest] = [
"r-yes": AgentStateRequest(tool: "CodexBash", arguments: .object(["command": .string("ls")])),
"r-yes-session": AgentStateRequest(tool: "CodexBash"),
"r-abort": AgentStateRequest(tool: "CodexBash"),
]
let harness = try await ChatInteractionHarness(
detail: chatDetail(flavor: "codex", agentState: AgentState(requests: requests))
)
harness.interactor.resolvePermission(requestId: "r-yes", action: .allow)
#expect(await eventually { await harness.performer.count("POST", pathSuffix: "/approve") == 1 })
harness.interactor.resolvePermission(requestId: "r-yes-session", action: .allowForSession)
#expect(await eventually { await harness.performer.count("POST", pathSuffix: "/approve") == 2 })
harness.interactor.resolvePermission(requestId: "r-abort", action: .abort)
#expect(await eventually { await harness.performer.count("POST", pathSuffix: "/deny") == 1 })
let approveBodies = await harness.performer.bodies("POST", pathSuffix: "/approve")
#expect(approveBodies == [
#"{"decision":"approved"}"#,
#"{"decision":"approved_for_session"}"#,
])
let denyBodies = await harness.performer.bodies("POST", pathSuffix: "/deny")
#expect(denyBodies == [#"{"decision":"abort"}"#])
let denyPaths = await harness.performer.paths("POST", pathSuffix: "/deny")
#expect(denyPaths == ["/api/sessions/sess-1/permissions/r-abort/deny"])
}
@Test func answersPostFlatForAskUserQuestionAndNestedForRequestUserInput() async throws {
let requests: [String: AgentStateRequest] = [
"r-ask": AgentStateRequest(tool: "AskUserQuestion"),
"r-input": AgentStateRequest(tool: "request_user_input"),
]
let harness = try await ChatInteractionHarness(
detail: chatDetail(flavor: "claude", agentState: AgentState(requests: requests))
)
harness.interactor.resolvePermission(
requestId: "r-ask",
action: .flatAnswers(["0": ["Option A", "free text"]])
)
#expect(await eventually { await harness.performer.count("POST", pathSuffix: "/approve") == 1 })
harness.interactor.resolvePermission(
requestId: "r-input",
action: .nestedAnswers(["field1": ["Yes", "user_note: extra note"]])
)
#expect(await eventually { await harness.performer.count("POST", pathSuffix: "/approve") == 2 })
let approveBodies = await harness.performer.bodies("POST", pathSuffix: "/approve")
#expect(approveBodies == [
#"{"answers":{"0":["Option A","free text"]}}"#,
#"{"answers":{"field1":{"answers":["Yes","user_note: extra note"]}}}"#,
])
}
@Test func permission404BecomesTheBenignAlreadyHandledOverride() async throws {
let harness = try await ChatInteractionHarness(
detail: chatDetail(agentState: AgentState(requests: ["r-gone": bashRequest()]))
)
await harness.performer.script(
"POST", "/api/sessions/sess-1/permissions/r-gone/approve",
status: 404, json: #"{"error":"Request not found"}"#
)
harness.interactor.resolvePermission(requestId: "r-gone", action: .allow)
#expect(await eventually {
harness.interactor.permissionOverrides["r-gone"] == .alreadyHandled
})
#expect(harness.events.contains(.notice("Request was already handled")))
}
@Test func successfulResolveKeepsTheRowResolvingUntilTheAgentStatePatchSettlesIt() async throws {
let harness = try await ChatInteractionHarness(
detail: chatDetail(agentState: AgentState(requests: ["r-live": bashRequest()]))
)
harness.interactor.resolvePermission(requestId: "r-live", action: .allow)
#expect(await eventually {
harness.interactor.permissionOverrides["r-live"] == .resolving
})
#expect(await eventually { await harness.performer.count("POST", pathSuffix: "/approve") == 1 })
// Still resolving after the POST succeeded — only the patch settles it.
#expect(harness.interactor.permissionOverrides["r-live"] == .resolving)
// The agentState patch lands (request moved to completedRequests).
try await harness.serveDetail(chatDetail(agentState: AgentState(requests: [:])))
_ = try await harness.store.loadSessionDetail(chatSessionID)
#expect(harness.interactor.permissionOverrides.isEmpty)
}
// MARK: Config
@Test func collaborationModeSwitchesIndependentlyOfPermissionsAndSerializesChanges() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(flavor: "codex"), activate: false)
let interactor = harness.interactor
#expect(interactor.config.collaborationMode == nil)
#expect(interactor.config.canChangeCollaborationMode)
interactor.setCollaborationMode(.default)
#expect(await harness.performer.count("POST", pathSuffix: "/collaboration-mode") == 0)
await harness.performer.gate("POST", pathSuffix: "/collaboration-mode")
defer { Task { await harness.performer.openGates() } }
interactor.setCollaborationMode(.plan)
#expect(interactor.config.collaborationMode == .plan)
#expect(interactor.config.permissionMode == .default)
#expect(interactor.configOpPending)
interactor.setCollaborationMode(.default)
interactor.setModel("other")
#expect(await eventually { await harness.performer.count("POST", pathSuffix: "/collaboration-mode") == 1 })
#expect(await harness.performer.count("POST", pathSuffix: "/model") == 0)
await harness.performer.openGates()
#expect(await eventually { !interactor.configOpPending })
interactor.setCollaborationMode(.plan)
interactor.setCollaborationMode(.default)
#expect(await eventually { !interactor.configOpPending })
#expect(await harness.performer.bodies("POST", pathSuffix: "/collaboration-mode") == [#"{"mode":"plan"}"#, #"{"mode":"default"}"#])
#expect(interactor.config.permissionMode == .default)
harness.store.applySessionEvent(.sessionUpdated(namespace: nil, sessionId: chatSessionID, data: .patch(SessionPatch(collaborationMode: .plan))))
#expect(interactor.config.collaborationMode == .plan)
#expect(await harness.performer.count("POST", pathSuffix: "/collaboration-mode") == 2)
}
@Test func collaborationModeRejectsUnavailableSessionsButAllowsConcurrentTerminals() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(flavor: "claude"), activate: false)
let interactor = harness.interactor
#expect(!interactor.config.canChangeCollaborationMode)
interactor.setCollaborationMode(.plan)
harness.store.updateDetailLocal(chatSessionID) {
$0.metadata?.flavor = "codex"
$0.active = false
}
#expect(!interactor.config.canChangeCollaborationMode)
interactor.setCollaborationMode(.plan)
harness.store.updateDetailLocal(chatSessionID) {
$0.active = true
$0.agentState = AgentState(controlledByUser: true)
}
#expect(!interactor.config.canChangeCollaborationMode)
interactor.setCollaborationMode(.plan)
#expect(await harness.performer.count("POST", pathSuffix: "/collaboration-mode") == 0)
harness.store.updateDetailLocal(chatSessionID) {
$0.metadata?.capabilities = SessionCapabilities(concurrentClients: true)
}
#expect(interactor.config.canChangeCollaborationMode)
interactor.setCollaborationMode(.plan)
#expect(await eventually { !interactor.configOpPending })
#expect(await harness.performer.count("POST", pathSuffix: "/collaboration-mode") == 1)
harness.store.releaseDetail(chatSessionID)
#expect(!interactor.config.canChangeCollaborationMode)
}
@Test func rejectedCollaborationModeReloadsServerTruthAndReportsFailure() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(flavor: "codex"), activate: false)
await harness.performer.script("POST", "/api/sessions/sess-1/collaboration-mode", status: 409, json: #"{"error":"Config rejected"}"#)
harness.interactor.setCollaborationMode(.plan)
#expect(harness.interactor.config.collaborationMode == .plan)
#expect(await eventually { !harness.interactor.configOpPending })
#expect(harness.interactor.config.collaborationMode == nil)
#expect(harness.events.contains { if case .notice = $0 { return true }; return false })
}
@Test func permissionModeSwitchAppliesOptimisticallyAndRollsBackToServerTruthOnError() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(permissionMode: .default))
await harness.performer.script(
"POST", "/api/sessions/sess-1/permission-mode",
status: 409, json: #"{"error":"apply_failed","code":"apply_failed"}"#
)
harness.interactor.setPermissionMode(.acceptEdits)
// Optimistic flip is visible synchronously in the detail cache.
#expect(harness.store.detail(for: chatSessionID)?.permissionMode == .acceptEdits)
// Rollback = reload server truth (served detail carries `default`).
#expect(await eventually {
harness.store.detail(for: chatSessionID)?.permissionMode == .default
})
#expect(await eventually {
harness.events.contains { event in
if case .notice = event { return true }
return false
}
})
let modeBodies = await harness.performer.bodies("POST", pathSuffix: "/permission-mode")
#expect(modeBodies == [#"{"mode":"acceptEdits"}"#])
let detailLoads = await harness.performer.count("GET", pathSuffix: "/api/sessions/sess-1")
#expect(detailLoads >= 2)
}
@Test func modelAndEffortSwitchesRoutePerFlavor() async throws {
let harness = try await ChatInteractionHarness(
detail: chatDetail(flavor: "claude", model: "sonnet", effort: nil)
)
harness.interactor.setModel("opus")
#expect(harness.store.detail(for: chatSessionID)?.model == "opus")
#expect(await eventually {
await harness.performer.count("POST", pathSuffix: "/model") == 1
&& !harness.interactor.configOpPending
})
let modelBodies = await harness.performer.bodies("POST", pathSuffix: "/model")
#expect(modelBodies == [#"{"model":"opus"}"#])
harness.interactor.setEffort("high")
#expect(harness.store.detail(for: chatSessionID)?.effort == "high")
#expect(await eventually {
await harness.performer.count("POST", pathSuffix: "/effort") == 1
})
let effortBodies = await harness.performer.bodies("POST", pathSuffix: "/effort")
#expect(effortBodies == [#"{"effort":"high"}"#])
}
@Test func codexEffortRoutesToModelReasoningEffort() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(flavor: "codex"))
harness.interactor.setEffort("medium")
#expect(await eventually {
await harness.performer.count("POST", pathSuffix: "/model-reasoning-effort") == 1
})
let bodies = await harness.performer.bodies("POST", pathSuffix: "/model-reasoning-effort")
#expect(bodies == [#"{"modelReasoningEffort":"medium"}"#])
#expect(harness.store.detail(for: chatSessionID)?.modelReasoningEffort == "medium")
}
@Test func codexModelOptionsLoadThroughTheSessionCatalogEndpoint() async throws {
let harness = try await ChatInteractionHarness(
detail: chatDetail(flavor: "codex", model: "gpt-5.3-codex")
)
await harness.performer.serve(
"GET", "/api/sessions/sess-1/codex-models",
json: """
{"success":true,"models":[{"id":"gpt-5.3-codex","displayName":"GPT-5.3 Codex",\
"isDefault":true,"supportedReasoningEfforts":["low","medium","high"]}]}
"""
)
harness.interactor.loadModelOptions()
#expect(await eventually {
harness.interactor.config.modelOptions?.isEmpty == false
})
let config = harness.interactor.config
#expect(config.modelOptions?.count == 1)
#expect(config.modelOptions?.first?.value == "gpt-5.3-codex")
#expect(config.modelOptions?.first?.label == "GPT-5.3 Codex · default")
#expect(config.effortOptions?.map(\.value) == [nil, "low", "medium", "high"])
}
// MARK: Codex plans (client actions, not permissions)
private func planSession() -> Session {
var detail = chatDetail(flavor: "codex", agentState: AgentState(codexPlanProposalId: "plan-1"))
detail.metadata?.capabilities = SessionCapabilities(concurrentClients: true)
detail.collaborationMode = .plan
return detail
}
@Test func codexPlanActionsRequireCurrentSharedActiveCodexProposal() async throws {
let harness = try await ChatInteractionHarness(detail: planSession(), activate: false)
let interactor = harness.interactor
#expect(interactor.codexPlanActions(planId: "plan-1").canAct)
#expect(!interactor.codexPlanActions(planId: "historical").isVisible)
#expect(!interactor.codexPlanActions(planId: "child-plan").isVisible)
for modify: (inout Session) -> Void in [
{ $0.active = false },
{ $0.metadata?.flavor = "claude" },
{ $0.metadata?.capabilities = nil },
{ $0.agentState?.codexPlanProposalId = nil },
{ $0.agentState?.codexPlanProposalId = "newer-plan" },
] {
var detail = planSession()
modify(&detail)
harness.store.updateDetailLocal(chatSessionID) { $0 = detail }
#expect(!interactor.codexPlanActions(planId: "plan-1").isVisible)
interactor.implementCodexPlan(planId: "plan-1")
interactor.continueCodexPlan(planId: "plan-1")
}
#expect(interactor.composerFocusRequest == 0)
#expect(await harness.performer.count("POST", pathSuffix: "/codex/plan/implement") == 0)
}
@Test func continuePlanningDismissesActionsAndPreservesDraftAndMode() async throws {
let harness = try await ChatInteractionHarness(detail: planSession(), activate: false)
harness.interactor.setComposerText("Please refine step two")
harness.interactor.continueCodexPlan(planId: "plan-1")
#expect(harness.interactor.composerFocusRequest == 1)
#expect(harness.interactor.composerText == "Please refine step two")
#expect(harness.store.detail(for: chatSessionID)?.collaborationMode == .plan)
#expect(await harness.performer.exchanges.filter { $0.method == "POST" }.isEmpty)
#expect(!harness.interactor.codexPlanActions(planId: "plan-1").isVisible)
harness.interactor.continueCodexPlan(planId: "plan-1")
harness.interactor.implementCodexPlan(planId: "plan-1")
#expect(harness.interactor.composerFocusRequest == 1)
harness.store.updateDetailLocal(chatSessionID) { $0 = planSession() }
#expect(!harness.interactor.codexPlanActions(planId: "plan-1").isVisible)
harness.store.updateDetailLocal(chatSessionID) {
$0.agentState?.codexPlanProposalId = "plan-2"
}
#expect(harness.interactor.codexPlanActions(planId: "plan-2").canAct)
}
@Test func implementingPlanIsSingleFlightAndSurvivesWithdrawalAndStaleRefresh() async throws {
let harness = try await ChatInteractionHarness(detail: planSession(), activate: false)
let interactor = harness.interactor
await harness.performer.gate("POST", pathSuffix: "/codex/plan/implement")
interactor.setComposerText("Keep this draft")
interactor.implementCodexPlan(planId: "plan-1")
interactor.implementCodexPlan(planId: "plan-1")
interactor.continueCodexPlan(planId: "plan-1")
#expect(interactor.codexPlanActions(planId: "plan-1").pending)
#expect(!interactor.codexPlanActions(planId: "plan-1").canAct)
#expect(interactor.composerFocusRequest == 0)
#expect(await eventually {
await harness.performer.count("POST", pathSuffix: "/codex/plan/implement") == 1
})
harness.store.applySessionEvent(.sessionUpdated(namespace: nil, sessionId: chatSessionID, data: .patch(SessionPatch(
agentState: VersionedValue(version: 2, value: AgentState())
))))
#expect(!interactor.codexPlanActions(planId: "plan-1").available)
#expect(interactor.codexPlanActions(planId: "plan-1").pending)
await harness.performer.openGates()
#expect(await eventually { !interactor.codexPlanActions(planId: "plan-1").pending })
// The GET deliberately still serves the old id; acceptance must win.
#expect(!interactor.codexPlanActions(planId: "plan-1").isVisible)
interactor.implementCodexPlan(planId: "plan-1")
#expect(await harness.performer.bodies("POST", pathSuffix: "/codex/plan/implement") == [#"{"planId":"plan-1"}"#])
#expect(await harness.performer.exchanges.filter { $0.method == "POST" }.count == 1)
#expect(await harness.performer.count("GET", pathSuffix: "/api/sessions/sess-1") == 2)
#expect(interactor.composerText == "Keep this draft")
}
@Test(arguments: [(409, "stale_plan"), (409, "unavailable"), (502, "failed"), (503, "indeterminate")])
func planFailureRemainsVisibleAfterWithdrawalWithoutAutomaticResubmission(status: Int, code: String) async throws {
let harness = try await ChatInteractionHarness(detail: planSession(), activate: false)
await harness.performer.script("POST", "/api/sessions/sess-1/codex/plan/implement", status: status,
json: "{\"ok\":false,\"code\":\"\(code)\",\"error\":\"Plan was not confirmed\"}")
var next = planSession()
next.agentState?.codexPlanProposalId = nil
try await harness.serveDetail(next)
harness.interactor.implementCodexPlan(planId: "plan-1")
#expect(await eventually { !harness.interactor.codexPlanActions(planId: "plan-1").pending })
let state = harness.interactor.codexPlanActions(planId: "plan-1")
#expect(state.error == "Plan was not confirmed")
#expect(state.isVisible && !state.available && !state.canAct)
#expect(await harness.performer.count("POST", pathSuffix: "/codex/plan/implement") == 1)
#expect(await harness.performer.count("GET", pathSuffix: "/api/sessions/sess-1") == 2)
#expect(harness.store.detail(for: chatSessionID)?.collaborationMode == .plan)
}
@Test func confirmedPlanFailureCanBeRetriedExplicitly() async throws {
let harness = try await ChatInteractionHarness(detail: planSession(), activate: false)
await harness.performer.script("POST", "/api/sessions/sess-1/codex/plan/implement", status: 502,
json: #"{"ok":false,"code":"failed","error":"Try again"}"#)
harness.interactor.implementCodexPlan(planId: "plan-1")
#expect(await eventually { !harness.interactor.codexPlanActions(planId: "plan-1").pending })
#expect(harness.interactor.codexPlanActions(planId: "plan-1").canAct)
harness.interactor.implementCodexPlan(planId: "plan-1")
#expect(harness.interactor.codexPlanActions(planId: "plan-1").error == nil)
#expect(await eventually { !harness.interactor.codexPlanActions(planId: "plan-1").isVisible })
#expect(await harness.performer.count("POST", pathSuffix: "/codex/plan/implement") == 2)
}
// MARK: Misc
@Test func draftPersistsOnTypingAndRestoresOnOpen() async throws {
let harness = try await ChatInteractionHarness(activate: false)
harness.drafts.storage[chatSessionID] = "restored draft"
harness.interactor.activate()
#expect(await eventually { harness.interactor.composerText == "restored draft" })
harness.interactor.setComposerText("newer draft")
// Debounced persist (10 ms in this harness).
#expect(await eventually { harness.drafts.storage[chatSessionID] == "newer draft" })
}
@Test func abortPostsConfirmFree() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(thinking: true))
harness.interactor.abortSession()
#expect(await eventually {
await harness.performer.count("POST", pathSuffix: "/api/sessions/sess-1/abort") == 1
})
}
// MARK: Session lifecycle
@Test func renameTrimsAndPatchesTheNameAndAFailureEmitsANotice() async throws {
let harness = try await ChatInteractionHarness()
harness.interactor.renameSession(" ")
harness.interactor.renameSession(" Release prep ")
#expect(await eventually {
await harness.performer.bodies("PATCH", pathSuffix: "/api/sessions/sess-1") == [#"{"name":"Release prep"}"#]
})
#expect(harness.store.detail(for: chatSessionID)?.metadata?.name == "Release prep")
await harness.performer.script("PATCH", "/api/sessions/sess-1", status: 500, json: #"{"error":"boom"}"#)
harness.interactor.renameSession("Other")
#expect(await eventually { harness.events.contains(.notice("Failed to rename session")) })
}
@Test func deleteConflictReportsArchiveFirstAndSuccessFiresTheRemoval() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(active: false))
var removals: [String] = []
harness.store.onSessionRemoved = { removals.append($0) }
await harness.performer.script(
"DELETE", "/api/sessions/sess-1",
status: 409, json: #"{"error":"Cannot delete active session. Archive it first."}"#
)
harness.interactor.deleteSession()
#expect(await eventually {
harness.events.contains(.notice("Active sessions can't be deleted — archive first"))
})
#expect(await eventually { !harness.interactor.sessionOpPending })
#expect(removals.isEmpty)
#expect(harness.store.detail(for: chatSessionID) != nil, "a failed delete restores the detail")
await harness.performer.script("DELETE", "/api/sessions/sess-1", json: #"{"ok":true}"#)
harness.interactor.deleteSession()
#expect(await eventually { removals == [chatSessionID] })
}
@Test func reopenWithASupersedingIdMovesTheDraftAndRenavigates() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(active: false))
await harness.performer.serve(
"POST", "/api/sessions/sess-1/reopen",
json: #"{"ok":true,"sessionId":"sess-2","resumed":true}"#
)
harness.interactor.setComposerText("unsent words")
harness.interactor.reopenSession()
#expect(await eventually { harness.events.contains(.sessionSuperseded(sessionId: "sess-2")) })
#expect(harness.drafts.storage["sess-2"] == "unsent words")
#expect(harness.drafts.storage[chatSessionID] == nil)
#expect(!harness.interactor.sessionOpPending)
}
@Test func reopenInPlaceMarksTheSessionActiveWithoutRenavigating() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(active: false))
await harness.performer.serve(
"POST", "/api/sessions/sess-1/reopen",
json: #"{"ok":true,"sessionId":"sess-1","resumed":true}"#
)
harness.interactor.reopenSession()
#expect(await eventually { harness.store.detail(for: chatSessionID)?.active == true })
#expect(await eventually { !harness.interactor.sessionOpPending })
#expect(!harness.events.contains { event in
if case .sessionSuperseded = event { return true }
return false
})
}
@Test func reopenMissingMetadataSurfacesTheHubMessage() async throws {
let harness = try await ChatInteractionHarness(detail: chatDetail(active: false))
await harness.performer.serve(
"POST", "/api/sessions/sess-1/reopen",
status: 422, json: #"{"error":"Session metadata is incomplete","missing":["path","machineId"]}"#
)
harness.interactor.reopenSession()
#expect(await eventually {
harness.events.contains(.notice("Session metadata is incomplete (missing: path, machineId)"))
})
}
}