From b414b4d7300b98bf5079a1a75f06257ef3680dc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 02:44:57 +0000 Subject: [PATCH 1/2] feat: conversational context (userId/conversationId/threadId) on toAi() routes toAi() previously only forwarded input/params/options to the runnable, with no way to carry conversational identity across a multi-turn exchange. Adds a shared resolveAiContext() used by the invoke/stream/batch sub-routes: - userId: request body's userId if provided, else Controller's own request/session tracking identifier (getUserSessionIdentifier()) - conversationId: passed through only if the caller supplies one - no default is generated - threadId: passed through if supplied, otherwise generated - always returned to the caller (JSON response on invoke/batch, X-Thread-Id header on all three, plus a leading SSE "thread" frame on stream, since EventSource clients can't read response headers) so a follow-up call can continue the same thread All three resolved values are merged into the options struct passed to the runnable's run()/stream() calls, so a handler implementation sees them at options.userId/options.conversationId/options.threadId. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR --- system/web/routing/Router.cfc | 81 ++++++++++++++++++++++-- tests/specs/web/routing/RouterAITest.cfc | 56 ++++++++++++++++ 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index 65855b7a3..fa5f47c4a 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,18 @@ component "response" : ( event, rc, prc ) => { var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable var body = event.getHTTPContent( json: true ) + var aiContext = resolveAiContext( body ) var result = runnableInstance.run( body.input ?: {}, body.params ?: {}, - body.options ?: {} + ( body.options ?: {} ).append( aiContext, true ) ) - return { "output" : result, "success" : true } + event.setHTTPHeader( name = "X-Thread-Id", value = aiContext.threadId ) + return { + "output" : result, + "success" : true, + "threadId" : aiContext.threadId + } } } ) @@ -2458,8 +2484,19 @@ 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 ?: {} ).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 +2505,7 @@ component }, body.input ?: {}, body.params ?: {}, - body.options ?: {} + options ); if ( !emitter.isClosed() ) { emitter.send( "[DONE]", "done" ); @@ -2503,10 +2540,12 @@ component var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable var body = event.getHTTPContent( json: true ) var params = body.params ?: {} - var options = body.options ?: {} + var aiContext = resolveAiContext( body ) + var options = ( body.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(){ From 032ddb5ba36dc99c65ebd842aec7967300350921 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 02:53:27 +0000 Subject: [PATCH 2/2] fix: Adobe ColdFusion parser crash on chained .append() in toAi() routes Adobe's parser cannot handle a member method call chained directly onto a parenthesized expression - ( body.options ?: {} ).append( aiContext, true ) crashed the compiler on adobe@2023/adobe@2025 CI with "Invalid CFML construct". Same category of ACF parser limitation already hit once in this file this session (array literals instead of a parenthesized Elvis expression this time). Fixed by assigning to a local var first, then calling .append() on the var, in all three sub-routes (invoke/stream/batch). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR --- system/web/routing/Router.cfc | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index fa5f47c4a..dd525dfe9 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -2449,11 +2449,9 @@ component var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable var body = event.getHTTPContent( json: true ) var aiContext = resolveAiContext( body ) - var result = runnableInstance.run( - body.input ?: {}, - body.params ?: {}, - ( body.options ?: {} ).append( aiContext, true ) - ) + 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, @@ -2485,7 +2483,8 @@ component var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable; var body = event.getHTTPContent( json: true ); var aiContext = resolveAiContext( body ); - var options = ( body.options ?: {} ).append( aiContext, true ); + 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 ); @@ -2541,8 +2540,9 @@ component var body = event.getHTTPContent( json: true ) var params = body.params ?: {} var aiContext = resolveAiContext( body ) - var options = ( body.options ?: {} ).append( aiContext, true ) - var inputs = body.inputs ?: [] + var options = body.options ?: {} + options.append( aiContext, true ) + var inputs = body.inputs ?: [] // Map the incoming outputs - context is resolved once per request and shared // by every item in the batch, same as params/options already are.