feat: conversational context (userId/conversationId/threadId) on toAi() routes - #681
Merged
Merged
Conversation
…() 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCmPkBvNZZhU6iuDcG4NR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
toAi()works great for single-shot inference but had no way to carry conversational identity across a multi-turn exchange - nouserId,conversationId, orthreadId. This adds all three to theinvoke/stream/batchsub-routestoAi()registers.Jira Issues
COLDBOX-1417
Type of change
Checklist
What changed
A new shared
resolveAiContext( body )private method onRouter.cfc, called by all three oftoAi()'s runnable-invoking sub-routes:userId- the request body'suserIdif provided, else the framework's ownController.getUserSessionIdentifier()(session id, or a cookie/URL-token-based tracking id as a fallback, or a per-request id as a last resort) - so every call is attributable to someone even when the caller doesn't manage its own user identity.conversationId- passed through as-is when the caller supplies one. No default is generated - an absentconversationIdmeans the caller isn't tracking conversations, and inventing one would imply a continuity that doesn't exist.threadId- passed through if supplied, otherwise a new one is generated (createUUID()). Always present in the result, so a follow-up call can continue the same thread whether the caller supplied a threadId or a new one had to be minted.All three resolved values are merged into the
optionsstruct passed to the runnable'srun()/stream()calls (options.userId,options.conversationId,options.threadId), so anIAiRunnableimplementation sees them without any interface changes.Request/response shape
threadIdis echoed back three ways so it's usable from any client:invoke/batch)X-Thread-Idresponse header (all three sub-routes)event: threadSSE frame on/stream, sent before the runnable's own chunks - browserEventSourceclients can't read response headers, so this is the only way they learn a server-generatedthreadIdin time to persist itbatchresolves context once per request and shares it across every item ininputs[], consistent with howparams/optionsalready work for batch.Example
Files touched
system/web/routing/Router.cfc-resolveAiContext()private method; wired into theinvoke,stream, andbatchsub-route response closures registered bytoAi(); docblock updated with the new request body fields and response/header behaviortests/specs/web/routing/RouterAITest.cfc- new specs coveringresolveAiContext()'s userId default/passthrough, conversationId passthrough/absence, and threadId passthrough/generation (viamakePublic(), matching this file's existing testing conventions)Testing notes
RouterAITest.cfcextendsBaseModelTest(skip="notBoxlang", matching the existing AI/MCP routing test file) and exercisesresolveAiContext()directly viamakePublic(), with no servlet/database dependency, so it's runnable in CI as-is. The sandbox this PR was authored in has no reachable test database, so the full TestBox HTTP runner couldn't be exercised locally regardless of engine; instead,Router.cfcwas verified to still parse and load correctly after every edit (arrow-function closure bodies are parsed eagerly in CFML/BoxLang, so a syntax error in the editedinvoke/stream/batchclosures would have surfaced immediately on instantiation, even without executing them), andresolveAiContext()was confirmed present on the compiled class viagetMetadata().