diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 592636e65c80..964dba337b14 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -5453,7 +5453,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, isDefault: $0.isDefault ?? false ) }, - defaultValue: defaultValue.map(FeatureModelOptionValue.string) + defaultValue: defaultValue.map(FeatureModelOptionValue.string), + promptInjectedValues: value.promptInjectedValues ) case let .boolean(value): return FeatureModelOptionDescriptor( diff --git a/apps/swift-ios/Features/Chat/FeatureComposerTraitsControl.swift b/apps/swift-ios/Features/Chat/FeatureComposerTraitsControl.swift new file mode 100644 index 000000000000..df6684491b2e --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerTraitsControl.swift @@ -0,0 +1,239 @@ +/// The composer traits menu is derived from the selected model's option +/// descriptors. It deliberately knows nothing about which providers expose +/// which sections: select descriptors use their advertised choices, boolean +/// descriptors use On/Off, and descriptors without a usable control disappear. +struct FeatureComposerTraitsControl: Equatable { + struct Choice: Identifiable, Equatable { + let id: String + let label: String + let detail: String? + let isDefault: Bool + let value: FeatureModelOptionValue + } + + struct Section: Identifiable, Equatable { + let id: String + let label: String + let choices: [Choice] + let currentChoiceID: String + } + + let sections: [Section] + let triggerLabel: String + let showsFastModeIcon: Bool + private let resolvedSelection: FeatureSelection + + static func resolve( + explicit: FeatureSelection?, + inherited: FeatureSelection?, + providers: [FeatureProvider], + materializesDefaultSelection: Bool + ) -> FeatureComposerTraitsControl? { + let providers = ProviderModelCatalogNormalizer.normalized(providers) + let selection = if materializesDefaultSelection { + ProviderModelSelectionResolver.materialized(explicit, in: providers) + } else { + ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: explicit, + inherited: inherited, + providers: providers + ) + } + guard let selection, + let provider = providers.first(where: { $0.id == selection.providerID }), + let model = provider.models.first(where: { $0.id == selection.modelID }) else { + return nil + } + + let sections = model.options.compactMap { + section(for: $0, selections: selection.options) + } + guard !sections.isEmpty else { return nil } + + let trigger = triggerDisplay( + sections: sections, + descriptors: model.options, + providerDriver: provider.driver + ) + return FeatureComposerTraitsControl( + sections: sections, + triggerLabel: trigger.label, + showsFastModeIcon: trigger.showsFastModeIcon, + resolvedSelection: selection + ) + } + + /// A traits choice writes through the same selection binding as the model + /// picker. The effective values of every visible descriptor are materialized + /// at the same time, matching Electron and preventing neighboring defaults + /// from disappearing on the next turn. + func selection(choosing choiceID: String, in descriptorID: String) -> FeatureSelection { + guard let section = sections.first(where: { $0.id == descriptorID }), + let choice = section.choices.first(where: { $0.id == choiceID }) else { + return resolvedSelection + } + + var next = resolvedSelection + for section in sections { + guard let current = section.choices.first(where: { + $0.id == section.currentChoiceID + }) else { continue } + next.options = DailyUXModelOptions.updating( + next.options, + id: section.id, + value: current.value + ) + } + next.options = DailyUXModelOptions.updating( + next.options, + id: descriptorID, + value: choice.value + ) + return next + } + + private static func section( + for descriptor: FeatureModelOptionDescriptor, + selections: [FeatureModelOptionSelection] + ) -> Section? { + switch descriptor.kind { + case .select: + let supportedChoices = descriptor.choices.filter { + !(descriptor.promptInjectedValues ?? []).contains($0.id) + } + guard !supportedChoices.isEmpty else { return nil } + return Section( + id: descriptor.id, + label: descriptor.label, + choices: supportedChoices.map { + Choice( + id: $0.id, + label: $0.label, + detail: $0.detail, + isDefault: $0.isDefault, + value: .string($0.id) + ) + }, + currentChoiceID: currentSelectChoiceID( + for: descriptor, + among: supportedChoices, + selections: selections + ) + ) + case .boolean: + let current = currentBooleanValue(for: descriptor, selections: selections) + return Section( + id: descriptor.id, + label: descriptor.label, + choices: [ + Choice( + id: "on", + label: "On", + detail: nil, + isDefault: false, + value: .boolean(true) + ), + Choice( + id: "off", + label: "Off", + detail: nil, + isDefault: false, + value: .boolean(false) + ), + ], + currentChoiceID: current ? "on" : "off" + ) + } + } + + private static func currentSelectChoiceID( + for descriptor: FeatureModelOptionDescriptor, + among choices: [FeatureModelOptionChoice], + selections: [FeatureModelOptionSelection] + ) -> String { + if case .string(let selected)? = selections.first(where: { + $0.id == descriptor.id + })?.value, + choices.contains(where: { $0.id == selected }) + || (descriptor.promptInjectedValues ?? []).contains(selected) { + return selected + } + if case .string(let defaultID) = descriptor.defaultValue, + choices.contains(where: { $0.id == defaultID }) { + return defaultID + } + return choices.first(where: \.isDefault)?.id ?? choices[0].id + } + + private static func currentBooleanValue( + for descriptor: FeatureModelOptionDescriptor, + selections: [FeatureModelOptionSelection] + ) -> Bool { + if case .boolean(let selected)? = selections.first(where: { + $0.id == descriptor.id + })?.value { + return selected + } + if case .boolean(let defaultValue) = descriptor.defaultValue { + return defaultValue + } + return false + } + + /// Mirrors Electron's compact TraitsPicker display. Fast mode is a bolt when + /// another trait supplies readable text; when it is the only trait its state + /// remains text so the trigger never becomes an unexplained icon. + private static func triggerDisplay( + sections: [Section], + descriptors: [FeatureModelOptionDescriptor], + providerDriver: String + ) -> (label: String, showsFastModeIcon: Bool) { + var fastModeFallbackLabel: String? + var fastModeEnabled = false + var labels: [String] = [] + + for descriptor in descriptors { + guard let section = sections.first(where: { $0.id == descriptor.id }) else { + continue + } + let current = section.choices.first(where: { + $0.id == section.currentChoiceID + }) + + if descriptor.id == "fastMode", descriptor.kind == .boolean { + fastModeEnabled = current?.value == .boolean(true) + fastModeFallbackLabel = fastModeEnabled ? "Fast" : "Normal" + continue + } + + if providerDriver == "codex", + descriptor.id == "serviceTier", + descriptor.kind == .select, + let fastChoice = section.choices.first(where: { $0.label == "Fast" }), + section.currentChoiceID == "default" + || section.currentChoiceID == fastChoice.id { + fastModeEnabled = section.currentChoiceID == fastChoice.id + fastModeFallbackLabel = current?.label + continue + } + + switch descriptor.kind { + case .select: + let label = current?.label ?? descriptor.choices.first(where: { + $0.id == section.currentChoiceID + })?.label + if let label { + labels.append(label) + } + case .boolean: + guard case .boolean(let value)? = current?.value else { continue } + labels.append("\(descriptor.label) \(value ? "On" : "Off")") + } + } + + if labels.isEmpty, let fastModeFallbackLabel { + return (fastModeFallbackLabel, false) + } + return (labels.joined(separator: " ยท "), fastModeEnabled) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 146485c38dab..36d60cef7ca4 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -5,6 +5,7 @@ struct FeatureComposerView: View { @State private var isManuallyExpanded = false @State private var isAttachmentFlowActive = false @State private var isModelPickerPresented = false + @State private var isTraitsPickerPresented = false @State private var restoresFocusAfterModelPickerDismissal = false @State private var attachmentPreparation = FeatureAttachmentPreparationState() @State private var pathEntries: [FeatureComposerPathEntry] = [] @@ -122,7 +123,9 @@ struct FeatureComposerView: View { isFocused: focused, textIsEmpty: textIsEmpty, attachmentsAreEmpty: attachments.isEmpty, - isAttachmentFlowActive: isAttachmentFlowActive || isModelPickerPresented, + isAttachmentFlowActive: isAttachmentFlowActive + || isModelPickerPresented + || isTraitsPickerPresented, isPreparingAttachments: attachmentPreparation.isPreparing ) { isManuallyExpanded = false @@ -298,7 +301,13 @@ struct FeatureComposerView: View { onPresentationChange: handleModelPickerPresentation ) .frame(maxWidth: 220, alignment: .leading) - .layoutPriority(2) + .layoutPriority(1) + + if let traitsControl { + traitsPicker(traitsControl) + .frame(minWidth: 28, maxWidth: 148, alignment: .trailing) + .layoutPriority(2) + } Spacer(minLength: 0) @@ -314,6 +323,56 @@ struct FeatureComposerView: View { .padding(.bottom, 8) } + /// The popover keeps all descriptor sections together and preserves their + /// catalog order, including option descriptions that a system Menu would + /// flatten away. + private func traitsPicker(_ control: FeatureComposerTraitsControl) -> some View { + Button { + isTraitsPickerPresented.toggle() + } label: { + traitsPickerLabel(control) + .frame( + minWidth: T3Metrics.minimumTapTarget, + minHeight: T3Metrics.minimumTapTarget + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .popover( + isPresented: $isTraitsPickerPresented, + attachmentAnchor: .rect(.bounds), + arrowEdge: .bottom + ) { + FeatureComposerTraitsMenu(control: control) { descriptorID, choiceID in + selection = control.selection(choosing: choiceID, in: descriptorID) + isTraitsPickerPresented = false + } + .presentationCompactAdaptation(.popover) + } + .accessibilityLabel("Model traits") + .accessibilityValue(control.triggerLabel) + .accessibilityIdentifier("composer-traits-picker") + } + + private func traitsPickerLabel(_ control: FeatureComposerTraitsControl) -> some View { + HStack(spacing: 3) { + if control.showsFastModeIcon { + Image(systemName: "bolt.fill") + .font(.system(size: 10, weight: .semibold)) + .accessibilityHidden(true) + } + Text(control.triggerLabel) + .lineLimit(1) + .truncationMode(.tail) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .bold)) + .fixedSize() + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .contentShape(Rectangle()) + } + private var submitButton: some View { Button(action: performPrimaryAction) { Image(systemName: submitSymbol) @@ -389,6 +448,15 @@ struct FeatureComposerView: View { ) } + private var traitsControl: FeatureComposerTraitsControl? { + FeatureComposerTraitsControl.resolve( + explicit: selection, + inherited: threadSelection, + providers: providers, + materializesDefaultSelection: materializesDefaultSelection + ) + } + /// Trigger detection walks the whole draft with character indices and is /// read from several computed properties per body evaluation, so one parse /// per keystroke is memoized instead of four. @@ -584,6 +652,103 @@ struct FeatureComposerView: View { } } +private struct FeatureComposerTraitsMenu: View { + let control: FeatureComposerTraitsControl + let onSelect: (String, String) -> Void + + var body: some View { + ScrollView { + VStack(spacing: 0) { + ForEach(Array(control.sections.enumerated()), id: \.element.id) { index, section in + if index > 0 { + Divider() + .overlay(T3Colors.separator) + .padding(.vertical, 5) + } + traitSection(section) + } + } + .padding(6) + } + .scrollIndicators(.hidden) + .frame(width: 292) + .frame(maxHeight: 520) + .background(T3Colors.surface) + .accessibilityIdentifier("composer-traits-menu") + } + + private func traitSection(_ section: FeatureComposerTraitsControl.Section) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(section.label) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + .padding(.horizontal, 10) + .padding(.top, 5) + .padding(.bottom, 3) + + ForEach(section.choices) { choice in + let isCurrent = choice.id == section.currentChoiceID + Button { + onSelect(section.id, choice.id) + } label: { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Text(choice.label) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + if choice.isDefault { + Text("Default") + .font(.caption2.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(T3Colors.subtleStrong, in: Capsule()) + } + } + if let detail = choice.detail { + Text(detail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 4) + if isCurrent { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(T3Colors.accent) + .padding(.top, 3) + } + } + .frame(maxWidth: .infinity, minHeight: 38, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, choice.detail == nil ? 1 : 4) + .background( + isCurrent ? T3Colors.accent.opacity(0.12) : .clear, + in: RoundedRectangle(cornerRadius: 7, style: .continuous) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(choice.label) + .accessibilityValue( + [choice.isDefault ? "Default" : nil, isCurrent ? "Current" : nil] + .compactMap { $0 } + .joined(separator: ", ") + ) + .accessibilityIdentifier( + "composer-trait-\(section.id)-choice-\(choice.id)" + ) + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(section.label) + .accessibilityIdentifier("composer-trait-section-\(section.id)") + } +} + enum FeatureComposerCollapsePolicy { static func shouldCollapse( isFocused: Bool, diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index dd8785cf33fc..0e7f153be0bd 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -764,6 +764,7 @@ public struct FeatureModelOptionDescriptor: Identifiable, Sendable, Equatable, H public var kind: FeatureModelOptionKind public var choices: [FeatureModelOptionChoice] public var defaultValue: FeatureModelOptionValue? + public var promptInjectedValues: [String]? public init( id: String, @@ -771,7 +772,8 @@ public struct FeatureModelOptionDescriptor: Identifiable, Sendable, Equatable, H detail: String? = nil, kind: FeatureModelOptionKind, choices: [FeatureModelOptionChoice] = [], - defaultValue: FeatureModelOptionValue? = nil + defaultValue: FeatureModelOptionValue? = nil, + promptInjectedValues: [String]? = nil ) { self.id = id self.label = label @@ -779,6 +781,7 @@ public struct FeatureModelOptionDescriptor: Identifiable, Sendable, Equatable, H self.kind = kind self.choices = choices self.defaultValue = defaultValue + self.promptInjectedValues = promptInjectedValues } } diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift index f03c2b49c734..0d3d459c6840 100644 --- a/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift @@ -210,6 +210,307 @@ struct FeatureComposerPowerTests { #expect(FeatureComposerPasteboardPolicy.containsImage(in: pasteboard)) } + @Test( + "The traits menu renders every supported descriptor in catalog order", + .bug("https://github.com/saphid/t3code-personal/issues/110") + ) + func traitsMenuIncludesReasoningAndServiceTierWithDescriptorMetadata() throws { + let control = try #require( + FeatureComposerTraitsControl.resolve( + explicit: .init( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [.init(id: "reasoningEffort", value: .string("high"))] + ), + inherited: nil, + providers: [Self.solProvider], + materializesDefaultSelection: true + ) + ) + + #expect(control.sections.map(\.id) == ["reasoningEffort", "serviceTier"]) + #expect(control.sections.map(\.label) == ["Reasoning", "Service Tier"]) + let reasoning = try #require(control.sections.first) + #expect(reasoning.choices.map(\.id) == ["low", "medium", "high", "xhigh", "max", "ultra"]) + #expect(reasoning.choices.first?.isDefault == true) + #expect(reasoning.currentChoiceID == "high") + let serviceTier = try #require(control.sections.last) + #expect(serviceTier.choices.map(\.label) == ["Standard", "Fast"]) + #expect(serviceTier.choices.first?.isDefault == true) + #expect(serviceTier.currentChoiceID == "default") + #expect(serviceTier.choices.last?.detail == "1.5x speed, increased usage.") + } + + @Test( + "The traits trigger matches Electron's Standard and Fast display", + .bug("https://github.com/saphid/t3code-personal/issues/110") + ) + func traitsTriggerUsesReasoningTextAndFastModeBolt() throws { + let standard = try #require( + FeatureComposerTraitsControl.resolve( + explicit: .init( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [ + .init(id: "reasoningEffort", value: .string("high")), + .init(id: "serviceTier", value: .string("default")), + ] + ), + inherited: nil, + providers: [Self.solProvider], + materializesDefaultSelection: true + ) + ) + let fastSelection = standard.selection(choosing: "priority", in: "serviceTier") + let fast = try #require( + FeatureComposerTraitsControl.resolve( + explicit: fastSelection, + inherited: nil, + providers: [Self.solProvider], + materializesDefaultSelection: true + ) + ) + + #expect(standard.triggerLabel == "High") + #expect(!standard.showsFastModeIcon) + #expect(fast.triggerLabel == "High") + #expect(fast.showsFastModeIcon) + } + + @Test( + "Trait choices materialize defaults and persist through subsequent turns", + .bug("https://github.com/saphid/t3code-personal/issues/110") + ) + func traitChoicesPersistBothEffectiveSelectionsOnTheSubmissionPath() throws { + let inherited = FeatureSelection( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [.init(id: "futureOption", value: .string("preserve-me"))] + ) + let initial = try #require( + FeatureComposerTraitsControl.resolve( + explicit: nil, + inherited: inherited, + providers: [Self.solProvider], + materializesDefaultSelection: false + ) + ) + let reasoningSelection = initial.selection(choosing: "xhigh", in: "reasoningEffort") + let afterReasoning = try #require( + FeatureComposerTraitsControl.resolve( + explicit: reasoningSelection, + inherited: inherited, + providers: [Self.solProvider], + materializesDefaultSelection: false + ) + ) + let effectiveSelection = afterReasoning.selection( + choosing: "priority", + in: "serviceTier" + ) + let submission = FeatureMessageSubmission( + threadID: "thread-1", + text: "Continue", + selection: effectiveSelection + ) + + #expect(submission.selection?.providerID == "codex") + #expect(submission.selection?.modelID == "gpt-5.6-sol") + #expect( + submission.selection?.options.first(where: { $0.id == "reasoningEffort" })?.value + == .string("xhigh") + ) + #expect( + submission.selection?.options.first(where: { $0.id == "serviceTier" })?.value + == .string("priority") + ) + #expect( + submission.selection?.options.first(where: { $0.id == "futureOption" })?.value + == .string("preserve-me") + ) + #expect(submission.selection?.options.filter { $0.id == "reasoningEffort" }.count == 1) + #expect(submission.selection?.options.filter { $0.id == "serviceTier" }.count == 1) + } + + @Test( + "Defaults, inherited selections, and provider changes resolve independently", + .bug("https://github.com/pingdotgg/t3code/pull/7344#discussion_r3826822638") + ) + func traitsFollowTheEffectiveModelSelection() throws { + let defaultControl = try #require( + FeatureComposerTraitsControl.resolve( + explicit: .init(providerID: "codex", modelID: "missing"), + inherited: nil, + providers: [Self.solProvider], + materializesDefaultSelection: true + ) + ) + #expect(defaultControl.sections.map(\.currentChoiceID) == ["low", "default"]) + + let inheritedControl = try #require( + FeatureComposerTraitsControl.resolve( + explicit: nil, + inherited: .init( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [ + .init(id: "reasoningEffort", value: .string("max")), + .init(id: "serviceTier", value: .string("priority")), + ] + ), + providers: [Self.solProvider], + materializesDefaultSelection: false + ) + ) + #expect(inheritedControl.sections.map(\.currentChoiceID) == ["max", "priority"]) + + let plainProvider = FeatureProvider( + id: "plain", + name: "Plain", + driver: "grok", + models: [FeatureModel(id: "basic", name: "Basic")] + ) + #expect( + FeatureComposerTraitsControl.resolve( + explicit: .init(providerID: "plain", modelID: "basic"), + inherited: nil, + providers: [Self.solProvider, plainProvider], + materializesDefaultSelection: true + ) == nil + ) + } + + @Test( + "Unsupported descriptors hide while boolean descriptors remain selectable", + .bug("https://github.com/saphid/t3code-personal/issues/110") + ) + func unsupportedDescriptorsDoNotCreateSections() throws { + let provider = FeatureProvider( + id: "mixed", + name: "Mixed", + driver: "cursor", + models: [ + FeatureModel( + id: "mixed-model", + name: "Mixed model", + isDefault: true, + options: [ + .init(id: "empty", label: "Empty", kind: .select), + .init( + id: "promptEffort", + label: "Prompt effort", + kind: .select, + choices: [.init(id: "ultrathink", label: "Ultrathink")], + promptInjectedValues: ["ultrathink"] + ), + .init( + id: "thinking", + label: "Thinking", + kind: .boolean, + defaultValue: .boolean(false) + ), + ] + ), + ] + ) + let control = try #require( + FeatureComposerTraitsControl.resolve( + explicit: nil, + inherited: nil, + providers: [provider], + materializesDefaultSelection: true + ) + ) + + #expect(control.sections.map(\.id) == ["thinking"]) + #expect(control.sections[0].choices.map(\.id) == ["on", "off"]) + #expect(control.sections[0].currentChoiceID == "off") + #expect(control.sections[0].choices.allSatisfy { !$0.isDefault }) + #expect(control.triggerLabel == "Thinking Off") + } + + @Test( + "Changing a visible trait preserves a hidden prompt-injected selection", + .bug("https://github.com/saphid/t3code-personal/issues/110") + ) + func visibleTraitChangesPreservePromptInjectedSelections() throws { + var provider = Self.solProvider + provider.models[0].options[0].choices.append( + .init(id: "ultrathink", label: "Ultrathink") + ) + provider.models[0].options[0].promptInjectedValues = ["ultrathink"] + let control = try #require( + FeatureComposerTraitsControl.resolve( + explicit: .init( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [ + .init(id: "reasoningEffort", value: .string("ultrathink")), + .init(id: "serviceTier", value: .string("default")), + ] + ), + inherited: nil, + providers: [provider], + materializesDefaultSelection: true + ) + ) + + #expect(control.sections[0].choices.allSatisfy { $0.id != "ultrathink" }) + #expect(control.sections[0].currentChoiceID == "ultrathink") + #expect(control.triggerLabel == "Ultrathink") + let selection = control.selection(choosing: "priority", in: "serviceTier") + #expect( + selection.options.first(where: { $0.id == "reasoningEffort" })?.value + == .string("ultrathink") + ) + #expect( + selection.options.first(where: { $0.id == "serviceTier" })?.value + == .string("priority") + ) + } + + /// Mirrors the live Codex descriptors Alex supplied for `gpt-5.6-sol`. + private static let solProvider = FeatureProvider( + id: "codex", + name: "Codex", + driver: "codex", + models: [ + FeatureModel( + id: "gpt-5.6-sol", + name: "GPT-5.6-Sol", + isDefault: true, + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning", + kind: .select, + choices: [ + .init(id: "low", label: "Low", isDefault: true), + .init(id: "medium", label: "Medium"), + .init(id: "high", label: "High"), + .init(id: "xhigh", label: "Extra High"), + .init(id: "max", label: "Max"), + .init(id: "ultra", label: "Ultra"), + ] + ), + .init( + id: "serviceTier", + label: "Service Tier", + kind: .select, + choices: [ + .init(id: "default", label: "Standard", isDefault: true), + .init( + id: "priority", + label: "Fast", + detail: "1.5x speed, increased usage." + ), + ] + ), + ] + ), + ] + ) + @Test func detectsCommandsModelsSkillsAndPathsAtTheCursor() { #expect( diff --git a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift index 93b83a03d9ef..1458c17f2d30 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift @@ -101,14 +101,34 @@ final class NativeMultiEnvironmentTests: XCTestCase { XCTAssertEqual(detail.thread.environmentName, "Steam Box") try await fixture.client.renameThread(id: remoteThread.id, title: "Remote rename") + let selection = FeatureSelection( + providerID: "codex", + modelID: "gpt-5.6-sol", + options: [ + .init(id: "reasoningEffort", value: .string("xhigh")), + .init(id: "serviceTier", value: .string("priority")), + ] + ) try await fixture.client.sendMessage( threadID: remoteThread.id, text: "Run this on Steam Box", - selection: nil + selection: selection ) - let routedHosts = await fixture.transport.dispatchHosts() - XCTAssertEqual(routedHosts, ["two.example", "two.example"]) + let records = await fixture.transport.dispatchRecords() + XCTAssertEqual(records.map(\.host), ["two.example", "two.example"]) + let turnSelection = try XCTUnwrap( + records.last?.command["modelSelection"]?.decode(ModelSelection.self) + ) + XCTAssertEqual(turnSelection.instanceId, selection.providerID) + XCTAssertEqual(turnSelection.model, selection.modelID) + XCTAssertEqual( + turnSelection.options, + [ + .init(id: "reasoningEffort", value: .string("xhigh")), + .init(id: "serviceTier", value: .string("priority")), + ] + ) await fixture.client.disconnect() }