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