Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

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');
});
});