Files
hapi/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SummaryPatching.swift
T
SSU-WEI HUANGandGitHub be1ef2a2e4 feat(dsh): integrate DeepSeek Harness through ACP (#1632)
* feat(dsh): add DeepSeek Harness ACP flavor

* fix(dsh): update mobile flavor catalogs

* fix(dsh): keep mobile spawn policy managed

* fix(dsh): keep managed policy and prompt retry

* fix(dsh): suppress unsupported runner policy flags

* fix(dsh): align native managed-policy UX
2026-08-22 12:37:28 +08:00

333 lines
17 KiB
Swift

import Foundation
/// Summary-side patch path — faithful port of the web reference:
/// derivations from `shared/src/sessionSummary.ts` (`computePendingRequests*`,
/// `computeTodoProgress`, `toSessionSummary`) and the list-cache patch rules
/// from `web/src/hooks/useSSE.ts` (`patchSessionSummary`,
/// `isRenderIrrelevantPatch`, `sameSessionSummaryMetadata`,
/// `canApplyVersionedSummaryPatch`). Mirrors the Android reference port
/// (`app.hapi.protocol.wire.SummaryPatching`) case for case.
///
/// ## `>=` vs the detail path's strict `>` (deliberate, replicated divergence)
///
/// The detail path (`applySessionDetailPatch(session:patch:)`) gates versioned
/// sub-patches with **strictly greater** versions. The web summary path —
/// replicated here — accepts **greater-or-equal**: re-deriving the summary
/// fields (`todoProgress`, `pendingRequests*`, projected metadata) from an
/// equal-version `value` is idempotent, because the summary stores only
/// derivations of the value, never the value itself. Applying the same version
/// twice recomputes the same numbers; the strict gate matters only where the
/// raw value is cached (a stale equal-version `agentState` replayed into the
/// *detail* cache could resurrect resolved permission requests).
/// `docs/api/client-contract/sse.md#versioned-patch-algorithm` documents both.
public enum SummaryPatching {
/// Cap on `pendingRequests` carried in `SessionSummary`
/// (`PENDING_REQUEST_SUMMARY_CAP`). `pendingRequestsCount` stays the
/// authoritative total; the capped slice is per-row hover/badge copy.
public static let pendingRequestSummaryCap = SessionSummary.pendingRequestSummaryCap
/// `INPUT_REQUEST_TOOLS` — tools that ask the operator, not for permission.
private static let inputRequestTools: Set<String> = [
"AskUserQuestion",
"ask_user_question",
"ExitPlanMode",
"exit_plan_mode",
"request_user_input",
]
private static func classifyKind(tool: String) -> PendingRequestKind {
inputRequestTools.contains(tool) ? .input : .permission
}
// MARK: - Derivations (shared/src/sessionSummary.ts)
/// `computePendingRequestKinds`: deduplicated kinds; when both are present
/// the canonical order is `[permission, input]`. (The TS version keeps
/// Set-insertion order otherwise, which for a single deduplicated kind is
/// just that kind — so an explicit two-flag scan is order-equivalent and
/// sidesteps Swift's unordered `Dictionary`/`Set` iteration.)
public static func computePendingRequestKinds(_ agentState: AgentState?) -> [PendingRequestKind] {
guard let requests = agentState?.requests else { return [] }
var hasPermission = false
var hasInput = false
for request in requests.values {
switch classifyKind(tool: request.tool) {
case .permission: hasPermission = true
case .input: hasInput = true
}
}
switch (hasPermission, hasInput) {
case (true, true): return [.permission, .input]
case (true, false): return [.permission]
case (false, true): return [.input]
case (false, false): return []
}
}
/// `computePendingRequests`: oldest-first (by `since`, then id) capped
/// slice. `fallbackSince` (typically the session's `updatedAt`)
/// substitutes for requests stored without `createdAt`. The (since, id)
/// sort is total for unique ids, so Swift's unordered `Dictionary`
/// iteration cannot change the result.
public static func computePendingRequests(
_ agentState: AgentState?,
fallbackSince: Int,
cap: Int = pendingRequestSummaryCap
) -> [PendingRequest] {
guard let requests = agentState?.requests else { return [] }
var items = requests.map { id, request in
PendingRequest(
id: id,
kind: classifyKind(tool: request.tool),
tool: request.tool,
since: request.createdAt ?? fallbackSince
)
}
items.sort { lhs, rhs in
if lhs.since != rhs.since { return lhs.since < rhs.since }
return lhs.id < rhs.id
}
return cap >= items.count ? items : Array(items.prefix(max(0, cap)))
}
/// `computePendingRequestsCount` — the authoritative (uncapped) total.
public static func computePendingRequestsCount(_ agentState: AgentState?) -> Int {
agentState?.requests?.count ?? 0
}
/// `computeTodoProgress`: `nil` for absent/empty todos.
public static func computeTodoProgress(_ todos: [TodoItem]?) -> TodoProgress? {
guard let todos, !todos.isEmpty else { return nil }
return TodoProgress(
completed: todos.lazy.filter { $0.status == .completed }.count,
total: todos.count
)
}
/// `getSummaryAgentSessionId`: with a known flavor, only that flavor's id
/// field counts (trimmed, blank ⇒ nil). The legacy fallback chain (raw,
/// untrimmed, `pi` excluded — replicated) applies only when the flavor is
/// missing or unknown.
private static func summaryAgentSessionId(_ metadata: SessionMetadata) -> String? {
if let flavor = metadata.flavor, AgentFlavor(rawValue: flavor).isKnown {
let flavorSessionId: String?
switch AgentFlavor(rawValue: flavor) {
case .claude: flavorSessionId = metadata.claudeSessionId
case .codex: flavorSessionId = metadata.codexSessionId
case .dsh: flavorSessionId = nil
case .gemini: flavorSessionId = metadata.geminiSessionId
case .opencode: flavorSessionId = metadata.opencodeSessionId
case .grok: flavorSessionId = metadata.grokSessionId
case .agy: flavorSessionId = metadata.agySessionId
case .cursor: flavorSessionId = metadata.cursorSessionId
case .kimi: flavorSessionId = metadata.kimiSessionId
case .copilot: flavorSessionId = metadata.copilotSessionId
case .pi: flavorSessionId = metadata.piSessionId
case .other: flavorSessionId = nil
}
let trimmed = flavorSessionId?.trimmingCharacters(in: .whitespacesAndNewlines)
return (trimmed?.isEmpty == false) ? trimmed : nil
}
// First non-nil, raw/untrimmed — written as a loop because the
// 9-deep `??` chain exceeded the Swift 6 type-checker budget.
let legacyFallbacks: [String?] = [
metadata.codexSessionId,
metadata.claudeSessionId,
metadata.geminiSessionId,
metadata.opencodeSessionId,
metadata.grokSessionId,
metadata.agySessionId,
metadata.cursorSessionId,
metadata.kimiSessionId,
metadata.copilotSessionId,
]
return legacyFallbacks.compactMap { $0 }.first
}
/// `toSessionSummaryMetadata` — list-sized projection of full metadata.
public static func toSessionSummaryMetadata(_ metadata: SessionMetadata?) -> SessionSummaryMetadata? {
guard let metadata else { return nil }
return SessionSummaryMetadata(
name: metadata.name,
path: metadata.path,
machineId: metadata.machineId,
summary: metadata.summary.map { SessionSummaryMetadata.Summary(text: $0.text) },
flavor: metadata.flavor,
worktree: metadata.worktree,
agentSessionId: summaryAgentSessionId(metadata),
lifecycleState: metadata.lifecycleState,
hapiMcpUrl: metadata.hapiMcpUrl
)
}
/// `toSessionSummary`: project a full `Session` (SSE full-session payload)
/// into a list row. `futureScheduledMessageCount`/`nextScheduledAt` are
/// hub-computed from the message table and NOT derivable from a `Session`
/// — they project to 0/nil; the caller preserves the previous row's values
/// (`upsertSessionSummary` in the web reference does exactly that).
public static func toSessionSummary(_ session: Session) -> SessionSummary {
SessionSummary(
id: session.id,
active: session.active,
thinking: session.thinking,
activeAt: session.activeAt,
updatedAt: session.updatedAt,
pinned: session.pinned ?? false,
globalPinned: session.globalPinned ?? false,
metadata: toSessionSummaryMetadata(session.metadata),
metadataVersion: session.metadataVersion,
agentStateVersion: session.agentStateVersion,
todosUpdatedAt: session.todosUpdatedAt ?? 0,
todoProgress: computeTodoProgress(session.todos),
pendingRequestsCount: computePendingRequestsCount(session.agentState),
pendingRequestKinds: computePendingRequestKinds(session.agentState),
pendingRequests: computePendingRequests(session.agentState, fallbackSince: session.updatedAt),
backgroundTaskCount: session.backgroundTaskCount ?? 0,
futureScheduledMessageCount: 0,
nextScheduledAt: nil,
model: session.model,
modelReasoningEffort: session.modelReasoningEffort,
effort: session.effort
)
}
// MARK: - Patch rules (web/src/hooks/useSSE.ts)
/// Apply a `SessionPatch` to a list row — the pure core of the web's
/// `patchSessionSummary`. Always returns the patched summary; the caller
/// decides whether to keep the old row via
/// ``isRenderIrrelevantPatch(current:next:)`` (the keep-alive
/// suppression) and re-sorts.
///
/// - Flat fields: last-write-wins when present; a present-`null`
/// `model`/`modelReasoningEffort`/`effort` clears. (The web's
/// `patch.backgroundTaskCount ?? 0` fallback guards a present-
/// `undefined` that the strict wire schema cannot produce; absent ⇒
/// keep, exactly what `Int?` encodes.)
/// - `updatedAt`: max-monotonic — stale versioned-patch replays must not
/// move the list clock backward.
/// - Versioned fields gate against THIS summary's watermarks with `>=`
/// (see the type doc for why equality is safe here and only here), then
/// recompute the derived fields. `pendingRequests`' fallback `since` is
/// the summary's post-patch `updatedAt`, matching the reference.
/// - `teamState` is a summary no-op (not rendered on the list).
/// - `activeTurnStartedAt` / `scratchlistUpdatedAt` / `serviceTier` /
/// `permissionMode` / `collaborationMode` / `copilotAgentMode` have no
/// summary counterpart — ignored, like the reference.
public static func applySessionSummaryPatch(
_ current: SessionSummary,
_ patch: SessionPatch
) -> SessionSummary {
var next = current
next.active = patch.active ?? current.active
next.thinking = patch.thinking ?? current.thinking
next.activeAt = patch.activeAt ?? current.activeAt
next.updatedAt = patch.updatedAt.map { max(current.updatedAt, $0) } ?? current.updatedAt
next.backgroundTaskCount = patch.backgroundTaskCount ?? current.backgroundTaskCount
if let field = patch.model { next.model = field.wireValue }
if let field = patch.modelReasoningEffort { next.modelReasoningEffort = field.wireValue }
if let field = patch.effort { next.effort = field.wireValue }
// Gate versioned fields against THIS summary's watermarks — not the
// detail cache (global SSE covers every session; requiring detail
// would force O(N) list refetches). Compared against `current`, like
// the reference (`next` carries the same watermark values anyway).
if let todos = patch.todos, todos.version >= current.todosUpdatedAt {
next.todoProgress = computeTodoProgress(todos.value)
next.todosUpdatedAt = todos.version
}
if let agentState = patch.agentState, agentState.version >= current.agentStateVersion {
next.pendingRequestsCount = computePendingRequestsCount(agentState.value)
next.pendingRequestKinds = computePendingRequestKinds(agentState.value)
next.pendingRequests = computePendingRequests(agentState.value, fallbackSince: next.updatedAt)
next.agentStateVersion = agentState.version
}
if let metadata = patch.metadata, metadata.version >= current.metadataVersion {
next.metadata = toSessionSummaryMetadata(metadata.value)
next.metadataVersion = metadata.version
}
return next
}
/// `sameSessionSummaryMetadata`: true when the two projections render the
/// same list row. Deliberately partial, like the reference — `hapiMcpUrl`
/// is not compared (nothing on the list renders it; a real metadata
/// change still surfaces through the `metadataVersion` check in
/// ``isRenderIrrelevantPatch(current:next:)``).
public static func sameSessionSummaryMetadata(
_ current: SessionSummaryMetadata?,
_ next: SessionSummaryMetadata?
) -> Bool {
guard let current, let next else { return current == nil && next == nil }
if current.name != next.name { return false }
if current.path != next.path { return false }
if current.machineId != next.machineId { return false }
if current.summary?.text != next.summary?.text { return false }
if current.flavor != next.flavor { return false }
if current.agentSessionId != next.agentSessionId { return false }
if current.lifecycleState != next.lifecycleState { return false }
if current.worktree?.basePath != next.worktree?.basePath { return false }
if current.worktree?.branch != next.worktree?.branch { return false }
if current.worktree?.name != next.worktree?.name { return false }
if current.worktree?.worktreePath != next.worktree?.worktreePath { return false }
if current.worktree?.createdAt != next.worktree?.createdAt { return false }
return true
}
/// `isRenderIrrelevantPatch`: true when the only difference between the
/// pre- and post-patch summaries is `activeAt` — the CLI keep-alive
/// re-broadcasts a full patch ~every 10 s per active session with only
/// `activeAt` moving, and nothing on the list renders `activeAt`. The
/// store keeps the previous row (and skips the re-sort) in that case.
///
/// Port notes: `pendingRequests` compares id/kind/tool but NOT `since`
/// (replicated — `since` only affects hover copy through the same
/// requests). `pinned`/`globalPinned` are intentionally absent, matching
/// the reference (patches never carry them; pin flips arrive as full
/// sessions or REST refetches). Swift's `String?` collapses the TS
/// `undefined !== null` distinction for `modelReasoningEffort`; the only
/// effect is suppressing a re-render that stores an equal-rendering value.
public static func isRenderIrrelevantPatch(current: SessionSummary, next: SessionSummary) -> Bool {
if current.active != next.active { return false }
if current.thinking != next.thinking { return false }
if current.updatedAt != next.updatedAt { return false }
if current.backgroundTaskCount != next.backgroundTaskCount { return false }
if current.model != next.model { return false }
if current.modelReasoningEffort != next.modelReasoningEffort { return false }
if current.effort != next.effort { return false }
if current.pendingRequestsCount != next.pendingRequestsCount { return false }
// Structured SSE patches (#897) can move these without touching the
// keep-alive fields above; omit them and a todos/metadata/agentState
// patch would be dropped as "activeAt-only" churn.
if current.todoProgress != next.todoProgress { return false }
if current.pendingRequestKinds != next.pendingRequestKinds { return false }
if current.pendingRequests.count != next.pendingRequests.count { return false }
for (lhs, rhs) in zip(current.pendingRequests, next.pendingRequests) {
if lhs.id != rhs.id || lhs.kind != rhs.kind || lhs.tool != rhs.tool { return false }
}
if !sameSessionSummaryMetadata(current.metadata, next.metadata) { return false }
if current.metadataVersion != next.metadataVersion { return false }
if current.agentStateVersion != next.agentStateVersion { return false }
if current.todosUpdatedAt != next.todosUpdatedAt { return false }
return true
}
/// `canApplyVersionedSummaryPatch` — the old "detail required" gate.
///
/// The reference keeps it only for unit tests of the pre-watermark rule;
/// the live summary path gates against the summary's own watermarks
/// instead (``applySessionSummaryPatch(_:_:)``). `teamState` never
/// blocks: it is not rendered on the list.
@available(
*, deprecated,
message: "Gate against SessionSummary watermarks (applySessionSummaryPatch); kept only to pin the legacy rule, mirroring the web reference."
)
public static func canApplyVersionedSummaryPatch(patch: SessionPatch, detailPresent: Bool) -> Bool {
if patch.metadata == nil && patch.agentState == nil && patch.todos == nil {
return true
}
return detailPresent
}
}