Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Chat/PermissionInputs.swift
T
weishu eed3dddb94 feat(ios): composer, permission actions, session config (A-M3ab)
Interaction layer turning the read-only chat into a working remote control,
mirroring the merged Android B-M3ab feature-for-feature (web authority where
the ports disagreed):

- ChatInteractor (HapiClient, fully swift-test covered): optimistic composer
  sends (appendOptimistic -> POST -> status settle), queue-by-default with a
  long-press Send&Steer intent while a turn is active, tap-to-retry on failed
  rows (steer retries degrade to queue), per-session drafts
  (UserDefaultsChatDrafts, hub-scoped keys, debounced + flushed on close).
- session_inactive (409) recovery: one POST /resume (current permissionMode)
  then retry; a superseding session id seeds the new window
  (MessageWindowControllers.seed), migrates the draft, retargets the
  optimistic row and emits sessionSuperseded -- ChatView/HomeView replace the
  navigation entry in place.
- Queued bar: uninvoked sends in web sort order with Cancel (optimistic
  DELETE; invoked-race ingests the authoritative row as sent), Edit
  (cancel + composer prefill, newer-draft guard) and Steer (invoked answers
  reconcile a missed consume); single-flight per-row op guard.
  reconcileQueuedState now runs on chat open and on session-pipe gap.
- Permission actions: flavor-exact bodies mirroring PermissionFooter.tsx --
  claude {} / allowTools (Bash(cmd)) / mode:acceptEdits, codex-family
  decision approved / approved_for_session / abort via deny -- plus
  AskUserQuestion flat answers (option cards, Other free text, no-questions
  fallback, cursor stable ids) and request_user_input nested answers
  (user_note suffix, required validation); optimistic Resolving /
  AlreadyHandled (404/409) overrides settled by the agentState patch.
  ChatPipeline now re-attaches the window row's client status so failed
  user rows actually render the retry affordance (web normalize.ts parity;
  the Android reference misses this overlay).
- Session config sheet (toolbar gear): catalog-driven permission-mode picker
  with tones, claude static model/effort catalogs (ModelCatalog port), codex
  models via new GET /sessions/:id/codex-models endpoint + wire types with
  per-model reasoning efforts; optimistic detail updates
  (SessionListStore.updateDetailLocal, new) rolled forward to server truth
  on error.
- Lifecycle: VisibilityReporter posts POST /api/visibility per tracked
  handshake subscriptionId on scene-phase flips (404 prunes); ChatSession
  exposes its subscriptionId and feeds the reporter; the global SSE pipe was
  already HubSession-lifetime on iOS.
- Tests: ChatInteractorTests transcribes the Android interaction suite
  against the real APIClient/AuthManager/SessionListStore/window registry
  with only HTTP scripted -- canonical JSON bodies asserted byte-for-byte
  (send/approve/deny/config), optimistic send happy/fail/retry, 409 resume
  both id paths, cancel invoked-race, steer reconcile, edit prefill,
  override lifecycle, config optimistic + rollback, drafts, abort.
2026-08-18 10:33:04 +08:00

213 lines
7.6 KiB
Swift

