mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +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.
82 lines
3.4 KiB
Swift
82 lines
3.4 KiB
Swift
import Foundation
|
|
import HapiClient
|
|
import Testing
|
|
|
|
/// Transcribes the Android reference suite (`JsonSnapshotStoreTest.kt`):
|
|
/// load semantics, the debounce window, and atomic replacement.
|
|
@MainActor
|
|
@Suite("DiskCache")
|
|
struct DiskCacheTests {
|
|
|
|
private func makeCache(
|
|
_ directory: URL,
|
|
filename: String,
|
|
debounce: Duration = .milliseconds(500)
|
|
) -> DiskCache<[String]> {
|
|
DiskCache<[String]>(directory: directory, filename: filename, debounce: debounce)
|
|
}
|
|
|
|
@Test func loadReturnsNilForAMissingFile() {
|
|
#expect(makeCache(makeTempDirectory(), filename: "missing.json").load() == nil)
|
|
}
|
|
|
|
@Test func loadReturnsNilForACorruptFile() throws {
|
|
let directory = makeTempDirectory()
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
try Data("{ not json".utf8).write(to: directory.appendingPathComponent("corrupt.json"))
|
|
#expect(makeCache(directory, filename: "corrupt.json").load() == nil)
|
|
}
|
|
|
|
@Test func writeRoundTripsThroughAFreshCache() async {
|
|
let directory = makeTempDirectory()
|
|
let writer = makeCache(directory, filename: "data.json")
|
|
writer.scheduleWrite(["a", "b"])
|
|
await writer.flush()
|
|
#expect(makeCache(directory, filename: "data.json").load() == ["a", "b"])
|
|
}
|
|
|
|
@Test func debounceDelaysTheWriteAndKeepsOnlyTheLatestValue() async throws {
|
|
let directory = makeTempDirectory()
|
|
// A debounce far beyond the test's runtime: only flush() may write.
|
|
let writer = makeCache(directory, filename: "debounced.json", debounce: .seconds(60))
|
|
let file = directory.appendingPathComponent("debounced.json")
|
|
|
|
writer.scheduleWrite(["v1"])
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
#expect(!FileManager.default.fileExists(atPath: file.path), "write must wait for the debounce window")
|
|
|
|
// A newer value restarts the window and supersedes v1.
|
|
writer.scheduleWrite(["v2"])
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
#expect(!FileManager.default.fileExists(atPath: file.path))
|
|
|
|
await writer.flush()
|
|
#expect(makeCache(directory, filename: "debounced.json").load() == ["v2"])
|
|
}
|
|
|
|
@Test func flushAfterTheDebouncedWriteAlreadyLandedIsANoOp() async throws {
|
|
let directory = makeTempDirectory()
|
|
let writer = makeCache(directory, filename: "settled.json")
|
|
let file = directory.appendingPathComponent("settled.json")
|
|
writer.scheduleWrite(["v1"])
|
|
await writer.flush()
|
|
#expect(makeCache(directory, filename: "settled.json").load() == ["v1"])
|
|
try FileManager.default.removeItem(at: file)
|
|
await writer.flush() // nothing pending → must not resurrect the file
|
|
#expect(!FileManager.default.fileExists(atPath: file.path))
|
|
}
|
|
|
|
@Test func writeReplacesPreviousContentAtomicallyWithoutTempResidue() async throws {
|
|
let directory = makeTempDirectory()
|
|
let writer = makeCache(directory, filename: "atomic.json")
|
|
writer.scheduleWrite(["first"])
|
|
await writer.flush()
|
|
writer.scheduleWrite(["second"])
|
|
await writer.flush()
|
|
#expect(makeCache(directory, filename: "atomic.json").load() == ["second"])
|
|
let residue = try FileManager.default.contentsOfDirectory(atPath: directory.path)
|
|
.filter { $0 != "atomic.json" }
|
|
#expect(residue.isEmpty, "atomic write must leave no temp files behind")
|
|
}
|
|
}
|