Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions native/Sources/RTCStore/RTCStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")!
Expand Down
20 changes: 20 additions & 0 deletions native/Tests/RTCStoreTests/StoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Loading