Files
hapi/ios/Packages/HapiKit/Tests/HapiProtocolTests/SessionPatchingTests.swift
T

310 lines
12 KiB
Swift

import Foundation
import HapiProtocol
import Testing
/// Ports the version-gate and detail-patch cases from
/// `web/src/hooks/useSSE.test.ts` (backed by `web/src/lib/sessionPatch.ts`).
@Suite("Session patching")
struct SessionPatchingTests {
private func makeSession(
updatedAt: Int = 2_000,
active: Bool = true,
activeAt: Int = 1_000,
metadata: SessionMetadata? = nil,
metadataVersion: Int = 1,
agentState: AgentState? = nil,
agentStateVersion: Int = 1,
thinking: Bool = false,
todos: [TodoItem]? = nil,
teamState: JSONValue? = nil,
todosUpdatedAt: Int? = 0,
teamStateUpdatedAt: Int? = 0,
model: String? = "gpt-5",
serviceTier: String? = nil,
permissionMode: PermissionMode? = .default,
copilotAgentMode: CopilotAgentMode? = .interactive
) -> Session {
Session(
id: "session-1",
namespace: "default",
seq: 1,
createdAt: 1_000,
updatedAt: updatedAt,
active: active,
activeAt: activeAt,
metadata: metadata,
metadataVersion: metadataVersion,
agentState: agentState,
agentStateVersion: agentStateVersion,
thinking: thinking,
thinkingAt: 0,
todos: todos,
teamState: teamState,
todosUpdatedAt: todosUpdatedAt,
teamStateUpdatedAt: teamStateUpdatedAt,
model: model,
serviceTier: serviceTier,
permissionMode: permissionMode,
copilotAgentMode: copilotAgentMode
)
}
// MARK: - isNewerVersionedPatch (useSSE.test.ts "isNewerVersionedPatch")
@Test func acceptsStrictlyNewerPatch() {
#expect(isNewerVersionedPatch(patchVersion: 5, cachedVersion: 4))
}
@Test func rejectsOlderPatch() {
// The bug case: stale buffered patch replayed on reconnect.
#expect(!isNewerVersionedPatch(patchVersion: 4, cachedVersion: 5))
}
@Test func rejectsSameVersionPatch() {
// Idempotent / duplicate replay.
#expect(!isNewerVersionedPatch(patchVersion: 5, cachedVersion: 5))
}
@Test func acceptsFirstWriteIntoFreshCache() {
#expect(isNewerVersionedPatch(patchVersion: 1, cachedVersion: 0))
}
// MARK: - applySessionDetailPatch (useSSE.test.ts "Copilot keep-alive")
@Test func appliesCopilotAgentModeKeepAliveChange() throws {
let session = makeSession()
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
active: true,
thinking: false,
activeAt: 11_000,
copilotAgentMode: .plan
)))
#expect(next.copilotAgentMode == .plan)
#expect(next.activeAt == 11_000)
}
@Test func returnsNilForKeepAliveRepeatingCurrentCopilotMode() {
let session = makeSession()
let next = applySessionDetailPatch(session: session, patch: SessionPatch(
active: true,
thinking: false,
activeAt: 11_000,
copilotAgentMode: .interactive
))
#expect(next == nil)
}
// MARK: - Version gates
@Test func dropsStaleMetadataPatch() {
let session = makeSession(metadataVersion: 5)
let next = applySessionDetailPatch(session: session, patch: SessionPatch(
metadata: VersionedValue(version: 4, value: SessionMetadata(path: "/new", host: "h"))
))
// Wrapper presence is render-relevant, but the gate rejects it and
// nothing else changed — so the reference returns null.
#expect(next == nil)
}
@Test func dropsSameVersionMetadataPatch() {
let session = makeSession(metadataVersion: 5)
let next = applySessionDetailPatch(session: session, patch: SessionPatch(
metadata: VersionedValue(version: 5, value: SessionMetadata(path: "/new", host: "h"))
))
#expect(next == nil)
}
@Test func appliesStrictlyNewerMetadataPatch() throws {
let session = makeSession(metadataVersion: 5)
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
metadata: VersionedValue(version: 6, value: SessionMetadata(path: "/new", host: "h"))
)))
#expect(next.metadata?.path == "/new")
#expect(next.metadataVersion == 6)
}
@Test func appliesFirstMetadataWriteIntoVersionZero() throws {
let session = makeSession(metadataVersion: 0)
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
metadata: VersionedValue(version: 1, value: SessionMetadata(path: "/p", host: "h"))
)))
#expect(next.metadataVersion == 1)
#expect(next.metadata?.host == "h")
}
@Test func dropsStaleAgentStatePatchButAppliesNewer() throws {
let pending = AgentState(requests: [
"req-1": AgentStateRequest(tool: "Bash", createdAt: 1_000),
])
let session = makeSession(agentState: pending, agentStateVersion: 3)
let stale = applySessionDetailPatch(session: session, patch: SessionPatch(
agentState: VersionedValue(version: 2, value: AgentState())
))
#expect(stale == nil)
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
agentState: VersionedValue(version: 4, value: AgentState())
)))
#expect(next.agentState?.requests == nil)
#expect(next.agentStateVersion == 4)
}
@Test func gatesTodosOnTodosUpdatedAtWatermark() throws {
let item = TodoItem(content: "port models", status: .inProgress, id: "t1")
let session = makeSession(todosUpdatedAt: 5)
#expect(applySessionDetailPatch(session: session, patch: SessionPatch(
todos: VersionedValue(version: 5, value: [item])
)) == nil)
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
todos: VersionedValue(version: 6, value: [item])
)))
#expect(next.todos == [item])
#expect(next.todosUpdatedAt == 6)
}
@Test func treatsAbsentTodosWatermarkAsZero() throws {
let item = TodoItem(content: "x", status: .pending)
let session = makeSession(todosUpdatedAt: nil)
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
todos: VersionedValue(version: 1, value: [item])
)))
#expect(next.todos == [item])
#expect(next.todosUpdatedAt == 1)
}
@Test func clearsTeamStateOnNullValueUnderNewerVersion() throws {
let session = makeSession(teamState: ["teamName": "alpha"], teamStateUpdatedAt: 1)
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
teamState: VersionedValue(version: 2, value: nil)
)))
#expect(next.teamState == nil)
#expect(next.teamStateUpdatedAt == 2)
}
// MARK: - updatedAt monotonicity
@Test func neverRewindsUpdatedAt() throws {
let session = makeSession(updatedAt: 2_000)
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
thinking: true,
updatedAt: 1_500
)))
#expect(next.updatedAt == 2_000)
#expect(next.thinking)
}
@Test func advancesUpdatedAtForward() throws {
let session = makeSession(updatedAt: 2_000)
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
updatedAt: 3_000
)))
#expect(next.updatedAt == 3_000)
}
@Test func staleUpdatedAtAloneIsANoOp() {
// Relevant by strict compare, but max() keeps the cached stamp and
// nothing else changes — the reference returns null.
let session = makeSession(updatedAt: 2_000)
#expect(applySessionDetailPatch(session: session, patch: SessionPatch(
updatedAt: 1_500
)) == nil)
}
// MARK: - Flat fields are unconditional
@Test func appliesFlatFieldsEvenWhenVersionedPartIsStale() throws {
let session = makeSession(metadataVersion: 5)
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
metadata: VersionedValue(version: 1, value: SessionMetadata(path: "/stale", host: "h")),
model: .value("opus")
)))
#expect(next.model == "opus")
#expect(next.metadata == nil)
#expect(next.metadataVersion == 5)
}
@Test func explicitNullClearsServiceTier() throws {
let session = makeSession(serviceTier: "fast")
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
serviceTier: .null
)))
#expect(next.serviceTier == nil)
}
@Test func absentServiceTierIsUntouched() throws {
let session = makeSession(serviceTier: "fast")
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
thinking: true
)))
#expect(next.serviceTier == "fast")
}
// MARK: - Keep-alive irrelevance
@Test func subMinuteActiveAtKeepAliveIsIrrelevant() {
let session = makeSession(activeAt: 1_000)
#expect(isRenderIrrelevantSessionPatch(session: session, patch: SessionPatch(
active: true,
thinking: false,
activeAt: 11_000,
model: .value("gpt-5"),
permissionMode: .default
)))
#expect(applySessionDetailPatch(session: session, patch: SessionPatch(
activeAt: 11_000
)) == nil)
}
@Test func minuteActiveAtMoveIsRelevantAndApplied() throws {
let session = makeSession(activeAt: 1_000)
#expect(!isRenderIrrelevantSessionPatch(session: session, patch: SessionPatch(
activeAt: 61_000
)))
let next = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
activeAt: 61_000
)))
#expect(next.activeAt == 61_000)
}
@Test func emptyPatchIsIrrelevant() {
let session = makeSession()
#expect(isRenderIrrelevantSessionPatch(session: session, patch: SessionPatch()))
#expect(applySessionDetailPatch(session: session, patch: SessionPatch()) == nil)
}
// MARK: - Replicated TS surprises
@Test func activeTurnStartedAtIsNeverApplied() throws {
// web/src/lib/sessionPatch.ts carries the field on the patch type but
// never assigns it; the port replicates that exactly.
let session = makeSession()
#expect(session.activeTurnStartedAt == nil)
let alone = applySessionDetailPatch(session: session, patch: SessionPatch(
activeTurnStartedAt: .value(5_000)
))
#expect(alone == nil)
let combined = try #require(applySessionDetailPatch(session: session, patch: SessionPatch(
thinking: true,
activeTurnStartedAt: .value(5_000)
)))
#expect(combined.thinking)
#expect(combined.activeTurnStartedAt == nil)
}
@Test func scratchlistUpdatedAtIsARefetchTriggerOnly() {
// Always render-relevant (Session has no such field), never applied.
let session = makeSession()
#expect(!isRenderIrrelevantSessionPatch(session: session, patch: SessionPatch(
scratchlistUpdatedAt: 5_000
)))
#expect(applySessionDetailPatch(session: session, patch: SessionPatch(
scratchlistUpdatedAt: 5_000
)) == nil)
}
}