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' }, + ]); + }); +}); diff --git a/test/mock-projects/agent-generate-template/force-app/main/default/aiAuthoringBundles/Willie_Resort_Manager/Willie_Resort_Manager.agent b/test/mock-projects/agent-generate-template/force-app/main/default/aiAuthoringBundles/Willie_Resort_Manager/Willie_Resort_Manager.agent index 58a840a4..56594677 100644 --- a/test/mock-projects/agent-generate-template/force-app/main/default/aiAuthoringBundles/Willie_Resort_Manager/Willie_Resort_Manager.agent +++ b/test/mock-projects/agent-generate-template/force-app/main/default/aiAuthoringBundles/Willie_Resort_Manager/Willie_Resort_Manager.agent @@ -25,6 +25,21 @@ variables: description: "This variable may also be referred to as MessagingSession EndUserLanguage" VerifiedCustomerId: mutable string description: "This variable may also be referred to as VerifiedCustomerId" + NutProbeText: mutable string = "" + description: "Test-only External var: a Text value injected via preview context variables." + visibility: "External" + NutProbeBool: mutable boolean = False + description: "Test-only External var: a Boolean value injected via preview context variables." + visibility: "External" + NutProbeNum: mutable number = 0 + description: "Test-only External var: a Number value injected via preview context variables." + visibility: "External" + NutProbeObj: mutable object = {} + description: "Test-only External var: a JSON object injected via preview context variables." + visibility: "External" + NutProbeList: mutable list[string] = [] + description: "Test-only External var: a List value injected via preview context variables." + visibility: "External" language: default_locale: "en_US" diff --git a/test/nuts/z0.agent.create.nut.ts b/test/nuts/z0.agent.create.nut.ts index 9f50c892..9b0b3edf 100644 --- a/test/nuts/z0.agent.create.nut.ts +++ b/test/nuts/z0.agent.create.nut.ts @@ -15,13 +15,13 @@ */ import { join } from 'node:path'; -import { readdirSync, statSync } from 'node:fs'; +import { appendFileSync, readdirSync, statSync } from 'node:fs'; import { expect } from 'chai'; import { genUniqueString, TestSession } from '@salesforce/cli-plugins-testkit'; import { execCmd } from '@salesforce/cli-plugins-testkit'; import type { AgentCreateSpecResult } from '../../src/commands/agent/generate/agent-spec.js'; import type { AgentCreateResult } from '../../src/commands/agent/create.js'; -import { getTestSession, getUsername } from './shared-setup.js'; +import { getAgentUsername, getTestSession, getUsername } from './shared-setup.js'; /* eslint-disable no-console */ @@ -68,6 +68,18 @@ describe('agent create', function () { const expectedFilePath = join(session.project.dir, 'specs', specFileName); const name = 'Plugin Agent Test'; const apiName = 'Plugin_Agent_Test'; + + // Reuse the Bot User pre-provisioned in shared setup (via `org create agent-user`) instead of + // letting core auto-create one during the save. The 'customer' agentType maps to core's + // EinsteinServiceAgent, which requires a licensed Bot User; when no user is supplied, core creates + // that user in the SAME transaction as the BotDefinition save, and the pre-save validation trigger + // intermittently cannot see the just-created license/permset assignment, failing with "User + // doesn't have access to agent." `agent create` sets agentSettings.userId from the spec's + // `agentUser`, so writing the already-committed agent user into the spec removes that race. + const agentUser = getAgentUsername(); + expect(agentUser, 'agent user should have been provisioned in shared setup').to.be.a('string'); + appendFileSync(expectedFilePath, `\nagentUser: "${agentUser!}"\n`); + const command = `agent create --spec ${expectedFilePath} --target-org ${username} --name "${name}" --api-name ${apiName} --json`; const result = execCmd(command, { ensureExitCode: 0 }).jsonOutput?.result; expect(result).to.be.ok; diff --git a/test/nuts/z3.agent.preview.context-variables.nut.ts b/test/nuts/z3.agent.preview.context-variables.nut.ts new file mode 100644 index 00000000..014ff899 --- /dev/null +++ b/test/nuts/z3.agent.preview.context-variables.nut.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; +import { Agent } from '@salesforce/agents'; +import { Org, SfProject } from '@salesforce/core'; +import type { AgentPreviewStartResult } from '../../src/commands/agent/preview/start.js'; +import type { AgentPreviewSendResult } from '../../src/commands/agent/preview/send.js'; +import { getTestSession, getUsername } from './shared-setup.js'; + +/** + * E2E coverage for typed preview context variables (--context-variables-json), W-24014400. + * + * Goal: prove the typed flag is wired up end-to-end against a real org — each wire type + * (Text/Boolean/Number/Json/List) serializes, reaches the server, and the injected value + * lands in the session — plus the flag-merge, comma-safety, and rejection paths. This + * deliberately does NOT assert boolean-gated routing behavior (planner-dependent and + * flaky); the manual e2e-context-variables-preview.sh suite covers that. + * + * How values are observed: injected context variables surface in the session trace (the + * same mechanism the --context-variables test relies on). Each case injects a distinctive + * sentinel value and asserts it appears in the trace after a send. + * + * Fixture: Willie_Resort_Manager declares test-only External vars NutProbeText (Text), + * NutProbeBool (Boolean), NutProbeNum (Number), NutProbeObj (Json object), NutProbeList + * (List), plus the pre-existing Internal var VerifiedCustomerId (no visibility) used by the + * server-rejection case. + */ +describe('agent preview --context-variables-json', function () { + this.timeout(30 * 60 * 1000); // 30 minutes (shared setup deploys + waits on Einstein) + + const bundle = 'Willie_Resort_Manager'; + let session: TestSession; + + before(async function () { + this.timeout(30 * 60 * 1000); + session = await getTestSession(); + }); + + // Start a simulate-mode session with the given raw flag string; returns the session id. + function startSession(flagArgs: string): string { + const org = getUsername(); + const res = execCmd( + `agent preview start --authoring-bundle ${bundle} --simulate-actions ${flagArgs} --target-org ${org} --json`, + { ensureExitCode: 0, cwd: session.project.dir } + ).jsonOutput?.result; + expect(res?.sessionId, 'session should start').to.be.a('string'); + return res!.sessionId; + } + + // Send one message, then return every trace serialized to a single searchable string. + async function sendAndCollectTraces(sessionId: string, utterance = 'hello'): Promise { + const sendRes = execCmd( + `agent preview send --session-id ${sessionId} --authoring-bundle ${bundle} --utterance "${utterance}" --target-org ${getUsername()} --json`, + { ensureExitCode: 0, cwd: session.project.dir } + ).jsonOutput?.result; + expect(sendRes?.messages).to.be.an('array').with.length.greaterThan(0); + + const org = await Org.create({ aliasOrUsername: getUsername() }); + const project = await SfProject.resolve(session.project.dir); + const agent = await Agent.init({ connection: org.getConnection(), project, aabName: bundle }); + agent.setSessionId(sessionId); + const traces = await agent.preview.getAllTraces(); + return JSON.stringify(traces); + } + + function endSession(sessionId: string): void { + execCmd( + `agent preview end --session-id ${sessionId} --authoring-bundle ${bundle} --target-org ${getUsername()} --json`, + { cwd: session.project.dir } + ); + } + + it('CV1: all wire types (Text/Number/Json/List/Boolean) round-trip in one session', async function () { + this.timeout(5 * 60 * 1000); + const TEXT = 'NUTCV-TEXT-7f3a9b'; + const NUM = 8_675_309; + const JSON_TAG = 'NUTCV-JSON-4d21c8'; + const LIST_ELEM = 'NUTCV-LIST-b58c1e'; + const payload = JSON.stringify([ + { name: 'NutProbeText', type: 'Text', value: TEXT }, + { name: 'NutProbeNum', type: 'Number', value: NUM }, + { name: 'NutProbeBool', type: 'Boolean', value: true }, + { name: 'NutProbeObj', type: 'Json', value: { tag: JSON_TAG } }, + { name: 'NutProbeList', type: 'List', value: [LIST_ELEM] }, + ]); + + const sessionId = startSession(`--context-variables-json '${payload}'`); + const haystack = await sendAndCollectTraces(sessionId); + + expect(haystack, 'Text value should reach the session').to.include(TEXT); + expect(haystack, 'Number value should reach the session').to.include(String(NUM)); + expect(haystack, 'Json object value should reach the session').to.include(JSON_TAG); + expect(haystack, 'List element should reach the session').to.include(LIST_ELEM); + expect(haystack, 'Boolean variable should be present in the session').to.include('NutProbeBool'); + + endSession(sessionId); + }); + + it('CV2: --context-variables-json wins over --context-variables on a duplicate name', async function () { + this.timeout(5 * 60 * 1000); + const OLD = 'NUTCV-OLD-LOSES'; + const NEW = 'NUTCV-NEW-WINS'; + const sessionId = startSession( + `--context-variables "NutProbeText=${OLD}" --context-variables-json '[{"name":"NutProbeText","type":"Text","value":"${NEW}"}]'` + ); + const haystack = await sendAndCollectTraces(sessionId); + + expect(haystack, 'JSON value should win').to.include(NEW); + expect(haystack, 'text value should have been overridden').to.not.include(OLD); + + endSession(sessionId); + }); + + it('CV3: a comma inside a value survives the JSON flag', async function () { + this.timeout(5 * 60 * 1000); + const COMMA_VALUE = 'c0,c1,c2'; + const sessionId = startSession( + `--context-variables-json '[{"name":"NutProbeText","type":"Text","value":"${COMMA_VALUE}"}]'` + ); + const haystack = await sendAndCollectTraces(sessionId); + + expect(haystack, 'comma-bearing value should round-trip intact').to.include(COMMA_VALUE); + + endSession(sessionId); + }); + + it('CV4: the old --context-variables flag mangles a comma value (control)', () => { + // The old flag splits on "," so "a,b,c" becomes 3 tokens; the 2nd has no "=". + const result = execCmd( + `agent preview start --authoring-bundle ${bundle} --simulate-actions --context-variables "NutProbeText=a,b,c" --target-org ${getUsername()} --json`, + { ensureExitCode: 1, cwd: session.project.dir } + ); + expect(JSON.stringify(result.shellOutput)).to.include('Expected Name=Value'); + }); + + it('CV5: the server rejects an Internal variable', () => { + // VerifiedCustomerId has no visibility -> Internal -> not settable via preview. + const result = execCmd( + `agent preview start --authoring-bundle ${bundle} --simulate-actions --context-variables-json '[{"name":"VerifiedCustomerId","type":"Text","value":"x"}]' --target-org ${getUsername()} --json`, + { cwd: session.project.dir } + ); + expect(result.shellOutput.code, 'command should fail').to.not.equal(0); + expect(JSON.stringify(result.shellOutput)).to.include('Internal'); + }); + + it('CV6: a value whose JSON type mismatches its declared type is rejected client-side', () => { + const result = execCmd( + `agent preview start --authoring-bundle ${bundle} --simulate-actions --context-variables-json '[{"name":"NutProbeBool","type":"Boolean","value":"true"}]' --target-org ${getUsername()} --json`, + { ensureExitCode: 1, cwd: session.project.dir } + ); + expect(JSON.stringify(result.shellOutput)).to.include('expects a boolean value'); + }); +});