Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/ChatPipelineTests.swift
T
weishu a4355d1837 feat(ios): read-only chat assembly (A-M2f)
Wires the merged M2 pieces into a usable pair -> list -> live chat flow:

- pbxproj: link HapiUI into the app target (UUIDs C7/C8, mirroring the
  existing HapiProtocol/HapiClient product references; only project change).
- HubSession: MessageWindowControllers registry + per-chat ChatSession
  factory with in-memory per-session SSE resume cursors and scene-phase
  forwarding to the active chat.
- ChatSession (app): session-scope SSEClient while the chat is open;
  window opened/activated before subscribing; a single consume task awaits
  every event into the window actor, giving Android-channel-equivalent
  arrival-order ingestion; session-updated and friends route through the
  shared SyncEventRouter (detail patching), session-removed clears the
  window, a gap handshake triggers full resync + detail refetch + catch-up
  tail sync; stop hands the cursor back for seamless reopen.
- ChatPipeline (HapiClient): actor running the reduction path off-main -
  queued-row filter, normalization memoized by row instance identity,
  reduce + buildVisibleChatBlocks with previousGroups-stable group ids.
- ChatModel: window state + detail agentState + machine labels -> serial
  ~100 ms-coalesced pipeline loop (first run immediate, publishes strictly
  ordered), header title cascade, loading/empty/error states, loadOlder /
  retry, last-seen stamping on every update.
- ChatView: bottom-anchored ScrollView/LazyVStack (defaultScrollAnchor +
  scrollPosition(id:)), auto-stick at bottom, new-messages pill, top
  sentinel paging with documented scroll re-anchoring, degraded banners.
- Block views: user bubble w/ attachment chips, agent markdown, collapsed
  reasoning, tool cards (knownTools.tsx-parity presentation, status chip,
  read-only permission row w/ pending banner, per-tool bodies: terminal,
  before/after edits, write content, unified-diff detection -> DiffTextView,
  checklists, read-only questions, pretty-JSON fallback, children rail),
  tool groups, centered event rows (presentation.ts port), cli output,
  generated images (authed bytes + in-memory cache + full-screen viewer),
  codex review verdict card.
