Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/NewSessionFormTests.swift
T
weishu 0c4abcb3d1 feat(codex): share sessions across terminal and web
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.

Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.

Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
2026-09-12 10:59:17 +08:00

307 lines
12 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.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.launchPermissionModes == [.default, .readOnly, .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)
}
}
}