Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/Chat/ChatInteractorTests.swift
T
weishu 3510a0f4f1 fix(ios): Linux compile + full package test round — fixtures green locally (A-CI-local)
ios/scripts/linux-test.sh stages HapiKit + shared/fixtures at repo depth
into a persistent tmp dir and runs swift test in swift:6.1-noble; the
manifest drops HapiUI (SwiftUI/swift-markdown/Highlightr) under
#if os(Linux). 458 tests green, including all 48 chat and 11 pagination
golden fixtures.

Fixes that fell out of the first real compile of the blind-written port:
- ChatTypes.CodexReview.wireValue + SummaryPatching legacy id fallback:
  split expressions that exceeded the Swift 6 type-checker budget
- FileEndpointsTests: raw string containing "# terminated the literal
  early (never compiled anywhere) — now ##-delimited
- SSEClient.backoffSleep: Task { try? ... } inferred Task<()?, Never>
- Darwin gates: Security/Keychain behind canImport(Security) (tests use
  InMemoryCredentialStore via CredentialStoring), CryptoKit digest with
  FNV-1a filename fallback, FoundationNetworking imports for URLSession
  types, corelibs URLCache diskPath: initializer, get-only
  waitsForConnectivity, delegate-based SSE transport where
  URLSession.bytes(for:) does not exist
2026-08-18 11:51:57 +08:00

923 lines
38 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 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: 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
})
}
}