mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +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.
66 lines
2.8 KiB
Swift
66 lines
2.8 KiB
Swift
import Foundation
|
|
import HapiClient
|
|
import HapiProtocol
|
|
import Testing
|
|
|
|
/// Transcribes the Android reference suite (`LastSeenStoreTest.kt`):
|
|
/// monotonic watermarks, the unread derivation, per-scope baseline seeding,
|
|
/// and the snapshot round-trip.
|
|
@MainActor
|
|
@Suite("LastSeenStore")
|
|
struct LastSeenStoreTests {
|
|
|
|
@Test func markSeenIsMonotonicMax() {
|
|
let store = LastSeenStore()
|
|
store.markSeen(sessionId: "s1", seenAt: 1_000)
|
|
#expect(store.lastSeenAt("s1") == 1_000)
|
|
store.markSeen(sessionId: "s1", seenAt: 500) // stale screen must not rewind
|
|
#expect(store.lastSeenAt("s1") == 1_000)
|
|
store.markSeen(sessionId: "s1", seenAt: 2_000)
|
|
#expect(store.lastSeenAt("s1") == 2_000)
|
|
#expect(store.lastSeenAt("unknown") == 0)
|
|
}
|
|
|
|
@Test func unreadComparesUpdatedAtAgainstTheWatermark() {
|
|
let row = storeSummary("s1", updatedAt: 1_000)
|
|
#expect(LastSeenStore.isUnread(row, lastSeenAt: 0))
|
|
#expect(LastSeenStore.isUnread(row, lastSeenAt: 999))
|
|
#expect(!LastSeenStore.isUnread(row, lastSeenAt: 1_000))
|
|
#expect(!LastSeenStore.isUnread(row, lastSeenAt: 2_000))
|
|
}
|
|
|
|
@Test func baselineSeedsMissingWatermarksOnlyOncePerScope() {
|
|
let store = LastSeenStore()
|
|
store.markSeen(sessionId: "seen", seenAt: 50)
|
|
store.initializeBaseline(
|
|
scopeKey: "hub-a",
|
|
sessions: [storeSummary("seen", updatedAt: 900), storeSummary("fresh", updatedAt: 700)]
|
|
)
|
|
// Existing watermarks are never overwritten; missing ones seed at
|
|
// updatedAt.
|
|
#expect(store.lastSeenAt("seen") == 50)
|
|
#expect(store.lastSeenAt("fresh") == 700)
|
|
#expect(!LastSeenStore.isUnread(storeSummary("fresh", updatedAt: 700), lastSeenAt: store.lastSeenAt("fresh")))
|
|
|
|
// Second call for the same scope is a no-op — later sessions stay
|
|
// unread.
|
|
store.initializeBaseline(scopeKey: "hub-a", sessions: [storeSummary("later", updatedAt: 999)])
|
|
#expect(store.lastSeenAt("later") == 0)
|
|
#expect(LastSeenStore.isUnread(storeSummary("later", updatedAt: 999), lastSeenAt: store.lastSeenAt("later")))
|
|
}
|
|
|
|
@Test func stateRoundTripsThroughTheSnapshot() async throws {
|
|
let directory = makeTempDirectory()
|
|
let store = LastSeenStore(snapshotDirectory: directory)
|
|
store.markSeen(sessionId: "s1", seenAt: 1_234)
|
|
store.initializeBaseline(scopeKey: "hub-a", sessions: [])
|
|
await store.flushPersistence()
|
|
|
|
let cold = LastSeenStore(snapshotDirectory: directory)
|
|
#expect(cold.lastSeenAt("s1") == 1_234)
|
|
// Baseline flag persists too — no re-seeding on cold start.
|
|
cold.initializeBaseline(scopeKey: "hub-a", sessions: [storeSummary("s2", updatedAt: 700)])
|
|
#expect(cold.lastSeenAt("s2") == 0)
|
|
}
|
|
}
|