diff --git a/command-snapshot.json b/command-snapshot.json index 39455470..935c24d9 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -299,6 +299,7 @@ "api-version", "authoring-bundle", "context-variables", + "context-variables-json", "flags-dir", "output-dir", "target-org", @@ -360,6 +361,7 @@ "api-version", "authoring-bundle", "context-variables", + "context-variables-json", "flags-dir", "json", "simulate-actions", diff --git a/messages/shared.md b/messages/shared.md index 1f95cbeb..a395e8ab 100644 --- a/messages/shared.md +++ b/messages/shared.md @@ -42,7 +42,23 @@ State variables use the bare developerName, no prefix. These seed mutable agent Both namespaces can be mixed in one value. Example: --context-variables '$Context.MyLinkedVar=foo,MyStateVar=bar'. -Tips: (1) Quote the whole value in single quotes so $Context isn't shell-expanded. (2) Names are sent verbatim — a bare name is treated as a state variable, not a linked context variable, so live actions that bind via $Context.Name will see null. (3) Type defaults to Text. +Tips: (1) Quote the whole value in single quotes so $Context isn't shell-expanded. (2) Names are sent verbatim — a bare name is treated as a state variable, not a linked context variable, so live actions that bind via $Context.Name will see null. (3) Type is always Text; to send a typed variable, use --context-variables-json. + +# flags.context-variables-json.summary + +Typed session variables for the agent preview session, as a JSON array. + +# flags.context-variables-json.description + +Sets typed variables on the agent preview session. Use this instead of --context-variables when a variable is not Text, for example a boolean-gated route (available when @variables.myFlag == True) that needs a real Boolean, or a Number, Object, List, or Json value. + +The value is a JSON array of objects, each with a "name", a "type", and an optional "value". The "type" is one of Text, Date, DateTime, Money, Ref, Boolean, Number, Object, List, or Json. The JSON type of "value" must match "type": Boolean takes a boolean, Number takes a number, the string types take a string, Object and List take an array, and Json takes an object. + +Example: --context-variables-json '[{"name":"probeGate","type":"Boolean","value":true},{"name":"retryCount","type":"Number","value":3}]'. + +You can pass both --context-variables and --context-variables-json in the same command. When the same variable name appears in both, the --context-variables-json value wins. + +Tip: names follow the same rules as --context-variables. Use the "$Context." prefix for linked context variables, and a bare name for state variables. # error.invalidAgentType diff --git a/src/commands/agent/preview.ts b/src/commands/agent/preview.ts index a5c1d96f..e54eb109 100644 --- a/src/commands/agent/preview.ts +++ b/src/commands/agent/preview.ts @@ -23,7 +23,13 @@ import { select } from '@inquirer/prompts'; import { Lifecycle, Messages, SfError } from '@salesforce/core'; import { AgentPreviewReact } from '../../components/agent-preview-react.js'; import { loadAgentJson } from '../../common.js'; -import { contextVariablesFlag, parseContextVariables } from '../../flags.js'; +import { + contextVariablesFlag, + contextVariablesJsonFlag, + mergeContextVariables, + parseContextVariables, + parseContextVariablesJson, +} from '../../flags.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview'); @@ -72,6 +78,7 @@ export default class AgentPreview extends SfCommand { default: false, }), 'context-variables': contextVariablesFlag, + 'context-variables-json': contextVariablesJsonFlag, 'agent-json': Flags.file({ summary: messages.getMessage('flags.agent-json.summary'), hidden: true, @@ -144,7 +151,10 @@ export default class AgentPreview extends SfCommand { selectedAgent.preview.setApexDebugging(flags['apex-debug']); - const contextVariables = parseContextVariables(flags['context-variables']); + const contextVariables = mergeContextVariables( + parseContextVariables(flags['context-variables']), + parseContextVariablesJson(flags['context-variables-json']) + ); const instance = render( React.createElement(AgentPreviewReact, { diff --git a/src/commands/agent/preview/start.ts b/src/commands/agent/preview/start.ts index 3dbe3449..2f40b3b7 100644 --- a/src/commands/agent/preview/start.ts +++ b/src/commands/agent/preview/start.ts @@ -19,7 +19,13 @@ import { EnvironmentVariable, Lifecycle, Messages, SfError } from '@salesforce/c import { Agent, ProductionAgent, ScriptAgent } from '@salesforce/agents'; import { createCache, SessionType } from '../../../previewSessionStore.js'; import { COMPILATION_API_EXIT_CODES, loadAgentJson } from '../../../common.js'; -import { contextVariablesFlag, parseContextVariables } from '../../../flags.js'; +import { + contextVariablesFlag, + contextVariablesJsonFlag, + mergeContextVariables, + parseContextVariables, + parseContextVariablesJson, +} from '../../../flags.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.preview.start'); @@ -70,6 +76,7 @@ export default class AgentPreviewStart extends SfCommandboolean, Number->number, + * string types->string, Object/List->array, Json->object). `value` is optional and + * nullable, so undefined/null pass. + */ +function validateContextVariableValue(name: string, type: ContextVariableType, value: unknown): void { + if (value === undefined || value === null) return; + const reject = (expected: string): never => { + throw new SfError( + `Invalid --context-variables-json: variable "${name}" of type "${type}" expects ${expected}, but got ${describeJsonValue( + value + )}.` + ); + }; + if (type === 'Boolean' && typeof value !== 'boolean') reject('a boolean value'); + else if (type === 'Number' && typeof value !== 'number') reject('a number value'); + else if (STRING_CONTEXT_VARIABLE_TYPES.includes(type) && typeof value !== 'string') reject('a string value'); + else if ((type === 'Object' || type === 'List') && !Array.isArray(value)) reject('an array value'); + else if (type === 'Json' && (typeof value !== 'object' || Array.isArray(value))) reject('a JSON object value'); +} + +function toContextVariable(entry: unknown, index: number): ContextVariable { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new SfError( + `Invalid --context-variables-json: entry at index ${index} must be an object with "name" and "type" (and optionally "value").` + ); + } + const { name, type, value } = entry as Record; + if (typeof name !== 'string' || name.trim() === '') { + throw new SfError(`Invalid --context-variables-json: entry at index ${index} is missing a non-empty "name".`); + } + if (typeof type !== 'string' || !CONTEXT_VARIABLE_TYPES.includes(type as ContextVariableType)) { + throw new SfError( + `Invalid --context-variables-json: variable "${name}" has invalid type "${String( + type + )}". Expected one of: ${CONTEXT_VARIABLE_TYPES.join(', ')}.` + ); + } + validateContextVariableValue(name, type as ContextVariableType, value); + return { name, type, value } as ContextVariable; +} + +const CONTEXT_VARIABLES_JSON_EXAMPLE = '[{"name":"probeGate","type":"Boolean","value":true}]'; + +/** + * Parses the --context-variables-json flag: a JSON array of typed context variables + * ({ name, type, value }) matching the preview API's Variable schema. Throws an + * SfError with a specific reason on malformed JSON, a non-array, or a bad entry. + */ +export function parseContextVariablesJson(raw: string | undefined): ContextVariable[] { + if (raw === undefined || raw.trim() === '') return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new SfError( + `Invalid --context-variables-json: value is not valid JSON. Expected a JSON array, e.g. ${CONTEXT_VARIABLES_JSON_EXAMPLE}.` + ); + } + if (!Array.isArray(parsed)) { + throw new SfError( + `Invalid --context-variables-json: expected a JSON array, e.g. ${CONTEXT_VARIABLES_JSON_EXAMPLE}.` + ); + } + return parsed.map(toContextVariable); +} + +/** + * Merges the text-form (--context-variables) and JSON-form (--context-variables-json) + * context variables into one array. When the same name appears in both, the JSON entry + * wins, keeping the text entry's original position. + */ +export function mergeContextVariables( + textVariables: ContextVariable[], + jsonVariables: ContextVariable[] +): ContextVariable[] { + const byName = new Map(); + for (const variable of textVariables) byName.set(variable.name, variable); + for (const variable of jsonVariables) byName.set(variable.name, variable); + return [...byName.values()]; +} + /** * Parses raw "Name=Value" entries from --context-variables into ContextVariable - * objects for the SDK. Type defaults to "Text" — the only empirically-observed - * variant on the wire today. + * objects for the SDK. Type is always "Text"; to send a typed variable + * (Boolean/Number/Object/List/Json) use --context-variables-json instead. * * Names pass through verbatim. The runtime distinguishes two namespaces by name * shape: "$Context." for linked context variables, bare "" diff --git a/test/flags.test.ts b/test/flags.test.ts index c69c6033..e97ce805 100644 --- a/test/flags.test.ts +++ b/test/flags.test.ts @@ -18,7 +18,13 @@ import { join, relative } from 'node:path'; import { mkdir, writeFile, rm } from 'node:fs/promises'; import { expect } from 'chai'; import { SfError } from '@salesforce/core'; -import { getHiddenDirs, parseContextVariables, traverseForFiles } from '../src/flags.js'; +import { + getHiddenDirs, + mergeContextVariables, + parseContextVariables, + parseContextVariablesJson, + traverseForFiles, +} from '../src/flags.js'; describe('traverseForFiles', () => { const testDir = join(process.cwd(), 'test-temp'); @@ -155,3 +161,130 @@ describe('parseContextVariables', () => { expect(() => parseContextVariables(['=value'])).to.throw(SfError, /Name cannot be empty/); }); }); + +describe('parseContextVariablesJson', () => { + it('returns [] for undefined', () => { + expect(parseContextVariablesJson(undefined)).to.deep.equal([]); + }); + + it('returns [] for empty/whitespace string', () => { + expect(parseContextVariablesJson('')).to.deep.equal([]); + expect(parseContextVariablesJson(' ')).to.deep.equal([]); + }); + + it('parses a Boolean with a native boolean value', () => { + expect(parseContextVariablesJson('[{"name":"probeGate","type":"Boolean","value":true}]')).to.deep.equal([ + { name: 'probeGate', type: 'Boolean', value: true }, + ]); + }); + + it('parses a Number with a native number value', () => { + expect(parseContextVariablesJson('[{"name":"retryCount","type":"Number","value":3}]')).to.deep.equal([ + { name: 'retryCount', type: 'Number', value: 3 }, + ]); + }); + + it('parses the string-valued types', () => { + const json = + '[{"name":"a","type":"Text","value":"hi"},{"name":"b","type":"Date","value":"2026-08-27"},{"name":"c","type":"Ref","value":"1M5"}]'; + expect(parseContextVariablesJson(json)).to.deep.equal([ + { name: 'a', type: 'Text', value: 'hi' }, + { name: 'b', type: 'Date', value: '2026-08-27' }, + { name: 'c', type: 'Ref', value: '1M5' }, + ]); + }); + + it('parses Object/List (arrays) and Json (object) values', () => { + const json = + '[{"name":"o","type":"Object","value":[{"name":"inner","type":"Text","value":"x"}]},{"name":"l","type":"List","value":[{"type":"ref","value":"1M5"}]},{"name":"j","type":"Json","value":{"a":1}}]'; + expect(parseContextVariablesJson(json)).to.deep.equal([ + { name: 'o', type: 'Object', value: [{ name: 'inner', type: 'Text', value: 'x' }] }, + { name: 'l', type: 'List', value: [{ type: 'ref', value: '1M5' }] }, + { name: 'j', type: 'Json', value: { a: 1 } }, + ]); + }); + + it('allows an omitted value (optional)', () => { + expect(parseContextVariablesJson('[{"name":"x","type":"Boolean"}]')).to.deep.equal([ + { name: 'x', type: 'Boolean', value: undefined }, + ]); + }); + + it('allows a null value (nullable)', () => { + expect(parseContextVariablesJson('[{"name":"x","type":"Boolean","value":null}]')).to.deep.equal([ + { name: 'x', type: 'Boolean', value: null }, + ]); + }); + + it('throws SfError on malformed JSON', () => { + expect(() => parseContextVariablesJson('not json')).to.throw(SfError, /not valid JSON/); + }); + + it('throws SfError when the top level is not an array', () => { + expect(() => parseContextVariablesJson('{"name":"x","type":"Text"}')).to.throw(SfError, /expected a JSON array/); + }); + + it('throws SfError when an entry is not an object', () => { + expect(() => parseContextVariablesJson('["x"]')).to.throw(SfError, /must be an object/); + }); + + it('throws SfError when an entry has no non-empty name', () => { + expect(() => parseContextVariablesJson('[{"type":"Text","value":"x"}]')).to.throw(SfError, /non-empty "name"/); + expect(() => parseContextVariablesJson('[{"name":" ","type":"Text"}]')).to.throw(SfError, /non-empty "name"/); + }); + + it('throws SfError on an unknown type', () => { + expect(() => parseContextVariablesJson('[{"name":"x","type":"Bogus","value":"y"}]')).to.throw( + SfError, + /invalid type "Bogus"/ + ); + }); + + it('throws SfError when the value type does not match the declared type', () => { + expect(() => parseContextVariablesJson('[{"name":"x","type":"Boolean","value":"true"}]')).to.throw( + SfError, + /type "Boolean" expects a boolean value, but got a string/ + ); + expect(() => parseContextVariablesJson('[{"name":"x","type":"Number","value":"3"}]')).to.throw( + SfError, + /type "Number" expects a number value/ + ); + expect(() => parseContextVariablesJson('[{"name":"x","type":"Text","value":3}]')).to.throw( + SfError, + /type "Text" expects a string value/ + ); + expect(() => parseContextVariablesJson('[{"name":"x","type":"Object","value":{}}]')).to.throw( + SfError, + /type "Object" expects an array value/ + ); + expect(() => parseContextVariablesJson('[{"name":"x","type":"Json","value":[]}]')).to.throw( + SfError, + /type "Json" expects a JSON object value/ + ); + }); +}); + +describe('mergeContextVariables', () => { + it('returns text-only variables when no JSON variables', () => { + const text = parseContextVariables(['a=1']); + expect(mergeContextVariables(text, [])).to.deep.equal([{ name: 'a', type: 'Text', value: '1' }]); + }); + + it('appends JSON-only variables after text variables', () => { + const text = parseContextVariables(['a=1']); + const json = parseContextVariablesJson('[{"name":"b","type":"Number","value":2}]'); + expect(mergeContextVariables(text, json)).to.deep.equal([ + { name: 'a', type: 'Text', value: '1' }, + { name: 'b', type: 'Number', value: 2 }, + ]); + }); + + it('lets the JSON variable win on a duplicate name, keeping the text position', () => { + const text = parseContextVariables(['flag=True', 'keep=x']); + const json = parseContextVariablesJson('[{"name":"flag","type":"Boolean","value":true}]'); + expect(mergeContextVariables(text, json)).to.deep.equal([ + { name: 'flag', type: 'Boolean', value: true }, + { name: 'keep', type: 'Text', value: 'x' }, + ]); + }); +});