mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-16 20:40:01 +00:00
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.
142 lines
6.0 KiB
Swift
142 lines
6.0 KiB
Swift
import Foundation
|
|
import HapiClient
|
|
import HapiProtocol
|
|
import Testing
|
|
|
|
/// Transcribes the Android reference suite (`StoreSyncTargetsTest.kt`):
|
|
/// routing rules of the SyncEvent fan-out, verified against fake stores
|
|
/// through the `SessionListStoring`/`MachineListStoring` seams.
|
|
@MainActor
|
|
private final class FakeSessionListStore: SessionListStoring {
|
|
var sessions: [SessionSummary] = []
|
|
var calls: [String] = []
|
|
|
|
func refresh() async throws { calls.append("refresh") }
|
|
func scheduleRefresh() { calls.append("scheduleRefresh") }
|
|
func fullResync() async throws { calls.append("fullResync") }
|
|
|
|
func applySessionEvent(_ event: SyncEvent) {
|
|
let label: String
|
|
switch event {
|
|
case .sessionAdded: label = "sessionAdded"
|
|
case .sessionUpdated: label = "sessionUpdated"
|
|
case .sessionRemoved: label = "sessionRemoved"
|
|
case .sessionEnded: label = "sessionEnded"
|
|
default: label = "unexpected"
|
|
}
|
|
calls.append("session:\(label)")
|
|
}
|
|
|
|
func setPinMode(sessionId: String, mode: SessionPinMode) async throws {
|
|
calls.append("pin:\(sessionId):\(mode.rawValue)")
|
|
}
|
|
|
|
func archiveSession(sessionId: String) async throws {
|
|
calls.append("archive:\(sessionId)")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class FakeMachineListStore: MachineListStoring {
|
|
var machines: [Machine] = []
|
|
var calls: [String] = []
|
|
|
|
func refresh() async throws { calls.append("refresh") }
|
|
func scheduleRefresh() { calls.append("scheduleRefresh") }
|
|
|
|
func applyMachineEvent(machineId: String, data: MachineUpdatedData?) {
|
|
calls.append("machine:\(machineId)")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite("SyncEventRouter")
|
|
struct SyncEventRoutingTests {
|
|
|
|
@Test func sessionLifecycleEventsReachTheSessionStore() throws {
|
|
let sessions = FakeSessionListStore()
|
|
let router = SyncEventRouter(sessions: sessions, machines: FakeMachineListStore())
|
|
router.route(try decodeSyncEvent("{\"type\":\"session-updated\",\"sessionId\":\"s1\",\"data\":{\"active\":true}}"))
|
|
router.route(try decodeSyncEvent("{\"type\":\"session-removed\",\"sessionId\":\"s1\"}"))
|
|
router.route(try decodeSyncEvent("{\"type\":\"session-ended\",\"sessionId\":\"s1\",\"reason\":\"completed\"}"))
|
|
#expect(sessions.calls == ["session:sessionUpdated", "session:sessionRemoved", "session:sessionEnded"])
|
|
}
|
|
|
|
@Test func machineUpdatedReachesTheMachineStore() throws {
|
|
let machines = FakeMachineListStore()
|
|
let router = SyncEventRouter(sessions: FakeSessionListStore(), machines: machines)
|
|
router.route(try decodeSyncEvent("{\"type\":\"machine-updated\",\"machineId\":\"m1\",\"data\":null}"))
|
|
#expect(machines.calls == ["machine:m1"])
|
|
}
|
|
|
|
@Test func globalMessageStreamEventsRefreshTheSessionList() throws {
|
|
let sessions = FakeSessionListStore()
|
|
let router = SyncEventRouter(sessions: sessions, machines: FakeMachineListStore())
|
|
router.route(try decodeSyncEvent("{\"type\":\"messages-invalidated\",\"sessionId\":\"s1\"}"))
|
|
router.route(try decodeSyncEvent("{\"type\":\"messages-consumed\",\"sessionId\":\"s1\",\"localIds\":[\"l1\"],\"invokedAt\":1}"))
|
|
router.route(try decodeSyncEvent("{\"type\":\"message-cancelled\",\"sessionId\":\"s1\",\"messageId\":\"m1\"}"))
|
|
router.route(try decodeSyncEvent("{\"type\":\"scheduled-matured\",\"sessionId\":\"s1\"}"))
|
|
#expect(sessions.calls == Array(repeating: "scheduleRefresh", count: 4))
|
|
}
|
|
|
|
@Test func messageReceivedRefreshesTheListOnlyWhenScheduled() throws {
|
|
let sessions = FakeSessionListStore()
|
|
let router = SyncEventRouter(sessions: sessions, machines: FakeMachineListStore())
|
|
router.route(try decodeSyncEvent(
|
|
"{\"type\":\"message-received\",\"sessionId\":\"s1\",\"message\":{\"id\":\"m1\",\"createdAt\":1}}"
|
|
))
|
|
#expect(sessions.calls == [])
|
|
router.route(try decodeSyncEvent(
|
|
"{\"type\":\"message-received\",\"sessionId\":\"s1\",\"message\":{\"id\":\"m2\",\"createdAt\":1,\"scheduledAt\":99}}"
|
|
))
|
|
#expect(sessions.calls == ["scheduleRefresh"])
|
|
}
|
|
|
|
@Test func sessionScopedMessageEventsDoNotTouchTheList() throws {
|
|
// Window territory (M2f) — the per-chat pipe must not churn the list.
|
|
let sessions = FakeSessionListStore()
|
|
let router = SyncEventRouter(sessions: sessions, machines: FakeMachineListStore())
|
|
router.route(
|
|
try decodeSyncEvent("{\"type\":\"messages-invalidated\",\"sessionId\":\"s1\"}"),
|
|
scope: .session("s1")
|
|
)
|
|
#expect(sessions.calls == [])
|
|
}
|
|
|
|
@Test func gapHandshakeTriggersTheFullResync() async throws {
|
|
let sessions = FakeSessionListStore()
|
|
let machines = FakeMachineListStore()
|
|
let router = SyncEventRouter(sessions: sessions, machines: machines)
|
|
router.handleHandshake(resume: .gap)
|
|
try await expectEventually { sessions.calls == ["fullResync"] && machines.calls == ["refresh"] }
|
|
}
|
|
|
|
@Test func okHandshakeSkipsTheResync() async throws {
|
|
let sessions = FakeSessionListStore()
|
|
let machines = FakeMachineListStore()
|
|
let router = SyncEventRouter(sessions: sessions, machines: machines)
|
|
router.handleHandshake(resume: .ok)
|
|
// Give a spawned (unexpected) resync task room to run.
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
#expect(sessions.calls == [])
|
|
#expect(machines.calls == [])
|
|
}
|
|
|
|
@Test func toastEventsReachTheCallback() throws {
|
|
final class ToastBox {
|
|
var toasts: [ToastPayload] = []
|
|
}
|
|
let box = ToastBox()
|
|
let router = SyncEventRouter(
|
|
sessions: FakeSessionListStore(),
|
|
machines: FakeMachineListStore(),
|
|
onToast: { box.toasts.append($0) }
|
|
)
|
|
router.route(try decodeSyncEvent(
|
|
"{\"type\":\"toast\",\"data\":{\"title\":\"t\",\"body\":\"b\",\"sessionId\":\"s1\",\"url\":\"/sessions/s1\"}}"
|
|
))
|
|
#expect(box.toasts.count == 1)
|
|
#expect(box.toasts.first?.title == "t")
|
|
}
|
|
}
|