426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
/** Unit tests for clarification coercion, resolution application, and dot-path set helpers (deterministic). */
|
|
import { describe, expect, test } from 'bun:test';
|
|
import {
|
|
applyResolutions,
|
|
coerceClarifications,
|
|
setByDotPath,
|
|
} from '../../src/lib/workflow-clarification';
|
|
|
|
type ClarificationNode = {
|
|
name?: string;
|
|
id?: string;
|
|
parameters: Record<string, unknown>;
|
|
};
|
|
|
|
function nodesOf(obj: Record<string, unknown>): ClarificationNode[] {
|
|
const raw = obj.nodes;
|
|
return Array.isArray(raw) ? (raw as ClarificationNode[]) : [];
|
|
}
|
|
|
|
type DraftTest = Record<string, unknown> & {
|
|
nodes: ClarificationNode[];
|
|
connections?: Record<string, unknown>;
|
|
_meta?: { userNotes?: string[] };
|
|
};
|
|
|
|
function draftUserNotes(draft: Record<string, unknown>): string[] | undefined {
|
|
const meta = draft._meta;
|
|
if (!meta || typeof meta !== 'object') return undefined;
|
|
const candidate = meta as { userNotes?: unknown };
|
|
const notes = candidate.userNotes;
|
|
if (!Array.isArray(notes) || !notes.every((item): item is string => typeof item === 'string')) {
|
|
return undefined;
|
|
}
|
|
return notes;
|
|
}
|
|
|
|
describe('setByDotPath', () => {
|
|
test('writes through a numeric array index (existing behavior)', () => {
|
|
const obj: Record<string, unknown> = {
|
|
nodes: [
|
|
{ name: 'A', parameters: {} },
|
|
{ name: 'B', parameters: {} },
|
|
],
|
|
};
|
|
setByDotPath(obj, 'nodes[1].parameters.channelId', 'C-123');
|
|
expect(nodesOf(obj)[1].parameters.channelId).toBe('C-123');
|
|
expect(nodesOf(obj)[0].parameters).toEqual({});
|
|
});
|
|
|
|
test('resolves a string array segment by entry .name (workflow nodes)', () => {
|
|
const obj: Record<string, unknown> = {
|
|
nodes: [
|
|
{ name: 'Webhook', parameters: { path: '/in' } },
|
|
{ name: 'Post to Slack', parameters: {} },
|
|
],
|
|
};
|
|
setByDotPath(obj, 'nodes["Post to Slack"].parameters.channelId', 'C-42');
|
|
expect(nodesOf(obj)[1].parameters.channelId).toBe('C-42');
|
|
expect(nodesOf(obj)[0].parameters.path).toBe('/in');
|
|
});
|
|
|
|
test('resolves a string array segment by entry .id when name does not match', () => {
|
|
const obj: Record<string, unknown> = {
|
|
nodes: [{ id: 'uuid-slack', name: 'Post to Slack', parameters: {} }],
|
|
};
|
|
setByDotPath(obj, 'nodes["uuid-slack"].parameters.channelId', 'C-99');
|
|
expect(nodesOf(obj)[0].parameters.channelId).toBe('C-99');
|
|
});
|
|
|
|
test('throws when string segment matches no element by name or id', () => {
|
|
const obj: Record<string, unknown> = {
|
|
nodes: [{ name: 'Webhook', parameters: {} }],
|
|
};
|
|
expect(() => setByDotPath(obj, 'nodes["Placeholder Notification"].parameters.x', 'y')).toThrow(
|
|
/did not match any element by name\/id/
|
|
);
|
|
});
|
|
|
|
test('dot identifiers still work end-to-end', () => {
|
|
const obj: Record<string, unknown> = { a: { b: { c: 1 } } };
|
|
setByDotPath(obj, 'a.b.c', 42);
|
|
const a = obj.a as { b: { c: number } };
|
|
expect(a.b.c).toBe(42);
|
|
});
|
|
|
|
test('refuses to overwrite an object with a non-object value (object case)', () => {
|
|
// The LLM sometimes points paramPath at a parent scope. Without this
|
|
// guard, the assignment silently replaces the parameters object with
|
|
// a string and the workflow runner rejects the deploy with `parameters
|
|
// must be object`.
|
|
const obj: Record<string, unknown> = {
|
|
nodes: [{ name: 'Trigger', parameters: { existing: 'field' } }],
|
|
};
|
|
expect(() => setByDotPath(obj, 'nodes["Trigger"].parameters', 'discord')).toThrow(
|
|
/refusing to overwrite with non-object value/
|
|
);
|
|
expect(nodesOf(obj)[0].parameters).toEqual({ existing: 'field' });
|
|
});
|
|
|
|
test('refuses to overwrite an object inside an array (array case)', () => {
|
|
const obj: Record<string, unknown> = {
|
|
items: [{ a: 1 }, { b: 2 }],
|
|
};
|
|
expect(() => setByDotPath(obj, 'items.0', 'string')).toThrow(
|
|
/refusing to overwrite with non-object value/
|
|
);
|
|
});
|
|
|
|
test('allows replacing a primitive with another primitive', () => {
|
|
const obj: Record<string, unknown> = {
|
|
nodes: [{ name: 'T', parameters: { hour: 9 } }],
|
|
};
|
|
setByDotPath(obj, 'nodes["T"].parameters.hour', 10);
|
|
expect(nodesOf(obj)[0].parameters.hour).toBe(10);
|
|
});
|
|
|
|
test('allows replacing an object with another object', () => {
|
|
const obj: Record<string, unknown> = {
|
|
nodes: [{ name: 'T', parameters: { old: 'x' } }],
|
|
};
|
|
setByDotPath(obj, 'nodes["T"].parameters', { new: 'y' });
|
|
expect(nodesOf(obj)[0].parameters).toEqual({ new: 'y' });
|
|
});
|
|
});
|
|
|
|
describe('applyResolutions', () => {
|
|
test('applies a name-keyed paramPath to the matching node', () => {
|
|
const draft: DraftTest = {
|
|
nodes: [
|
|
{ name: 'Hourly Trigger', parameters: { rule: 'everyHour' } },
|
|
{ name: 'Notify', parameters: {} },
|
|
],
|
|
};
|
|
const result = applyResolutions(draft, [
|
|
{ paramPath: 'nodes["Notify"].parameters.channelId', value: 'discord-channel-1' },
|
|
]);
|
|
expect(result.ok).toBe(true);
|
|
expect(draft.nodes[1].parameters.channelId).toBe('discord-channel-1');
|
|
});
|
|
|
|
test('falls back to userNotes when paramPath references a non-existent node', () => {
|
|
const draft: DraftTest = {
|
|
nodes: [{ name: 'Hourly Trigger', parameters: {} }],
|
|
};
|
|
const result = applyResolutions(draft, [
|
|
{ paramPath: 'nodes["Placeholder Notification"].parameters', value: 'discord' },
|
|
]);
|
|
expect(result.ok).toBe(true);
|
|
expect(draftUserNotes(draft)).toEqual(['discord']);
|
|
expect(draft.nodes.length).toBe(1);
|
|
expect(draft.nodes[0].name).toBe('Hourly Trigger');
|
|
});
|
|
|
|
test('falls back to userNotes when paramPath points at a parent object scope', () => {
|
|
// The exact LLM failure: clarification asked for a notification channel
|
|
// but paramPath was `nodes["Trigger"].parameters` — the parameters object
|
|
// itself, not a leaf field. Old behavior overwrote parameters with the
|
|
// string "discord" and broke deploy.
|
|
const draft: DraftTest = {
|
|
nodes: [{ name: 'Hourly Trigger', parameters: { mode: 'everyHour' } }],
|
|
};
|
|
const result = applyResolutions(draft, [
|
|
{ paramPath: 'nodes["Hourly Trigger"].parameters', value: 'discord' },
|
|
]);
|
|
expect(result.ok).toBe(true);
|
|
expect(draftUserNotes(draft)).toEqual(['discord']);
|
|
expect(draft.nodes[0].parameters).toEqual({ mode: 'everyHour' });
|
|
});
|
|
|
|
test('falls back to userNotes when paramPath descends into a non-object', () => {
|
|
const draft = {
|
|
nodes: [{ name: 'X', parameters: 'this is a string not an object' }],
|
|
} as Record<string, unknown>;
|
|
const result = applyResolutions(draft, [
|
|
{ paramPath: 'nodes["X"].parameters.channelId', value: 'C-1' },
|
|
]);
|
|
expect(result.ok).toBe(true);
|
|
expect(draftUserNotes(draft)).toEqual(['C-1']);
|
|
});
|
|
|
|
test('empty paramPath stores answer as userNote (existing behavior)', () => {
|
|
const draft: DraftTest = { nodes: [], connections: {} };
|
|
const result = applyResolutions(draft, [{ paramPath: '', value: 'use email' }]);
|
|
expect(result.ok).toBe(true);
|
|
expect(draftUserNotes(draft)).toEqual(['use email']);
|
|
});
|
|
|
|
test('multiple resolutions can mix successful path writes and userNote fallbacks', () => {
|
|
const draft: DraftTest = {
|
|
nodes: [{ name: 'Real Node', parameters: {} }],
|
|
};
|
|
const result = applyResolutions(draft, [
|
|
{ paramPath: 'nodes["Real Node"].parameters.target', value: 'ok' },
|
|
{ paramPath: 'nodes["Imaginary Node"].parameters.target', value: 'fallback' },
|
|
{ paramPath: '', value: 'free-form note' },
|
|
]);
|
|
expect(result.ok).toBe(true);
|
|
expect(draft.nodes[0].parameters.target).toBe('ok');
|
|
expect(draftUserNotes(draft)).toEqual(['fallback', 'free-form note']);
|
|
});
|
|
|
|
test('non-string value still rejects the batch (validation, not path failure)', () => {
|
|
const draft: Record<string, unknown> = { nodes: [] };
|
|
const result = applyResolutions(draft, [{ paramPath: 'x', value: 42 }]);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toContain('must be a string');
|
|
}
|
|
});
|
|
|
|
test('structurally invalid paramPath fails the batch (does not silently fall through to userNotes)', () => {
|
|
const draft: Record<string, unknown> = { nodes: [] };
|
|
const result = applyResolutions(draft, [{ paramPath: 'nodes["Unclosed', value: 'X' }]);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toContain('structurally invalid');
|
|
expect(result.paramPath).toBe('nodes["Unclosed');
|
|
}
|
|
expect(draft._meta).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('coerceClarifications — sort order', () => {
|
|
test('target_server is asked before target_channel even when LLM emits reverse order', () => {
|
|
const raw = [
|
|
{
|
|
kind: 'target_channel',
|
|
platform: 'discord',
|
|
question: 'Which Discord channel should receive the alert?',
|
|
paramPath: 'nodes["Send"].parameters.channelId',
|
|
},
|
|
{
|
|
kind: 'target_server',
|
|
platform: 'discord',
|
|
question: 'Which Discord server?',
|
|
paramPath: 'nodes["Send"].parameters.guildId',
|
|
},
|
|
];
|
|
const out = coerceClarifications(raw);
|
|
expect(out.map((c) => c.kind)).toEqual(['target_server', 'target_channel']);
|
|
});
|
|
|
|
test('preserves LLM order within the same kind bucket (stable sort)', () => {
|
|
const raw = [
|
|
{ kind: 'value', question: 'First value', paramPath: 'a' },
|
|
{ kind: 'value', question: 'Second value', paramPath: 'b' },
|
|
{ kind: 'value', question: 'Third value', paramPath: 'c' },
|
|
];
|
|
const out = coerceClarifications(raw);
|
|
expect(out.map((c) => c.question)).toEqual(['First value', 'Second value', 'Third value']);
|
|
});
|
|
|
|
test('recipient sorts after target_server (recipient depends on server context)', () => {
|
|
const raw = [
|
|
{
|
|
kind: 'recipient',
|
|
platform: 'slack',
|
|
question: 'Which user to DM?',
|
|
paramPath: 'nodes["DM"].parameters.userId',
|
|
},
|
|
{
|
|
kind: 'target_server',
|
|
platform: 'slack',
|
|
question: 'Which Slack workspace?',
|
|
paramPath: 'nodes["DM"].parameters.workspaceId',
|
|
},
|
|
];
|
|
const out = coerceClarifications(raw);
|
|
expect(out[0].kind).toBe('target_server');
|
|
expect(out[1].kind).toBe('recipient');
|
|
});
|
|
|
|
test('free_text drops to the end', () => {
|
|
const raw = [
|
|
{ kind: 'free_text', question: 'Anything else to note?', paramPath: '' },
|
|
{
|
|
kind: 'value',
|
|
question: 'What hour to run?',
|
|
paramPath: 'nodes["Cron"].parameters.hour',
|
|
},
|
|
{
|
|
kind: 'target_server',
|
|
platform: 'discord',
|
|
question: 'Which server?',
|
|
paramPath: 'nodes["Send"].parameters.guildId',
|
|
},
|
|
];
|
|
const out = coerceClarifications(raw);
|
|
expect(out.map((c) => c.kind)).toEqual(['target_server', 'value', 'free_text']);
|
|
});
|
|
|
|
test('legacy bare-string clarifications normalize to free_text and stay at the end', () => {
|
|
const raw = [
|
|
'Anything special about your setup?',
|
|
{
|
|
kind: 'target_server',
|
|
platform: 'discord',
|
|
question: 'Which server?',
|
|
paramPath: 'x',
|
|
},
|
|
];
|
|
const out = coerceClarifications(raw);
|
|
expect(out[0].kind).toBe('target_server');
|
|
expect(out[1].kind).toBe('free_text');
|
|
expect(out[1].question).toBe('Anything special about your setup?');
|
|
});
|
|
|
|
test('mixed multi-platform: server-then-channel ordering applies per platform group', () => {
|
|
const raw = [
|
|
{ kind: 'target_channel', platform: 'discord', question: 'Discord channel?', paramPath: 'x' },
|
|
{ kind: 'target_channel', platform: 'slack', question: 'Slack channel?', paramPath: 'y' },
|
|
{ kind: 'target_server', platform: 'discord', question: 'Discord server?', paramPath: 'z' },
|
|
{ kind: 'target_server', platform: 'slack', question: 'Slack workspace?', paramPath: 'w' },
|
|
];
|
|
const out = coerceClarifications(raw);
|
|
expect(out[0].kind).toBe('target_server');
|
|
expect(out[1].kind).toBe('target_server');
|
|
expect(out[2].kind).toBe('target_channel');
|
|
expect(out[3].kind).toBe('target_channel');
|
|
expect(out[0].platform).toBe('discord');
|
|
expect(out[1].platform).toBe('slack');
|
|
});
|
|
});
|