Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift
T
weishuandClaude Opus 5.5 d01f03eef7 feat(ios): pick models for every agent when creating a session
The create form only offered models for Claude and Codex, so every
other agent spawned on its default model. The form now loads the
catalogs web uses. Agy, cursor, dsh and pi read
/machines/:id/<flavor>-models. Copilot, grok, kimi and opencode probe
the same route with cwd once the directory exists; the view refetches
when the typed path turns out to exist.

Option rows follow the web builders:
- Copilot and cursor label the default row "Auto".
- Kimi and pi prefix the provider; pi values are provider/modelId.
- OpenCode marks the agent's own default.
- Cursor shows a flat list of ACP ids, without the variant drill-down.

Grok efforts and pi thinking levels follow the selected model, and an
OpenCode variant becomes the reasoning effort. Grok's auto permission
mode is hidden when the probe reports it unsupported. A restored
selection the catalog no longer offers falls back to Default, and
Create waits while a non-default selection is still being validated.

The spawn body sends model for all of these agents, and cursor always
sends it, with 'auto' as the default. Grok and pi send effort; opencode
sends modelReasoningEffort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:00:00 +08:00

358 lines
15 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 always sends its model (`cursorSpawnModelId`), Auto included.
let cursor = try canonicalBody(
NewSessionForm(directory: "/repo", agent: .cursor, model: "sonic", yolo: true),
codexFastTierVisible: false
)
#expect(cursor == """
{"agent":"cursor","directory":"/repo","model":"sonic","sessionType":"simple","yolo":true}
""")
let cursorAuto = try canonicalBody(
NewSessionForm(directory: "/repo", agent: .cursor, yolo: false),
codexFastTierVisible: false
)
#expect(cursorAuto == """
{"agent":"cursor","directory":"/repo","model":"auto","sessionType":"simple","yolo":false}
""")
}
@Test func discoveredCatalogFlavorsSendTheirModelAndEffortFields() throws {
let grok = try canonicalBody(
NewSessionForm(directory: "/repo", agent: .grok, model: "grok-4", effort: "high",
modelReasoningEffort: "max", permissionMode: .default),
codexFastTierVisible: false
)
#expect(grok == """
{"agent":"grok","directory":"/repo","effort":"high","model":"grok-4","permissionMode":"default","sessionType":"simple"}
""")
let opencode = try canonicalBody(
NewSessionForm(directory: "/repo", agent: .opencode, model: "openai/gpt-5", effort: "high",
modelReasoningEffort: "max", permissionMode: .default),
codexFastTierVisible: false
)
#expect(opencode == """
{"agent":"opencode","directory":"/repo","model":"openai/gpt-5","modelReasoningEffort":"max",\
"permissionMode":"default","sessionType":"simple"}
""")
let pi = try canonicalBody(
NewSessionForm(directory: "/repo", agent: .pi, model: "anthropic/claude-x", effort: "minimal"),
codexFastTierVisible: false
)
#expect(pi == """
{"agent":"pi","directory":"/repo","effort":"minimal","model":"anthropic/claude-x","sessionType":"simple","yolo":false}
""")
// Default sentinels stay off the wire.
let kimi = try canonicalBody(
NewSessionForm(directory: "/repo", agent: .kimi, permissionMode: .default),
codexFastTierVisible: false
)
#expect(kimi == """
{"agent":"kimi","directory":"/repo","permissionMode":"default","sessionType":"simple"}
""")
}
@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)
}
}
}