Skip to content

Commit 688739c

Browse files
authored
Merge pull request #681 from ColdBox/claude/ai-routing-thread-context
feat: conversational context (userId/conversationId/threadId) on toAi() routes
2 parents a85f7a5 + 032ddb5 commit 688739c

2 files changed

Lines changed: 135 additions & 10 deletions

File tree

system/web/routing/Router.cfc

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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

tests/specs/web/routing/RouterAITest.cfc

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,62 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" {
104104
} )
105105
} )
106106

107+
story( "I want conversational context resolution on toAi() sub-routes", function(){
108+
beforeEach( function(){
109+
makePublic( router, "resolveAiContext" )
110+
} )
111+
112+
given( "no userId in the request body", function(){
113+
then( "it defaults to the controller's session identifier", function(){
114+
controller.$( "getUserSessionIdentifier" ).$results( "mock-session-id" )
115+
var ctx = router.resolveAiContext( {} )
116+
expect( ctx.userId ).toBe( "mock-session-id" )
117+
} )
118+
} )
119+
120+
given( "a userId in the request body", function(){
121+
then( "it is passed through untouched", function(){
122+
var ctx = router.resolveAiContext( { "userId" : "explicit-user" } )
123+
expect( ctx.userId ).toBe( "explicit-user" )
124+
} )
125+
} )
126+
127+
given( "no conversationId in the request body", function(){
128+
then( "the result carries no conversationId key at all", function(){
129+
var ctx = router.resolveAiContext( {} )
130+
expect( ctx ).notToHaveKey( "conversationId" )
131+
} )
132+
} )
133+
134+
given( "a conversationId in the request body", function(){
135+
then( "it is passed through untouched", function(){
136+
var ctx = router.resolveAiContext( { "conversationId" : "conv-42" } )
137+
expect( ctx.conversationId ).toBe( "conv-42" )
138+
} )
139+
} )
140+
141+
given( "no threadId in the request body", function(){
142+
then( "a new one is generated and always present in the result", function(){
143+
var ctx = router.resolveAiContext( {} )
144+
expect( ctx.threadId ).toBeString()
145+
expect( ctx.threadId ).notToBeEmpty()
146+
} )
147+
148+
then( "two separate calls generate two different thread ids", function(){
149+
var first = router.resolveAiContext( {} )
150+
var second = router.resolveAiContext( {} )
151+
expect( first.threadId ).notToBe( second.threadId )
152+
} )
153+
} )
154+
155+
given( "a threadId in the request body", function(){
156+
then( "it is passed through untouched, not regenerated", function(){
157+
var ctx = router.resolveAiContext( { "threadId" : "thread-99" } )
158+
expect( ctx.threadId ).toBe( "thread-99" )
159+
} )
160+
} )
161+
} )
162+
107163
story( "I want argument validation on toAi()", function(){
108164
given( "a numeric value as runnable", function(){
109165
then( "it should throw InvalidArgumentException", function(){

0 commit comments

Comments
 (0)