@@ -26,6 +26,33 @@ struct ChatViewModelTests {
2626 return ( vm, chat)
2727 }
2828
29+ // MARK: - Mock API client
30+
31+ private final class MockServiceLogsProvider : ServiceLogsProvider {
32+ var streams : [ AsyncThrowingStream < ServiceLogEvent , Error > ]
33+ var statuses : [ String ]
34+ private( set) var streamCallCount = 0
35+ private( set) var statusCallCount = 0
36+
37+ init ( streams: [ AsyncThrowingStream < ServiceLogEvent , Error > ] , statuses: [ String ] ) {
38+ self . streams = streams
39+ self . statuses = statuses
40+ }
41+
42+ func streamServiceLogs( spriteName: String , serviceName: String ) -> AsyncThrowingStream < ServiceLogEvent , Error > {
43+ let idx = streamCallCount
44+ streamCallCount += 1
45+ return idx < streams. count ? streams [ idx] : AsyncThrowingStream { $0. finish ( ) }
46+ }
47+
48+ func getServiceStatus( spriteName: String , serviceName: String ) async throws -> ServiceInfo {
49+ let idx = statusCallCount
50+ statusCallCount += 1
51+ let status = idx < statuses. count ? statuses [ idx] : " stopped "
52+ return ServiceInfo ( name: serviceName, state: ServiceInfo . ServiceState ( status: status) )
53+ }
54+ }
55+
2956 // MARK: - handleEvent: system
3057
3158 @Test func handleEvent_systemSetsModelName( ) throws {
@@ -723,6 +750,65 @@ struct ChatViewModelTests {
723750 #expect( vm. inputText == " " )
724751 }
725752
753+ // MARK: - reconnectToServiceLogs: retriedAfterServiceStopped
754+
755+ @Test func reconnectToServiceLogs_retriesOnceWhenServiceStoppedWithNoResult_thenDeliversResult( ) async throws {
756+ let ctx = try makeModelContext ( )
757+ let ( vm, _) = makeChatViewModel ( modelContext: ctx)
758+
759+ // First stream: delivers a system event but no result — simulates the
760+ // stream dying just before Claude finishes.
761+ let systemLine = #"{"type":"system","session_id":"s1","model":"claude-sonnet-4-20250514"}"# + " \n "
762+ let stream1 = AsyncThrowingStream < ServiceLogEvent , Error > { continuation in
763+ continuation. yield ( ServiceLogEvent ( type: . stdout, data: systemLine, exitCode: nil , timestamp: nil , logFiles: nil ) )
764+ continuation. finish ( )
765+ }
766+
767+ // Second stream: delivers the result event that landed after the first stream closed.
768+ let resultLine = #"{"type":"result","session_id":"s1","subtype":"success"}"# + " \n "
769+ let stream2 = AsyncThrowingStream < ServiceLogEvent , Error > { continuation in
770+ continuation. yield ( ServiceLogEvent ( type: . stdout, data: systemLine, exitCode: nil , timestamp: nil , logFiles: nil ) )
771+ continuation. yield ( ServiceLogEvent ( type: . stdout, data: resultLine, exitCode: nil , timestamp: nil , logFiles: nil ) )
772+ continuation. finish ( )
773+ }
774+
775+ let mock = MockServiceLogsProvider ( streams: [ stream1, stream2] , statuses: [ " stopped " ] )
776+
777+ await vm. runReconnectLoop ( apiClient: mock, modelContext: ctx)
778+
779+ #expect( mock. streamCallCount == 2 , " Should replay logs twice: once on initial reconnect, once on retry " )
780+ #expect( mock. statusCallCount == 1 , " Should only check status once (before the retry) " )
781+ guard case . idle = vm. status else {
782+ Issue . record ( " Expected idle status after reconnect completes, got \( vm. status) " )
783+ return
784+ }
785+ }
786+
787+ @Test func reconnectToServiceLogs_givesUpAfterOneRetryWhenServiceStillStopped( ) async throws {
788+ let ctx = try makeModelContext ( )
789+ let ( vm, _) = makeChatViewModel ( modelContext: ctx)
790+
791+ // Both streams return no result event, and the service stays stopped.
792+ let systemLine = #"{"type":"system","session_id":"s1","model":"claude-sonnet-4-20250514"}"# + " \n "
793+ let makeStream = {
794+ AsyncThrowingStream < ServiceLogEvent , Error > { continuation in
795+ continuation. yield ( ServiceLogEvent ( type: . stdout, data: systemLine, exitCode: nil , timestamp: nil , logFiles: nil ) )
796+ continuation. finish ( )
797+ }
798+ }
799+
800+ let mock = MockServiceLogsProvider ( streams: [ makeStream ( ) , makeStream ( ) ] , statuses: [ " stopped " , " stopped " ] )
801+
802+ await vm. runReconnectLoop ( apiClient: mock, modelContext: ctx)
803+
804+ #expect( mock. streamCallCount == 2 , " Should attempt exactly two replays: initial + one retry " )
805+ #expect( mock. statusCallCount == 2 , " Should check status once per iteration that yields no result event " )
806+ guard case . idle = vm. status else {
807+ Issue . record ( " Expected idle status after giving up, got \( vm. status) " )
808+ return
809+ }
810+ }
811+
726812 @Test func stashDraft_leavesInputReadyForNextMessage( ) throws {
727813 let ctx = try makeModelContext ( )
728814 let ( vm, _) = makeChatViewModel ( modelContext: ctx)
0 commit comments