diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index 65855b7a3..dd525dfe9 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -2368,6 +2368,26 @@ component * .toAi( "ChatRunnable" ); * * + * ### Conversational context (invoke/stream/batch) + * + * Alongside `input`/`params`/`options`, the request body may carry `userId`, `conversationId`, + * and `threadId`. Whatever's resolved is merged into `options` before the runnable is called + * (`options.userId`, `options.conversationId`, `options.threadId`), and `threadId` is always + * echoed back to the caller - as `threadId` on the JSON response (invoke/batch) and as an + * `X-Thread-Id` response header on all three, plus a leading `thread` SSE frame on stream (since + * EventSource clients can't read response headers): + * + * - `userId` - defaults to `Controller.getUserSessionIdentifier()` if not supplied + * - `conversationId` - passed through only if supplied; no default is generated + * - `threadId` - passed through if supplied, otherwise a new one is minted - always present in + * the response so a follow-up call can continue the same thread + * + *
+ * // POST /api/chat/invoke { "input": "hi", "threadId": "t-123" }
+ * // → runnable.run( "hi", {}, { userId: "", threadId: "t-123" } )
+ * // → { "output": ..., "success": true, "threadId": "t-123" }
+ *
+ *
* @runnable A WireBox ID string or a live IAiRunnable instance
*
* @return Router instance for chaining
@@ -2428,12 +2448,16 @@ component
"response" : ( event, rc, prc ) => {
var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable
var body = event.getHTTPContent( json: true )
- var result = runnableInstance.run(
- body.input ?: {},
- body.params ?: {},
- body.options ?: {}
- )
- return { "output" : result, "success" : true }
+ var aiContext = resolveAiContext( body )
+ var options = body.options ?: {}
+ options.append( aiContext, true )
+ var result = runnableInstance.run( body.input ?: {}, body.params ?: {}, options )
+ event.setHTTPHeader( name = "X-Thread-Id", value = aiContext.threadId )
+ return {
+ "output" : result,
+ "success" : true,
+ "threadId" : aiContext.threadId
+ }
}
} )
@@ -2458,8 +2482,20 @@ component
"response" : ( event, rc, prc ) => {
var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable;
var body = event.getHTTPContent( json: true );
+ var aiContext = resolveAiContext( body );
+ var options = body.options ?: {};
+ options.append( aiContext, true );
+
+ // Headers must go out before the stream opens
+ event.setHTTPHeader( name = "X-Thread-Id", value = aiContext.threadId );
+
SSE(
callback: ( emitter ) => {
+ // Lead with the resolved thread id - EventSource clients cannot read
+ // response headers, so this is the only way a browser caller learns a
+ // server-generated threadId in time to persist it for the next request.
+ emitter.send( { "threadId" : aiContext.threadId }, "thread" );
+
runnableInstance.stream(
( chunk ) => {
if ( !emitter.isClosed() ) {
@@ -2468,7 +2504,7 @@ component
},
body.input ?: {},
body.params ?: {},
- body.options ?: {}
+ options
);
if ( !emitter.isClosed() ) {
emitter.send( "[DONE]", "done" );
@@ -2503,10 +2539,13 @@ component
var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable
var body = event.getHTTPContent( json: true )
var params = body.params ?: {}
+ var aiContext = resolveAiContext( body )
var options = body.options ?: {}
- var inputs = body.inputs ?: []
+ options.append( aiContext, true )
+ var inputs = body.inputs ?: []
- // Map the incoming outputs
+ // Map the incoming outputs - context is resolved once per request and shared
+ // by every item in the batch, same as params/options already are.
var outputs = inputs.map( ( input ) => {
try {
return {
@@ -2517,7 +2556,8 @@ component
return { error : e.message, success : false };
}
} )
- return { "outputs" : outputs }
+ event.setHTTPHeader( name = "X-Thread-Id", value = aiContext.threadId )
+ return { "outputs" : outputs, "threadId" : aiContext.threadId }
}
} )
@@ -2583,6 +2623,35 @@ component
return this;
}
+ /**
+ * Resolve the conversational identity/thread context for an AI request - shared by the
+ * invoke/stream/batch sub-routes toAi() registers.
+ *
+ * - `userId`: the request body's `userId` if provided, else the framework's own request/session
+ * tracking identifier (`Controller.getUserSessionIdentifier()`) - so every call is attributable
+ * to *someone* even when the caller doesn't manage its own user identity.
+ * - `conversationId`: passed through as-is when provided. No default is generated - an absent
+ * conversationId means the caller isn't tracking conversations, and inventing one would imply
+ * a continuity that doesn't exist.
+ * - `threadId`: the request body's `threadId` if provided, else a freshly generated one. Always
+ * present in the result so the caller can echo it back on the next request to continue the
+ * same thread, whether they supplied it or a new one had to be minted.
+ *
+ * @body The parsed JSON request body - invoke/stream/batch all pass their raw body here
+ *
+ * @return `{ userId, threadId, conversationId? }`
+ */
+ private struct function resolveAiContext( required struct body ){
+ var ctx = {
+ "userId" : len( arguments.body.userId ?: "" ) ? arguments.body.userId : variables.controller.getUserSessionIdentifier(),
+ "threadId" : len( arguments.body.threadId ?: "" ) ? arguments.body.threadId : createUUID()
+ };
+ if ( len( arguments.body.conversationId ?: "" ) ) {
+ ctx.conversationId = arguments.body.conversationId;
+ }
+ return ctx;
+ }
+
/**
* Terminates the route to expose a BoxLang MCP (Model Context Protocol) server via HTTP.
* This delegates the entire request to the MCP server's HTTP handler, enabling MCP clients
diff --git a/tests/specs/web/routing/RouterAITest.cfc b/tests/specs/web/routing/RouterAITest.cfc
index 138a19f67..ed1aea443 100644
--- a/tests/specs/web/routing/RouterAITest.cfc
+++ b/tests/specs/web/routing/RouterAITest.cfc
@@ -104,6 +104,62 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" {
} )
} )
+ story( "I want conversational context resolution on toAi() sub-routes", function(){
+ beforeEach( function(){
+ makePublic( router, "resolveAiContext" )
+ } )
+
+ given( "no userId in the request body", function(){
+ then( "it defaults to the controller's session identifier", function(){
+ controller.$( "getUserSessionIdentifier" ).$results( "mock-session-id" )
+ var ctx = router.resolveAiContext( {} )
+ expect( ctx.userId ).toBe( "mock-session-id" )
+ } )
+ } )
+
+ given( "a userId in the request body", function(){
+ then( "it is passed through untouched", function(){
+ var ctx = router.resolveAiContext( { "userId" : "explicit-user" } )
+ expect( ctx.userId ).toBe( "explicit-user" )
+ } )
+ } )
+
+ given( "no conversationId in the request body", function(){
+ then( "the result carries no conversationId key at all", function(){
+ var ctx = router.resolveAiContext( {} )
+ expect( ctx ).notToHaveKey( "conversationId" )
+ } )
+ } )
+
+ given( "a conversationId in the request body", function(){
+ then( "it is passed through untouched", function(){
+ var ctx = router.resolveAiContext( { "conversationId" : "conv-42" } )
+ expect( ctx.conversationId ).toBe( "conv-42" )
+ } )
+ } )
+
+ given( "no threadId in the request body", function(){
+ then( "a new one is generated and always present in the result", function(){
+ var ctx = router.resolveAiContext( {} )
+ expect( ctx.threadId ).toBeString()
+ expect( ctx.threadId ).notToBeEmpty()
+ } )
+
+ then( "two separate calls generate two different thread ids", function(){
+ var first = router.resolveAiContext( {} )
+ var second = router.resolveAiContext( {} )
+ expect( first.threadId ).notToBe( second.threadId )
+ } )
+ } )
+
+ given( "a threadId in the request body", function(){
+ then( "it is passed through untouched, not regenerated", function(){
+ var ctx = router.resolveAiContext( { "threadId" : "thread-99" } )
+ expect( ctx.threadId ).toBe( "thread-99" )
+ } )
+ } )
+ } )
+
story( "I want argument validation on toAi()", function(){
given( "a numeric value as runnable", function(){
then( "it should throw InvalidArgumentException", function(){