Files
hapi/ios/Packages/HapiKit/Sources/HapiProtocol/Models/ApiResponses.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

483 lines
15 KiB
Swift

import Foundation
// Response shapes for the REST surface (`docs/api/client-contract/rest.md`).
// Field names mirror `shared/src/apiTypes.ts` / `shared/src/schemas.ts`
// one-to-one — no CodingKeys anywhere in this file. Discriminated unions
// (`SpawnResponse`, `CancelMessageResponse`, `SteerQueuedMessageResponse`)
// are decode-only: the client never sends them.
// MARK: - Auth & health
/// The `user` object of the auth response.
public struct AuthUser: Codable, Equatable, Sendable {
public var id: Int
public var username: String?
public var firstName: String?
public var lastName: String?
public init(id: Int, username: String? = nil, firstName: String? = nil, lastName: String? = nil) {
self.id = id
self.username = username
self.firstName = firstName
self.lastName = lastName
}
}
/// Success body of `POST /api/auth`.
public struct AuthResponse: Codable, Equatable, Sendable {
/// The JWT (HS256, `{uid, ns}`, 4 h expiry).
public var token: String
public var user: AuthUser
public init(token: String, user: AuthUser) {
self.token = token
self.user = user
}
}
/// Additive capability flags of `GET /health`. Unknown keys are ignored.
public struct HubCapabilities: Codable, Equatable, Sendable {
public var workGraph: Bool?
public var titleSuggestion: Bool?
public init(workGraph: Bool? = nil, titleSuggestion: Bool? = nil) {
self.workGraph = workGraph
self.titleSuggestion = titleSuggestion
}
}
/// Body of `GET /health` (unauthenticated).
public struct HubHealthResponse: Codable, Equatable, Sendable {
public var status: String
public var protocolVersion: Int
public var capabilities: HubCapabilities?
public init(status: String, protocolVersion: Int, capabilities: HubCapabilities? = nil) {
self.status = status
self.protocolVersion = protocolVersion
self.capabilities = capabilities
}
}
// MARK: - Envelopes
/// Envelope of `GET /api/sessions`.
public struct SessionsResponse: Codable, Equatable, Sendable {
public var sessions: [SessionSummary]
public init(sessions: [SessionSummary]) {
self.sessions = sessions
}
}
/// Envelope of `GET /api/sessions/:id`.
public struct SessionResponse: Codable, Equatable, Sendable {
public var session: Session
public init(session: Session) {
self.session = session
}
}
/// Envelope of `GET /api/machines`.
public struct MachinesResponse: Codable, Equatable, Sendable {
public var machines: [Machine]
public init(machines: [Machine]) {
self.machines = machines
}
}
// MARK: - Session lifecycle
/// Body of `POST /api/sessions/:id/resume` — `{type: 'success', sessionId}`.
/// Only `sessionId` is modeled; failures arrive as HTTP errors.
///
/// The returned id may differ from the one the call was made on (fresh spawn
/// under a new id) — callers must follow it.
public struct ResumeSessionResponse: Codable, Equatable, Sendable {
public var sessionId: String
public init(sessionId: String) {
self.sessionId = sessionId
}
}
/// Body of `POST /api/sessions/:id/reopen`.
///
/// Mirrors `ReopenSessionResponseSchema` (`shared/src/apiTypes.ts`). As with
/// resume, `sessionId` may differ from the id the call was made on.
public struct ReopenSessionResponse: Codable, Equatable, Sendable {
public var ok: Bool
public var sessionId: String
public var resumed: Bool
/// `'acp' | 'stream-json'`; kept open for forward compatibility.
public var cursorSessionProtocol: String?
public init(ok: Bool, sessionId: String, resumed: Bool, cursorSessionProtocol: String? = nil) {
self.ok = ok
self.sessionId = sessionId
self.resumed = resumed
self.cursorSessionProtocol = cursorSessionProtocol
}
}
// MARK: - Spawning
/// Body of `POST /api/machines/:id/spawn`, discriminated on `type` — a failed
/// spawn is still HTTP 200 with `type: 'error'`.
public enum SpawnResponse: Equatable, Sendable {
case success(sessionId: String)
case error(message: String)
}
extension SpawnResponse: Decodable {
private enum CodingKeys: String, CodingKey {
case type
case sessionId
case message
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "success":
self = .success(sessionId: try container.decode(String.self, forKey: .sessionId))
case "error":
self = .error(message: try container.decode(String.self, forKey: .message))
default:
throw DecodingError.dataCorrupted(DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown SpawnResponse type '\(type)'"
))
}
}
}
// MARK: - Message queue operations
public struct RetryIndeterminateMessageResponse: Decodable, Equatable, Sendable {
public let status: String
public let localId: String?
public let message: DecryptedMessage?
}
/// Body of `DELETE /api/sessions/:id/messages/:messageId`, discriminated on
/// `status`. `invoked` means the cancel arrived too late — the message was
/// already handed to the agent.
public enum CancelMessageResponse: Equatable, Sendable {
case cancelled(localId: String?)
case invoked(message: DecryptedMessage)
case busy(localId: String)
}
extension CancelMessageResponse: Decodable {
private enum CodingKeys: String, CodingKey {
case status
case localId
case message
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let status = try container.decode(String.self, forKey: .status)
switch status {
case "cancelled":
self = .cancelled(localId: try container.decodeIfPresent(String.self, forKey: .localId))
case "invoked":
self = .invoked(message: try container.decode(DecryptedMessage.self, forKey: .message))
case "busy":
self = .busy(localId: try container.decode(String.self, forKey: .localId))
default:
throw DecodingError.dataCorrupted(DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown CancelMessageResponse status '\(status)'"
))
}
}
}
/// Body of `POST /api/sessions/:id/messages/:messageId/steer`, discriminated
/// on `status`.
public enum SteerQueuedMessageResponse: Equatable, Sendable {
case steered(localId: String)
case invoked(message: DecryptedMessage)
case failed(error: String, localId: String?)
}
extension SteerQueuedMessageResponse: Decodable {
private enum CodingKeys: String, CodingKey {
case status
case localId
case message
case error
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let status = try container.decode(String.self, forKey: .status)
switch status {
case "steered":
self = .steered(localId: try container.decode(String.self, forKey: .localId))
case "invoked":
self = .invoked(message: try container.decode(DecryptedMessage.self, forKey: .message))
case "failed":
self = .failed(
error: try container.decode(String.self, forKey: .error),
localId: try container.decodeIfPresent(String.self, forKey: .localId)
)
default:
throw DecodingError.dataCorrupted(DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown SteerQueuedMessageResponse status '\(status)'"
))
}
}
}
/// One invoked entry of the queued-state response.
public struct InvokedLocalMessage: Codable, Equatable, Sendable {
public var localId: String
public var invokedAt: Int
public init(localId: String, invokedAt: Int) {
self.localId = localId
self.invokedAt = invokedAt
}
}
/// Body of `POST /api/sessions/:id/messages/queued-state` — resyncs
/// optimistic sends after a reconnect.
public struct QueuedStateResponse: Codable, Equatable, Sendable {
public var queuedLocalIds: [String]
public var invokedLocalMessages: [InvokedLocalMessage]
public var indeterminateLocalIds: [String]?
public init(
queuedLocalIds: [String],
invokedLocalMessages: [InvokedLocalMessage],
indeterminateLocalIds: [String]? = nil
) {
self.queuedLocalIds = queuedLocalIds
self.invokedLocalMessages = invokedLocalMessages
self.indeterminateLocalIds = indeterminateLocalIds
}
}
// MARK: - Session catalogs (RPC-wrapped)
/// One slash command of the session catalog. `source` is kept as a raw string
/// (`'builtin' | 'user' | 'plugin' | 'project'` today) so a new source never
/// breaks decoding.
public struct SlashCommand: Codable, Equatable, Sendable {
public var name: String
public var description: String?
public var source: String
public var content: String?
public var pluginName: String?
public init(
name: String,
description: String? = nil,
source: String,
content: String? = nil,
pluginName: String? = nil
) {
self.name = name
self.description = description
self.source = source
self.content = content
self.pluginName = pluginName
}
}
/// Body of `GET /api/sessions/:id/slash-commands` (RPC envelope — check
/// `success`, HTTP 200 alone means nothing).
public struct SlashCommandsResponse: Codable, Equatable, Sendable {
public var success: Bool
public var commands: [SlashCommand]?
public var error: String?
public init(success: Bool, commands: [SlashCommand]? = nil, error: String? = nil) {
self.success = success
self.commands = commands
self.error = error
}
}
/// One skill of the session catalog.
public struct SkillSummary: Codable, Equatable, Sendable {
public var name: String
public var description: String?
public init(name: String, description: String? = nil) {
self.name = name
self.description = description
}
}
/// Body of `GET /api/sessions/:id/skills` (RPC envelope).
public struct SkillsResponse: Codable, Equatable, Sendable {
public var success: Bool
public var skills: [SkillSummary]?
public var error: String?
public init(success: Bool, skills: [SkillSummary]? = nil, error: String? = nil) {
self.success = success
self.skills = skills
self.error = error
}
}
// MARK: - Machine directory browsing
/// Entry type of a directory listing.
public enum DirectoryEntryType: String, Codable, Sendable {
case file
case directory
case other
}
/// One entry of `GET /api/sessions/:id/directory`.
public struct DirectoryEntry: Codable, Equatable, Sendable {
public var name: String
public var type: DirectoryEntryType
/// Size in bytes (files only).
public var size: Int?
/// mtime, epoch ms.
public var modified: Double?
public init(name: String, type: DirectoryEntryType, size: Int? = nil, modified: Double? = nil) {
self.name = name
self.type = type
self.size = size
self.modified = modified
}
}
/// One entry of `POST /api/machines/:id/list-directory`
/// (`DirectoryEntry & {isGitRepo?}` flattened).
public struct MachineDirectoryEntry: Codable, Equatable, Sendable {
public var name: String
public var type: DirectoryEntryType
public var size: Int?
public var modified: Double?
public var isGitRepo: Bool?
public init(
name: String,
type: DirectoryEntryType,
size: Int? = nil,
modified: Double? = nil,
isGitRepo: Bool? = nil
) {
self.name = name
self.type = type
self.size = size
self.modified = modified
self.isGitRepo = isGitRepo
}
}
/// Body of `POST /api/machines/:id/list-directory` (RPC envelope).
public struct MachineListDirectoryResponse: Codable, Equatable, Sendable {
public var success: Bool
public var entries: [MachineDirectoryEntry]?
public var error: String?
public init(success: Bool, entries: [MachineDirectoryEntry]? = nil, error: String? = nil) {
self.success = success
self.entries = entries
self.error = error
}
}
/// Body of `POST /api/machines/:id/paths/exists`.
public struct MachinePathsExistsResponse: Codable, Equatable, Sendable {
public var exists: [String: Bool]
public init(exists: [String: Bool]) {
self.exists = exists
}
}
// MARK: - Codex model catalog
/// One row of `GET /api/machines/:id/codex-models` (also the session-level
/// twin). Mirrors `CodexModelSummary` (`shared/src/apiTypes.ts`).
public struct CodexModelSummary: Codable, Equatable, Sendable {
public var id: String
public var displayName: String
public var isDefault: Bool
public var defaultReasoningEffort: String?
public var defaultServiceTier: String?
public var supportedReasoningEfforts: [String]?
/// Service tier ids advertised for this model in the current auth/plan
/// context (e.g. `fast`).
public var serviceTiers: [String]?
public init(
id: String,
displayName: String,
isDefault: Bool,
defaultReasoningEffort: String? = nil,
defaultServiceTier: String? = nil,
supportedReasoningEfforts: [String]? = nil,
serviceTiers: [String]? = nil
) {
self.id = id
self.displayName = displayName
self.isDefault = isDefault
self.defaultReasoningEffort = defaultReasoningEffort
self.defaultServiceTier = defaultServiceTier
self.supportedReasoningEfforts = supportedReasoningEfforts
self.serviceTiers = serviceTiers
}
}
/// Body of `GET /api/machines/:id/codex-models` and the session-level twin
/// `GET /api/sessions/:id/codex-models` (RPC envelope — check `success`). A
/// runner that does not expose the machine-scoped RPC answers HTTP 503
/// `{success:false, code:'rpc_target_missing'}`, which surfaces as the
/// transport `APIError`, not this body. Mirrors `CodexModelsResponse`
/// (`shared/src/apiTypes.ts`).
public struct CodexModelsResponse: Codable, Equatable, Sendable {
public var success: Bool
public var models: [CodexModelSummary]?
public var error: String?
public init(success: Bool, models: [CodexModelSummary]? = nil, error: String? = nil) {
self.success = success
self.models = models
self.error = error
}
}
// MARK: - Uploads
/// Body of `POST /api/sessions/:id/upload` (RPC envelope). `path` feeds the
/// `attachments` metadata of a subsequent send-message.
public struct UploadFileResponse: Codable, Equatable, Sendable {
public var success: Bool
public var path: String?
public var error: String?
public init(success: Bool, path: String? = nil, error: String? = nil) {
self.success = success
self.path = path
self.error = error
}
}
/// Body of `POST /api/sessions/:id/upload/delete` (RPC envelope).
public struct DeleteUploadResponse: Codable, Equatable, Sendable {
public var success: Bool
public var error: String?
public init(success: Bool, error: String? = nil) {
self.success = success
self.error = error
}
}