Fix {{state.x}} AI-prompt templating always resolving to empty
Context
Reported bug: in artifact "my fifth poem" (custom abab-poem-lesson workflow, built via the workflow-builder), the check-poem handler's AI step has systemPrompt containing {{state.poemDraft}}. The logged, fully-resolved prompt (workflowlogs._id: 6a4ec66181d48ed01b963fdd) shows Poem draft: "" even though the artifact's persisted state.poemDraft was non-empty at that time — confirmed directly against MongoDB (artifacts._id: 6a4ec58481d48ed01b963fb5).
Root-caused via direct code tracing (not a timing race, despite the two relevant messages — sync-poem-draft then check-poem — landing only 517ms apart in the logs): WorkflowContext.state, the object {{state.x}} templating resolves against, is never populated anywhere in the live message-processing path. EventProcessorWorker.ts:205 calls engine.execute({ message, user, permissionLevel }, ...) with no state field at all, and WorkflowEngine.executeStep's recursive database-query/parallel-queries continuations only forward context.state (state: context.state), never derive it from a DB read. So context.state is undefined for every handler invocation, substitutePromptTemplate resolves state.poemDraft to undefined, and per its if (value == null) return ''; rule, silently substitutes an empty string. This affects every workflow using {{state.x}} as documented in docs/workflow-reference/ai-step-configuration.md:32 — it's a general engine bug, not specific to this one config. (add-text's {{state.chatMessages}} in the same config is equally broken, just less noticeable since the chat coach still responds plausibly without it.)
Critical related finding: the generic $-sigil resolver (resolveValue in WorkflowEngine.ts, used for transform values including update-state action path/value fields) also resolves any $xxx.yyy string against context[xxx] — including $state.foo and $temp.bar. Today this is harmless only because context.state/context.temp are always undefined, so the resolver's rootObj == null branch returns the literal string unchanged (confirmed by the existing test '$state.* preserved as-is (not server-resolved)', WorkflowEngine.spec.ts:321). DatabasePersistor.ts:48-52 depends on this: it reads action['path'] as a literal string and does path.startsWith('$state.') to compute the Mongo write path. If context.state is populated without also excluding state/temp roots from this sigil resolver, every update-state action's path field (e.g. "$state.chatMessages") would be silently resolved into the live value at that path instead of staying a literal path string — corrupting action.path into an array/object and breaking DatabasePersistor (and the equivalent client-side path handling) for every database-route step across the entire app. This is confirmed as intentional-but-currently-accidental behavior by the docs' sigil table (docs/workflow-reference/summary.md:35-40), which lists only $message.x, $user.x, $uuid as server-resolved — $state.x/$temp.x are documented as action.path destinations only, never listed as server-resolved values.
So the correct fix has two parts, both in apps/api/src/app/websocket/WorkflowEngine.ts + EventProcessorWorker.ts.
Design
Part 1 — populate context.state from the live artifact document
apps/api/src/app/websocket/WorkflowEngine.ts:
- Add to
WorkflowEngineDeps (around line 99, alongside getChannelContext):
getArtifactState?: (artifactId: string) => Promise<Record<string, unknown> | null>;
- In
execute() (around line 220, where enrichedContext is built from channelCtx), fetch state once per top-level execution chain — only when the caller hasn't already supplied one (recursive database-query/parallel-queries continuations already pass state: context.state through unchanged, so this naturally fetches exactly once per incoming message, before any of that message's handler steps run):
const state = context.state ?? (channelCtx.artifactId && this.deps.getArtifactState
? (await this.deps.getArtifactState(channelCtx.artifactId)) ?? undefined
: context.state);
const enrichedContext: WorkflowContext = {
...context,
state,
groupId: context.groupId ?? channelCtx.groupId,
parentChannel: context.parentChannel ?? channelCtx.parentChannelId,
targetChannelId: context.targetChannelId ?? channelCtx.targetChannelId,
};
Fetching before any steps in the handler run (rather than re-fetching per step) is also semantically correct for existing prompts like add-text's "Chat history so far: {{state.chatMessages}}" — that prompt intentionally wants the state before the current step's own database-route append of the new message, to avoid duplicating "what they just said" ({{message.text}}) with the freshly-appended entry.
apps/api/src/app/websocket/EventProcessorWorker.ts:
- Add a new function alongside
getChannelContext (~line 50):
async function getArtifactState(artifactId: string): Promise<Record<string, unknown> | null> {
try {
await dbReady;
const doc = await mongoClient.db().collection('artifacts')
.findOne({ _id: new ObjectId(artifactId) }, { projection: { state: 1 } });
return (doc?.['state'] as Record<string, unknown> | undefined) ?? null;
} catch {
return null;
}
}
- Wire it into the
engine = new WorkflowEngine({ ... }, configDir) deps object (~line 161-185), alongside the existing getChannelContext.
No userId-ownership filter on the findOne — consistent with computeChannelAccessLevel's existing unrestricted artifacts.findOne({_id: ...}) in the same file; access is already gated separately via permissionLevel/requiredAccess, not via a raw ownership WHERE.
Part 2 — exclude state/temp roots from the $-sigil resolver (required for safety)
apps/api/src/app/websocket/WorkflowEngine.ts, in resolveValue's $-branch (~line 129-136):
if (value.startsWith('$')) {
if (value === '$uuid') return randomUUID();
const [root, ...rest] = value.slice(1).split('.');
if (root === 'state' || root === 'temp') return value; // action.path destinations — never server-resolved
const rootObj = (context as unknown as Record<string, unknown>)[root] as Record<string, unknown>;
if (rootObj == null) return value;
if (rest.length === 0) return rootObj;
return resolveDotPath(rootObj, rest.join('.'));
}
This is a zero-behavior-change guard for existing configs (the outcome — literal passthrough — is identical to today's rootObj == null fallback) that becomes load-bearing once context.state is actually populated by Part 1. It does not affect substitutePromptTemplate's {{state.x}} handling (a separate function, only used for AI prompt interpolation, never for action.path), nor JSONata ~{ state.x } conditions (evaluated directly against the full context object, bypassing this sigil branch entirely) — both of these should and now will correctly resolve live state.
Tests (apps/api/src/app/websocket/WorkflowEngine.spec.ts)
Add to WORKFLOW_CONFIG:
'state-prompt-message': {
steps: [{ route: 'ai', ai: { model: 'claude-haiku-4-5-20251001', maxTokens: 64, systemPrompt: 'Draft: {{state.poemDraft}}' } }],
},
New tests:
{{state.x}} resolves via getArtifactState — makeDeps({ getArtifactState: jest.fn().mockResolvedValue({ poemDraft: 'hello world' }) }), execute 'state-prompt-message', assert sendToAi was called with a systemPrompt containing 'Draft: hello world', and that getArtifactState was called with 'art-1' (the artifactId from the default getChannelContext mock).
- No fetch when channel has no artifact — mock
getChannelContext to return { workflowType: 'test-workflow' } (no artifactId); assert getArtifactState is not called and nothing throws.
- No re-fetch when
context.state is already provided — construct a context manually with state: { poemDraft: 'existing' } set, execute, assert getArtifactState is not called (only relevant for recursive continuations, but exercises the guard directly).
- Regression guard —
$state.x/$temp.x stay literal even when state is populated — reuse the existing 'state-path-message' handler (transform: { ref: '$state.foo', tmp: '$temp.bar' }) but this time with getArtifactState mocked to return a real object (e.g. { foo: 'should-not-leak' }); assert out['ref'] === '$state.foo' and out['tmp'] === '$temp.bar' exactly as before — proving Part 2 prevents the action.path corruption described above.
Verification
npx nx test api — confirm new and existing WorkflowEngine.spec.ts tests pass (especially the existing '$state.* preserved as-is' test and the new regression guard).
- Restart servers (
pnpm run restart:both), open the existing "my fifth poem" artifact (or a fresh abab-poem-lesson artifact), type a 4-line draft into the writing area (wait ~10s for the throttled sync-poem-draft to land), click "Check My Poem", and confirm via db.workflowlogs (most recent check-poem / route: 'ai' entry) that resolvedMessage.systemPrompt now contains the actual typed poem text instead of Poem draft: "".
- Spot-check that a
database-route write still persists correctly afterward (e.g. type more text, confirm state.poemDraft in db.artifacts updates as expected) — proving Part 2's $state.x literal-preservation guard didn't regress persistence.
Fix
{{state.x}}AI-prompt templating always resolving to emptyContext
Reported bug: in artifact "my fifth poem" (custom
abab-poem-lessonworkflow, built via the workflow-builder), thecheck-poemhandler's AI step hassystemPromptcontaining{{state.poemDraft}}. The logged, fully-resolved prompt (workflowlogs._id: 6a4ec66181d48ed01b963fdd) showsPoem draft: ""even though the artifact's persistedstate.poemDraftwas non-empty at that time — confirmed directly against MongoDB (artifacts._id: 6a4ec58481d48ed01b963fb5).Root-caused via direct code tracing (not a timing race, despite the two relevant messages —
sync-poem-draftthencheck-poem— landing only 517ms apart in the logs):WorkflowContext.state, the object{{state.x}}templating resolves against, is never populated anywhere in the live message-processing path.EventProcessorWorker.ts:205callsengine.execute({ message, user, permissionLevel }, ...)with nostatefield at all, andWorkflowEngine.executeStep's recursivedatabase-query/parallel-queriescontinuations only forwardcontext.state(state: context.state), never derive it from a DB read. Socontext.stateisundefinedfor every handler invocation,substitutePromptTemplateresolvesstate.poemDrafttoundefined, and per itsif (value == null) return '';rule, silently substitutes an empty string. This affects every workflow using{{state.x}}as documented indocs/workflow-reference/ai-step-configuration.md:32— it's a general engine bug, not specific to this one config. (add-text's{{state.chatMessages}}in the same config is equally broken, just less noticeable since the chat coach still responds plausibly without it.)Critical related finding: the generic
$-sigil resolver (resolveValueinWorkflowEngine.ts, used fortransformvalues includingupdate-stateactionpath/valuefields) also resolves any$xxx.yyystring againstcontext[xxx]— including$state.fooand$temp.bar. Today this is harmless only becausecontext.state/context.tempare alwaysundefined, so the resolver'srootObj == nullbranch returns the literal string unchanged (confirmed by the existing test'$state.* preserved as-is (not server-resolved)',WorkflowEngine.spec.ts:321).DatabasePersistor.ts:48-52depends on this: it readsaction['path']as a literal string and doespath.startsWith('$state.')to compute the Mongo write path. Ifcontext.stateis populated without also excludingstate/temproots from this sigil resolver, everyupdate-stateaction'spathfield (e.g."$state.chatMessages") would be silently resolved into the live value at that path instead of staying a literal path string — corruptingaction.pathinto an array/object and breakingDatabasePersistor(and the equivalent client-side path handling) for everydatabase-route step across the entire app. This is confirmed as intentional-but-currently-accidental behavior by the docs' sigil table (docs/workflow-reference/summary.md:35-40), which lists only$message.x,$user.x,$uuidas server-resolved —$state.x/$temp.xare documented asaction.pathdestinations only, never listed as server-resolved values.So the correct fix has two parts, both in
apps/api/src/app/websocket/WorkflowEngine.ts+EventProcessorWorker.ts.Design
Part 1 — populate
context.statefrom the live artifact documentapps/api/src/app/websocket/WorkflowEngine.ts:WorkflowEngineDeps(around line 99, alongsidegetChannelContext):execute()(around line 220, whereenrichedContextis built fromchannelCtx), fetch state once per top-level execution chain — only when the caller hasn't already supplied one (recursivedatabase-query/parallel-queriescontinuations already passstate: context.statethrough unchanged, so this naturally fetches exactly once per incoming message, before any of that message's handler steps run):add-text's"Chat history so far: {{state.chatMessages}}"— that prompt intentionally wants the state before the current step's owndatabase-route append of the new message, to avoid duplicating "what they just said" ({{message.text}}) with the freshly-appended entry.apps/api/src/app/websocket/EventProcessorWorker.ts:getChannelContext(~line 50):engine = new WorkflowEngine({ ... }, configDir)deps object (~line 161-185), alongside the existinggetChannelContext.No
userId-ownership filter on thefindOne— consistent withcomputeChannelAccessLevel's existing unrestrictedartifacts.findOne({_id: ...})in the same file; access is already gated separately viapermissionLevel/requiredAccess, not via a raw ownershipWHERE.Part 2 — exclude
state/temproots from the$-sigil resolver (required for safety)apps/api/src/app/websocket/WorkflowEngine.ts, inresolveValue's$-branch (~line 129-136):This is a zero-behavior-change guard for existing configs (the outcome — literal passthrough — is identical to today's
rootObj == nullfallback) that becomes load-bearing oncecontext.stateis actually populated by Part 1. It does not affectsubstitutePromptTemplate's{{state.x}}handling (a separate function, only used for AI prompt interpolation, never foraction.path), nor JSONata~{ state.x }conditions (evaluated directly against the full context object, bypassing this sigil branch entirely) — both of these should and now will correctly resolve live state.Tests (
apps/api/src/app/websocket/WorkflowEngine.spec.ts)Add to
WORKFLOW_CONFIG:New tests:
{{state.x}}resolves viagetArtifactState—makeDeps({ getArtifactState: jest.fn().mockResolvedValue({ poemDraft: 'hello world' }) }), execute'state-prompt-message', assertsendToAiwas called with asystemPromptcontaining'Draft: hello world', and thatgetArtifactStatewas called with'art-1'(theartifactIdfrom the defaultgetChannelContextmock).getChannelContextto return{ workflowType: 'test-workflow' }(noartifactId); assertgetArtifactStateis not called and nothing throws.context.stateis already provided — construct a context manually withstate: { poemDraft: 'existing' }set, execute, assertgetArtifactStateis not called (only relevant for recursive continuations, but exercises the guard directly).$state.x/$temp.xstay literal even when state is populated — reuse the existing'state-path-message'handler (transform: { ref: '$state.foo', tmp: '$temp.bar' }) but this time withgetArtifactStatemocked to return a real object (e.g.{ foo: 'should-not-leak' }); assertout['ref'] === '$state.foo'andout['tmp'] === '$temp.bar'exactly as before — proving Part 2 prevents theaction.pathcorruption described above.Verification
npx nx test api— confirm new and existingWorkflowEngine.spec.tstests pass (especially the existing'$state.* preserved as-is'test and the new regression guard).pnpm run restart:both), open the existing "my fifth poem" artifact (or a freshabab-poem-lessonartifact), type a 4-line draft into the writing area (wait ~10s for the throttledsync-poem-draftto land), click "Check My Poem", and confirm viadb.workflowlogs(most recentcheck-poem/route: 'ai'entry) thatresolvedMessage.systemPromptnow contains the actual typed poem text instead ofPoem draft: "".database-route write still persists correctly afterward (e.g. type more text, confirmstate.poemDraftindb.artifactsupdates as expected) — proving Part 2's$state.xliteral-preservation guard didn't regress persistence.