Skip to content
Open
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
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export {
type AgentPreviewError,
type AgentPreviewStartOptions,
type ContextVariable,
type ContextVariableType,
AgentSource,
type ScriptAgentType,
type ProductionAgentType,
Expand Down
28 changes: 23 additions & 5 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,29 @@ export type AgentPreviewInterface = {
setApexDebugging: (apexDebugging: boolean) => void;
};

export type ContextVariable = {
name: string;
type: 'Object' | 'Boolean' | 'DateTime' | 'Money' | 'Number' | 'Text' | 'Ref' | 'List';
value: string;
};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This old code was not in parity with the actual preview API contract.

// The `type` discriminator for a ContextVariable, matching the preview API's
// `Variable.type` enum (agent-api v1.1 OpenAPI schema `Variable`).
export type ContextVariableType = 'Text' | 'Date' | 'DateTime' | 'Money' | 'Ref' | 'Boolean' | 'Number' | 'Object' | 'List' | 'Json';

/**
* A context/custom variable sent when starting a preview session or a message.
*
* This mirrors the preview API's polymorphic `Variable` schema: the JSON type of
* `value` is determined by `type`. Boolean values are booleans, Number values are
* numbers, Object/List values are arrays, and Json is any JSON object. Sending a
* boolean as the string "True" (the old behavior) leaves a boolean-gated route
* closed, because the runtime compares "True" (Text) against True (Boolean).
*
* `value` is optional and nullable to match the API, where only `name` and `type`
* are required.
*/
export type ContextVariable =
| { name: string; type: 'Boolean'; value?: boolean | null }
| { name: string; type: 'Number'; value?: number | null }
| { name: string; type: 'Text' | 'Date' | 'DateTime' | 'Money' | 'Ref'; value?: string | null }
| { name: string; type: 'Object'; value?: ContextVariable[] | null }
| { name: string; type: 'List'; value?: Array<Record<string, unknown>> | null }
| { name: string; type: 'Json'; value?: Record<string, unknown> | null };

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have gone with a looser single value union but having the context variables clearly defined here is the better trade off. A bit more code but much clearer about what is supported.


export type AgentPreviewStartOptions = {
contextVariables?: ContextVariable[];
Expand Down
155 changes: 154 additions & 1 deletion test/nuts/agent.nut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,46 @@ describe('agent NUTs', () => {
// Set the default agent user in the agent script so we can test live preview
const aabDir = join(session.project.dir, 'force-app', 'main', 'default', 'aiAuthoringBundles', bundleApiName);
const agentScriptFile = readFileSync(join(aabDir, 'Test_AAB_Preview.agent'), 'utf8');
const updatedAgentScriptFile = agentScriptFile.replace('NEW AGENT USER', defaultAgentUsername);
const updatedAgentScriptFile = agentScriptFile
.replace('NEW AGENT USER', defaultAgentUsername)
// Declare one External, settable var per wire type so the typed-context-variable
// round-trip test below has a target for each. External visibility is what makes a
// mutable var settable via preview; without it the server rejects the mutation.
.replace(
' description: "This variable may also be referred to as VerifiedCustomerId"',
[
' description: "This variable may also be referred to as VerifiedCustomerId"',
' NutProbeText: mutable string = ""',
' description: "Test-only External var: a Text value set via a preview context variable."',
' visibility: "External"',
' NutProbeBool: mutable boolean = False',
' description: "Test-only External var: a Boolean value set via a preview context variable."',
' visibility: "External"',
' NutProbeNum: mutable number = 0',
' description: "Test-only External var: a Number value set via a preview context variable."',
' visibility: "External"',
' NutProbeObj: mutable object = {}',
' description: "Test-only External var: a JSON object set via a preview context variable."',
' visibility: "External"',
' NutProbeList: mutable list[string] = []',
' description: "Test-only External var: a List value set via a preview context variable."',
' visibility: "External"',
// The string-on-wire scalar types (Date/DateTime/Money/Ref) declare no default value,
// unlike the string/boolean/number/object/list vars above.
' NutProbeDate: mutable date',
' description: "Test-only External var: a Date value set via a preview context variable."',
' visibility: "External"',
' NutProbeDateTime: mutable datetime',
' description: "Test-only External var: a DateTime value set via a preview context variable."',
' visibility: "External"',
' NutProbeMoney: mutable currency',
' description: "Test-only External var: a Money value set via a preview context variable."',
' visibility: "External"',
' NutProbeRef: mutable id',
' description: "Test-only External var: a Ref (record id) value set via a preview context variable."',
' visibility: "External"',
].join('\n')
);
writeFileSync(join(aabDir, 'Test_AAB_Preview.agent'), updatedAgentScriptFile);
});

Expand Down Expand Up @@ -506,6 +545,115 @@ describe('agent NUTs', () => {
expect(allTracesJson).to.contain(overrideValue, 'expected override value to appear in at least one trace');
});

it('should round-trip every context-variable wire type into the live preview session trace', async () => {
const agent = await Agent.init({ connection, project, aabName: bundleApiName });
agent.preview.setMockMode('Live Test');

// Distinctive sentinels per wire type so a substring match can't collide across types.
const textValue = 'SDKCV-TEXT-7f3a9b';
const numberValue = 8_675_309;
const jsonTag = 'SDKCV-JSON-4d21c8';
const listElement = 'SDKCV-LIST-b58c1e';

// External mutable vars are set by their bare name (not the $Context. prefix that linked
// vars use). Each type maps to its own Variable union member on the wire.
await agent.preview.start({
contextVariables: [
{ name: 'NutProbeText', type: 'Text', value: textValue },
{ name: 'NutProbeNum', type: 'Number', value: numberValue },
{ name: 'NutProbeBool', type: 'Boolean', value: true },
{ name: 'NutProbeObj', type: 'Json', value: { tag: jsonTag } },
{ name: 'NutProbeList', type: 'List', value: [listElement] },
],
});

await agent.preview.send('hello');

const traces = await agent.preview.getAllTraces();
expect(traces).to.be.an('array');
expect(traces.length).to.be.greaterThan(0, 'expected at least one trace from the planner');

const allTracesJson = traces.map((t) => JSON.stringify(t)).join('\n');
expect(allTracesJson).to.contain(textValue, 'Text value should reach the session');
expect(allTracesJson).to.contain(String(numberValue), 'Number value should reach the session');
expect(allTracesJson).to.contain(jsonTag, 'Json object value should reach the session');
expect(allTracesJson).to.contain(listElement, 'List element should reach the session');
expect(allTracesJson).to.contain('NutProbeBool', 'Boolean variable should be present in the session');
});

it('should round-trip the string-on-wire scalar types (Date/DateTime/Money/Ref) into the live preview session trace', async () => {
const agent = await Agent.init({ connection, project, aabName: bundleApiName });
agent.preview.setMockMode('Live Test');

// The four string-on-wire scalar API types each serialize as a JSON string. Distinct
// per-type sentinels keep the substring match from colliding across types.
const dateValue = '2026-01-15';
const dateTimeValue = '2026-01-15T09:00:00Z';
const moneyAmount = '42.50'; // Money wire shape is "<ISO> <amount>", e.g. "USD 42.50".
const refValue = '003000000000002AAA';

await agent.preview.start({
contextVariables: [
{ name: 'NutProbeDate', type: 'Date', value: dateValue },
{ name: 'NutProbeDateTime', type: 'DateTime', value: dateTimeValue },
{ name: 'NutProbeMoney', type: 'Money', value: `USD ${moneyAmount}` },
{ name: 'NutProbeRef', type: 'Ref', value: refValue },
],
});

await agent.preview.send('hello');

const traces = await agent.preview.getAllTraces();
expect(traces).to.be.an('array');
expect(traces.length).to.be.greaterThan(0, 'expected at least one trace from the planner');

const allTracesJson = traces.map((t) => JSON.stringify(t)).join('\n');
expect(allTracesJson).to.contain(dateValue, 'Date value should reach the session');
// DateTime is trimmed to minute precision on the wire (seconds/zone dropped), so assert the
// minute prefix, which is a substring of both the full and the trimmed form.
expect(allTracesJson).to.contain('2026-01-15T09:00', 'DateTime value should reach the session');
// Money display coerces to the org currency, so assert the amount (the invariant part) rather
// than the ISO prefix we sent.
expect(allTracesJson).to.contain(moneyAmount, 'Money value should reach the session');
expect(allTracesJson).to.contain(refValue, 'Ref value should reach the session');
});

it('should round-trip structured composition (API Object type and a multi-entry List) into the live preview session trace', async () => {
const agent = await Agent.init({ connection, project, aabName: bundleApiName });
agent.preview.setMockMode('Live Test');

// API `Object` (an array of nested typed vars) is distinct from `Json` ({...}); both target
// an object-typed agent var. A multi-entry List must carry every element, not just the first.
const objectFieldValue = 'SDKCV-OBJ-9a1f2e';
const listFirst = 'SDKCV-LIST0-3c7d55';
const listSecond = 'SDKCV-LIST1-e12b90';

await agent.preview.start({
contextVariables: [
{ name: 'NutProbeObj', type: 'Object', value: [{ name: 'name', type: 'Text', value: objectFieldValue }] },
{
name: 'NutProbeList',
type: 'List',
value: [
{ type: 'Text', value: listFirst },
{ type: 'Text', value: listSecond },
],
},
],
});

await agent.preview.send('hello');

const traces = await agent.preview.getAllTraces();
expect(traces).to.be.an('array');
expect(traces.length).to.be.greaterThan(0, 'expected at least one trace from the planner');

const allTracesJson = traces.map((t) => JSON.stringify(t)).join('\n');
expect(allTracesJson).to.contain(objectFieldValue, 'nested Object field value should reach the session');
expect(allTracesJson).to.contain(listFirst, 'first List element should reach the session');
expect(allTracesJson).to.contain(listSecond, 'second List element should reach the session');
});

it('should end the preview session', async () => {
const agent = await Agent.init({ connection, project, aabName: bundleApiName });
const previewSession = await agent.preview.start();
Expand Down Expand Up @@ -709,6 +857,11 @@ describe('agent NUTs', () => {
saveAgent: true,
agentSettings: {
agentName: legacyAgentName,
// Reuse the Bot User already provisioned in the describe `before` (and committed via
// waitForPermSetAssignment) instead of letting core auto-create one in the same
// transaction, which intermittently races the pre-save validation trigger and fails
// with "User doesn't have access to agent". Mirrors the sibling spec-create test.
userId: botUserId,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should alleviate user provisioning race condition we are hitting on core for these e2e tests.

},
generationInfo: {
defaultInfo: {
Expand Down
25 changes: 24 additions & 1 deletion test/productionAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,7 @@ describe('ProductionAgent', () => {

const contextVariables: ContextVariable[] = [
{ name: 'CustomerId', type: 'Text', value: '001xx000003DGb2' },
{ name: 'IsVip', type: 'Boolean', value: 'true' },
{ name: 'IsVip', type: 'Boolean', value: true },
];

const agent = new ProductionAgent({ connection, project: sfProject, apiNameOrId: 'TestAgent' });
Expand All @@ -817,6 +817,29 @@ describe('ProductionAgent', () => {
expect(getStartRequestBody().variables).to.deep.equal(contextVariables);
});

it('carries typed values through to the `variables` array as native JSON (not strings)', async () => {
$$.SANDBOX.stub(connection, 'singleRecordQuery').resolves(buildBotMetadata('EinsteinServiceAgent'));

const contextVariables: ContextVariable[] = [
{ name: 'ProbeGate', type: 'Boolean', value: true },
{ name: 'RetryCount', type: 'Number', value: 3 },
{ name: 'Greeting', type: 'Text', value: 'hi' },
{ name: 'Nested', type: 'Object', value: [{ name: 'Inner', type: 'Boolean', value: false }] },
{ name: 'Items', type: 'List', value: [{ type: 'ref', value: '1M5xx000000000BCAQ' }] },
{ name: 'Blob', type: 'Json', value: { a: 1, b: [true, 'x'] } },
];

const agent = new ProductionAgent({ connection, project: sfProject, apiNameOrId: 'TestAgent' });
await agent.preview.start({ contextVariables });

// The request body is JSON.stringify'd, so a boolean/number reaches the wire as
// native JSON, not the string "true"/"3" that closed boolean-gated routes before.
const sent = getStartRequestBody().variables as ContextVariable[];
expect(sent).to.deep.equal(contextVariables);
expect(sent[0].value).to.equal(true);
expect(sent[1].value).to.equal(3);
});

it('defaults `variables` to an empty array when no context variables are provided', async () => {
$$.SANDBOX.stub(connection, 'singleRecordQuery').resolves(buildBotMetadata('EinsteinServiceAgent'));

Expand Down
Loading