@@ -2368,6 +2368,26 @@ component
23682368 * .toAi( "ChatRunnable" );
23692369 * </pre>
23702370 *
2371+ * ### Conversational context (invoke/stream/batch)
2372+ *
2373+ * Alongside `input`/`params`/`options`, the request body may carry `userId`, `conversationId`,
2374+ * and `threadId`. Whatever's resolved is merged into `options` before the runnable is called
2375+ * (`options.userId`, `options.conversationId`, `options.threadId`), and `threadId` is always
2376+ * echoed back to the caller - as `threadId` on the JSON response (invoke/batch) and as an
2377+ * `X-Thread-Id` response header on all three, plus a leading `thread` SSE frame on stream (since
2378+ * EventSource clients can't read response headers):
2379+ *
2380+ * - `userId` - defaults to `Controller.getUserSessionIdentifier()` if not supplied
2381+ * - `conversationId` - passed through only if supplied; no default is generated
2382+ * - `threadId` - passed through if supplied, otherwise a new one is minted - always present in
2383+ * the response so a follow-up call can continue the same thread
2384+ *
2385+ * <pre >
2386+ * // POST /api/chat/invoke { "input": "hi", "threadId": "t-123" }
2387+ * // → runnable.run( "hi", {}, { userId: "<session id>", threadId: "t-123" } )
2388+ * // → { "output": ..., "success": true, "threadId": "t-123" }
2389+ * </pre>
2390+ *
23712391 * @runnable A WireBox ID string or a live IAiRunnable instance
23722392 *
23732393 * @return Router instance for chaining
@@ -2428,12 +2448,16 @@ component
24282448 " response" : ( event , rc , prc ) = > {
24292449 var runnableInstance = isSimpleValue ( capturedRunnable ) ? getInstance ( capturedRunnable ) : capturedRunnable
24302450 var body = event .getHTTPContent ( json : true )
2431- var result = runnableInstance .run (
2432- body .input ?: {},
2433- body .params ?: {},
2434- body .options ?: {}
2435- )
2436- return { " output" : result , " success" : true }
2451+ var aiContext = resolveAiContext ( body )
2452+ var options = body .options ?: {}
2453+ options .append ( aiContext , true )
2454+ var result = runnableInstance .run ( body .input ?: {}, body .params ?: {}, options )
2455+ event .setHTTPHeader ( name = " X-Thread-Id" , value = aiContext .threadId )
2456+ return {
2457+ " output" : result ,
2458+ " success" : true ,
2459+ " threadId" : aiContext .threadId
2460+ }
24372461 }
24382462 } )
24392463
@@ -2458,8 +2482,20 @@ component
24582482 " response" : ( event , rc , prc ) = > {
24592483 var runnableInstance = isSimpleValue ( capturedRunnable ) ? getInstance ( capturedRunnable ) : capturedRunnable ;
24602484 var body = event .getHTTPContent ( json : true );
2485+ var aiContext = resolveAiContext ( body );
2486+ var options = body .options ?: {};
2487+ options .append ( aiContext , true );
2488+
2489+ // Headers must go out before the stream opens
2490+ event .setHTTPHeader ( name = " X-Thread-Id" , value = aiContext .threadId );
2491+
24612492 SSE (
24622493 callback : ( emitter ) = > {
2494+ // Lead with the resolved thread id - EventSource clients cannot read
2495+ // response headers, so this is the only way a browser caller learns a
2496+ // server-generated threadId in time to persist it for the next request.
2497+ emitter .send ( { " threadId" : aiContext .threadId }, " thread" );
2498+
24632499 runnableInstance .stream (
24642500 ( chunk ) = > {
24652501 if ( ! emitter .isClosed () ) {
@@ -2468,7 +2504,7 @@ component
24682504 },
24692505 body .input ?: {},
24702506 body .params ?: {},
2471- body . options ?: {}
2507+ options
24722508 );
24732509 if ( ! emitter .isClosed () ) {
24742510 emitter .send ( " [DONE]" , " done" );
@@ -2503,10 +2539,13 @@ component
25032539 var runnableInstance = isSimpleValue ( capturedRunnable ) ? getInstance ( capturedRunnable ) : capturedRunnable
25042540 var body = event .getHTTPContent ( json : true )
25052541 var params = body .params ?: {}
2542+ var aiContext = resolveAiContext ( body )
25062543 var options = body .options ?: {}
2507- var inputs = body .inputs ?: []
2544+ options .append ( aiContext , true )
2545+ var inputs = body .inputs ?: []
25082546
2509- // Map the incoming outputs
2547+ // Map the incoming outputs - context is resolved once per request and shared
2548+ // by every item in the batch, same as params/options already are.
25102549 var outputs = inputs .map ( ( input ) = > {
25112550 try {
25122551 return {
@@ -2517,7 +2556,8 @@ component
25172556 return { error : e .message , success : false };
25182557 }
25192558 } )
2520- return { " outputs" : outputs }
2559+ event .setHTTPHeader ( name = " X-Thread-Id" , value = aiContext .threadId )
2560+ return { " outputs" : outputs , " threadId" : aiContext .threadId }
25212561 }
25222562 } )
25232563
@@ -2583,6 +2623,35 @@ component
25832623 return this ;
25842624 }
25852625
2626+ /**
2627+ * Resolve the conversational identity/thread context for an AI request - shared by the
2628+ * invoke/stream/batch sub-routes toAi() registers.
2629+ *
2630+ * - `userId`: the request body's `userId` if provided, else the framework's own request/session
2631+ * tracking identifier (`Controller.getUserSessionIdentifier()`) - so every call is attributable
2632+ * to *someone* even when the caller doesn't manage its own user identity.
2633+ * - `conversationId`: passed through as-is when provided. No default is generated - an absent
2634+ * conversationId means the caller isn't tracking conversations, and inventing one would imply
2635+ * a continuity that doesn't exist.
2636+ * - `threadId`: the request body's `threadId` if provided, else a freshly generated one. Always
2637+ * present in the result so the caller can echo it back on the next request to continue the
2638+ * same thread, whether they supplied it or a new one had to be minted.
2639+ *
2640+ * @body The parsed JSON request body - invoke/stream/batch all pass their raw body here
2641+ *
2642+ * @return `{ userId, threadId, conversationId? }`
2643+ */
2644+ private struct function resolveAiContext ( required struct body ){
2645+ var ctx = {
2646+ " userId" : len ( arguments .body .userId ?: " " ) ? arguments .body .userId : variables .controller .getUserSessionIdentifier (),
2647+ " threadId" : len ( arguments .body .threadId ?: " " ) ? arguments .body .threadId : createUUID ()
2648+ };
2649+ if ( len ( arguments .body .conversationId ?: " " ) ) {
2650+ ctx .conversationId = arguments .body .conversationId ;
2651+ }
2652+ return ctx ;
2653+ }
2654+
25862655 /**
25872656 * Terminates the route to expose a BoxLang MCP (Model Context Protocol) server via HTTP.
25882657 * This delegates the entire request to the MCP server's HTTP handler, enabling MCP clients
0 commit comments