@@ -16,6 +16,7 @@ const {
1616 mockGenerateId,
1717 mockRequestExplicitStreamAbort,
1818 mockResolveBillingAttribution,
19+ mockResolveOrCreateChat,
1920 mockRunHeadlessCopilotLifecycle,
2021} = vi . hoisted ( ( ) => ( {
2122 MockV2ApiKeyUnauthenticatedError : class MockV2ApiKeyUnauthenticatedError extends Error { } ,
@@ -35,6 +36,7 @@ const {
3536 mockCheckPreAuthRate : vi . fn ( ) ,
3637 mockGenerateId : vi . fn ( ) ,
3738 mockResolveBillingAttribution : vi . fn ( ) ,
39+ mockResolveOrCreateChat : vi . fn ( ) ,
3840 mockRequestExplicitStreamAbort : vi . fn ( ) . mockResolvedValue ( undefined ) ,
3941 mockRunHeadlessCopilotLifecycle : vi . fn ( ) ,
4042} ) )
@@ -78,6 +80,10 @@ vi.mock('@/lib/copilot/chat/workspace-context', () => ({
7880 generateWorkspaceContext : vi . fn ( ) . mockResolvedValue ( 'workspace context' ) ,
7981} ) )
8082
83+ vi . mock ( '@/lib/copilot/chat/lifecycle' , ( ) => ( {
84+ resolveOrCreateChat : mockResolveOrCreateChat ,
85+ } ) )
86+
8187vi . mock ( '@/lib/copilot/chat/payload' , ( ) => ( {
8288 buildIntegrationToolSchemas : vi . fn ( ) . mockResolvedValue ( [ { name : 'run_workflow' } ] ) ,
8389} ) )
@@ -111,6 +117,17 @@ const personalAuth = {
111117 keyType : 'personal' ,
112118}
113119
120+ /**
121+ * The route never echoes the caller's string back as the conversation id: it
122+ * reports whatever the owner-scoped resolver returns.
123+ */
124+ const SERVER_ISSUED_CHAT_ID = 'chat-server-1'
125+ const OWNED_CONVERSATION_ID = '11111111-1111-4111-8111-111111111111'
126+
127+ function chatRow ( id : string ) {
128+ return { id, userId : 'user-1' , workspaceId : 'workspace-1' , workflowId : null , type : 'mothership' }
129+ }
130+
114131const successResult = {
115132 success : true ,
116133 content : 'Hello there' ,
@@ -144,6 +161,12 @@ describe('POST /api/v2/chat', () => {
144161 mockResolveBillingAttribution . mockResolvedValue ( billingAttributionSnapshot )
145162 mockRequestExplicitStreamAbort . mockResolvedValue ( undefined )
146163 mockRunHeadlessCopilotLifecycle . mockResolvedValue ( successResult )
164+ mockResolveOrCreateChat . mockResolvedValue ( {
165+ chatId : SERVER_ISSUED_CHAT_ID ,
166+ chat : chatRow ( SERVER_ISSUED_CHAT_ID ) ,
167+ conversationHistory : [ ] ,
168+ isNew : true ,
169+ } )
147170 } )
148171
149172 it ( 'rejects a missing or invalid API key' , async ( ) => {
@@ -187,15 +210,15 @@ describe('POST /api/v2/chat', () => {
187210 expect ( mockRunHeadlessCopilotLifecycle ) . not . toHaveBeenCalled ( )
188211 } )
189212
190- it ( 'runs one turn and answers the reply with a generated conversation id' , async ( ) => {
213+ it ( 'runs one turn and answers the reply with a server-issued conversation id' , async ( ) => {
191214 const response = await callChat ( { workspaceId : 'workspace-1' , message : 'hi' } )
192215
193216 expect ( response . status ) . toBe ( 200 )
194217 const body = await response . json ( )
195218 expect ( body . data ) . toEqual ( {
196219 content : 'Hello there' ,
197- model : 'mothership ' ,
198- conversationId : 'generated-1' ,
220+ model : 'sim ' ,
221+ conversationId : SERVER_ISSUED_CHAT_ID ,
199222 tokens : { prompt : 10 , completion : 5 , total : 15 } ,
200223 cost : { total : 0.01 } ,
201224 toolCalls : [ { name : 'run_workflow' } ] ,
@@ -206,7 +229,7 @@ describe('POST /api/v2/chat', () => {
206229 messages : [ { role : 'user' , content : 'hi' } ] ,
207230 userId : 'user-1' ,
208231 workspaceId : 'workspace-1' ,
209- chatId : 'generated-1' ,
232+ chatId : SERVER_ISSUED_CHAT_ID ,
210233 mode : 'agent' ,
211234 isHosted : true ,
212235 workspaceContext : 'workspace context' ,
@@ -216,7 +239,7 @@ describe('POST /api/v2/chat', () => {
216239 expect ( options ) . toMatchObject ( {
217240 userId : 'user-1' ,
218241 workspaceId : 'workspace-1' ,
219- chatId : 'generated-1' ,
242+ chatId : SERVER_ISSUED_CHAT_ID ,
220243 goRoute : '/api/mothership/execute' ,
221244 autoExecuteTools : true ,
222245 interactive : false ,
@@ -230,17 +253,88 @@ describe('POST /api/v2/chat', () => {
230253 } )
231254 } )
232255
233- it ( 'continues the conversation the caller names' , async ( ) => {
256+ it ( 'mints a server-issued conversation when the caller names none' , async ( ) => {
257+ const response = await callChat ( { workspaceId : 'workspace-1' , message : 'hi' } )
258+
259+ expect ( response . status ) . toBe ( 200 )
260+ const body = await response . json ( )
261+ expect ( body . data . conversationId ) . toBe ( SERVER_ISSUED_CHAT_ID )
262+ const resolverInput = mockResolveOrCreateChat . mock . calls [ 0 ] [ 0 ] as Record < string , unknown >
263+ expect ( Object . hasOwn ( resolverInput , 'chatId' ) ) . toBe ( false )
264+ expect ( resolverInput ) . toMatchObject ( {
265+ userId : 'user-1' ,
266+ workspaceId : 'workspace-1' ,
267+ type : 'mothership' ,
268+ } )
269+ } )
270+
271+ it ( 'resolves a named conversation against the calling user and workspace before continuing it' , async ( ) => {
272+ mockResolveOrCreateChat . mockResolvedValue ( {
273+ chatId : OWNED_CONVERSATION_ID ,
274+ chat : chatRow ( OWNED_CONVERSATION_ID ) ,
275+ conversationHistory : [ ] ,
276+ isNew : false ,
277+ } )
278+
234279 const response = await callChat ( {
235280 workspaceId : 'workspace-1' ,
236281 message : 'and then?' ,
237- conversationId : 'conv-9' ,
282+ conversationId : OWNED_CONVERSATION_ID ,
238283 } )
239284
240285 expect ( response . status ) . toBe ( 200 )
286+ expect ( mockResolveOrCreateChat ) . toHaveBeenCalledWith (
287+ expect . objectContaining ( {
288+ chatId : OWNED_CONVERSATION_ID ,
289+ userId : 'user-1' ,
290+ workspaceId : 'workspace-1' ,
291+ } )
292+ )
293+ const body = await response . json ( )
294+ expect ( body . data . conversationId ) . toBe ( OWNED_CONVERSATION_ID )
295+ expect ( mockRunHeadlessCopilotLifecycle . mock . calls [ 0 ] [ 0 ] ) . toMatchObject ( {
296+ chatId : OWNED_CONVERSATION_ID ,
297+ } )
298+ } )
299+
300+ it ( 'answers 404 and runs nothing when the resolver refuses the named conversation' , async ( ) => {
301+ mockResolveOrCreateChat . mockResolvedValue ( {
302+ chatId : OWNED_CONVERSATION_ID ,
303+ chat : null ,
304+ conversationHistory : [ ] ,
305+ isNew : false ,
306+ } )
307+
308+ const response = await callChat ( {
309+ workspaceId : 'workspace-1' ,
310+ message : 'and then?' ,
311+ conversationId : OWNED_CONVERSATION_ID ,
312+ } )
313+
314+ expect ( response . status ) . toBe ( 404 )
241315 const body = await response . json ( )
242- expect ( body . data . conversationId ) . toBe ( 'conv-9' )
243- expect ( mockRunHeadlessCopilotLifecycle . mock . calls [ 0 ] [ 0 ] ) . toMatchObject ( { chatId : 'conv-9' } )
316+ expect ( body . error . code ) . toBe ( 'NOT_FOUND' )
317+ expect ( mockResolveOrCreateChat ) . toHaveBeenCalledWith (
318+ expect . objectContaining ( {
319+ chatId : OWNED_CONVERSATION_ID ,
320+ userId : 'user-1' ,
321+ workspaceId : 'workspace-1' ,
322+ } )
323+ )
324+ // No tokens may be billed against an id the caller could not be given.
325+ expect ( mockRunHeadlessCopilotLifecycle ) . not . toHaveBeenCalled ( )
326+ } )
327+
328+ it ( 'rejects a malformed conversation id before resolving anything' , async ( ) => {
329+ const response = await callChat ( {
330+ workspaceId : 'workspace-1' ,
331+ message : 'and then?' ,
332+ conversationId : 'not-a-conversation-id' ,
333+ } )
334+
335+ expect ( response . status ) . toBe ( 400 )
336+ expect ( mockResolveOrCreateChat ) . not . toHaveBeenCalled ( )
337+ expect ( mockRunHeadlessCopilotLifecycle ) . not . toHaveBeenCalled ( )
244338 } )
245339
246340 it ( 'answers a failed run as a 500 with the run error' , async ( ) => {
@@ -282,7 +376,10 @@ describe('POST /api/v2/chat', () => {
282376 expect ( chunks . map ( ( chunk ) => chunk . content ) ) . toEqual ( [ 'Hello' , ' there' ] )
283377 const final = events . at ( - 1 ) as { type : string ; data : Record < string , unknown > }
284378 expect ( final . type ) . toBe ( 'final' )
285- expect ( final . data ) . toMatchObject ( { content : 'Hello there' , conversationId : 'generated-1' } )
379+ expect ( final . data ) . toMatchObject ( {
380+ content : 'Hello there' ,
381+ conversationId : SERVER_ISSUED_CHAT_ID ,
382+ } )
286383 } )
287384
288385 it ( 'ends the NDJSON stream with an error event when the run fails' , async ( ) => {
0 commit comments