mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
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.
307 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|