Files
hapi/ios/Packages/HapiKit/Tests/HapiProtocolTests/CatalogTests.swift
T
3f8686e623 feat(claude): offer the four current Claude models, drop the 1M presets (#2153)
* fix(claude): drop the obsolete 1M model presets

The opus, sonnet and fable aliases now resolve to models with a native 1M
window (Opus 5.5, Sonnet 5.5, Fable 5.1), so Claude Code documents the
"[1m]" suffix as having no effect on them and its own /model picker no
longer lists 1M rows. Remove Sonnet 1M / Opus 1M / Fable 1M from the
shared, Android and iOS catalogs.

Sessions stored with a "[1m]" value keep working: the composer keeps
the current model selectable, and the context-budget fallback treats any
"[1m]" model and the remaining aliases as 1M, so local-mode sessions on
plain opus/sonnet no longer show a 200k budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): seed a 1M turn-1 context window for bare Claude aliases

With the 1M presets gone, a session on plain fable/opus/sonnet must not
seed 200k before its first result: those aliases now resolve to models
with a native 1M window. Seed 1M when the selected model is a preset
alias; an explicit full id still seeds conservatively until its result
arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(claude): offer the four current Claude models as presets

The Claude presets now mirror the aliases Claude Code's /model lists, in
its order: Opus, Fable, Sonnet, Haiku. Each alias resolves to the newest
model of its family, and all four have a native 1M window, so neither
1M variants nor pinned older versions are offered. Haiku was missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(claude): read the effective context window from get_context_usage

The 1M seed for the aliases is wrong for users who disable 1M
(CLAUDE_CODE_DISABLE_1M_CONTEXT) or cap it with autoCompactWindow. In
remote mode, ask Claude via the get_context_usage control request after
each system/init and use its maxTokens, which is what /context shows.
A result's raw contextWindow no longer overwrites that value. Older CLIs
that reject the request keep the existing seed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: weishu <twsxtd@gmail.com>
2026-10-29 22:28:59 +08:00

272 lines
12 KiB
Swift

