mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-25 22:28:04 +00:00
Interaction layer turning the read-only chat into a working remote control,
mirroring the merged Android B-M3ab feature-for-feature (web authority where
the ports disagreed):
- ChatInteractor (HapiClient, fully swift-test covered): optimistic composer
sends (appendOptimistic -> POST -> status settle), queue-by-default with a
long-press Send&Steer intent while a turn is active, tap-to-retry on failed
rows (steer retries degrade to queue), per-session drafts
(UserDefaultsChatDrafts, hub-scoped keys, debounced + flushed on close).
- session_inactive (409) recovery: one POST /resume (current permissionMode)
then retry; a superseding session id seeds the new window
(MessageWindowControllers.seed), migrates the draft, retargets the
optimistic row and emits sessionSuperseded -- ChatView/HomeView replace the
navigation entry in place.
- Queued bar: uninvoked sends in web sort order with Cancel (optimistic
DELETE; invoked-race ingests the authoritative row as sent), Edit
(cancel + composer prefill, newer-draft guard) and Steer (invoked answers
reconcile a missed consume); single-flight per-row op guard.
reconcileQueuedState now runs on chat open and on session-pipe gap.
- Permission actions: flavor-exact bodies mirroring PermissionFooter.tsx --
claude {} / allowTools (Bash(cmd)) / mode:acceptEdits, codex-family
decision approved / approved_for_session / abort via deny -- plus
AskUserQuestion flat answers (option cards, Other free text, no-questions
fallback, cursor stable ids) and request_user_input nested answers
(user_note suffix, required validation); optimistic Resolving /
AlreadyHandled (404/409) overrides settled by the agentState patch.
ChatPipeline now re-attaches the window row's client status so failed
user rows actually render the retry affordance (web normalize.ts parity;
the Android reference misses this overlay).
- Session config sheet (toolbar gear): catalog-driven permission-mode picker
with tones, claude static model/effort catalogs (ModelCatalog port), codex
models via new GET /sessions/:id/codex-models endpoint + wire types with
per-model reasoning efforts; optimistic detail updates
(SessionListStore.updateDetailLocal, new) rolled forward to server truth
on error.
- Lifecycle: VisibilityReporter posts POST /api/visibility per tracked
handshake subscriptionId on scene-phase flips (404 prunes); ChatSession
exposes its subscriptionId and feeds the reporter; the global SSE pipe was
already HubSession-lifetime on iOS.
- Tests: ChatInteractorTests transcribes the Android interaction suite
against the real APIClient/AuthManager/SessionListStore/window registry
with only HTTP scripted -- canonical JSON bodies asserted byte-for-byte
(send/approve/deny/config), optimistic send happy/fail/retry, 409 resume
both id paths, cancel invoked-race, steer reconcile, edit prefill,
override lifecycle, config optimistic + rollback, drafts, abort.
738 lines
30 KiB
Swift
738 lines
30 KiB
Swift
import Foundation
|
|
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))] = []
|
|
|
|
/// 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))
|
|
}
|
|
|
|
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) }
|
|
))
|
|
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\":[]}")
|
|
|
|
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: 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
|
|
})
|
|
}
|
|
}
|