mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
* fix(codex): drop safe-yolo from Codex permission modes Every Codex session runs on the native shared runtime (runCodex is runSharedCodex), which rejects safe-yolo. The mode stayed in CODEX_PERMISSION_MODES and six filters hid it again in the web picker, composer, hub permission-mode route, hapi set-mode and both native apps. API callers that validate against the flavor catalog (e.g. a spawn with permissionMode: 'safe-yolo') still passed it through and only failed inside the runner. Remove it from the Codex catalog (shared, iOS, Android, fixture) and delete the filters. autoReview is the native replacement. Safe Yolo stays for Gemini, Kimi and Copilot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ios): Codex option list now has four modes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
334 lines
16 KiB
Swift
334 lines
16 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.unlistedModelOption, "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.unlistedModelOption, CatalogOption(value: "custom-codex", label: "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 testCodexCatalogWithoutDefaultKeepsAnUnselectedReadOnlyMenuTag() async throws {
|
|
let harness = try await SessionConfigTestHarness(flavor: "codex")
|
|
await harness.http.setModels([
|
|
CodexModelSummary(id: "fast", displayName: "Fast", isDefault: false),
|
|
])
|
|
harness.model.loadModels()
|
|
try await configEventually { !harness.model.config.modelOptionsLoading }
|
|
XCTAssertNil(harness.model.currentModel)
|
|
XCTAssertEqual(harness.model.unlistedModelOption, CatalogOption(value: nil, label: "Default"))
|
|
harness.model.selectModel(nil)
|
|
let posts = await harness.http.posts
|
|
XCTAssertTrue(posts.isEmpty, "A synthetic nil tag is display-only, not an unsupported edit")
|
|
}
|
|
|
|
func testPendingCodexChangeAlsoGuardsCollaborationAndCatalogReloads() async throws {
|
|
let harness = try await SessionConfigTestHarness(flavor: "codex")
|
|
harness.model.loadModels()
|
|
try await configEventually { !harness.model.config.modelOptionsLoading }
|
|
await harness.http.holdPosts()
|
|
defer { Task { await harness.http.releasePosts() } }
|
|
harness.model.selectModel("fast")
|
|
harness.model.selectCollaborationMode(.plan)
|
|
harness.model.selectPermission(.yolo)
|
|
harness.model.selectEffort("high")
|
|
harness.model.loadModels()
|
|
try await configEventually { await harness.http.posts.count == 1 }
|
|
XCTAssertEqual(harness.model.collaborationMode, .default)
|
|
XCTAssertEqual(harness.model.permission, .default)
|
|
let requests = await harness.http.modelRequests
|
|
XCTAssertEqual(requests, 1)
|
|
}
|
|
|
|
func testSelectionSkipsUnchangedValuesAndGuardsAllEditsWhileApplying() async throws {
|
|
let harness = try await SessionConfigTestHarness(model: "sonnet")
|
|
let model = harness.model
|
|
model.selectPermission(.default)
|
|
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.selectModel("opus")
|
|
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 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.selectPermission(.plan)
|
|
XCTAssertEqual(harness.model.permission, .plan)
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|