mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
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.
213 lines
7.6 KiB
Swift
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
|
|
}
|