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>
313 lines
13 KiB
Swift
313 lines
13 KiB
Swift
import Foundation
|
|
import HapiClient
|
|
import HapiProtocol
|
|
import Testing
|
|
|
|
/// New-session pure logic (A-M3c), transcribed from the Android reference
|
|
/// suite (`NewSessionViewModelTest.kt`: `SpawnBodyTest` +
|
|
/// `NewSessionLogicTest`), which is itself asserted against the web create
|
|
/// form. Spawn bodies are compared as canonical JSON (`HapiJSON.encoder`
|
|
/// sorts keys), so a drifted field set or a leaked default fails loudly.
|
|
@Suite("New-session spawn body exactness")
|
|
struct NewSessionSpawnBodyTests {
|
|
private func canonicalBody(_ form: NewSessionForm, codexFastTierVisible: Bool) throws -> String {
|
|
let request = NewSessionLogic.buildSpawnRequest(
|
|
form: form,
|
|
codexFastTierVisible: codexFastTierVisible
|
|
)
|
|
let data = try HapiJSON.encoder.encode(request)
|
|
return String(decoding: data, as: UTF8.self)
|
|
}
|
|
|
|
@Test func claudeSimpleSessionWithModelEffortAndPermissionMode() throws {
|
|
// Exact SpawnSessionRequestSchema field set — claude is on the
|
|
// native permission select now, so permissionMode IS sent (even a
|
|
// picked non-default mode) and yolo is absent, even when set on the
|
|
// form (a stale toggle value from before the flavor switch).
|
|
let body = try canonicalBody(
|
|
NewSessionForm(
|
|
machineId: "m1",
|
|
directory: " /data/github/hapi ",
|
|
agent: .claude,
|
|
model: "opus",
|
|
effort: "high",
|
|
permissionMode: .plan,
|
|
yolo: true
|
|
),
|
|
codexFastTierVisible: false
|
|
)
|
|
#expect(body == """
|
|
{"agent":"claude","directory":"/data/github/hapi","effort":"high",\
|
|
"model":"opus","permissionMode":"plan","sessionType":"simple"}
|
|
""")
|
|
}
|
|
|
|
@Test func codexWorktreeWithReasoningEffortPermissionModePlanAndFastTier() throws {
|
|
// yolo=true must NOT leak into the body for codex-family; the
|
|
// worktree name is trimmed; startingMode stays unset in v1 (runner
|
|
// defaults to remote).
|
|
let body = try canonicalBody(
|
|
NewSessionForm(
|
|
machineId: "m1",
|
|
directory: "/repo",
|
|
agent: .codex,
|
|
model: "gpt-5.2-codex",
|
|
modelReasoningEffort: "high",
|
|
permissionMode: .readOnly,
|
|
yolo: true,
|
|
sessionType: .worktree,
|
|
worktreeName: " feature-x ",
|
|
serviceTier: .fast,
|
|
collaborationMode: .plan
|
|
),
|
|
codexFastTierVisible: true
|
|
)
|
|
#expect(body == """
|
|
{"agent":"codex","collaborationMode":"plan","directory":"/repo",\
|
|
"model":"gpt-5.2-codex","modelReasoningEffort":"high",\
|
|
"permissionMode":"read-only","serviceTier":"fast",\
|
|
"sessionType":"worktree","worktreeName":"feature-x"}
|
|
""")
|
|
}
|
|
|
|
@Test func grokSendsPermissionModeNotYoloCursorSendsYoloNotPermissionMode() throws {
|
|
let grok = try canonicalBody(
|
|
NewSessionForm(directory: "/repo", agent: .grok, permissionMode: .auto, yolo: true),
|
|
codexFastTierVisible: false
|
|
)
|
|
#expect(grok == """
|
|
{"agent":"grok","directory":"/repo","permissionMode":"auto","sessionType":"simple"}
|
|
""")
|
|
|
|
// Cursor has no v1 model picker: even a stashed model id is not sent.
|
|
let cursor = try canonicalBody(
|
|
NewSessionForm(directory: "/repo", agent: .cursor, model: "sonic", yolo: true),
|
|
codexFastTierVisible: false
|
|
)
|
|
#expect(cursor == """
|
|
{"agent":"cursor","directory":"/repo","sessionType":"simple","yolo":true}
|
|
""")
|
|
}
|
|
|
|
@Test func dshUsesManagedPermissionPolicyAndOmitsYolo() throws {
|
|
let body = try canonicalBody(
|
|
NewSessionForm(directory: "/repo", agent: .dsh, yolo: true),
|
|
codexFastTierVisible: false
|
|
)
|
|
#expect(body == """
|
|
{"agent":"dsh","directory":"/repo","sessionType":"simple"}
|
|
""")
|
|
}
|
|
|
|
@Test func codexDefaultSelectionsSendNoOptionalFieldsAndHiddenFastTierNoServiceTier() throws {
|
|
let body = try canonicalBody(
|
|
NewSessionForm(directory: "/repo", agent: .codex, serviceTier: .fast),
|
|
codexFastTierVisible: false
|
|
)
|
|
// permissionMode 'default' IS sent for codex-family.
|
|
#expect(body == """
|
|
{"agent":"codex","directory":"/repo","permissionMode":"default","sessionType":"simple"}
|
|
""")
|
|
}
|
|
}
|
|
|
|
@Suite("New-session pure logic")
|
|
struct NewSessionLogicTests {
|
|
private func dir(_ name: String) -> MachineDirectoryEntry {
|
|
MachineDirectoryEntry(name: name, type: .directory)
|
|
}
|
|
|
|
@Test func parentQueryDerivation() {
|
|
#expect(
|
|
NewSessionLogic.parentQuery(for: "/data/gi")
|
|
== NewSessionLogic.ParentQuery(parent: "/data", prefix: "gi")
|
|
)
|
|
#expect(
|
|
NewSessionLogic.parentQuery(for: "/data/")
|
|
== NewSessionLogic.ParentQuery(parent: "/data", prefix: "")
|
|
)
|
|
#expect(
|
|
NewSessionLogic.parentQuery(for: "/d")
|
|
== NewSessionLogic.ParentQuery(parent: "/", prefix: "d")
|
|
)
|
|
#expect(
|
|
NewSessionLogic.parentQuery(for: "/")
|
|
== NewSessionLogic.ParentQuery(parent: "/", prefix: "")
|
|
)
|
|
#expect(NewSessionLogic.parentQuery(for: "relative/path") == nil)
|
|
#expect(NewSessionLogic.parentQuery(for: "") == nil)
|
|
}
|
|
|
|
@Test func suggestionsFilterDirectoriesByPrefixAndJoinWithParent() {
|
|
let query = NewSessionLogic.ParentQuery(parent: "/data", prefix: "gi")
|
|
let entries = [
|
|
dir("github"),
|
|
dir("gists"),
|
|
dir("archive"),
|
|
MachineDirectoryEntry(name: "gitconfig", type: .file),
|
|
]
|
|
#expect(
|
|
NewSessionLogic.buildSuggestions(query: query, entries: entries)
|
|
== ["/data/github", "/data/gists"]
|
|
)
|
|
// Root parent must not double the slash.
|
|
#expect(
|
|
NewSessionLogic.buildSuggestions(
|
|
query: NewSessionLogic.ParentQuery(parent: "/", prefix: "da"),
|
|
entries: [dir("data")]
|
|
) == ["/data"]
|
|
)
|
|
}
|
|
|
|
@Test func windowsDriveAndUNCAutocompletePreserveSeparators() {
|
|
#expect(NewSessionLogic.directoryAutocompleteQuery(path: "C:\\wo", roots: ["C:\\work"])
|
|
== .roots(["C:\\work"]))
|
|
#expect(NewSessionLogic.directoryAutocompleteQuery(path: "\\\\server\\sh", roots: ["\\\\server\\share"])
|
|
== .roots(["\\\\server\\share"]))
|
|
#expect(NewSessionLogic.directoryAutocompleteQuery(path: "C:\\work", roots: ["C:\\work"])
|
|
== .directory(NewSessionLogic.ParentQuery(parent: "C:\\work", prefix: "", separator: "\\")))
|
|
#expect(
|
|
NewSessionLogic.parentQuery(for: "C:\\Users\\pro")
|
|
== NewSessionLogic.ParentQuery(parent: "C:\\Users", prefix: "pro", separator: "\\")
|
|
)
|
|
#expect(
|
|
NewSessionLogic.parentQuery(for: "C:\\Use")
|
|
== NewSessionLogic.ParentQuery(parent: "C:\\", prefix: "Use", separator: "\\")
|
|
)
|
|
#expect(
|
|
NewSessionLogic.parentQuery(for: "\\\\server\\share\\pro")
|
|
== NewSessionLogic.ParentQuery(
|
|
parent: "\\\\server\\share",
|
|
prefix: "pro",
|
|
separator: "\\"
|
|
)
|
|
)
|
|
#expect(
|
|
NewSessionLogic.buildSuggestions(
|
|
query: NewSessionLogic.ParentQuery(
|
|
parent: "C:\\Users",
|
|
prefix: "pro",
|
|
separator: "\\"
|
|
),
|
|
entries: [dir("projects")]
|
|
) == ["C:\\Users\\projects"]
|
|
)
|
|
}
|
|
|
|
@Test func recentPathsLRUDedupesToFrontAndCapsAtEight() {
|
|
var list: [String] = []
|
|
for index in 1...10 {
|
|
list = NewSessionLogic.pushRecent(list, path: "/p\(index)")
|
|
}
|
|
#expect(list.count == 8)
|
|
#expect(list.first == "/p10")
|
|
#expect(list.last == "/p3")
|
|
|
|
list = NewSessionLogic.pushRecent(list, path: "/p5")
|
|
#expect(list.first == "/p5")
|
|
#expect(list.count == 8)
|
|
#expect(Set(list).count == list.count)
|
|
|
|
#expect(NewSessionLogic.pushRecent(list, path: " ") == list)
|
|
}
|
|
|
|
@Test func worktreeNameValidation() {
|
|
#expect(NewSessionLogic.worktreeNameError("") == nil)
|
|
#expect(NewSessionLogic.worktreeNameError("feature-x") == nil)
|
|
#expect(NewSessionLogic.worktreeNameError("Fix Bug #42") == nil)
|
|
#expect(NewSessionLogic.worktreeNameError("---") != nil)
|
|
#expect(NewSessionLogic.worktreeNameError("!!!") != nil)
|
|
}
|
|
|
|
@Test func codexFastTierDetectionFollowsTheActiveModel() {
|
|
let models = [
|
|
CodexModelSummary(
|
|
id: "gpt-5.2-codex",
|
|
displayName: "GPT-5.2",
|
|
isDefault: true,
|
|
serviceTiers: ["standard", "fast"]
|
|
),
|
|
CodexModelSummary(
|
|
id: "gpt-5.2-mini",
|
|
displayName: "Mini",
|
|
isDefault: false,
|
|
serviceTiers: ["standard"]
|
|
),
|
|
]
|
|
#expect(NewSessionLogic.codexModelAdvertisesFastTier(model: "auto", models: models))
|
|
#expect(NewSessionLogic.codexModelAdvertisesFastTier(model: "gpt-5.2-codex", models: models))
|
|
#expect(!NewSessionLogic.codexModelAdvertisesFastTier(model: "gpt-5.2-mini", models: models))
|
|
#expect(!NewSessionLogic.codexModelAdvertisesFastTier(model: "auto", models: []))
|
|
}
|
|
|
|
@Test func codexReasoningEffortsNormalizeAndFallBackToNil() {
|
|
let models = [
|
|
CodexModelSummary(
|
|
id: "gpt-5.2-codex",
|
|
displayName: "GPT-5.2",
|
|
isDefault: true,
|
|
supportedReasoningEfforts: [" Low ", "medium", "MEDIUM", "", "high"]
|
|
),
|
|
CodexModelSummary(id: "bare", displayName: "Bare", isDefault: false),
|
|
]
|
|
#expect(
|
|
NewSessionLogic.codexReasoningEfforts(models: models, model: "auto")
|
|
== ["low", "medium", "high"]
|
|
)
|
|
// A row without advertised efforts falls back to the static list (nil).
|
|
#expect(NewSessionLogic.codexReasoningEfforts(models: models, model: "bare") == nil)
|
|
#expect(NewSessionLogic.codexReasoningEfforts(models: [], model: "auto") == nil)
|
|
}
|
|
|
|
@Test func draftSanitizationCoercesUncreatableAgentAndStalePermissionMode() {
|
|
// Gemini is decodable (stored sessions stay viewable) but not
|
|
// creatable: the draft coerces to claude and drops the
|
|
// agent-dependent fields while keeping directory/yolo/session type.
|
|
let gemini = NewSessionLogic.sanitizeDraft(
|
|
NewSessionForm(directory: "/repo", agent: .gemini, model: "gemini-2.5-pro")
|
|
)
|
|
#expect(gemini.agent == .claude)
|
|
#expect(gemini.model == "auto")
|
|
#expect(gemini.directory == "/repo")
|
|
|
|
let badMode = NewSessionLogic.sanitizeDraft(
|
|
NewSessionForm(agent: .codex, permissionMode: .bypassPermissions)
|
|
)
|
|
#expect(badMode.permissionMode == .default)
|
|
|
|
let staleMode = NewSessionLogic.sanitizeDraft(
|
|
NewSessionForm(agent: .codex, permissionMode: .safeYolo)
|
|
)
|
|
#expect(staleMode.permissionMode == .default)
|
|
#expect(AgentFlavor.codex.permissionModes == [.default, .readOnly, .autoReview, .yolo])
|
|
#expect(NewSessionLogic.sanitizeDraft(NewSessionForm(agent: .kimi, permissionMode: .safeYolo)).permissionMode == .safeYolo)
|
|
}
|
|
|
|
@Test func draftDecodingToleratesMissingKeysAndRoundTrips() throws {
|
|
// Older-version draft with most keys absent → per-field defaults.
|
|
let partial = Data(#"{"directory":"/repo","agent":"codex"}"#.utf8)
|
|
let decoded = try HapiJSON.decoder.decode(NewSessionForm.self, from: partial)
|
|
#expect(decoded == NewSessionForm(directory: "/repo", agent: .codex))
|
|
|
|
// Full round-trip preserves every field.
|
|
let full = NewSessionForm(
|
|
machineId: "m1",
|
|
directory: "/repo",
|
|
agent: .copilot,
|
|
permissionMode: .yolo,
|
|
sessionType: .worktree,
|
|
worktreeName: "wip",
|
|
copilotAgentMode: .autopilot
|
|
)
|
|
let data = try HapiJSON.encoder.encode(full)
|
|
#expect(try HapiJSON.decoder.decode(NewSessionForm.self, from: data) == full)
|
|
|
|
// A value the catalog enums cannot represent fails the decode — the
|
|
// caller treats that as "no draft" (web try/catch parity).
|
|
let unknownMode = Data(#"{"permissionMode":"brand-new-mode"}"#.utf8)
|
|
#expect(throws: (any Error).self) {
|
|
try HapiJSON.decoder.decode(NewSessionForm.self, from: unknownMode)
|
|
}
|
|
}
|
|
}
|