@@ -5,13 +5,13 @@ import { copilotHttpMock, copilotHttpMockFns, permissionsMock } from '@sim/testi
55import { NextRequest } from 'next/server'
66import { beforeEach , describe , expect , it , vi } from 'vitest'
77
8- const { mockSelect, mockFrom, mockWhere, mockOrderBy, mockGetActiveChatStreamIds } = vi . hoisted (
8+ const { mockSelect, mockFrom, mockWhere, mockOrderBy, mockReconcileChatStreamMarkers } = vi . hoisted (
99 ( ) => ( {
1010 mockSelect : vi . fn ( ) ,
1111 mockFrom : vi . fn ( ) ,
1212 mockWhere : vi . fn ( ) ,
1313 mockOrderBy : vi . fn ( ) ,
14- mockGetActiveChatStreamIds : vi . fn ( ) ,
14+ mockReconcileChatStreamMarkers : vi . fn ( ) ,
1515 } )
1616)
1717
@@ -43,8 +43,8 @@ vi.mock('drizzle-orm', () => ({
4343vi . mock ( '@/lib/copilot/request/http' , ( ) => copilotHttpMock )
4444vi . mock ( '@/lib/workspaces/permissions/utils' , ( ) => permissionsMock )
4545
46- vi . mock ( '@/lib/copilot/request/session/abort ' , ( ) => ( {
47- getActiveChatStreamIds : mockGetActiveChatStreamIds ,
46+ vi . mock ( '@/lib/copilot/chat/stream-liveness ' , ( ) => ( {
47+ reconcileChatStreamMarkers : mockReconcileChatStreamMarkers ,
4848} ) )
4949
5050vi . mock ( '@/lib/copilot/tasks' , ( ) => ( {
@@ -77,7 +77,19 @@ describe('GET /api/mothership/chats', () => {
7777 mockFrom . mockReturnValue ( { where : mockWhere } )
7878 mockSelect . mockReturnValue ( { from : mockFrom } )
7979
80- mockGetActiveChatStreamIds . mockResolvedValue ( new Set < string > ( ) )
80+ mockReconcileChatStreamMarkers . mockImplementation (
81+ async ( candidates : Array < { chatId : string ; streamId : string | null } > ) =>
82+ new Map (
83+ candidates . map ( ( candidate ) => [
84+ candidate . chatId ,
85+ {
86+ chatId : candidate . chatId ,
87+ streamId : candidate . streamId ,
88+ status : candidate . streamId ? 'active' : 'inactive' ,
89+ } ,
90+ ] )
91+ )
92+ )
8193 } )
8294
8395 it ( 'clears activeStreamId on chats whose redis lock has expired (stuck-yellow bug)' , async ( ) => {
@@ -105,13 +117,26 @@ describe('GET /api/mothership/chats', () => {
105117 lastSeenAt : null ,
106118 } ,
107119 ] )
108- mockGetActiveChatStreamIds . mockResolvedValueOnce ( new Set ( [ 'chat-live' ] ) )
120+ mockReconcileChatStreamMarkers . mockResolvedValueOnce (
121+ new Map ( [
122+ [ 'chat-stuck' , { chatId : 'chat-stuck' , streamId : null , status : 'inactive' } ] ,
123+ [ 'chat-live' , { chatId : 'chat-live' , streamId : 'stream-live' , status : 'active' } ] ,
124+ [ 'chat-idle' , { chatId : 'chat-idle' , streamId : null , status : 'inactive' } ] ,
125+ ] )
126+ )
109127
110128 const response = await GET ( createRequest ( 'ws-1' ) )
111129 expect ( response . status ) . toBe ( 200 )
112130 const body = await response . json ( )
113131
114- expect ( mockGetActiveChatStreamIds ) . toHaveBeenCalledWith ( [ 'chat-stuck' , 'chat-live' ] )
132+ expect ( mockReconcileChatStreamMarkers ) . toHaveBeenCalledWith (
133+ [
134+ { chatId : 'chat-stuck' , streamId : 'stream-orphaned' } ,
135+ { chatId : 'chat-live' , streamId : 'stream-live' } ,
136+ { chatId : 'chat-idle' , streamId : null } ,
137+ ] ,
138+ { repairVerifiedStaleMarkers : true }
139+ )
115140 expect ( body . success ) . toBe ( true )
116141 expect ( body . data ) . toEqual ( [
117142 expect . objectContaining ( { id : 'chat-stuck' , activeStreamId : null } ) ,
@@ -120,7 +145,7 @@ describe('GET /api/mothership/chats', () => {
120145 ] )
121146 } )
122147
123- it ( 'issues no Redis MGET when no chat has a stream marker set (empty candidateIds) ' , async ( ) => {
148+ it ( 'preserves chats when no chat has a stream marker set' , async ( ) => {
124149 const now = new Date ( '2026-05-11T12:00:00Z' )
125150 mockOrderBy . mockResolvedValueOnce ( [
126151 { id : 'chat-1' , title : null , updatedAt : now , activeStreamId : null , lastSeenAt : null } ,
@@ -130,11 +155,18 @@ describe('GET /api/mothership/chats', () => {
130155 const response = await GET ( createRequest ( 'ws-1' ) )
131156 expect ( response . status ) . toBe ( 200 )
132157
133- expect ( mockGetActiveChatStreamIds ) . toHaveBeenCalledWith ( [ ] )
158+ expect ( mockReconcileChatStreamMarkers ) . toHaveBeenCalledWith (
159+ [
160+ { chatId : 'chat-1' , streamId : null } ,
161+ { chatId : 'chat-2' , streamId : null } ,
162+ ] ,
163+ { repairVerifiedStaleMarkers : true }
164+ )
134165 const body = await response . json ( )
135- expect (
136- body . data . every ( ( c : { activeStreamId : string | null } ) => c . activeStreamId === null )
137- ) . toBe ( true )
166+ expect ( body . data ) . toEqual ( [
167+ expect . objectContaining ( { id : 'chat-1' , activeStreamId : null } ) ,
168+ expect . objectContaining ( { id : 'chat-2' , activeStreamId : null } ) ,
169+ ] )
138170 } )
139171
140172 it ( 'leaves activeStreamId untouched when redis confirms every lock is live' , async ( ) => {
@@ -143,7 +175,6 @@ describe('GET /api/mothership/chats', () => {
143175 { id : 'chat-a' , title : null , updatedAt : now , activeStreamId : 'stream-a' , lastSeenAt : null } ,
144176 { id : 'chat-b' , title : null , updatedAt : now , activeStreamId : 'stream-b' , lastSeenAt : null } ,
145177 ] )
146- mockGetActiveChatStreamIds . mockResolvedValueOnce ( new Set ( [ 'chat-a' , 'chat-b' ] ) )
147178
148179 const response = await GET ( createRequest ( 'ws-1' ) )
149180 const body = await response . json ( )
@@ -154,6 +185,32 @@ describe('GET /api/mothership/chats', () => {
154185 ] )
155186 } )
156187
188+ it ( 'uses Redis lock owner when it differs from a stale activeStreamId' , async ( ) => {
189+ const now = new Date ( '2026-05-11T12:00:00Z' )
190+ mockOrderBy . mockResolvedValueOnce ( [
191+ {
192+ id : 'chat-mismatch' ,
193+ title : null ,
194+ updatedAt : now ,
195+ activeStreamId : 'stream-stale' ,
196+ lastSeenAt : null ,
197+ } ,
198+ ] )
199+ mockReconcileChatStreamMarkers . mockResolvedValueOnce (
200+ new Map ( [
201+ [ 'chat-mismatch' , { chatId : 'chat-mismatch' , streamId : 'stream-live' , status : 'active' } ] ,
202+ ] )
203+ )
204+
205+ const response = await GET ( createRequest ( 'ws-1' ) )
206+ expect ( response . status ) . toBe ( 200 )
207+ const body = await response . json ( )
208+
209+ expect ( body . data ) . toEqual ( [
210+ expect . objectContaining ( { id : 'chat-mismatch' , activeStreamId : 'stream-live' } ) ,
211+ ] )
212+ } )
213+
157214 it ( 'returns 401 when unauthenticated' , async ( ) => {
158215 copilotHttpMockFns . mockAuthenticateCopilotRequestSessionOnly . mockResolvedValueOnce ( {
159216 userId : null ,
@@ -163,6 +220,6 @@ describe('GET /api/mothership/chats', () => {
163220 const response = await GET ( createRequest ( 'ws-1' ) )
164221 expect ( response . status ) . toBe ( 401 )
165222 expect ( mockSelect ) . not . toHaveBeenCalled ( )
166- expect ( mockGetActiveChatStreamIds ) . not . toHaveBeenCalled ( )
223+ expect ( mockReconcileChatStreamMarkers ) . not . toHaveBeenCalled ( )
167224 } )
168225} )
0 commit comments