Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractionState.swift
T
SSU-WEI HUANGandGitHub f0e5ba9c0f feat(codex): mid-turn Steer via app-server turn/steer (#888) (#1606)
* feat(shared): steer capability gates and live steered signal schemas

- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which
  agents can deliver queued messages into the active turn (pi, codex,
  cursor ACP; legacy stream-json cursor excluded)
- AgentState.steeringActive, DecryptedMessage.steered and
  messages-consumed  live signal (never persisted by the hub)

* feat(cli): queue reservations and steered messages-consumed option

- MessageQueue2 gains takeByLocalId/restoreReservation/
  beginReservationDispatch/commitReservation so an async steer can reserve
  a queued row without racing the main loop's turn/start drain
- emitMessagesConsumed accepts steered: true to mark mid-turn delivery

* feat(codex): mid-turn steer via app-server turn/steer (#888)

- CodexAppServerClient.steerTurn + TurnSteerParams/Response types
- CodexRemoteLauncher registers the steer-queued-message RPC handler:
  reserves the queued row, validates it against the active turn (no
  control commands, matching mode hash), injects via turn/steer with an
  epoch guard that invalidates in-flight steers on abort/cleanup
- steeringActive agent state tracks the active-turn window
- hub syncEngine gate opens to codex; messages-consumed relays steered

* feat(web): Steered badge and steer gating for codex sessions

- HappyUserMessage shows a ↳ Steered badge fed by the live
  messages-consumed steered signal, preserved across server echoes and
  refetches (mergeMessages carries the optimistic marker)
- SessionChat gates canSteer via isSteeringSupportedForSession instead of
  the pi-only check
- clearStaleQueuedStatus normalizes a queued status on an invoked message
- fix(web): drop duplicate showSessionSummaryInChat in markdown test
  (upstream typecheck breakage)

* fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer

- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise
  codex and pi only; cursor joins when its soft-steer handler lands (#1609)
- turn/steer now splits dispatch (stdin accepted) from completion (turn
  finished): the hub RPC acks once dispatch succeeds — never on the
  concurrent turn's completion, which can exceed the 30s RPC window
- queue row commits only after the turn settles; a rejected/aborted steer
  restores the row so the message still delivers via turn/start, and a
  dispatched steer is never restored (no duplicate delivery)
- steer carries clientUserMessageId (echoed as userMessage.clientId) so
  ambiguous transport failures can reconcile the thread later
- client tests cover dispatch/complete split and stdin-write failure

* fix(codex): reconcile dispatched steers before restoring; align error copy

- A dispatched turn/steer whose completion fails (disconnect / protocol
  error) is now reconciled via thread/read by clientUserMessageId before
  the queued row is restored — the instruction is only re-delivered by
  turn/start when the thread never received it
- Reconcile targets the pinned steer thread, not whichever turn is
  current when completion fails
- syncEngine unsupported-flavor error now matches the capability gate
  (Pi and Codex only until the cursor handler lands)
- launcher tests cover steer success (ack on dispatch), reconcile-accepted
  and reconcile-rejected outcomes

* fix(codex): consume the row at dispatch; drop background reconcile

- The hub RPC acks and the queue row is consumed as soon as stdin accepts
  turn/steer; completion is background-only logging. A dispatched steer is
  never restored, so the same localId cannot be re-delivered via turn/start
  after the caller was told the steer succeeded
- Dispatch failure (stdin write error) still restores the row and reports
  failure
- steer.completed rejection is always handled (no unhandled rejection on
  the dispatch-failure path)
- tests updated: completion failure after dispatch keeps the row consumed;
  dispatch failure restores it

* fix(codex): distinguish definite rejection from indeterminate completion

- Transport-level failures (timeout, abort, disconnect, spawn, protocol)
  carry an indeterminate marker; explicit JSON-RPC error responses do not
- After a dispatched steer, turn completion resolves → commit + consumed;
  a definite app-server rejection restores the row (instruction was never
  accepted, so turn/start cannot duplicate it); an indeterminate outcome
  leaves the row reserved so it can never be delivered twice
- Completion handling registers before awaiting dispatch so the
  dispatch-failure path cannot leak an unhandled rejection
- client/launcher tests cover explicit rejection (restore), indeterminate
  outcome (row stays reserved) and dispatch failure

* fix(codex): reconcile indeterminate steers instead of a permanent reservation

- After an indeterminate completion (disconnect/protocol), reconcile the
  thread by clientUserMessageId immediately: accepted → commit + consumed,
  provably rejected → restore, still unreadable → keep the reservation and
  retry from the main-loop top on later passes (post-reconnect)
- A row never sits in dispatching forever: the hub cannot stamp it invoked
  while the instruction may never have been accepted
- tests: indeterminate keeps reserved while thread unreadable; accepted
  reconciliation consumes; rejected path restores

* fix(codex): accept all thread item shapes; retry reconcile; ack through abort

- Reconcile matcher accepts userMessage/user_message with clientId/
  client_id, matching the shapes the thread parser supports — an accepted
  steer can no longer be misclassified as rejected
- A pending reconciliation schedules a wakeLoop retry, so a temporary
  app-server outage cannot strand the reservation behind waitForTurnOrRecovery
- The success-path ACK no longer checks the steer epoch: the hub already
  reported steered on dispatch, so commit + messages-consumed must reach
  it even when an abort resets the queue in between

* fix(codex): reinit reconnected app-server; keep reconcile retries alive

- thread/read after a disconnect auto-connects a fresh app-server, which
  must be initialized before any request — reconcile now ensures
  connect + initialize (isConnected getter added to the client)
- every still-unknown loop-top reconciliation schedules the next retry,
  so recovery without external traffic is eventually observed
- launcher mock gains isConnected

* fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK

- Reconciliation runs on a self-rescheduling 1s timer independent of the
  main loop (wakes it too), so idle loops and waitForTurnOrRecovery still
  observe app-server recovery; abort clears nothing implicitly — the ACK
  path commits and consumes even when the reservation was cancelled
- Absence of a durable client id is ambiguous: unmatched reads stay
  'unknown' and keep retrying instead of restoring the row
- CodexAppServerClient tracks initialized state (reset on disconnect/exit)
  so ensureAppServerInitialized re-initializes a fresh process before
  thread/read; initialize failures leave the flag false for the next retry
- tests: accepted reconciliation via scheduled timer, indeterminate
  keeps reserved, explicit rejection restores

* fix(codex): bind reconciliation to the launcher lifecycle

- runSteerReconciliation clears any armed retry timer on entry and never
  installs a second one, so loop-top and timer-driven passes cannot
  multiply
- shuttingDown is set when the main loop ends: timers are cleared and the
  pending map is dropped, so an unresolved steer can never respawn an
  app-server after cleanup (remote-to-local switch included)

* fix(codex): report steered only after app-server acceptance

- The handler now awaits steer.completed (the inject-acceptance response):
  an explicit JSON-RPC rejection surfaces as failed and restores the row
  for the normal turn/start path instead of a false steered
- Transport failure after dispatch reports 'Steer outcome is being
  reconciled' and keeps the row reserved while the timer-driven thread
  reconciliation runs
- dispatch-failure path also swallows the paired completion rejection

* fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait

- MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching
  steer reservation: the hub neither deletes the row nor stamps invoked_at
  (new CancelMessageResponse 'busy' status; web restores the optimistic
  row); pushIsolateAndClear and reset/close share cancelReservations so
  /clear-style commands cannot have a rejected steer resurrect a discarded
  prompt
- turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a
  lost response is indeterminate and funnels into thread reconciliation
  instead of stranding the reservation
- tests updated for the tri-state cancel contract

* fix(codex,web): busy-aware edit flow; bound reconciliation reads

- QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it
  never prefills the composer when the row is inside an async steer, so a
  second client cannot send a duplicate
- reconcileSteerByClientId bounds thread/read with a 5s timeout so a
  connected-but-silent app-server cannot hold the reservation in-flight
  indefinitely

* fix(steer): inFlight-dominated cancel acks; bounded reconciliation

- hub cancel-queued-message acks check inFlight before removed: a stale
  duplicate socket reporting removed can no longer delete the durable row
  while another socket is dispatching the steer
- reconciliation entries expire after 60s and mark delivered: after the
  rejection window, a dispatched steer that the app-server never proved
  (client ids dropped on restart) is committed instead of polling
  thread/read forever
- pre-dispatch failures (abort before write included) never enter
  reconciliation — they restore the row and report failure

* fix(steer): persist indeterminate outcomes without replay

* fix(steer): make ambiguous delivery restart-safe

* fix(steer): recover crash-held rows and preserve retry dedup

* fix(steer): ack retries and bound stdin dispatch

* fix(steer): reconcile indeterminate dispatches and serialize retries

* fix(codex): classify stdin callback failures as indeterminate

* fix(steer): recheck indeterminate cancels after ACK

* fix(steer): close retry and abort races

* fix(steer): serialize live retries and abort admission

* fix(steer): distinguish live dispatching from unknown

* fix(steer): keep ACK failures held and reconcile busy cancel

* fix(steer): distinguish held cancel from removal

* fix(store): combine schema v24 migrations

* fix(store): reserve schema v25 for steer delivery state

* fix(steer): keep held cancel state and notify requeue

* fix(steer): release explicitly cancelled unknown reservations

* fix(codex): reject cancelled reservations before native steer

* fix(codex): make reservation restore atomic with state

* fix(codex): terminate abandoned transport writes

* fix(steer): own abandoned app-server lifecycle and consume races

* fix(codex): confirm dispatch and recover abandoned turns

* test(codex): mock abandoned transport callback

* fix(codex): clear visible turn state on transport loss

* fix(steer): claim retries and cover native delivery state

* fix(native): preserve indeterminate state on Android hydration

* fix(steer): make retry claims single-winner

* fix(steer): serialize concurrent retry claims

* fix(socket): tolerate missing steer-state ACK callbacks

* fix(native): serialize retry operations

* docs(web): document unknown steer delivery and retry controls

* fix(steer): handle retry failures and abort-before-connect

* fix(steer): reinitialize after transport loss and finish iOS retry errors

* fix(steer): preserve indeterminate rows across reconnect gaps

* test(web): mock indeterminate queued recovery state

* fix(steer): recover consumed ACK tombstones

* fix(steer): expose consumed cancel tombstones
2026-08-19 20:07:39 +08:00

300 lines
10 KiB
Swift

import Foundation
import HapiProtocol
// Value types + pure mapping logic of the chat interaction layer (A-M3ab),
// mirroring the Android `ChatViewModel`'s M3 surface. The stateful
// orchestration lives in `ChatInteractor`.
// MARK: - Composer
/// Composer bar state.
public struct ComposerState: Equatable, Sendable {
public let text: String
/// A send (or its inactive-session resume) is in flight — spinner on the
/// send button.
public let isSending: Bool
/// A turn is active: long-press send offers Steer, and Abort is shown.
public let canSteer: Bool
public init(text: String, isSending: Bool, canSteer: Bool) {
self.text = text
self.isSending = isSending
self.canSteer = canSteer
}
}
/// One row of the queued-messages bar (uninvoked sends).
public struct QueuedMessageRow: Equatable, Sendable, Identifiable {
public let id: String
public let localId: String?
public let text: String
public let attachmentNames: [String]
public let scheduledAt: Int?
/// Server echo has landed (`id != localId`) and no queued operation is in
/// flight — Cancel/Edit act only then (web `computeCanCancel`).
public let canAct: Bool
/// Steer offered: turn active, not future-scheduled, actionable.
public let canSteer: Bool
public let indeterminate: Bool
public init(
id: String,
localId: String?,
text: String,
attachmentNames: [String],
scheduledAt: Int?,
canAct: Bool,
canSteer: Bool,
indeterminate: Bool = false
) {
self.id = id
self.localId = localId
self.text = text
self.attachmentNames = attachmentNames
self.scheduledAt = scheduledAt
self.canAct = canAct
self.canSteer = canSteer
self.indeterminate = indeterminate
}
}
// MARK: - Permissions
/// Optimistic-permission UI state layered over the reduced blocks.
public enum PermissionRowOverride: Equatable, Sendable {
/// Decision POSTed; waiting for the agentState patch to settle it.
case resolving
/// The hub said the request is no longer pending (404/409) — benign.
case alreadyHandled
}
/// A permission decision the UI can request (bodies mirror
/// `PermissionFooter.tsx` / `AskUserQuestionFooter.tsx` /
/// `RequestUserInputFooter.tsx`).
public enum PermissionAction: Equatable, Sendable {
/// Plain allow — `{}` (claude family) or `{"decision":"approved"}`
/// (codex family).
case allow
/// Claude: `{"allowTools":[…]}`; everyone else:
/// `{"decision":"approved_for_session"}`.
case allowForSession
/// Claude edit tools: `{"mode":"acceptEdits"}`.
case allowAllEdits
/// Plain deny — `{}`.
case deny
/// Codex family: deny with `{"decision":"abort"}`.
case abort
/// AskUserQuestion: flat `{"<key>": ["label", …]}`.
case flatAnswers([String: [String]])
/// request_user_input: nested `{"<fieldId>": {"answers": […]}}`.
case nestedAnswers([String: [String]])
}
/// `PermissionFooter.isCodexSession` twin: codex family or cursor flavor, or
/// a codex-dialect tool name — selects both the button set and the plain
/// allow body.
public func isCodexPermissionUX(flavor: String?, toolName: String?) -> Bool {
if isCodexFamilyFlavor(flavor) || flavor == "cursor" {
return true
}
guard let toolName else { return false }
return toolName.hasPrefix("Codex")
|| toolName.hasPrefix("Gemini")
|| toolName.hasPrefix("OpenCode")
|| toolName.hasPrefix("Copilot")
|| toolName.hasPrefix("Cursor")
}
/// The web footer's tool-name gates.
public enum PermissionGates {
/// Tools whose approval may offer "Allow all edits" (claude).
public static let editTools: Set<String> = ["Edit", "MultiEdit", "Write", "NotebookEdit"]
/// Tools that never offer "Allow for this session".
public static let hideAllowForSession: Set<String> =
editTools.union(["exit_plan_mode", "ExitPlanMode", "CursorCreatePlan"])
}
/// Builds the approve body for one decision — the exact `PermissionFooter`
/// mapping table. `deny`/`abort` never reach this (they use the deny
/// endpoint).
public func permissionApproveBody(
action: PermissionAction,
flavor: String?,
toolName: String?,
arguments: JSONValue?
) -> PermissionApproveRequest {
switch action {
case .allow:
if isCodexPermissionUX(flavor: flavor, toolName: toolName) {
return PermissionApproveRequest(decision: .approved)
}
return PermissionApproveRequest()
case .allowForSession:
if flavor == "claude" {
let command = toolName == "Bash" ? inputString(arguments, keys: ["command", "cmd"]) : nil
let toolIdentifier: String
if toolName == "Bash", let command {
toolIdentifier = "Bash(\(command))"
} else {
toolIdentifier = toolName ?? ""
}
return PermissionApproveRequest(allowTools: [toolIdentifier])
}
return PermissionApproveRequest(decision: .approvedForSession)
case .allowAllEdits:
return PermissionApproveRequest(mode: .acceptEdits)
case .flatAnswers(let answers):
return PermissionApproveRequest(answers: .object(
answers.mapValues { values in .array(values.map(JSONValue.string)) }
))
case .nestedAnswers(let answers):
return PermissionApproveRequest(answers: .object(
answers.mapValues { values in
.object(["answers": .array(values.map(JSONValue.string))])
}
))
case .deny, .abort:
assertionFailure("deny actions do not build approve bodies")
return PermissionApproveRequest()
}
}
// MARK: - Session config
/// Load state of the per-session codex model catalog.
public enum CodexModelsState: Equatable, Sendable {
case idle
case loading
case loaded([CodexModelSummary])
case failed
}
/// Session config sheet model.
public struct SessionConfigState: Equatable, Sendable {
public let flavor: String?
public let active: Bool
/// Terminal-controlled sessions reject config posts with 409.
public let controlledByUser: Bool
/// Current wire mode; nil renders as the Default row.
public let permissionMode: PermissionMode?
/// Catalog modes for this flavor; empty → hide the section (pi).
public let permissionModes: [PermissionModeOption]
public let model: String?
/// nil → hide the model section (flavor without a known catalog).
public let modelOptions: [CatalogOption]?
/// True while the codex model catalog loads (sheet shows a spinner row).
public let modelOptionsLoading: Bool
/// Claude `effort` or codex `modelReasoningEffort`, whichever applies.
public let effort: String?
/// nil → hide the effort section.
public let effortOptions: [CatalogOption]?
public init(
flavor: String?,
active: Bool,
controlledByUser: Bool,
permissionMode: PermissionMode?,
permissionModes: [PermissionModeOption],
model: String?,
modelOptions: [CatalogOption]?,
modelOptionsLoading: Bool,
effort: String?,
effortOptions: [CatalogOption]?
) {
self.flavor = flavor
self.active = active
self.controlledByUser = controlledByUser
self.permissionMode = permissionMode
self.permissionModes = permissionModes
self.model = model
self.modelOptions = modelOptions
self.modelOptionsLoading = modelOptionsLoading
self.effort = effort
self.effortOptions = effortOptions
}
}
/// Catalog-driven derivation of the config sheet model (Android
/// `buildConfigUi`).
public func buildSessionConfigState(
detail: Session?,
summary: SessionSummary?,
codexModels: CodexModelsState
) -> SessionConfigState {
let flavor = detail?.metadata?.flavor ?? summary?.metadata?.flavor
let model = detail?.model
var modelOptions: [CatalogOption]?
var modelOptionsLoading = false
var effort: String?
var effortOptions: [CatalogOption]?
switch flavor {
case "claude":
modelOptions = ModelCatalog.claudeModelOptions(currentModel: model)
effort = detail?.effort
effortOptions = ModelCatalog.claudeEffortOptions(currentEffort: effort)
case "codex":
switch codexModels {
case .loaded(let models):
modelOptions = models.map { row in
CatalogOption(
value: row.id,
label: row.displayName + (row.isDefault ? " · default" : "")
)
}
let selected = models.first { $0.id == model } ?? models.first { $0.isDefault }
let efforts = selected?.supportedReasoningEfforts ?? []
if !efforts.isEmpty {
effort = detail?.modelReasoningEffort
effortOptions = [CatalogOption(value: nil, label: "Default")] + efforts.map { level in
CatalogOption(value: level, label: ModelCatalog.capitalizedFirst(level))
}
}
case .loading:
modelOptions = []
modelOptionsLoading = true
case .idle, .failed:
modelOptions = []
}
default:
modelOptions = nil // Generic fallback: hide the picker.
}
return SessionConfigState(
flavor: flavor,
active: detail?.active ?? summary?.active ?? false,
controlledByUser: detail?.agentState?.controlledByUser == true,
permissionMode: detail?.permissionMode,
permissionModes: permissionModeOptions(forFlavor: flavor),
model: model,
modelOptions: modelOptions,
modelOptionsLoading: modelOptionsLoading,
effort: effort,
effortOptions: effortOptions
)
}
// MARK: - Events
/// One-shot side effects for the chat screen.
public enum ChatInteractionEvent: Equatable, Sendable {
/// Resume returned a different session id — renavigate to it.
case sessionSuperseded(sessionId: String)
/// Transient failure/notice for a toast.
case notice(String)
}