From 1e8f95a4ab262562327cc2764b96e42b99c175f9 Mon Sep 17 00:00:00 2001 From: xentristech Date: Sun, 2 Aug 2026 14:26:49 -0500 Subject: [PATCH] fix(conversational-skills): JS SDK fails on Node 22+ and leaks slot state 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 `_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. --- .../src/mock-server/app.js | 56 +++++++++++++++++-- .../src/sdk/LanguageManager.js | 7 ++- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/conversational-skills/procode-skill-sdk-js/src/mock-server/app.js b/conversational-skills/procode-skill-sdk-js/src/mock-server/app.js index f12c0858..2e7c436d 100644 --- a/conversational-skills/procode-skill-sdk-js/src/mock-server/app.js +++ b/conversational-skills/procode-skill-sdk-js/src/mock-server/app.js @@ -16,7 +16,10 @@ app.use(bodyParser.json()); // routes and api calls -const bluepointsSkill = await createBluepointsSkill('en'); +// NOTE: the skill is built per request, not once at module load. `Skill` holds +// `slotsInFlight`, and its `Slot` objects are mutated during orchestration (e.g. +// `slotInFlight.setError`). A single shared instance leaks that mutated state into +// every later turn -- across sessions and across users. /** * This function lists the skills available by your provider. It will be called by the Assistant UI when selecting skills to create a code-based action. @@ -96,16 +99,61 @@ function getSkill(req,res,next) { res.json(response); } +/** + * Restores slot visibility and entity schemas that earlier turns produced. + * + * Because the skill is rebuilt per request, anything a previous turn did to a Slot + * object (`show()`, `schema = ...`) is gone. watsonx round-trips `state` back to us + * on every turn, so that is where such decisions have to live. This mirrors what the + * Java SDK already does in SkillOrchestrator.initializeSlotHandlers, via the same + * `visible_slots` and `_schema` keys. + */ +function rehydrateSlots(skill, state) { + const localVars = state?.local_variables || {}; + + for (const slotName of localVars.visible_slots || []) { + const slot = skill.slotsInFlight?.find(slotName); + if (!slot) continue; + slot.show(); + const schema = localVars[`${slotName}_schema`]; + if (schema) slot.schema = schema; + } +} + +/** + * Persists the inverse of rehydrateSlots, so the next turn can restore it. + */ +function persistSlots(skillResponse) { + const slots = skillResponse.output?.generic?.find(item => item.response_type === 'slots')?.slots; + if (!slots || !skillResponse.state) return; + + skillResponse.state.local_variables ??= {}; + skillResponse.state.local_variables.visible_slots = slots.map(slot => slot.name); + for (const slot of slots) { + if (slot.schema) skillResponse.state.local_variables[`${slot.name}_schema`] = slot.schema; + } +} + /** * This function implements the runtime contract between watsonx Assistant and the Provider * It follows the OAS specification /orchestrate Operation. See https://github.com/watson-developer-cloud/assistant-toolkit/blob/master/conversational-skills/procode-endpoints.md#oas * @param req - This is an express request. The `body` property adheres to the schema defined in the OAS specification `OrchestrationRequest` component * @param res - This is an express response. The json returned should adhere to the schema defined in the OAS specification `OrchestrationResponse` component - * @param next + * @param next */ async function orchestrate(req,res,next) { - const skillResponse = await bluepointsSkill.orchestrate({input:req.body.input,context:req.body.context, slots:req.body.slots, state: req.body.state, confirmation_event: req.body.confirmation_event}); - res.json(skillResponse); + try { + const bluepointsSkill = await createBluepointsSkill(req.body.context?.global?.language || 'en'); + rehydrateSlots(bluepointsSkill, req.body.state); + const skillResponse = await bluepointsSkill.orchestrate({input:req.body.input,context:req.body.context, slots:req.body.slots, state: req.body.state, confirmation_event: req.body.confirmation_event}); + persistSlots(skillResponse); + res.json(skillResponse); + } catch (error) { + // Without this, a throw inside the skill becomes an unhandled rejection and + // takes the whole process down -- killing every other user's session too. + console.error('orchestrate failed:', error); + res.status(500).json({error: error.message}); + } } diff --git a/conversational-skills/procode-skill-sdk-js/src/sdk/LanguageManager.js b/conversational-skills/procode-skill-sdk-js/src/sdk/LanguageManager.js index 877bbc36..e2e2fa82 100644 --- a/conversational-skills/procode-skill-sdk-js/src/sdk/LanguageManager.js +++ b/conversational-skills/procode-skill-sdk-js/src/sdk/LanguageManager.js @@ -36,7 +36,7 @@ export class LanguageManager { // Try to dynamically import the language bundle try { - const bundle = (await import(`${this.basePath}/${scenario}/${lang}.json`, { assert: { type: 'json' } })).default; + const bundle = (await import(`${this.basePath}/${scenario}/${lang}.json`, { with: { type: 'json' } })).default; // Cache the loaded bundle if (!this.cache[scenario]) { @@ -50,7 +50,10 @@ export class LanguageManager { if (lang !== 'en') { return this.loadLanguageBundle(scenario, 'en'); } - throw new Error(`Language bundle not found for scenario: ${scenario}, language: ${lang}`); + // Keep the original error as the cause. Anything can land here -- a syntax + // error in the JSON, an unsupported import attribute -- and reporting all of + // them as "not found" sends you looking for a file that is right there. + throw new Error(`Language bundle not found for scenario: ${scenario}, language: ${lang}`, { cause: error }); } }