@@ -27,6 +27,8 @@ const {
2727 getPendingChatStreamId,
2828 releasePendingChatStream,
2929 resolveOrCreateChat,
30+ finalizeAssistantTurn,
31+ mockPublishStatusChanged,
3032} = vi . hoisted ( ( ) => ( {
3133 getEffectiveDecryptedEnv : vi . fn ( ) ,
3234 generateWorkspaceContext : vi . fn ( ) ,
@@ -38,6 +40,8 @@ const {
3840 getPendingChatStreamId : vi . fn ( ) ,
3941 releasePendingChatStream : vi . fn ( ) ,
4042 resolveOrCreateChat : vi . fn ( ) ,
43+ finalizeAssistantTurn : vi . fn ( ) ,
44+ mockPublishStatusChanged : vi . fn ( ) ,
4145} ) )
4246
4347const getSession = authMockFns . mockGetSession
@@ -78,9 +82,13 @@ vi.mock('@/lib/copilot/chat/lifecycle', () => ({
7882 resolveOrCreateChat,
7983} ) )
8084
85+ vi . mock ( '@/lib/copilot/chat/terminal-state' , ( ) => ( {
86+ finalizeAssistantTurn,
87+ } ) )
88+
8189vi . mock ( '@/lib/copilot/tasks' , ( ) => ( {
8290 taskPubSub : {
83- publishStatusChanged : vi . fn ( ) ,
91+ publishStatusChanged : mockPublishStatusChanged ,
8492 } ,
8593} ) )
8694
@@ -137,6 +145,13 @@ describe('handleUnifiedChatPost', () => {
137145 conversationHistory : [ ] ,
138146 isNew : true ,
139147 } )
148+ finalizeAssistantTurn . mockResolvedValue ( {
149+ found : true ,
150+ updated : true ,
151+ appendedAssistant : true ,
152+ workspaceId : 'ws-1' ,
153+ outcome : 'appended_assistant' ,
154+ } )
140155 } )
141156
142157 it ( 'routes workflow-attached chat requests through the copilot backend path' , async ( ) => {
@@ -176,6 +191,7 @@ describe('handleUnifiedChatPost', () => {
176191 body : JSON . stringify ( {
177192 message : 'Hello' ,
178193 workspaceId : 'ws-1' ,
194+ createNewChat : true ,
179195 } ) ,
180196 } )
181197 )
@@ -205,6 +221,90 @@ describe('handleUnifiedChatPost', () => {
205221 )
206222 } )
207223
224+ it ( 'persists cancelled partial responses from the server lifecycle' , async ( ) => {
225+ await handleUnifiedChatPost (
226+ new NextRequest ( 'http://localhost/api/copilot/chat' , {
227+ method : 'POST' ,
228+ body : JSON . stringify ( {
229+ message : 'Hello' ,
230+ workspaceId : 'ws-1' ,
231+ createNewChat : true ,
232+ } ) ,
233+ } )
234+ )
235+
236+ const streamArgs = createSSEStream . mock . calls [ 0 ] ?. [ 0 ]
237+ const onComplete = streamArgs ?. orchestrateOptions ?. onComplete
238+ expect ( onComplete ) . toBeTypeOf ( 'function' )
239+
240+ await onComplete ( {
241+ success : false ,
242+ cancelled : true ,
243+ content : 'partial answer' ,
244+ contentBlocks : [ ] ,
245+ toolCalls : [ ] ,
246+ chatId : 'chat-1' ,
247+ requestId : 'request-1' ,
248+ } )
249+
250+ expect ( finalizeAssistantTurn ) . toHaveBeenCalledWith (
251+ expect . objectContaining ( {
252+ chatId : 'chat-1' ,
253+ userMessageId : expect . any ( String ) ,
254+ streamMarkerPolicy : 'active-or-cleared' ,
255+ assistantMessage : expect . objectContaining ( {
256+ role : 'assistant' ,
257+ content : 'partial answer' ,
258+ contentBlocks : expect . arrayContaining ( [
259+ expect . objectContaining ( { type : 'complete' , status : 'cancelled' } ) ,
260+ ] ) ,
261+ } ) ,
262+ } )
263+ )
264+ } )
265+
266+ it ( 'republishes completed status when cancelled lifecycle persistence already ran' , async ( ) => {
267+ await handleUnifiedChatPost (
268+ new NextRequest ( 'http://localhost/api/copilot/chat' , {
269+ method : 'POST' ,
270+ body : JSON . stringify ( {
271+ message : 'Hello' ,
272+ workspaceId : 'ws-1' ,
273+ createNewChat : true ,
274+ } ) ,
275+ } )
276+ )
277+
278+ const streamArgs = createSSEStream . mock . calls [ 0 ] ?. [ 0 ]
279+ const onComplete = streamArgs ?. orchestrateOptions ?. onComplete
280+ expect ( onComplete ) . toBeTypeOf ( 'function' )
281+
282+ finalizeAssistantTurn . mockResolvedValueOnce ( {
283+ found : true ,
284+ updated : false ,
285+ appendedAssistant : false ,
286+ workspaceId : 'ws-1' ,
287+ outcome : 'assistant_already_persisted' ,
288+ } )
289+
290+ await onComplete ( {
291+ success : false ,
292+ cancelled : true ,
293+ content : 'partial answer' ,
294+ contentBlocks : [ ] ,
295+ toolCalls : [ ] ,
296+ chatId : 'chat-1' ,
297+ requestId : 'request-1' ,
298+ } )
299+
300+ expect ( mockPublishStatusChanged ) . toHaveBeenCalledWith ( {
301+ workspaceId : 'ws-1' ,
302+ chatId : 'chat-1' ,
303+ type : 'completed' ,
304+ streamId : streamArgs ?. streamId ,
305+ } )
306+ } )
307+
208308 it ( 'rejects requests that have neither workflow nor workspace attachment' , async ( ) => {
209309 const response = await handleUnifiedChatPost (
210310 new NextRequest ( 'http://localhost/api/copilot/chat' , {
0 commit comments