mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
328 lines
15 KiB
Swift
328 lines
15 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import SwiftUI
|
|
import XCTest
|
|
@testable import Hapi
|
|
|
|
@MainActor
|
|
final class SessionConfigTests: XCTestCase {
|
|
func testCollaborationMenuUsesLiveStateWithoutDependingOnTheModelCatalog() async throws {
|
|
let harness = try await SessionConfigTestHarness(flavor: "codex")
|
|
let model = harness.model
|
|
XCTAssertTrue(model.showsCollaborationMode)
|
|
XCTAssertEqual(model.collaborationMode, .default)
|
|
await harness.http.setModelsFailure(true)
|
|
model.loadModels()
|
|
try await configEventually { model.modelLoadFailed }
|
|
XCTAssertTrue(model.config.canChangeCollaborationMode)
|
|
model.selectCollaborationMode(.plan)
|
|
XCTAssertEqual(model.collaborationMode, .plan)
|
|
XCTAssertEqual(model.permission, .default)
|
|
try await configEventually { !model.isApplying }
|
|
let posts = await harness.http.posts
|
|
XCTAssertEqual(posts.first?.path, "/api/sessions/config/collaboration-mode")
|
|
XCTAssertEqual(posts.first?.body, #"{"mode":"plan"}"#)
|
|
harness.store.applySessionEvent(.sessionUpdated(namespace: nil, sessionId: "config", data: .patch(SessionPatch(collaborationMode: .default))))
|
|
XCTAssertEqual(model.collaborationMode, .default)
|
|
harness.store.updateDetailLocal("config") { $0.active = false }
|
|
XCTAssertTrue(model.showsCollaborationMode)
|
|
model.selectCollaborationMode(.plan)
|
|
let finalPosts = await harness.http.posts
|
|
XCTAssertEqual(finalPosts.count, 1)
|
|
let claude = try await SessionConfigTestHarness()
|
|
XCTAssertFalse(claude.model.showsCollaborationMode)
|
|
}
|
|
|
|
func testClaudeDefaultsAndUnknownValuesKeepTheirCatalogSemantics() async throws {
|
|
let harness = try await SessionConfigTestHarness()
|
|
let model = harness.model
|
|
for value in [nil, "auto", "default", " "] as [String?] {
|
|
harness.store.updateDetailLocal("config") {
|
|
$0.model = value
|
|
$0.effort = value
|
|
}
|
|
XCTAssertNil(model.currentModel)
|
|
XCTAssertEqual(model.modelLabel, "Default")
|
|
XCTAssertNil(model.currentEffort)
|
|
XCTAssertEqual(model.effortOptions.first?.label, "Auto")
|
|
}
|
|
harness.store.updateDetailLocal("config") {
|
|
$0.model = " custom-model "
|
|
$0.effort = " TURBO "
|
|
}
|
|
XCTAssertEqual(model.currentModel, "custom-model")
|
|
XCTAssertEqual(model.modelLabel, "custom-model")
|
|
XCTAssertNil(model.unlistedModel, "Claude's catalog includes its synthetic current row")
|
|
XCTAssertEqual(model.currentEffort, "turbo")
|
|
XCTAssertTrue(model.canSelectEffort("turbo"))
|
|
XCTAssertEqual(model.effortOptions[1].label, "Turbo")
|
|
}
|
|
|
|
func testCodexDefaultIsNotTheFirstItemAndUnknownModelsAreNotCoerced() async throws {
|
|
let harness = try await SessionConfigTestHarness(flavor: "codex")
|
|
harness.model.loadModels()
|
|
try await configEventually { !harness.model.config.modelOptionsLoading }
|
|
XCTAssertEqual(harness.model.currentModel, "deep")
|
|
XCTAssertEqual(harness.model.modelLabel, "Deep · default")
|
|
harness.model.selectModel("deep")
|
|
let initialPosts = await harness.http.posts
|
|
XCTAssertTrue(initialPosts.isEmpty, "Selecting the effective default must not send a redundant POST")
|
|
harness.store.updateDetailLocal("config") { $0.model = "custom-codex" }
|
|
XCTAssertEqual(harness.model.modelLabel, "custom-codex")
|
|
XCTAssertEqual(harness.model.unlistedModel, "custom-codex")
|
|
harness.model.selectModel("custom-codex")
|
|
let posts = await harness.http.posts
|
|
XCTAssertTrue(posts.isEmpty, "An unlisted model is displayed, not offered as a new edit")
|
|
}
|
|
|
|
func testSelectionSkipsUnchangedValuesAndGuardsAllEditsWhileApplying() async throws {
|
|
let harness = try await SessionConfigTestHarness(model: "sonnet")
|
|
let model = harness.model
|
|
model.path = [.permission]
|
|
model.selectPermission(.default)
|
|
XCTAssertTrue(model.path.isEmpty)
|
|
model.path = [.model]
|
|
model.selectModel("sonnet")
|
|
model.selectEffort(nil)
|
|
let initialPosts = await harness.http.posts
|
|
XCTAssertTrue(initialPosts.isEmpty)
|
|
|
|
await harness.http.holdPosts()
|
|
defer { Task { await harness.http.releasePosts() } }
|
|
model.path = [.model]
|
|
model.selectModel("opus")
|
|
XCTAssertTrue(model.path.isEmpty)
|
|
XCTAssertEqual(model.detent, .medium)
|
|
XCTAssertEqual(model.modelLabel, "Opus")
|
|
XCTAssertTrue(model.isApplying)
|
|
model.selectModel("sonnet")
|
|
model.selectPermission(.plan)
|
|
model.selectEffort("high")
|
|
try await configEventually { await harness.http.posts.count == 1 }
|
|
let posts = await harness.http.posts
|
|
XCTAssertEqual(posts.first?.path, "/api/sessions/config/model")
|
|
XCTAssertEqual(posts.first?.body, #"{"model":"opus"}"#)
|
|
|
|
await harness.http.releasePosts()
|
|
try await configEventually { !model.isApplying }
|
|
model.selectEffort("high")
|
|
try await configEventually { !model.isApplying }
|
|
let finalPosts = await harness.http.posts
|
|
XCTAssertEqual(finalPosts.count, 2)
|
|
XCTAssertEqual(finalPosts.last?.path, "/api/sessions/config/effort")
|
|
}
|
|
|
|
func testNavigationRestoresRootHeightAndBackDoesNotApply() async throws {
|
|
let harness = try await SessionConfigTestHarness()
|
|
let model = harness.model
|
|
XCTAssertEqual(model.detent, .medium)
|
|
model.path = [.permission]
|
|
XCTAssertEqual(model.detent, .large)
|
|
model.detent = .large // A sheet callback while a detail is displayed.
|
|
model.path.removeAll()
|
|
XCTAssertEqual(model.detent, .medium)
|
|
model.detent = .large
|
|
model.path = [.model]
|
|
model.path.removeAll()
|
|
XCTAssertEqual(model.detent, .large)
|
|
let posts = await harness.http.posts
|
|
XCTAssertTrue(posts.isEmpty)
|
|
}
|
|
|
|
func testModelSwitchUpdatesEffortMenuWithoutSilentlyChangingStaleEffort() async throws {
|
|
let harness = try await SessionConfigTestHarness(flavor: "codex", model: "deep")
|
|
harness.store.updateDetailLocal("config") { $0.modelReasoningEffort = "xhigh" }
|
|
let model = harness.model
|
|
model.loadModels()
|
|
try await configEventually { !model.config.modelOptionsLoading }
|
|
XCTAssertTrue(model.canSelectEffort("xhigh"))
|
|
model.selectModel("fast")
|
|
XCTAssertEqual(model.currentEffort, "xhigh")
|
|
XCTAssertEqual(model.effortOptions.first?.value, "xhigh")
|
|
XCTAssertFalse(model.canSelectEffort("xhigh"), "Keep the stale tag read-only, not a supported option")
|
|
XCTAssertTrue(model.canSelectEffort("low"))
|
|
try await configEventually { !model.isApplying }
|
|
model.selectEffort("xhigh")
|
|
model.selectEffort("low")
|
|
try await configEventually { !model.isApplying }
|
|
let posts = await harness.http.posts
|
|
XCTAssertEqual(posts.count, 2)
|
|
XCTAssertEqual(posts.last?.path, "/api/sessions/config/model-reasoning-effort")
|
|
XCTAssertEqual(posts.last?.body, #"{"modelReasoningEffort":"low"}"#)
|
|
XCTAssertEqual(model.currentEffort, "low")
|
|
}
|
|
|
|
func testFailureReloadsServerTruthAndPreservesTheOwnersNoticeCallback() async throws {
|
|
let harness = try await SessionConfigTestHarness()
|
|
await harness.http.rejectChanges()
|
|
var notices: [String] = []
|
|
harness.interactor.onEvent = {
|
|
if case .notice(let message) = $0 { notices.append(message) }
|
|
}
|
|
harness.model.path = [.permission]
|
|
harness.model.selectPermission(.plan)
|
|
XCTAssertEqual(harness.model.permission, .plan)
|
|
XCTAssertTrue(harness.model.path.isEmpty)
|
|
try await configEventually { !harness.model.isApplying }
|
|
XCTAssertEqual(harness.model.permission, .default)
|
|
XCTAssertEqual(notices, ["HTTP 409 (Config rejected)"])
|
|
}
|
|
|
|
func testModelFailureRetriesAndLoadedEmptyCatalogIsUnavailableNotFailed() async throws {
|
|
let harness = try await SessionConfigTestHarness(flavor: "codex")
|
|
await harness.http.setModelsFailure(true)
|
|
harness.model.loadModels()
|
|
XCTAssertTrue(harness.model.config.modelOptionsLoading)
|
|
XCTAssertFalse(harness.model.showsEffort)
|
|
try await configEventually { harness.model.modelLoadFailed }
|
|
await harness.http.setModelsFailure(false)
|
|
harness.model.loadModels()
|
|
try await configEventually { !harness.model.config.modelOptionsLoading }
|
|
XCTAssertFalse(harness.model.modelLoadFailed)
|
|
XCTAssertTrue(harness.model.showsEffort)
|
|
let count = await harness.http.modelRequests
|
|
XCTAssertEqual(count, 2)
|
|
|
|
let empty = try await SessionConfigTestHarness(flavor: "codex")
|
|
await empty.http.setModels([])
|
|
empty.model.loadModels()
|
|
try await configEventually { !empty.model.config.modelOptionsLoading }
|
|
XCTAssertEqual(empty.model.config.modelOptions, [])
|
|
XCTAssertFalse(empty.model.modelLoadFailed)
|
|
XCTAssertTrue(empty.model.showsModel)
|
|
XCTAssertFalse(empty.model.showsEffort)
|
|
}
|
|
|
|
func testCapabilitiesAndUnsupportedAgentsRemainCatalogDriven() async throws {
|
|
for flavor in ["pi", "dsh"] {
|
|
let harness = try await SessionConfigTestHarness(flavor: flavor)
|
|
XCTAssertFalse(harness.model.hasSettings)
|
|
harness.model.selectPermission(.yolo)
|
|
harness.model.selectModel("opus")
|
|
harness.model.selectEffort("high")
|
|
let posts = await harness.http.posts
|
|
XCTAssertTrue(posts.isEmpty)
|
|
}
|
|
let harness = try await SessionConfigTestHarness(flavor: "codex")
|
|
harness.store.updateDetailLocal("config") {
|
|
$0.active = false
|
|
$0.agentState = AgentState(controlledByUser: true)
|
|
$0.metadata?.capabilities = SessionCapabilities(concurrentClients: true)
|
|
}
|
|
XCTAssertFalse(harness.model.config.active)
|
|
XCTAssertFalse(harness.model.config.controlledByUser)
|
|
XCTAssertFalse(harness.model.config.permissionModes.contains { $0.mode == .safeYolo })
|
|
harness.model.selectPermission(.safeYolo)
|
|
let posts = await harness.http.posts
|
|
XCTAssertTrue(posts.isEmpty)
|
|
// Offline/terminal notices do not introduce a blanket UI authorization gate.
|
|
harness.store.updateDetailLocal("config") { $0.metadata?.capabilities = nil }
|
|
XCTAssertTrue(harness.model.config.controlledByUser)
|
|
XCTAssertTrue(harness.model.config.permissionModes.contains { $0.mode == .safeYolo })
|
|
}
|
|
}
|
|
|
|
/// Real app model, interactor, store and API over fake HTTP. No hub connection,
|
|
/// Keychain, window activation, saved drafts, or shared conformance fixtures.
|
|
@MainActor
|
|
final class SessionConfigTestHarness {
|
|
let http: SessionConfigTestHTTP
|
|
let store: SessionListStore
|
|
let interactor: ChatInteractor
|
|
let model: SessionConfigModel
|
|
|
|
init(flavor: String = "claude", model: String? = nil) async throws {
|
|
let detail = Session(
|
|
id: "config", namespace: "test", seq: 1, createdAt: 1, updatedAt: 1,
|
|
active: true, activeAt: 1,
|
|
metadata: SessionMetadata(path: "/repo/app", host: "test", flavor: flavor),
|
|
metadataVersion: 1, agentState: nil, agentStateVersion: 1,
|
|
thinking: false, thinkingAt: 0, model: model, permissionMode: .default
|
|
)
|
|
let http = SessionConfigTestHTTP(detail: detail)
|
|
self.http = http
|
|
let baseURL = URL(string: "http://127.0.0.1:1")!
|
|
let credentials = InMemoryCredentialStore()
|
|
let payload = Data(#"{"uid":1,"exp":4102444800,"ns":"test"}"#.utf8).base64EncodedString()
|
|
try credentials.store(HubCredentials(
|
|
hubUrl: baseURL.absoluteString, accessToken: "test", jwt: "e30.\(payload).test"
|
|
))
|
|
let auth = AuthManager(baseURL: baseURL, credentialStore: credentials, performer: http)
|
|
let api = APIClient(baseURL: baseURL, authManager: auth, performer: http)
|
|
store = SessionListStore(api: api)
|
|
try await store.loadSessionDetail("config")
|
|
interactor = ChatInteractor(
|
|
sessionId: "config", api: api, sessionStore: store,
|
|
windows: MessageWindowControllers(provider: api)
|
|
)
|
|
self.model = SessionConfigModel(interactor: interactor)
|
|
}
|
|
}
|
|
|
|
actor SessionConfigTestHTTP: HTTPPerforming {
|
|
struct Post: Sendable {
|
|
let path: String
|
|
let body: String?
|
|
}
|
|
|
|
private let detail: Session
|
|
private var models = [
|
|
CodexModelSummary(id: "fast", displayName: "Fast", isDefault: false, supportedReasoningEfforts: ["low", "high"]),
|
|
CodexModelSummary(id: "deep", displayName: "Deep", isDefault: true, supportedReasoningEfforts: ["high", "xhigh"]),
|
|
]
|
|
private var modelsFailure = false
|
|
private var changesRejected = false
|
|
private var postsHeld = false
|
|
private var postWaiters: [CheckedContinuation<Void, Never>] = []
|
|
private(set) var posts: [Post] = []
|
|
private(set) var modelRequests = 0
|
|
|
|
init(detail: Session) { self.detail = detail }
|
|
func setModelsFailure(_ value: Bool) { modelsFailure = value }
|
|
func setModels(_ values: [CodexModelSummary]) { models = values }
|
|
func rejectChanges() { changesRejected = true }
|
|
func holdPosts() { postsHeld = true }
|
|
func releasePosts() {
|
|
postsHeld = false
|
|
let waiters = postWaiters
|
|
postWaiters.removeAll()
|
|
for waiter in waiters { waiter.resume() }
|
|
}
|
|
|
|
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
|
let url = try XCTUnwrap(request.url)
|
|
let path = url.path
|
|
let data: Data
|
|
var status = 200
|
|
if request.httpMethod == "POST" {
|
|
posts.append(Post(path: path, body: request.httpBody.map { String(decoding: $0, as: UTF8.self) }))
|
|
if postsHeld { await withCheckedContinuation { postWaiters.append($0) } }
|
|
status = changesRejected ? 409 : 200
|
|
data = Data((changesRejected ? #"{"error":"Config rejected"}"# : #"{"ok":true}"#).utf8)
|
|
} else if path.hasSuffix("/codex-models") {
|
|
modelRequests += 1
|
|
data = try HapiJSON.encoder.encode(CodexModelsResponse(
|
|
success: !modelsFailure, models: modelsFailure ? nil : models,
|
|
error: modelsFailure ? "Catalog unavailable" : nil
|
|
))
|
|
} else if path == "/api/sessions/config" {
|
|
data = try HapiJSON.encoder.encode(SessionResponse(session: detail))
|
|
} else {
|
|
throw URLError(.unsupportedURL)
|
|
}
|
|
return (data, try XCTUnwrap(HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func configEventually(
|
|
file: StaticString = #filePath, line: UInt = #line,
|
|
_ condition: () async -> Bool
|
|
) async throws {
|
|
for _ in 0..<200 {
|
|
if await condition() { return }
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
XCTFail("Timed out waiting for session config state", file: file, line: line)
|
|
}
|