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

296 lines
11 KiB
Swift

import Foundation
/// Client-side send state of an optimistic user message. The web reference
/// extends the wire `DecryptedMessage` with `status?: MessageStatus`
/// (`web/src/types/api.ts`); the wire never carries it — servers echo rows
/// without a status and the client re-attaches it during merge.
public enum MessageStatus: String, Codable, Sendable {
case queued
case sending
case sent
case failed
case indeterminate
}
/// Wire tri-state of `invokedAt` (`pagination.md` "Queued semantics"):
/// an absent key means already-invoked (pre-V8 hubs omit the field), an
/// explicit `null` means still queued, a number is the invocation time.
/// The strict-null queued check and the fixture projection both need the
/// three states kept apart, which the collapsed `DecryptedMessage.invokedAt`
/// (`Int?`) cannot do.
public enum InvokedAtField: Equatable, Sendable {
case absent
case null
case number(Int)
/// Collapsed JS view (`invokedAt ?? …` semantics): the number, or `nil`
/// for both `null` and absent.
public var numberValue: Int? {
if case .number(let value) = self { return value }
return nil
}
/// JS `invokedAt !== undefined` — the wire carried the key.
public var isPresent: Bool { self != .absent }
}
/// One row of the message window: the wire `DecryptedMessage` fields plus the
/// client-side ``status``. Mirrors the web's `DecryptedMessage & {status?}`.
///
/// Deliberately a **class**: `applyLatestResponse`'s request-baseline
/// comparison is by reference (web `!==`, Android object identity), so rows
/// need identity, and transitions must only create new instances for rows
/// they actually change — which every function in this package does.
/// ``Equatable`` is therefore identity (`===`) as well: two deep-equal rows
/// are different instances on purpose (that difference is what classifies a
/// row as "changed since the request left" during a reset replace).
///
/// Instances are immutable (`let` storage), so the class is safely `Sendable`.
public final class WindowMessage: Sendable, Equatable {
// MARK: Wire fields (`DecryptedMessageSchema`, with tri-state invokedAt)
/// Server uuid. Optimistic rows use the client `localId` until echoed.
public let id: String
/// Per-session insert counter; `nil` on optimistic rows.
public let seq: Int?
/// Client-generated id for optimistic reconciliation.
public let localId: String?
/// Role-wrapped envelope, kept wire-verbatim.
public let content: JSONValue
/// Hub receive time (epoch ms).
public let createdAt: Int
/// Invocation time, kept tri-state (see ``InvokedAtField``).
public let invokedAt: InvokedAtField
/// Future-scheduled send time (epoch ms), when set.
public let scheduledAt: Int?
// MARK: Client-side
public let status: MessageStatus?
public init(
id: String,
seq: Int? = nil,
localId: String? = nil,
content: JSONValue = .null,
createdAt: Int,
invokedAt: InvokedAtField = .absent,
scheduledAt: Int? = nil,
status: MessageStatus? = nil
) {
self.id = id
self.seq = seq
self.localId = localId
self.content = content
self.createdAt = createdAt
self.invokedAt = invokedAt
self.scheduledAt = scheduledAt
self.status = status
}
/// Wrap a wire row. `DecryptedMessage` collapses the invokedAt tri-state
/// to `Int?`; `nil` maps to an **explicit null** here because V8+ hubs
/// always send the key (see the caveat on `DecryptedMessage.invokedAt` —
/// if pre-V8 hub support is ever needed, the wire model must learn the
/// tri-state and this initializer picks it up).
public convenience init(wire: DecryptedMessage, status: MessageStatus? = nil) {
self.init(
id: wire.id,
seq: wire.seq,
localId: wire.localId,
content: wire.content,
createdAt: wire.createdAt,
invokedAt: wire.invokedAt.map(InvokedAtField.number) ?? .null,
scheduledAt: wire.scheduledAt,
status: status ?? (wire.deliveryState == "indeterminate" ? .indeterminate : nil)
)
}
/// Identity equality — see the type comment.
public static func == (lhs: WindowMessage, rhs: WindowMessage) -> Bool {
lhs === rhs
}
// MARK: Derived
/// Collapsed invocation time (JS `invokedAt ?? …` operand).
public var invokedAtNumber: Int? { invokedAt.numberValue }
/// Position time `invokedAt ?? createdAt` (`pagination.md` "Position key").
public var positionAt: Int { invokedAt.numberValue ?? createdAt }
/// JS `message.invokedAt === null`.
public var hasExplicitNullInvokedAt: Bool { invokedAt == .null }
/// A row is optimistic iff it has a localId and `id === localId`
/// (web `optimisticMessage`).
public var isOptimistic: Bool {
localId != nil && id == localId
}
/// Web `isUserMessage` (`web/src/lib/messages.ts`): the content envelope
/// is an object whose `role` is the string `'user'`.
public var isUserMessage: Bool {
content.objectValue?["role"]?.stringValue == "user"
}
/// Web `isQueuedForInvocation`: a user message whose `invokedAt` is an
/// explicit `null` and whose send did not fail. Only these rows sit in
/// the queued bar and survive window trims.
public var isQueuedForInvocation: Bool {
isUserMessage && hasExplicitNullInvokedAt && status != .failed
}
// MARK: Copies (each returns a NEW instance — identity change is meaningful)
/// Copy with `invokedAt` stamped to an explicit number.
public func withInvokedAt(_ invokedAt: Int) -> WindowMessage {
WindowMessage(
id: id, seq: seq, localId: localId, content: content,
createdAt: createdAt, invokedAt: .number(invokedAt),
scheduledAt: scheduledAt, status: status
)
}
public func withDeliveryState(_ state: String?) -> WindowMessage {
withStatus(state == "indeterminate" ? .indeterminate : (status ?? .queued))
}
/// Copy with the client-side status replaced.
public func withStatus(_ status: MessageStatus) -> WindowMessage {
WindowMessage(
id: id, seq: seq, localId: localId, content: content,
createdAt: createdAt, invokedAt: invokedAt,
scheduledAt: scheduledAt, status: status
)
}
/// The chat pipeline's collapsed wire view of this row (feeds
/// `normalizeDecryptedMessage`).
public var asDecryptedMessage: DecryptedMessage {
DecryptedMessage(
id: id,
seq: seq,
localId: localId,
content: content,
createdAt: createdAt,
invokedAt: invokedAt.numberValue,
scheduledAt: scheduledAt,
deliveryState: status == .indeterminate ? "indeterminate" : nil
)
}
}
// MARK: - Codable (wire object plus an optional `status` key)
/// Serializes as the wire object plus an optional `status` key — the same
/// shape the web persists (and the pagination fixtures use for op inputs).
/// `invokedAt` round-trips the tri-state: key omitted when absent, `null`
/// when explicit null.
extension WindowMessage: Codable {
private enum CodingKeys: String, CodingKey {
case id, seq, localId, content, createdAt, invokedAt, scheduledAt, status
}
public convenience init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let invokedAt: InvokedAtField
if container.contains(.invokedAt) {
if try container.decodeNil(forKey: .invokedAt) {
invokedAt = .null
} else {
invokedAt = .number(try container.decode(Int.self, forKey: .invokedAt))
}
} else {
invokedAt = .absent
}
self.init(
id: try container.decode(String.self, forKey: .id),
seq: try container.decodeIfPresent(Int.self, forKey: .seq),
localId: try container.decodeIfPresent(String.self, forKey: .localId),
content: try container.decodeIfPresent(JSONValue.self, forKey: .content) ?? .null,
createdAt: try container.decode(Int.self, forKey: .createdAt),
invokedAt: invokedAt,
scheduledAt: try container.decodeIfPresent(Int.self, forKey: .scheduledAt),
// Unknown status strings degrade to nil (Android `fromWire`) —
// a new client-side state must not break snapshot hydration.
status: (try container.decodeIfPresent(String.self, forKey: .status))
.flatMap(MessageStatus.init(rawValue:))
)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
// Optional-encoding writes explicit `null`s for seq/localId, matching
// the wire rows the hub emits.
try container.encode(seq, forKey: .seq)
try container.encode(localId, forKey: .localId)
try container.encode(content, forKey: .content)
try container.encode(createdAt, forKey: .createdAt)
switch invokedAt {
case .absent:
break
case .null:
try container.encodeNil(forKey: .invokedAt)
case .number(let value):
try container.encode(value, forKey: .invokedAt)
}
try container.encodeIfPresent(scheduledAt, forKey: .scheduledAt)
try container.encodeIfPresent(status?.rawValue, forKey: .status)
}
}
// MARK: - Optimistic row construction
/// Builds the optimistic row appended on send, mirroring
/// `createOptimisticMessage` in `web/src/hooks/mutations/useSendMessage.ts`
/// and the contract's "Optimistic sends" lifecycle: `id = localId`,
/// `seq = null`, explicit `invokedAt: null` (so the strict-null queued check
/// matches), content `{role:'user', content:{type:'text', text, attachments?},
/// meta:{deliveryMode}}`.
public func buildOptimisticMessage(
localId: String,
text: String,
createdAt: Int,
attachments: [AttachmentMetadata]? = nil,
scheduledAt: Int? = nil,
deliveryMode: String = "queue",
status: MessageStatus = .sending
) -> WindowMessage {
var inner: [String: JSONValue] = [
"type": .string("text"),
"text": .string(text),
]
if let attachments {
inner["attachments"] = .array(attachments.map { attachment in
var object: [String: JSONValue] = [
"id": .string(attachment.id),
"filename": .string(attachment.filename),
"mimeType": .string(attachment.mimeType),
"size": .number(Double(attachment.size)),
"path": .string(attachment.path),
]
if let previewUrl = attachment.previewUrl {
object["previewUrl"] = .string(previewUrl)
}
return .object(object)
})
}
let content: JSONValue = .object([
"role": .string("user"),
"content": .object(inner),
"meta": .object(["deliveryMode": .string(deliveryMode)]),
])
return WindowMessage(
id: localId,
seq: nil,
localId: localId,
content: content,
createdAt: createdAt,
invokedAt: .null,
scheduledAt: scheduledAt,
status: status
)
}