Skip to content

Fix {{state.x}} AI-prompt templating always resolving to empty string #242

Description

@droarty

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:

  1. {{state.x}} resolves via getArtifactStatemakeDeps({ 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).
  2. No fetch when channel has no artifact — mock getChannelContext to return { workflowType: 'test-workflow' } (no artifactId); assert getArtifactState is not called and nothing throws.
  3. 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).
  4. 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

  1. 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).
  2. 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: "".
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions