Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@
"api-version",
"authoring-bundle",
"context-variables",
"context-variables-json",
"flags-dir",
"output-dir",
"target-org",
Expand Down Expand Up @@ -360,6 +361,7 @@
"api-version",
"authoring-bundle",
"context-variables",
"context-variables-json",
"flags-dir",
"json",
"simulate-actions",
Expand Down
18 changes: 17 additions & 1 deletion messages/shared.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 12 additions & 2 deletions src/commands/agent/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -72,6 +78,7 @@ export default class AgentPreview extends SfCommand<AgentPreviewResult> {
default: false,
}),
'context-variables': contextVariablesFlag,
'context-variables-json': contextVariablesJsonFlag,
'agent-json': Flags.file({
summary: messages.getMessage('flags.agent-json.summary'),
hidden: true,
Expand Down Expand Up @@ -144,7 +151,10 @@ export default class AgentPreview extends SfCommand<AgentPreviewResult> {

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, {
Expand Down
14 changes: 12 additions & 2 deletions src/commands/agent/preview/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -70,6 +76,7 @@ export default class AgentPreviewStart extends SfCommand<AgentPreviewStartResult
exclusive: ['use-live-actions'],
}),
'context-variables': contextVariablesFlag,
'context-variables-json': contextVariablesJsonFlag,
'agent-json': Flags.file({
summary: messages.getMessage('flags.agent-json.summary'),
hidden: true,
Expand Down Expand Up @@ -159,7 +166,10 @@ export default class AgentPreviewStart extends SfCommand<AgentPreviewStartResult
}

// Track telemetry for preview start
const contextVariables = parseContextVariables(flags['context-variables']);
const contextVariables = mergeContextVariables(
parseContextVariables(flags['context-variables']),
parseContextVariablesJson(flags['context-variables-json'])
);
let session;
try {
session = await agent.preview.start({ contextVariables });
Expand Down
127 changes: 124 additions & 3 deletions src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ import { Connection, Messages, SfError, SfProject } from '@salesforce/core';
import { camelCaseToTitleCase } from '@salesforce/kit';
import { select, input as inquirerInput } from '@inquirer/prompts';
import autocomplete from 'inquirer-autocomplete-standalone';
import { AgentTest, AgentTestResultsResponse, type ContextVariable } from '@salesforce/agents';
import {
AgentTest,
AgentTestResultsResponse,
type ContextVariable,
type ContextVariableType,
} from '@salesforce/agents';
import { theme } from './inquirer-theme.js';
import { AgentTestResultsResult } from './commands/agent/test/results.js';

Expand Down Expand Up @@ -81,10 +86,126 @@ export const contextVariablesFlag = Flags.string({
description: messages.getMessage('flags.context-variables.description'),
});

/**
* JSON form of --context-variables that carries the variable's type, so callers can
* send Boolean/Number/Object/List/Json values (not just Text). Deliberately has no
* `delimiter`, so a comma inside the JSON (or inside a List/Object value) is safe.
*/
export const contextVariablesJsonFlag = Flags.string({
summary: messages.getMessage('flags.context-variables-json.summary'),
description: messages.getMessage('flags.context-variables-json.description'),
});

// The valid ContextVariable.type values, mirroring the preview API's Variable schema.
const CONTEXT_VARIABLE_TYPES: readonly ContextVariableType[] = [
'Text',
'Date',
'DateTime',
'Money',
'Ref',
'Boolean',
'Number',
'Object',
'List',
'Json',
];

// Types whose `value` is a plain string on the wire.
const STRING_CONTEXT_VARIABLE_TYPES: readonly ContextVariableType[] = ['Text', 'Date', 'DateTime', 'Money', 'Ref'];

function describeJsonValue(value: unknown): string {
if (value === null) return 'null';
if (Array.isArray(value)) return 'an array';
return `a ${typeof value}`;
}

/**
* Validates that a decoded JSON `value` matches its declared `type`, matching the
* preview API's per-type Variable schema (Boolean->boolean, 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<string, unknown>;
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<string, ContextVariable>();
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.<Name>" for linked context variables, bare "<developerName>"
Expand Down
135 changes: 134 additions & 1 deletion test/flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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' },
]);
});
});
Loading