From 6cf0d59fdeaa11dcb8ae3bd35fa6d42807282578 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 15:31:48 -0400 Subject: [PATCH] test: stamp canned chat replies after the send, not at test start Seven ChatViewModelTests stamped canned gateway replies from a timestamp captured at test start plus 10 s (70 s in two tests). The view model credits a reply to a send only when the reply is stamped at or after the local send: `clearPendingRunIfAssistantMessagePresent(after:)` retires the pending run, and `canInvalidateOlderHistory` requires the latest user turn to be answered. When a starved cooperative pool delays the send past the offset, the reply lands before the local user row. Under a 12 s stall: - "stale fallback refresh keeps retrying while run remains pending" applies the reply, but the run stays pending and the next refresh is 60 s away. - "session activity without chat snapshot ..." never clears its run. - "bounded repeated same text reply ..." leaves the latest user turn unanswered, so the stale refresh overwrites the bounded answer. - "terminal wait keeps ownership ..." passes, but only through the 10 s no-output grace (22 s instead of 0.07 s). "superseded pending refresh ...", "completion wait ..." and "agent lifecycle end ..." use the same stamp and are changed the same way. A file-local `ReplyClock` now stamps a canned reply the first time the fake serves it after the send and reuses that base for later responses. In the bounded test, the first turn predates the second send, so it is stamped just before test start. Instrumented runs show the test still takes the same `hasAnsweredUser` path as before. Diagnostics ruled out an extra history request under a long stall: index 4 reached the intended refresh. The terminal grace and retry intervals played no part, and the view model behaved as designed. This is a test-data fix only. Co-Authored-By: Claude Opus 5.5 --- .../OpenClawKitTests/ChatViewModelTests.swift | 136 +++++++++++------- 1 file changed, 88 insertions(+), 48 deletions(-) diff --git a/Tests/OpenClawKitTests/ChatViewModelTests.swift b/Tests/OpenClawKitTests/ChatViewModelTests.swift index 0894844..5ecb9fd 100644 --- a/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -672,6 +672,22 @@ private actor AsyncCounter { } } +/// The view model credits a reply to a send only when the reply is stamped at or +/// after the local send. Stamping canned replies at test start plus a fixed offset +/// breaks once a starved pool delays the send past the offset, so stamp them when +/// the fake first serves them after the send. +private actor ReplyClock { + private var baseMs: Double? + + /// Captured on the first call and reused, so repeated responses keep one order. + func base() -> Double { + if let baseMs { return baseMs } + let now = Date().timeIntervalSince1970 * 1000 + self.baseMs = now + return now + } +} + private actor AsyncStringRecorder { private var values: [String] = [] @@ -5530,13 +5546,14 @@ struct ChatViewModelTests { func `superseded pending refresh preserves an in-flight run`(refreshIndex: Int) async throws { let historyGate = AsyncGate() let historyStarted = AsyncCounter() - let now = Date().timeIntervalSince1970 * 1000 + 10000 + let replyClock = ReplyClock() let (transport, vm) = await makeViewModel( historyResponses: [historyPayload(), historyPayload()], historyResponseHook: { _, index, runIds in guard index >= refreshIndex, let runId = runIds.last else { return nil } _ = await historyStarted.increment() await historyGate.wait() + let now = await replyClock.base() return historyPayload( messages: [ chatTextMessage( @@ -5633,19 +5650,23 @@ struct ChatViewModelTests { @Test func `completion wait refreshes history and clears pending run`() async throws { let sessionId = "sess-main" - let now = (Date().timeIntervalSince1970 * 1000) + 10000 + let replyClock = ReplyClock() let history1 = historyPayload(sessionId: sessionId) let history2 = historyPayload(sessionId: sessionId, messages: []) - let history3 = historyPayload( - sessionId: sessionId, - messages: [ - chatTextMessage( - role: "assistant", - text: "completed after wait", - timestamp: now + 60000), - ]) let (transport, vm) = await makeViewModel( - historyResponses: [history1, history2, history3], + historyResponses: [history1, history2], + historyResponseHook: { _, index, _ in + guard index >= 2 else { return nil } + let now = await replyClock.base() + return historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "assistant", + text: "completed after wait", + timestamp: now), + ]) + }, sendMessageStatus: "pending", waitForRunCompletionHook: { _, _ in .terminal(.completed) }) try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) @@ -5672,24 +5693,28 @@ struct ChatViewModelTests { let historyCalls = AsyncCounter() let waitCalls = AsyncCounter() let sessionId = "sess-main" - let now = (Date().timeIntervalSince1970 * 1000) + 10000 + let replyClock = ReplyClock() let empty = historyPayload(sessionId: sessionId) - let completed = historyPayload( - sessionId: sessionId, - messages: [ - chatTextMessage( - role: "assistant", - text: "recovered after history failure", - timestamp: now + 1), - ]) let (transport, vm) = await makeViewModel( - historyResponses: [empty, empty, empty, completed], + historyResponses: [empty, empty, empty], requestHistoryHook: { _ in let count = await historyCalls.increment() if count == 3 { throw NSError(domain: "ChatViewModelTests", code: 1) } }, + historyResponseHook: { _, index, _ in + guard index >= 3 else { return nil } + let now = await replyClock.base() + return historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "assistant", + text: "recovered after history failure", + timestamp: now), + ]) + }, sendMessageStatus: "pending", waitForRunCompletionHook: { _, _ in await waitCalls.increment() == 1 ? .terminal(.completed) : .unavailable @@ -5781,19 +5806,23 @@ struct ChatViewModelTests { @Test func `agent lifecycle end refreshes history and clears pending run`() async throws { let sessionId = "sess-main" - let now = (Date().timeIntervalSince1970 * 1000) + 10000 + let replyClock = ReplyClock() let history1 = historyPayload(sessionId: sessionId) let history2 = historyPayload(sessionId: sessionId, messages: []) - let history3 = historyPayload( - sessionId: sessionId, - messages: [ - chatTextMessage( - role: "assistant", - text: "completed from lifecycle", - timestamp: now + 60000), - ]) let (transport, vm) = await makeViewModel( - historyResponses: [history1, history2, history3], + historyResponses: [history1, history2], + historyResponseHook: { _, index, _ in + guard index >= 2 else { return nil } + let now = await replyClock.base() + return historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "assistant", + text: "completed from lifecycle", + timestamp: now), + ]) + }, sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) @@ -6961,20 +6990,16 @@ struct ChatViewModelTests { let staleRefreshGate = SessionSubscribeGate() let historyCount = AsyncCounter() let staleRefreshReleasedCount = AsyncCounter() - let now = (Date().timeIntervalSince1970 * 1000) + 10000 + let replyClock = ReplyClock() + let now = Date().timeIntervalSince1970 * 1000 let firstTurn = [ - chatTextMessage(role: "user", text: "retry", timestamp: now), - chatTextMessage(role: "assistant", text: "first answer", timestamp: now + 1), - ] - let latestBoundedTurn = [ - chatTextMessage(role: "user", text: "retry", timestamp: now + 2), - chatTextMessage(role: "assistant", text: "second answer", timestamp: now + 3), + chatTextMessage(role: "user", text: "retry", timestamp: now - 2), + chatTextMessage(role: "assistant", text: "first answer", timestamp: now - 1), ] let (transport, vm) = await makeViewModel( historyResponses: [ historyPayload(sessionId: sessionId, messages: firstTurn), historyPayload(sessionId: sessionId, messages: firstTurn), - historyPayload(sessionId: sessionId, messages: latestBoundedTurn), ], requestHistoryHook: { sessionKey in guard sessionKey == "main" else { return } @@ -6983,6 +7008,16 @@ struct ChatViewModelTests { await staleRefreshGate.wait() _ = await staleRefreshReleasedCount.increment() } + }, + historyResponseHook: { _, index, _ in + guard index >= 2 else { return nil } + let now = await replyClock.base() + return historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage(role: "user", text: "retry", timestamp: now), + chatTextMessage(role: "assistant", text: "second answer", timestamp: now + 1), + ]) }) try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) @@ -10777,7 +10812,7 @@ struct ChatViewModelTests { let staleFallbackGate = SessionSubscribeGate() let mainHistoryCount = AsyncCounter() let staleFallbackReleasedCount = AsyncCounter() - let now = (Date().timeIntervalSince1970 * 1000) + 10000 + let replyClock = ReplyClock() let (transport, vm) = await makeViewModel( historyResponses: [historyPayload(sessionKey: "main", sessionId: "sess-main")], requestHistoryHook: { sessionKey in @@ -10790,6 +10825,7 @@ struct ChatViewModelTests { }, historyResponseHook: { _, index, sentRunIds in guard let runId = sentRunIds.last else { return nil } + let now = await replyClock.base() if (1...3).contains(index) { let sessionId = switch index { case 1: "sess-main-send-refresh" @@ -10848,16 +10884,20 @@ struct ChatViewModelTests { @Test @MainActor func `session activity without chat snapshot does not retain completed pending run`() async throws { let historyCalls = AsyncCounter() - let now = (Date().timeIntervalSince1970 * 1000) + 10000 - let completedHistory = historyPayload( - messages: [ - chatTextMessage(role: "user", text: "hello", timestamp: now), - chatTextMessage(role: "assistant", text: "done", timestamp: now + 1), - ], - hasActiveRun: true) + let replyClock = ReplyClock() let (transport, vm) = await makeViewModel( - historyResponses: [historyPayload(), completedHistory], + historyResponses: [historyPayload()], requestHistoryHook: { _ in _ = await historyCalls.increment() }, + historyResponseHook: { _, index, _ in + guard index >= 1 else { return nil } + let now = await replyClock.base() + return historyPayload( + messages: [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "done", timestamp: now + 1), + ], + hasActiveRun: true) + }, sendMessageStatus: "pending") try await loadAndWaitBootstrap(vm: vm)