import Foundation
import HapiProtocol
import Testing
/// Checks of the mode/flavor catalogs: exhaustively against the generated
/// `shared/fixtures/catalogs/modes.json` (track K, from `shared/src/modes.ts`),
/// plus inline spot checks transcribed from `shared/src/modes.ts`,
/// `shared/src/flavors.ts`, and `shared/src/copilotModes.ts` for fast
/// diagnosis when the generated catalog and the port disagree.
@Suite("Mode and flavor catalogs")
struct CatalogTests {
// MARK: - Generated catalog (shared/fixtures/catalogs/modes.json)
private struct GeneratedModesCatalog: Decodable {
struct PermissionEntry: Decodable {
let label: String
let mode: PermissionMode
let tone: PermissionModeTone
}
struct CollaborationEntry: Decodable {
let label: String
let mode: CodexCollaborationMode
}
let codexCollaborationModes: [CollaborationEntry]
let permissionModesByFlavor: [String: [PermissionEntry]]
}
/// Same resolution as FixtureDecodingTests: this file lives in
/// `ios/Packages/HapiKit/Tests/HapiProtocolTests/`, so the package root is
/// three levels up and the repo fixtures sit at `../../../shared/fixtures`.
private static func generatedCatalogURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // Tests/HapiProtocolTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // package root: ios/Packages/HapiKit
.appendingPathComponent("../../../shared/fixtures/catalogs/modes.json")
.standardizedFileURL
}
@Test func portMatchesGeneratedModesCatalogExactly() throws {
let data = try Data(contentsOf: Self.generatedCatalogURL())
let catalog = try JSONDecoder().decode(GeneratedModesCatalog.self, from: data)
// Every known flavor is present in the generated catalog and vice
// versa (the catalog only carries known flavors).
#expect(
catalog.permissionModesByFlavor.keys.sorted()
== AgentFlavor.knownFlavors.map(\.rawValue).sorted()
)
for (flavor, expected) in catalog.permissionModesByFlavor.sorted(by: { $0.key < $1.key }) {
let actual = permissionModeOptions(forFlavor: flavor)
#expect(
actual.map(\.mode) == expected.map(\.mode),
"mode list mismatch for flavor \(flavor)"
)
for (actualOption, expectedEntry) in zip(actual, expected) {
#expect(
actualOption.label == expectedEntry.label,
"label mismatch for \(flavor)/\(expectedEntry.mode.rawValue)"
)
#expect(
actualOption.tone == expectedEntry.tone,
"tone mismatch for \(flavor)/\(expectedEntry.mode.rawValue)"
)
}
}
#expect(catalog.codexCollaborationModes.map(\.mode) == CodexCollaborationMode.allCases)
for entry in catalog.codexCollaborationModes {
#expect(entry.mode.label == entry.label)
}
}
// MARK: - Permission modes per flavor (modes.ts getPermissionModesForFlavor)
@Test func codexFamilyFlavorsShareTheCodexModeSet() {
let expected: [PermissionMode] = [.default, .readOnly, .safeYolo, .yolo]
#expect(permissionModes(forFlavor: "codex") == [.default, .readOnly, .autoReview, .yolo])
#expect(permissionModes(forFlavor: "gemini") == expected)
#expect(permissionModes(forFlavor: "kimi") == expected)
#expect(permissionModes(forFlavor: "copilot") == expected)
}
@Test func claudeSetIsTheFallbackForNilAndUnknownFlavors() {
let expected: [PermissionMode] = [.default, .acceptEdits, .auto, .bypassPermissions, .plan]
#expect(permissionModes(forFlavor: "claude") == expected)
#expect(permissionModes(forFlavor: nil) == expected)
#expect(permissionModes(forFlavor: "some-future-agent") == expected)
}
@Test func remainingFlavorsMatchTheirDeclaredSets() {
#expect(permissionModes(forFlavor: "grok") == [.default, .auto, .plan, .bypassPermissions])
#expect(permissionModes(forFlavor: "opencode") == [.default, .plan, .yolo])
#expect(permissionModes(forFlavor: "agy") == [.requestReview, .alwaysProceed])
#expect(permissionModes(forFlavor: "cursor") == [.default, .plan, .ask, .debug, .autoReview, .yolo])
// Pi has no runtime permission switching (always auto-approve).
#expect(permissionModes(forFlavor: "pi") == [])
}
@Test func modeAllowanceFollowsTheFlavorSets() {
#expect(isPermissionModeAllowed(.acceptEdits, forFlavor: "claude"))
#expect(!isPermissionModeAllowed(.acceptEdits, forFlavor: "codex"))
#expect(isPermissionModeAllowed(.yolo, forFlavor: "opencode"))
#expect(!isPermissionModeAllowed(.yolo, forFlavor: "claude"))
}
// MARK: - Labels, tones, raw values (modes.ts data tables)
@Test func permissionModeCatalogHasThirteenEntries() {
#expect(PermissionMode.allCases.count == 13)
}
@Test func labelsMatchModesTs() {
#expect(PermissionMode.default.label == "Default")
#expect(PermissionMode.acceptEdits.label == "Accept Edits")
#expect(PermissionMode.bypassPermissions.label == "Yolo")
#expect(PermissionMode.autoReview.label == "Auto")
#expect(PermissionMode.readOnly.label == "Read Only")
#expect(PermissionMode.plan.label == "Plan Mode")
#expect(PermissionMode.alwaysProceed.label == "Always Proceed")
}
@Test func tonesMatchModesTs() {
#expect(PermissionMode.default.tone == .neutral)
#expect(PermissionMode.plan.tone == .info)
#expect(PermissionMode.safeYolo.tone == .warning)
#expect(PermissionMode.yolo.tone == .danger)
#expect(PermissionMode.bypassPermissions.tone == .danger)
#expect(PermissionMode.alwaysProceed.tone == .danger)
#expect(PermissionMode.requestReview.tone == .neutral)
}
@Test func kebabCaseWireValuesRoundTrip() throws {
#expect(PermissionMode.readOnly.rawValue == "read-only")
#expect(PermissionMode.safeYolo.rawValue == "safe-yolo")
#expect(PermissionMode.requestReview.rawValue == "request-review")
#expect(PermissionMode.alwaysProceed.rawValue == "always-proceed")
let decoded = try JSONDecoder().decode(PermissionMode.self, from: Data("\"read-only\"".utf8))
#expect(decoded == .readOnly)
}
@Test func optionListsCarryLabelAndTone() {
let options = permissionModeOptions(forFlavor: "codex")
#expect(options.count == 4)
#expect(options.first == PermissionModeOption(mode: .default))
#expect(options.first?.label == "Default")
#expect(options.first?.tone == .neutral)
}
// MARK: - Agent flavors (modes.ts + flavors.ts)
@Test func knownFlavorsMatchAgentFlavorsOrder() {
#expect(AgentFlavor.knownFlavors.map(\.rawValue) == [
"agy", "claude", "codex", "dsh", "copilot", "cursor",
"gemini", "grok", "kimi", "opencode", "pi",
])
}
@Test func creatableFlavorsExcludeOnlyGemini() {
#expect(AgentFlavor.creatableFlavors.count == 10)
#expect(!AgentFlavor.creatableFlavors.contains(.gemini))
#expect(AgentFlavor.creatableFlavors.contains(.claude))
}
@Test func unknownFlavorsAreCapturedNotCrashed() throws {
let flavor = AgentFlavor(rawValue: "mystery")
#expect(flavor == .other("mystery"))
#expect(flavor.rawValue == "mystery")
#expect(!flavor.isKnown)
let decoded = try JSONDecoder().decode(AgentFlavor.self, from: Data("\"newagent\"".utf8))
#expect(decoded == .other("newagent"))
let known = try JSONDecoder().decode(AgentFlavor.self, from: Data("\"grok\"".utf8))
#expect(known == .grok)
}
@Test func flavorLabelsMatchFlavorsTs() {
#expect(AgentFlavor.agy.displayLabel == "Antigravity")
#expect(AgentFlavor.grok.displayLabel == "Grok Build")
#expect(AgentFlavor.opencode.displayLabel == "OpenCode")
#expect(AgentFlavor.dsh.displayLabel == "DeepSeek Harness")
#expect(AgentFlavor.other("x").displayLabel == "Unknown")
#expect(flavorLabel(forFlavor: "pi") == "Pi")
#expect(flavorLabel(forFlavor: nil) == "Unknown")
}
@Test func effortSupportMatchesFlavorsTs() {
#expect(AgentFlavor.claude.supportsEffort)
#expect(AgentFlavor.grok.supportsEffort)
#expect(AgentFlavor.pi.supportsEffort)
#expect(!AgentFlavor.codex.supportsEffort)
#expect(!AgentFlavor.cursor.supportsEffort)
#expect(supportsEffort(forFlavor: "claude"))
#expect(!supportsEffort(forFlavor: "unknown"))
#expect(!supportsEffort(forFlavor: nil))
}
@Test func modelChangeIsSupportedByEveryModelConfigFlavor() {
for flavor in AgentFlavor.knownFlavors where flavor != .dsh {
#expect(flavor.supportsModelChange, "\(flavor.rawValue) should support model change")
}
#expect(!AgentFlavor.dsh.supportsModelChange)
#expect(!AgentFlavor.other("x").supportsModelChange)
#expect(!supportsModelChange(forFlavor: nil))
}
@Test func codexFamilyMatchesFlavorsTs() {
#expect(isCodexFamilyFlavor("codex"))
#expect(isCodexFamilyFlavor("gemini"))
#expect(isCodexFamilyFlavor("grok"))
#expect(isCodexFamilyFlavor("kimi"))
#expect(isCodexFamilyFlavor("copilot"))
#expect(isCodexFamilyFlavor("opencode"))
#expect(!isCodexFamilyFlavor("dsh"))
#expect(!isCodexFamilyFlavor("claude"))
#expect(!isCodexFamilyFlavor("cursor"))
#expect(!isCodexFamilyFlavor("pi"))
#expect(!isCodexFamilyFlavor("agy"))
#expect(!isCodexFamilyFlavor(nil))
}
// MARK: - Codex collaboration + Copilot agent modes
@Test func codexCollaborationModesMatchModesTs() {
#expect(CodexCollaborationMode.allCases == [.default, .plan])
#expect(CodexCollaborationMode.default.label == "Default")
#expect(CodexCollaborationMode.plan.label == "Plan")
}
@Test func copilotAgentModesMatchCopilotModesTs() throws {
#expect(CopilotAgentMode.allCases == [.interactive, .plan, .autopilot])
#expect(CopilotAgentMode.autopilot.label == "Autopilot")
// Legacy `fleet` coerces to interactive on decode and in normalize.
let fleet = try JSONDecoder().decode(CopilotAgentMode.self, from: Data("\"fleet\"".utf8))
#expect(fleet == .interactive)
#expect(CopilotAgentMode.normalize("fleet") == .interactive)
#expect(CopilotAgentMode.normalize("plan") == .plan)
#expect(CopilotAgentMode.normalize(nil) == .interactive)
#expect(CopilotAgentMode.normalize("bogus") == .interactive)
}
// MARK: - New-session catalogs (derived from the claude catalog data, #39)
@Test func newSessionCatalogsMatchTheWebOptionLists() {
#expect(NewSessionCatalogs.claudeModels == [
NewSessionOption(value: "auto", label: "Default"),
NewSessionOption(value: "opus", label: "Opus"),
NewSessionOption(value: "fable", label: "Fable"),
NewSessionOption(value: "sonnet", label: "Sonnet"),
NewSessionOption(value: "haiku", label: "Haiku"),
])
#expect(NewSessionCatalogs.claudeEfforts == [
NewSessionOption(value: "auto", label: "Auto"),
NewSessionOption(value: "low", label: "Low"),
NewSessionOption(value: "medium", label: "Medium"),
NewSessionOption(value: "high", label: "High"),
NewSessionOption(value: "xhigh", label: "XHigh"),
NewSessionOption(value: "max", label: "Max"),
])
#expect(NewSessionCatalogs.codexReasoningEfforts == [
NewSessionOption(value: "default", label: "Default"),
NewSessionOption(value: "low", label: "Low"),
NewSessionOption(value: "medium", label: "Medium"),
NewSessionOption(value: "high", label: "High"),
NewSessionOption(value: "xhigh", label: "XHigh"),
])
#expect(NewSessionCatalogs.effortLabel("xhigh") == "Xhigh")
}
}