- Navigation/links: session list now pushes ChatView; app-level
  \.hapiOpenURL handler (https/http -> SFSafariViewController, custom
  schemes confirm first, hapi-file:// -> M4 placeholder) + theme injection.
- Tests: ChatPipelineTests drive the runner with fixture-derived windows
  (stable ids, memo-stable recomputes, queued filter, group-id stability
  across an older-page arrival).
- README: M2f app-layer description.
2026-08-17 20:55:36 +08:00

188 lines
7.1 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Testing
/// Unit tests for the M2f `ChatPipeline` runner — the reduction path the
/// app's `ChatModel` drives (normalize memo → reduce → group with
/// `previousGroups`). Inputs come from real chat fixtures so the scripted
/// window states exercise the same wire shapes the golden suite does; the
/// golden byte-for-byte conformance itself lives in
/// `HapiProtocolTests/ChatFixtureTests`.
@Suite("Chat pipeline runner (M2f)")
struct ChatPipelineTests {
// MARK: - Fixture plumbing (same #filePath scheme as PaginationFixtureTests)
private struct FixtureDocument: Decodable {
struct Input: Decodable {
let messages: [DecryptedMessage]
let agentState: AgentState?
}
let input: Input
}
private static func fixturesDirectory() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // Tests/HapiClientTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // package root: ios/Packages/HapiKit
.appendingPathComponent("../../../shared/fixtures")
.standardizedFileURL
}
private static func loadFixture(_ fileName: String) throws -> FixtureDocument {
let url = fixturesDirectory()
.appendingPathComponent("chat")
.appendingPathComponent(fileName)
return try JSONDecoder().decode(FixtureDocument.self, from: Data(contentsOf: url))
}
/// Window rows for fixture messages. Unlike `WindowMessage(wire:)` —
/// which maps a V8 hub page's collapsed `invokedAt` nil to the explicit
/// null of a queued row — fixture inputs model historical rows, where an
/// absent key means already-invoked. Mapping absence to `.absent` keeps
/// them out of the queued filter, as on a real historical page.
private static func windowRows(_ document: FixtureDocument) -> [WindowMessage] {
document.input.messages.map { wire in
WindowMessage(
id: wire.id,
seq: wire.seq,
localId: wire.localId,
content: wire.content,
createdAt: wire.createdAt,
invokedAt: wire.invokedAt.map(InvokedAtField.number) ?? .absent,
scheduledAt: wire.scheduledAt
)
}
}
private static func blockIds(_ blocks: [VisibleChatBlock]) -> [String] {
blocks.map { block in
switch block {
case .block(let value): return value.id
case .toolGroup(let group): return group.id
}
}
}
private static func groupIds(_ blocks: [VisibleChatBlock]) -> [String] {
blocks.compactMap { block in
if case .toolGroup(let group) = block { return group.id }
return nil
}
}
// MARK: - Reduction path over scripted fixture windows
@Test(
"fixture windows reduce to non-empty stable-id blocks",
arguments: [
"claude-assistant-text.json",
"claude-tool-use-result-pair.json",
"codex-exploration-tool-group.json",
]
)
func reducesFixtureWindow(_ fileName: String) async throws {
let document = try Self.loadFixture(fileName)
let rows = Self.windowRows(document)
let pipeline = ChatPipeline()
let visible = await pipeline.run(
messages: rows,
agentState: document.input.agentState,
hasMoreMessages: false
)
#expect(!visible.isEmpty, "\(fileName): expected rendered blocks")
let ids = Self.blockIds(visible)
#expect(ids.allSatisfy { !$0.isEmpty }, "\(fileName): empty stable id")
#expect(Set(ids).count == ids.count, "\(fileName): duplicate stable ids \(ids)")
}
@Test func memoizedRecomputeIsStable() async throws {
let document = try Self.loadFixture("claude-tool-use-result-pair.json")
let rows = Self.windowRows(document)
let pipeline = ChatPipeline()
let first = await pipeline.run(
messages: rows,
agentState: document.input.agentState,
hasMoreMessages: false
)
// Same row instances → every normalization is a memo hit; the
// output must be value-identical (ids included).
let second = await pipeline.run(
messages: rows,
agentState: document.input.agentState,
hasMoreMessages: false
)
#expect(first == second)
#expect(Self.blockIds(first) == Self.blockIds(second))
}
@Test func queuedNotInvokedRowsStayOutOfTheThread() async throws {
let document = try Self.loadFixture("claude-assistant-text.json")
var rows = Self.windowRows(document)
// A queued composer row: user role, explicit-null invokedAt.
rows.append(
buildOptimisticMessage(
localId: "local-queued-1",
text: "queued follow-up",
createdAt: 1_755_000_999_000,
status: .queued
)
)
let pipeline = ChatPipeline()
let visible = await pipeline.run(messages: rows, agentState: nil, hasMoreMessages: false)
#expect(!visible.isEmpty)
#expect(
!Self.blockIds(visible).contains("local-queued-1"),
"queued-not-invoked rows belong to the composer bar, not the thread"
)
}
// MARK: - Group-id stability via previousGroups
@Test func groupIdSurvivesOlderHistoryArrival() async throws {
let document = try Self.loadFixture("codex-exploration-tool-group.json")
let allRows = Self.windowRows(document)
// Drop the leading user prompt: the exploration group is now the
// oldest visible block with more history behind it.
let tailRows = Array(allRows.dropFirst())
let pipeline = ChatPipeline()
let truncated = await pipeline.run(
messages: tailRows,
agentState: document.input.agentState,
hasMoreMessages: true
)
let truncatedGroupIds = Self.groupIds(truncated)
#expect(truncatedGroupIds.count == 1, "expected one exploration group")
// The older page lands (full window, no more history): the group no
// longer starts at the oldest boundary, but previousGroups pins the
// id it already rendered under.
let complete = await pipeline.run(
messages: allRows,
agentState: document.input.agentState,
hasMoreMessages: false
)
let completeGroupIds = Self.groupIds(complete)
#expect(completeGroupIds == truncatedGroupIds, "group id must not change across recomputes")
// Control: a fresh pipeline (no previousGroups) keys the same window
// by its first tool instead — proof the pin above did the work.
let fresh = await ChatPipeline().run(
messages: allRows,
agentState: document.input.agentState,
hasMoreMessages: false
)
let freshGroupIds = Self.groupIds(fresh)
#expect(freshGroupIds.count == 1)
#expect(freshGroupIds != truncatedGroupIds)
}
}