mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +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.
428 lines
17 KiB
Swift
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
|
|
))
|
|
}
|
|
}
|