import Foundation
import HapiProtocol
// Question/option models + input parsers for the two interactive request
// tools, ported from `web/src/components/ToolCard/askUserQuestion.ts`,
// `cursorAskQuestion.ts` and `requestUserInput.ts` via the Android port's
// `PermissionInputs.kt`. Pure functions over `JSONValue` — unit-tested
// alongside the interactor.
public struct AskOption: Equatable, Sendable {
/// Stable option id (Cursor ACP); falls back to the label on submit.
public let id: String?
public let label: String
public let description: String?
public init(id: String?, label: String, description: String?) {
self.id = id
self.label = label
self.description = description
}
}
public struct AskQuestion: Equatable, Sendable {
/// Stable question id (Cursor ACP); falls back to the index on submit.
public let id: String?
public let header: String?
public let question: String
public let options: [AskOption]
public let multiSelect: Bool
public init(id: String?, header: String?, question: String, options: [AskOption], multiSelect: Bool) {
self.id = id
self.header = header
self.question = question
self.options = options
self.multiSelect = multiSelect
}
/// The flat-answers key: stable id when present, else the index (web parity).
public func answerKey(index: Int, useStableIds: Bool) -> String {
if useStableIds, let id, !id.isEmpty {
return id
}
return String(index)
}
}
public func isCursorAskQuestionToolName(_ toolName: String) -> Bool {
toolName == "CursorAskQuestion"
}
private func trimmedString(_ value: JSONValue?) -> String? {
value?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// `parseAskUserQuestionInput` / `parseCursorAskQuestionInput` merged: the
/// Cursor dialect adds `prompt`/`title`/`allowMultiple` synonyms and stable
/// ids; both collapse to the same `AskQuestion` list.
public func parseAskUserQuestions(_ input: JSONValue?, cursorDialect: Bool) -> [AskQuestion] {
guard let root = input?.objectValue,
let rawQuestions = root["questions"]?.arrayValue else {
return []
}
let requestTitle = cursorDialect ? (trimmedString(root["title"]) ?? "") : ""
var questions: [AskQuestion] = []
for raw in rawQuestions {
guard let object = raw.objectValue else { continue }
let question: String
let header: String
let multiSelect: Bool
let questionId: String?
if cursorDialect {
question = trimmedString(object["prompt"]) ?? trimmedString(object["question"]) ?? ""
header = trimmedString(object["title"]) ?? trimmedString(object["header"]) ?? ""
multiSelect = object["allowMultiple"]?.boolValue == true
|| object["multiSelect"]?.boolValue == true
let rawId = trimmedString(object["id"]) ?? ""
questionId = rawId.isEmpty ? String(questions.count) : rawId
} else {
question = trimmedString(object["question"]) ?? ""
header = trimmedString(object["header"]) ?? ""
multiSelect = object["multiSelect"]?.boolValue == true
questionId = nil
}
var options: [AskOption] = []
for rawOption in object["options"]?.arrayValue ?? [] {
guard let optionObject = rawOption.objectValue else { continue }
let label: String
if cursorDialect {
label = trimmedString(optionObject["label"]) ?? trimmedString(optionObject["id"]) ?? ""
} else {
label = trimmedString(optionObject["label"]) ?? ""
}
guard !label.isEmpty else { continue }
let optionId: String?
if cursorDialect {
let rawId = trimmedString(optionObject["id"]) ?? ""
optionId = rawId.isEmpty ? label : rawId
} else {
optionId = nil
}
let description = cursorDialect
? nil
: trimmedString(optionObject["description"]).flatMap { $0.isEmpty ? nil : $0 }
options.append(AskOption(id: optionId, label: label, description: description))
}
if question.isEmpty && options.isEmpty { continue }
let effectiveHeader = header.isEmpty ? (requestTitle.isEmpty ? nil : requestTitle) : header
questions.append(AskQuestion(
id: questionId,
header: effectiveHeader,
question: question,
options: options,
multiSelect: multiSelect
))
}
return questions
}
public struct RequestUserInputQuestion: Equatable, Sendable {
public let id: String
public let question: String
public let required: Bool
public let multiple: Bool
public let options: [AskOption]
public let placeholder: String?
public let prefill: String?
public init(
id: String,
question: String,
required: Bool,
multiple: Bool,
options: [AskOption],
placeholder: String?,
prefill: String?
) {
self.id = id
self.question = question
self.required = required
self.multiple = multiple
self.options = options
self.placeholder = placeholder
self.prefill = prefill
}
}
/// `parseRequestUserInputInput` (the URL-confirmation flow is web-only).
public func parseRequestUserInputQuestions(_ input: JSONValue?) -> [RequestUserInputQuestion] {
guard let root = input?.objectValue,
let rawQuestions = root["questions"]?.arrayValue else {
return []
}
var questions: [RequestUserInputQuestion] = []
for raw in rawQuestions {
guard let object = raw.objectValue else { continue }
let id = trimmedString(object["id"]) ?? ""
guard !id.isEmpty else { continue }
var options: [AskOption] = []
for rawOption in object["options"]?.arrayValue ?? [] {
guard let optionObject = rawOption.objectValue else { continue }
let label = trimmedString(optionObject["label"]) ?? ""
guard !label.isEmpty else { continue }
options.append(AskOption(
id: nil,
label: label,
description: trimmedString(optionObject["description"]).flatMap { $0.isEmpty ? nil : $0 }
))
}
questions.append(RequestUserInputQuestion(
id: id,
question: trimmedString(object["question"]) ?? "",
required: object["required"]?.boolValue != false,
multiple: object["multiple"]?.boolValue == true,
options: options,
placeholder: object["placeholder"]?.stringValue,
prefill: object["prefill"]?.stringValue
))
}
return questions
}
/// `formatRequestUserInputAnswers` value building for ONE field: selected
/// option labels plus a trailing `user_note: <text>` entry when a note was
/// typed. The nested `{answers: [...]}` wrapper is applied by the interactor.
public func requestUserInputAnswerValues(selected: [String], note: String) -> [String] {
var values = selected
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
values.append("user_note: \(trimmed)")
}
return values
}
/// `isRequestUserInputQuestionAnswered`.
public func isRequestUserInputAnswered(
_ question: RequestUserInputQuestion,
selected: [String],
note: String
) -> Bool {
if !question.required { return true }
if !question.options.isEmpty { return !selected.isEmpty }
return !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}