Files
hapi/ios/Packages/HapiKit/Tests/HapiProtocolTests/SummaryPatchingTests.swift
T
weishu 67b7fe7304 feat(ios): session list store + UI (A-M2a)
HapiProtocol (pure, mirroring the Android reference port):
- SummaryPatching: sessionSummary.ts derivations (pending requests
  cap-5/oldest-first/id-tiebreak, kinds, todo progress), toSessionSummary
  projection with the per-flavor agentSessionId resolution (legacy chain
  omits piSessionId, replicated), applySessionSummaryPatch with the
  deliberate >= version gates (vs the detail path's strict >), the
  keep-alive render-irrelevance filter (pendingRequests compare ignores
  'since'; metadata compare ignores hapiMcpUrl), max-monotonic updatedAt,
  post-patch updatedAt as fallback 'since', and the deprecated legacy
  detail-required gate kept for rule pinning.
- SessionSorting: exact list comparator (globalPinned > pinned > active >
  pendingRequestsCount desc among active > updatedAt desc; stable).
- SessionMetadata: per-flavor session-id fields (claude/codex/gemini/
  opencode/grok/agy/cursor/kimi/copilot/pi).

HapiClient stores (@MainActor @Observable, per hub):
- DiskCache: 500 ms debounced atomic JSON snapshots + SnapshotLocations.
- SessionListStore: sorted summaries + detail cache; full-session upsert
  preserving hub-computed scheduled fields; strict-> detail vs >= summary
  patch paths; keep-alive identity preservation (listRevision); empty-{}
  and unparseable payloads take the REST fallback; 16 ms coalesced
  refresh; optimistic pin (roll-forward) and archive (restore).
- MachineStore: the exact machine-updated decision tree.
- LastSeenStore: monotonic unread watermarks + per-scope baseline.
- SyncEventRouter: SyncEvent fan-out to the stores, global-scope message
  events refresh the list, gap handshake triggers the full resync.

App:
- HubSession owns the stores + router (replaces the TODO(M2) routing);
  background flushes snapshots.
- Features/Sessions: SessionListView + SessionListModel (status dot with
  thinking pulse, title cascade, flavor·machine·worktree meta, relative
  age on a minute timeline, pending/todo badges, unread dots, pinned
  section, machine filter chips >= 2, pull-to-refresh, empty/loading/
  offline states, long-press pin/archive context menu) + an M2f chat
  placeholder pushed on row tap.
- HomePlaceholderView -> HomeView hosting the list (hub switcher +
  connection dot kept in the toolbar).

Tests transcribe the Android suites: SummaryPatchingTest (18),
SessionSortingTest (6), SessionStoreTest, MachineStoreTest,
LastSeenStoreTest, JsonSnapshotStoreTest, StoreSyncTargetsTest — using
the existing HTTPPerforming recording stub plus a path-routing performer
for concurrent refetches.
2026-08-17 20:17:02 +08:00

428 lines
17 KiB
Swift

import Foundation
import HapiProtocol
import Testing
/// Port of the summary-path cases from `web/src/hooks/useSSE.test.ts`
/// (`canApplyVersionedSummaryPatch`, `isRenderIrrelevantPatch`) plus pins for
/// the pieces the web only exercises through the hook: the `>=` versioned
/// gates of `patchSessionSummary`, the `shared/src/sessionSummary.ts`
/// derivations, and the `toSessionSummary` projection. Transcribed from the
/// Android reference suite (`SummaryPatchingTest.kt`) case for case.
@Suite("Summary patching")
struct SummaryPatchingTests {
private func makeSummary(
id: String = "session-1",
active: Bool = true,
thinking: Bool = false,
activeAt: Int = 1_000,
updatedAt: Int = 2_000,
pinned: Bool? = nil,
globalPinned: Bool? = nil,
metadata: SessionSummaryMetadata? = nil,
metadataVersion: Int = 0,
agentStateVersion: Int = 0,
todosUpdatedAt: Int = 0,
todoProgress: TodoProgress? = nil,
pendingRequestsCount: Int = 0,
pendingRequestKinds: [PendingRequestKind] = [],
pendingRequests: [PendingRequest] = [],
backgroundTaskCount: Int = 0,
model: String? = nil,
modelReasoningEffort: String? = nil,
effort: String? = nil
) -> SessionSummary {
SessionSummary(
id: id,
active: active,
thinking: thinking,
activeAt: activeAt,
updatedAt: updatedAt,
pinned: pinned,
globalPinned: globalPinned,
metadata: metadata,
metadataVersion: metadataVersion,
agentStateVersion: agentStateVersion,
todosUpdatedAt: todosUpdatedAt,
todoProgress: todoProgress,
pendingRequestsCount: pendingRequestsCount,
pendingRequestKinds: pendingRequestKinds,
pendingRequests: pendingRequests,
backgroundTaskCount: backgroundTaskCount,
futureScheduledMessageCount: 0,
nextScheduledAt: nil,
model: model,
modelReasoningEffort: modelReasoningEffort,
effort: effort
)
}
private func request(_ tool: String, createdAt: Int? = nil) -> AgentStateRequest {
AgentStateRequest(tool: tool, arguments: .null, createdAt: createdAt)
}
// MARK: - Derivations
@Test func pendingRequestKindsDedupeAndCanonicalizeBothKindsOrder() {
#expect(SummaryPatching.computePendingRequestKinds(nil) == [])
#expect(SummaryPatching.computePendingRequestKinds(AgentState()) == [])
let permissionOnly = AgentState(requests: ["a": request("Bash"), "b": request("Edit")])
#expect(SummaryPatching.computePendingRequestKinds(permissionOnly) == [.permission])
let inputOnly = AgentState(requests: ["a": request("AskUserQuestion")])
#expect(SummaryPatching.computePendingRequestKinds(inputOnly) == [.input])
// Input encountered first — canonical order is still permission,input.
let both = AgentState(requests: [
"a": request("request_user_input"),
"b": request("Bash"),
])
#expect(SummaryPatching.computePendingRequestKinds(both) == [.permission, .input])
}
@Test func pendingRequestsSortOldestFirstWithIdTiebreakAndFallbackSince() {
let state = AgentState(requests: [
"req-c": request("Bash", createdAt: 300),
"req-a": request("Edit", createdAt: nil), // → fallbackSince = 100
"req-b": request("ExitPlanMode", createdAt: 100),
])
let items = SummaryPatching.computePendingRequests(state, fallbackSince: 100)
#expect(items.map(\.id) == ["req-a", "req-b", "req-c"])
#expect(items.map(\.since) == [100, 100, 300])
#expect(items.map(\.kind) == [.permission, .input, .permission])
}
@Test func pendingRequestsCapAtFiveButCountStaysAuthoritative() {
var requests: [String: AgentStateRequest] = [:]
for index in 1...7 {
requests["req-\(index)"] = request("Bash", createdAt: index)
}
let state = AgentState(requests: requests)
let items = SummaryPatching.computePendingRequests(state, fallbackSince: 0)
#expect(items.count == 5)
#expect(items.map(\.id) == (1...5).map { "req-\($0)" })
#expect(SummaryPatching.computePendingRequestsCount(state) == 7)
}
@Test func todoProgressIsNilForAbsentOrEmptyAndCountsCompleted() {
#expect(SummaryPatching.computeTodoProgress(nil) == nil)
#expect(SummaryPatching.computeTodoProgress([]) == nil)
let todos = [
TodoItem(content: "a", status: .completed),
TodoItem(content: "b", status: .inProgress),
TodoItem(content: "c", status: .completed),
]
#expect(SummaryPatching.computeTodoProgress(todos) == TodoProgress(completed: 2, total: 3))
}
// MARK: - Projection
private func makeSession(
metadata: SessionMetadata? = nil,
agentState: AgentState? = nil,
todos: [TodoItem]? = nil
) -> Session {
Session(
id: "session-1",
namespace: "default",
seq: 1,
createdAt: 1_000,
updatedAt: 2_000,
active: true,
activeAt: 1_000,
metadata: metadata,
metadataVersion: 3,
agentState: agentState,
agentStateVersion: 4,
thinking: true,
thinkingAt: 0,
backgroundTaskCount: 2,
todos: todos,
todosUpdatedAt: 5,
model: "opus",
effort: "high"
)
}
@Test func toSessionSummaryProjectsDerivedFieldsAndWatermarks() {
let session = makeSession(
metadata: SessionMetadata(
path: "/repo",
host: "host",
name: "My session",
summary: SessionMetadataSummary(text: "doing things", updatedAt: 9),
flavor: "claude",
claudeSessionId: " abc "
),
agentState: AgentState(requests: ["r1": request("Bash", createdAt: 50)]),
todos: [TodoItem(content: "a", status: .completed)]
)
let summary = SummaryPatching.toSessionSummary(session)
#expect(summary.id == "session-1")
#expect(summary.active)
#expect(summary.thinking)
#expect(summary.pinned == false)
#expect(summary.globalPinned == false)
#expect(summary.metadataVersion == 3)
#expect(summary.agentStateVersion == 4)
#expect(summary.todosUpdatedAt == 5)
#expect(summary.todoProgress == TodoProgress(completed: 1, total: 1))
#expect(summary.pendingRequestsCount == 1)
#expect(summary.pendingRequestKinds == [.permission])
#expect(summary.metadata?.agentSessionId == "abc") // trimmed
#expect(summary.metadata?.summary?.text == "doing things")
#expect(summary.backgroundTaskCount == 2)
#expect(summary.futureScheduledMessageCount == 0)
#expect(summary.nextScheduledAt == nil)
}
@Test func agentSessionIdUsesOnlyKnownFlavorFieldLegacyChainOtherwise() {
// Known flavor: other flavors' ids are ignored, blank collapses to nil.
let claude = SummaryPatching.toSessionSummaryMetadata(
SessionMetadata(
path: "/p", host: "h", flavor: "claude",
claudeSessionId: " ", codexSessionId: "cx"
)
)
#expect(claude?.agentSessionId == nil)
// Unknown flavor: legacy fallback chain, codex first, untrimmed.
let unknown = SummaryPatching.toSessionSummaryMetadata(
SessionMetadata(
path: "/p", host: "h", flavor: "newagent",
claudeSessionId: "cl", codexSessionId: "cx"
)
)
#expect(unknown?.agentSessionId == "cx")
// Legacy chain never reads piSessionId (replicated web quirk).
let piOnly = SummaryPatching.toSessionSummaryMetadata(
SessionMetadata(path: "/p", host: "h", flavor: nil, piSessionId: "pi-1")
)
#expect(piOnly?.agentSessionId == nil)
}
// MARK: - Patch application
@Test func flatFieldsAreLastWriteWinsAndUpdatedAtIsMaxMonotonic() {
let current = makeSummary(updatedAt: 2_000, backgroundTaskCount: 1, model: "opus")
let next = SummaryPatching.applySessionSummaryPatch(
current,
SessionPatch(
active: false,
thinking: true,
activeAt: 9_000,
updatedAt: 1_500, // stale — must not rewind
model: .null, // present null clears
effort: .value("low"),
backgroundTaskCount: 3
)
)
#expect(!next.active)
#expect(next.thinking)
#expect(next.activeAt == 9_000)
#expect(next.updatedAt == 2_000)
#expect(next.model == nil)
#expect(next.effort == "low")
#expect(next.backgroundTaskCount == 3)
}
@Test func absentOptionalFieldsLeaveTheSummaryUntouched() {
let current = makeSummary(model: "opus", modelReasoningEffort: "high", effort: "medium")
let next = SummaryPatching.applySessionSummaryPatch(current, SessionPatch(activeAt: 5_000))
#expect(next.model == "opus")
#expect(next.modelReasoningEffort == "high")
#expect(next.effort == "medium")
}
@Test func agentStatePatchWithEqualVersionReappliesSummaryPathAcceptsGte() {
// DIVERGENCE FROM DETAIL PATH, replicated from web: the detail cache
// gates with strict `>`; the summary path re-derives from `>=`
// because the derivation is idempotent
// (sse.md#versioned-patch-algorithm).
let current = makeSummary(agentStateVersion: 5, pendingRequestsCount: 0)
let state = AgentState(requests: ["r1": request("Bash", createdAt: 10)])
let next = SummaryPatching.applySessionSummaryPatch(
current,
SessionPatch(agentState: VersionedValue(version: 5, value: state))
)
#expect(next.pendingRequestsCount == 1)
#expect(next.pendingRequestKinds == [.permission])
#expect(next.agentStateVersion == 5)
}
@Test func staleVersionedPatchesAreRejected() {
let current = makeSummary(
metadataVersion: 5,
agentStateVersion: 5,
todosUpdatedAt: 5,
todoProgress: TodoProgress(completed: 1, total: 2),
pendingRequestsCount: 2
)
let next = SummaryPatching.applySessionSummaryPatch(
current,
SessionPatch(
metadata: VersionedValue(version: 4, value: SessionMetadata(path: "/new", host: "h")),
agentState: VersionedValue(version: 4, value: AgentState()),
todos: VersionedValue(version: 4, value: [])
)
)
#expect(next.metadataVersion == 5)
#expect(next.agentStateVersion == 5)
#expect(next.todosUpdatedAt == 5)
#expect(next.pendingRequestsCount == 2)
#expect(next.todoProgress == TodoProgress(completed: 1, total: 2))
#expect(next.metadata == nil)
}
@Test func newerVersionedPatchesRecomputeTheDerivedFields() {
let current = makeSummary(updatedAt: 2_000, metadataVersion: 1, agentStateVersion: 1, todosUpdatedAt: 1)
let next = SummaryPatching.applySessionSummaryPatch(
current,
SessionPatch(
updatedAt: 8_000,
metadata: VersionedValue(
version: 2,
value: SessionMetadata(path: "/repo", host: "h", name: "n")
),
agentState: VersionedValue(
version: 2,
value: AgentState(requests: ["r1": request("Edit")])
),
todos: VersionedValue(version: 2, value: [TodoItem(content: "a", status: .pending)])
)
)
#expect(next.updatedAt == 8_000)
#expect(next.metadataVersion == 2)
#expect(next.metadata?.path == "/repo")
#expect(next.agentStateVersion == 2)
#expect(next.pendingRequestsCount == 1)
// Requests without createdAt use the POST-patch updatedAt as `since`.
#expect(next.pendingRequests.count == 1)
#expect(next.pendingRequests.first?.since == 8_000)
#expect(next.todosUpdatedAt == 2)
#expect(next.todoProgress == TodoProgress(completed: 0, total: 1))
}
@Test func agentStateNullValueClearsThePendingFields() {
let current = makeSummary(
agentStateVersion: 1,
pendingRequestsCount: 2,
pendingRequestKinds: [.permission],
pendingRequests: [PendingRequest(id: "r1", kind: .permission, tool: "Bash", since: 1)]
)
let next = SummaryPatching.applySessionSummaryPatch(
current,
SessionPatch(agentState: VersionedValue(version: 2, value: nil))
)
#expect(next.pendingRequestsCount == 0)
#expect(next.pendingRequestKinds == [])
#expect(next.pendingRequests == [])
}
@Test func teamStatePatchIsASummaryNoOp() {
let current = makeSummary()
let next = SummaryPatching.applySessionSummaryPatch(
current,
SessionPatch(teamState: VersionedValue(version: 9, value: nil))
)
#expect(next == current)
}
// MARK: - Render-irrelevance filter
@Test func keepAliveThatOnlyMovesActiveAtIsIrrelevant() {
let current = makeSummary(activeAt: 1_000)
let next = makeSummary(activeAt: 11_000)
#expect(SummaryPatching.isRenderIrrelevantPatch(current: current, next: next))
}
@Test func identicalSummariesAreIrrelevant() {
#expect(SummaryPatching.isRenderIrrelevantPatch(current: makeSummary(), next: makeSummary()))
}
@Test func eachRenderedFieldChangeIsRelevant() {
let cases: [SessionSummary] = [
makeSummary(active: false),
makeSummary(thinking: true),
makeSummary(updatedAt: 9_999),
makeSummary(backgroundTaskCount: 3),
makeSummary(model: "opus"),
makeSummary(modelReasoningEffort: "high"),
makeSummary(effort: "medium"),
makeSummary(pendingRequestsCount: 2),
makeSummary(metadata: SessionSummaryMetadata(path: "/other")),
makeSummary(metadata: SessionSummaryMetadata(path: "/tmp", flavor: "claude")),
makeSummary(metadata: SessionSummaryMetadata(path: "/tmp", machineId: "Teemo")),
makeSummary(
metadata: SessionSummaryMetadata(
path: "/tmp",
worktree: WorktreeMetadata(
basePath: "/tmp", branch: "feat/x", name: "x", worktreePath: "/tmp/x"
)
)
),
makeSummary(todoProgress: TodoProgress(completed: 1, total: 2)),
makeSummary(pendingRequestKinds: [.input]),
makeSummary(metadataVersion: 7),
makeSummary(agentStateVersion: 7),
makeSummary(todosUpdatedAt: 7),
]
for changed in cases {
var next = changed
next.activeAt = 11_000
#expect(
!SummaryPatching.isRenderIrrelevantPatch(current: makeSummary(), next: next),
"expected relevant: \(changed)"
)
}
}
@Test func pendingRequestsCompareIdKindToolButNotSince() {
let current = makeSummary(
pendingRequests: [PendingRequest(id: "r1", kind: .permission, tool: "Bash", since: 1)]
)
let sinceOnly = makeSummary(
pendingRequests: [PendingRequest(id: "r1", kind: .permission, tool: "Bash", since: 2)]
)
#expect(SummaryPatching.isRenderIrrelevantPatch(current: current, next: sinceOnly))
let toolChanged = makeSummary(
pendingRequests: [PendingRequest(id: "r1", kind: .permission, tool: "Edit", since: 1)]
)
#expect(!SummaryPatching.isRenderIrrelevantPatch(current: current, next: toolChanged))
}
// MARK: - Legacy detail-required gate
@available(*, deprecated) // silences the deliberate use of the legacy gate
@Test func canApplyVersionedSummaryPatchMirrorsTheLegacyRule() {
// Non-versioned patches never need detail.
#expect(SummaryPatching.canApplyVersionedSummaryPatch(patch: SessionPatch(), detailPresent: false))
// metadata/agentState/todos each require detail.
#expect(!SummaryPatching.canApplyVersionedSummaryPatch(
patch: SessionPatch(metadata: VersionedValue(version: 1, value: nil)),
detailPresent: false
))
#expect(!SummaryPatching.canApplyVersionedSummaryPatch(
patch: SessionPatch(agentState: VersionedValue(version: 1, value: nil)),
detailPresent: false
))
#expect(!SummaryPatching.canApplyVersionedSummaryPatch(
patch: SessionPatch(todos: VersionedValue(version: 1, value: [])),
detailPresent: false
))
// teamState-only is a summary no-op and never blocks.
#expect(SummaryPatching.canApplyVersionedSummaryPatch(
patch: SessionPatch(teamState: VersionedValue(version: 1, value: nil)),
detailPresent: false
))
// With detail present everything is allowed.
#expect(SummaryPatching.canApplyVersionedSummaryPatch(
patch: SessionPatch(metadata: VersionedValue(version: 2, value: nil)),
detailPresent: true
))
}
}