Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 79 additions & 10 deletions system/web/routing/Router.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -2368,6 +2368,26 @@ component
* .toAi( "ChatRunnable" );
* </pre>
*
* ### 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
*
* <pre>
* // POST /api/chat/invoke { "input": "hi", "threadId": "t-123" }
* // → runnable.run( "hi", {}, { userId: "<session id>", threadId: "t-123" } )
* // → { "output": ..., "success": true, "threadId": "t-123" }
* </pre>
*
* @runnable A WireBox ID string or a live IAiRunnable instance
*
* @return Router instance for chaining
Expand Down Expand Up @@ -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
}
}
} )

Expand All @@ -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() ) {
Expand All @@ -2468,7 +2504,7 @@ component
},
body.input ?: {},
body.params ?: {},
body.options ?: {}
options
);
if ( !emitter.isClosed() ) {
emitter.send( "[DONE]", "done" );
Expand Down Expand Up @@ -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 {
Expand All @@ -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 }
}
} )

Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions tests/specs/web/routing/RouterAITest.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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(){
Expand Down
Loading