diff --git a/native/Sources/RTCStore/RTCStore.swift b/native/Sources/RTCStore/RTCStore.swift index 831692e..ebc8cf7 100644 --- a/native/Sources/RTCStore/RTCStore.swift +++ b/native/Sources/RTCStore/RTCStore.swift @@ -213,6 +213,13 @@ public final class SQLiteConversationEventRepository: ConversationReplayReposito public func page(reviewID: ReviewID, conversationID: UUID, after: Int, maximumEvents: Int, maximumBytes: Int) async throws -> ConversationPage { guard after >= 0, maximumEvents > 0, maximumBytes > 0 else { throw RTCStoreError.corrupt("invalid conversation page") } return try await store.read { db in + // A newly opened conversation has no durable binding until its first write. + // Cursor zero is the only valid replay request for that virgin stream; keep + // all established-stream scope and cursor validation unchanged below. + guard try String.fetchOne(db, sql: "SELECT review_id FROM conversations WHERE id = ?", arguments: [conversationID.uuidString]) != nil else { + guard after == 0 else { throw RTCStoreError.corrupt("conversation scope") } + return ConversationPage(after: 0, nextCursor: 0, events: [], hasMore: false) + } try self.requireBinding(db, reviewID: reviewID, conversationID: conversationID) let last = (try Int.fetchOne(db, sql: "SELECT COALESCE(MAX(sequence), 0) FROM conversation_events WHERE conversation_id = ?", arguments: [conversationID.uuidString])) ?? 0 guard after <= last else { throw RTCStoreError.corrupt("conversation cursor ahead") } diff --git a/native/Tests/RTCAgentChatTests/AgentChatCoordinatorTests.swift b/native/Tests/RTCAgentChatTests/AgentChatCoordinatorTests.swift index 5c63b96..2b0ec2e 100644 --- a/native/Tests/RTCAgentChatTests/AgentChatCoordinatorTests.swift +++ b/native/Tests/RTCAgentChatTests/AgentChatCoordinatorTests.swift @@ -4,6 +4,15 @@ import RTCContracts import RTCAgentChat final class AgentChatCoordinatorTests: XCTestCase { + func testEmptyConversationReplayLeavesChatRailUsable() async throws { + let reviewID = try ReviewID("0123456789abcdef01234567") + let coordinator = AgentChatCoordinator(reviewID: reviewID, conversationID: UUID(), repository: MemoryRepository(), wakeSink: NoopWake()) + let snapshot = try await coordinator.replay() + XCTAssertTrue(snapshot.events.isEmpty) + XCTAssertEqual(snapshot.cursor, 0) + XCTAssertFalse(snapshot.ended) + } + func testReplayRejectsGapsAndWrongStream() async throws { let reviewID = try ReviewID("0123456789abcdef01234567") let conversationID = UUID(uuidString: "00000000-0000-0000-0000-000000000010")! diff --git a/native/Tests/RTCStoreTests/StoreTests.swift b/native/Tests/RTCStoreTests/StoreTests.swift index f037d19..6768b6d 100644 --- a/native/Tests/RTCStoreTests/StoreTests.swift +++ b/native/Tests/RTCStoreTests/StoreTests.swift @@ -15,6 +15,26 @@ final class StoreTests: XCTestCase { } extension StoreTests { + func testVirginConversationAllowsOnlyEmptyCursorZeroPage() async throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("rtc-virgin-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let store = try SQLiteStore(rootURL: root) + let repository = SQLiteConversationEventRepository(store: store) + let reviewID = try ReviewID("0123456789abcdef01234567") + let conversationID = UUID(uuidString: "00000000-0000-0000-0000-000000000010")! + + let empty = try await repository.page(reviewID: reviewID, conversationID: conversationID, after: 0, maximumEvents: 100, maximumBytes: 1024) + XCTAssertTrue(empty.events.isEmpty) + XCTAssertEqual(empty.nextCursor, 0) + do { _ = try await repository.page(reviewID: reviewID, conversationID: conversationID, after: 1, maximumEvents: 100, maximumBytes: 1024); XCTFail("virgin nonzero cursor must fail") } catch { } + + let otherReview = try ReviewID("fedcba9876543210fedcba98") + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + let fixture = try decoder.decode([ConversationEvent].self, from: Data(contentsOf: URL(fileURLWithPath: "native/Fixtures/Chat/replay-lossless.json"))) + try await repository.append(fixture[0]) + do { _ = try await repository.page(reviewID: otherReview, conversationID: conversationID, after: 0, maximumEvents: 100, maximumBytes: 1024); XCTFail("mismatched binding must fail") } catch { } + } + func testConversationReplayIsLosslessAcrossRepositoryInstances() async throws { let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("rtc-conversation-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: root) }