Skip to content

fix(conversational-skills): JS pro-code SDK fails to start on Node 22+ and leaks slot state across sessions - #333

Open
xentristech wants to merge 1 commit into
watson-developer-cloud:masterfrom
xentristech:fix/procode-js-sdk-node22-and-state-leak
Open

fix(conversational-skills): JS pro-code SDK fails to start on Node 22+ and leaks slot state across sessions#333
xentristech wants to merge 1 commit into
watson-developer-cloud:masterfrom
xentristech:fix/procode-js-sdk-node22-and-state-leak

Conversation

@xentristech

Copy link
Copy Markdown

Summary

The JS pro-code skill mock server does not start on Node 22+, and once it does, it leaks slot state between sessions and users. This fixes both, plus a crash that takes down the whole process.

All 46 existing jest tests still pass.

Verified on Windows 11, Node v24.14.1, npm 11.11.0.


1. Server does not start on Node >= 22

LanguageManager loads i18n bundles with import assertions:

await import(`...${lang}.json`, { assert: { type: 'json' } })

Node 22 removed assert: in favour of with:. On Node 24 the import throws:

TypeError: Module "...en.json" needs an import attribute of "type: json"

The catch then replaces that with a misleading message, so the reported failure is:

Error: Language bundle not found for scenario: ., language: en

The bundle is there. I went looking for a missing file for a while before checking what the real exception was.

Fixed: assert: -> with:, and the fallback error now carries the original as cause so the next person sees what actually happened.

Note: with: requires Node >= 18.20 / 20.10. If the SDK needs to support older
runtimes, fs.readFile + JSON.parse would work everywhere. Happy to switch.


2. Slot state leaks across sessions and users

app.js built the skill once at module load:

const bluepointsSkill = await createBluepointsSkill('en');

Skill holds slotsInFlight, Skill.orchestrate passes that same reference into every
SkillResponse, and the bluepoints handler mutates it:

slotInFlight.setError = slotInFlight.errorTemplate;   // BluepointsSkillFactory.js

SlotsInFlight._slots holds Slot instances, so that mutation is permanent and global.

Reproduction against the unmodified server (mock balance is 1500):

# request validation_error
1 session A asks for 200 absent, correct
2 session A asks for 5000 present, correct
3 session A asks for 200 present — never cleared
4 session B, different user, asks for 200 present — never did anything wrong

From step 2 onward every request to the server carries that error forever.

Fixed: build the skill per request, which is what the factory was already for.

Side benefit: it now honours context.global.language, so the de / es / fr / ja / pt_br
bundles are actually reachable. Previously everything was hardcoded to en.


3. Per-request instances need the visibility to be persisted

Fixing #2 alone breaks the multi-turn flow, because slot visibility only survived
because of the shared mutation.

Measured: user types "kim" (ambiguous), the selector slot appears. If on the next turn they
ignore the selector and answer the other prompt instead, the selector disappears — while
recipientLookupResult is still sitting in state. The ambiguity is never resolved, recipient
stays "kim", and onConfirm then reads a recipient that does not exist.

The Java SDK already solves this: SkillOrchestrator.initializeSlotHandlers persists
visible_slots and <slot>_schema into local_variables and rehydrates them each request.
watsonx round-trips state on every turn, so that is where the decision belongs.

Fixed: rehydrateSlots / persistSlots in app.js, using the same keys as the Java SDK.

These arguably belong in Skill.orchestrate rather than in the mock server, so every JS
provider gets the behaviour instead of reimplementing it. I kept the change in app.js to
avoid redesigning the SDK in a bug-fix PR — glad to move it if you would prefer that.


4. One exception kills the process

Skill.orchestrate logs and rethrows. The express handler was async with no try/catch
and no next(err), so the throw became an unhandled rejection and Node 24 terminated the
process. One malformed request took the server down for every other user.

Fixed: try/catch returning 500.


Verified end to end after the changes

turn input result
2 "kim" (ambiguous) selector offers Kim Blatt / Kimberley Clark
3 picks "Kim Blatt" recipient resolved into local_variables
4 "200" within balance, no error
5 confirms skill_complete + "Bluepoints transfer successful."
cancels skill_cancel + "No rush, you can transfer bluepoints anytime you want."

Cross-session check re-run after the fix: session B is no longer contaminated by session A.


Not addressed here

  • Skill.js line 92 passes conversation_memory to onLLMPassThru, but that identifier is
    not a parameter of orchestrate and is not in scope — that branch throws ReferenceError
    as soon as it is used. I did not guess at whether it should come from context or state.
  • The selector stays visible after the user has picked from it, so the disambiguation is
    re-offered on later turns. That looked like demo business logic rather than a clear defect,
    so I left it alone.
  • npm test invokes node_modules/.bin/jest, a bash script, which fails on Windows.
    Workaround: node --experimental-vm-modules node_modules/jest/bin/jest.js.

I authored all of the code in this PR and have the right to contribute it.
This is not part of an IBM offering or a contracted services engagement.

Investigation and implementation were done with AI assistance (Claude). Every claim above
was verified by running the code — the reproduction tables are measured output, not inferred.

…tate

The pro-code mock server does not start on Node 22+, and once it does it
leaks slot state between sessions and users.

- LanguageManager used import assertions (`assert:`), removed in Node 22.
  Switched to `with:`, and the fallback error now keeps the original as
  `cause` -- it previously reported every failure as "bundle not found",
  which sends you looking for a file that is present.

- app.js built the skill once at module load. Skill.orchestrate hands that
  same slotsInFlight reference to every response and the handler mutates it,
  so a validation error raised by one user was served to every later request,
  in every session. Build the skill per request instead. This also makes
  context.global.language reachable, so the non-English bundles are usable.

- Per-request instances need slot visibility persisted, which the shared
  mutation had been providing by accident. Added rehydrateSlots/persistSlots
  using the same `visible_slots` and `<slot>_schema` keys the Java SDK
  already uses in SkillOrchestrator.initializeSlotHandlers.

- An exception in orchestrate became an unhandled rejection and terminated
  the process, so one malformed request took the server down for everyone.
  Wrapped in try/catch returning 500.

All 46 existing jest tests still pass. Verified on Node v24.14.1.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant