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
504 lines
15 KiB
TypeScript
504 lines
15 KiB
TypeScript
/**
|
|
* W1-F — Contract validation tests.
|
|
*
|
|
* Confirms register / get / list / byCapability behavior on the three
|
|
* registries (connectors, channels, send-policy), plus the dispatch-policy
|
|
* decision matrix from `wave1-interfaces.md` §3.1 and `GAP_ASSESSMENT.md`
|
|
* §3.17, plus the priority-posture map and the default escalation ladders.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
type ChannelContribution,
|
|
createChannelRegistry,
|
|
PRIORITY_TO_POSTURE,
|
|
type ScheduledTaskPriority,
|
|
} from "../src/lifeops/channels/index.js";
|
|
import {
|
|
type ConnectorContribution,
|
|
createConnectorRegistry,
|
|
type DispatchResult,
|
|
decideDispatchPolicy,
|
|
} from "../src/lifeops/connectors/index.js";
|
|
import { DEFAULT_ESCALATION_LADDERS } from "../src/lifeops/escalation-ladders.js";
|
|
import {
|
|
createSendPolicyRegistry,
|
|
type SendPolicyContext,
|
|
type SendPolicyContribution,
|
|
} from "../src/lifeops/send-policy/index.js";
|
|
|
|
function makeConnector(
|
|
partial: Partial<ConnectorContribution> & {
|
|
kind: string;
|
|
capabilities: string[];
|
|
},
|
|
): ConnectorContribution {
|
|
return {
|
|
kind: partial.kind,
|
|
capabilities: partial.capabilities,
|
|
modes: partial.modes ?? ["cloud"],
|
|
describe: partial.describe ?? { label: partial.kind },
|
|
start: partial.start ?? (async () => {}),
|
|
disconnect: partial.disconnect ?? (async () => {}),
|
|
verify: partial.verify ?? (async () => true),
|
|
status:
|
|
partial.status ??
|
|
(async () => ({
|
|
state: "ok" as const,
|
|
observedAt: new Date(0).toISOString(),
|
|
})),
|
|
send: partial.send,
|
|
read: partial.read,
|
|
requiresApproval: partial.requiresApproval,
|
|
};
|
|
}
|
|
|
|
function makeChannel(
|
|
partial: Partial<ChannelContribution> & { kind: string },
|
|
): ChannelContribution {
|
|
return {
|
|
kind: partial.kind,
|
|
describe: partial.describe ?? { label: partial.kind },
|
|
capabilities: partial.capabilities ?? {
|
|
send: true,
|
|
read: false,
|
|
reminders: false,
|
|
voice: false,
|
|
attachments: false,
|
|
quietHoursAware: false,
|
|
},
|
|
send: partial.send,
|
|
};
|
|
}
|
|
|
|
describe("ConnectorRegistry", () => {
|
|
it("register / get / list / byCapability", () => {
|
|
const registry = createConnectorRegistry();
|
|
const google = makeConnector({
|
|
kind: "google",
|
|
capabilities: ["google.calendar.read", "google.gmail.draft.create"],
|
|
modes: ["cloud"],
|
|
});
|
|
const telegram = makeConnector({
|
|
kind: "telegram",
|
|
capabilities: ["telegram.send", "telegram.read"],
|
|
modes: ["local", "cloud"],
|
|
});
|
|
|
|
registry.register(google);
|
|
registry.register(telegram);
|
|
|
|
expect(registry.get("google")).toBe(google);
|
|
expect(registry.get("missing")).toBeNull();
|
|
|
|
const all = registry.list();
|
|
expect(all).toHaveLength(2);
|
|
expect(all.map((c) => c.kind).sort()).toEqual(["google", "telegram"]);
|
|
|
|
expect(
|
|
registry.list({ capability: "google.calendar.read" }).map((c) => c.kind),
|
|
).toEqual(["google"]);
|
|
expect(registry.list({ mode: "local" }).map((c) => c.kind)).toEqual([
|
|
"telegram",
|
|
]);
|
|
expect(
|
|
registry
|
|
.list({ mode: "cloud" })
|
|
.map((c) => c.kind)
|
|
.sort(),
|
|
).toEqual(["google", "telegram"]);
|
|
|
|
expect(registry.byCapability("telegram.send").map((c) => c.kind)).toEqual([
|
|
"telegram",
|
|
]);
|
|
expect(registry.byCapability("does.not.exist")).toEqual([]);
|
|
});
|
|
|
|
it("rejects duplicate kind on second register", () => {
|
|
const registry = createConnectorRegistry();
|
|
registry.register(makeConnector({ kind: "x", capabilities: [] }));
|
|
expect(() =>
|
|
registry.register(makeConnector({ kind: "x", capabilities: [] })),
|
|
).toThrow(/already registered/);
|
|
});
|
|
|
|
it("rejects empty kind", () => {
|
|
const registry = createConnectorRegistry();
|
|
expect(() =>
|
|
registry.register(makeConnector({ kind: "", capabilities: [] })),
|
|
).toThrow(/kind is required/);
|
|
});
|
|
});
|
|
|
|
describe("ChannelRegistry", () => {
|
|
it("register / get / list with capability filter", () => {
|
|
const registry = createChannelRegistry();
|
|
const inApp = makeChannel({
|
|
kind: "in_app",
|
|
capabilities: {
|
|
send: true,
|
|
read: false,
|
|
reminders: true,
|
|
voice: false,
|
|
attachments: true,
|
|
quietHoursAware: true,
|
|
},
|
|
});
|
|
const voice = makeChannel({
|
|
kind: "voice",
|
|
capabilities: {
|
|
send: true,
|
|
read: false,
|
|
reminders: false,
|
|
voice: true,
|
|
attachments: false,
|
|
quietHoursAware: true,
|
|
},
|
|
});
|
|
|
|
registry.register(inApp);
|
|
registry.register(voice);
|
|
|
|
expect(registry.get("in_app")).toBe(inApp);
|
|
expect(registry.get("missing")).toBeNull();
|
|
|
|
expect(
|
|
registry
|
|
.list()
|
|
.map((c) => c.kind)
|
|
.sort(),
|
|
).toEqual(["in_app", "voice"]);
|
|
expect(
|
|
registry.list({ supports: { voice: true } }).map((c) => c.kind),
|
|
).toEqual(["voice"]);
|
|
expect(
|
|
registry.list({ supports: { reminders: true } }).map((c) => c.kind),
|
|
).toEqual(["in_app"]);
|
|
expect(
|
|
registry
|
|
.list({ supports: { send: true, quietHoursAware: true } })
|
|
.map((c) => c.kind)
|
|
.sort(),
|
|
).toEqual(["in_app", "voice"]);
|
|
});
|
|
|
|
it("rejects duplicate kind", () => {
|
|
const registry = createChannelRegistry();
|
|
registry.register(makeChannel({ kind: "in_app" }));
|
|
expect(() => registry.register(makeChannel({ kind: "in_app" }))).toThrow(
|
|
/already registered/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("SendPolicyRegistry", () => {
|
|
function makePolicy(
|
|
partial: Partial<SendPolicyContribution> & { kind: string },
|
|
): SendPolicyContribution {
|
|
return {
|
|
kind: partial.kind,
|
|
describe: partial.describe ?? { label: partial.kind },
|
|
priority: partial.priority,
|
|
appliesTo: partial.appliesTo,
|
|
evaluate: partial.evaluate ?? (async () => ({ kind: "allow" })),
|
|
};
|
|
}
|
|
|
|
const baseContext: SendPolicyContext = {
|
|
source: { kind: "connector", key: "google" },
|
|
capability: "google.gmail.draft.create",
|
|
payload: { to: "owner@example.com" },
|
|
};
|
|
|
|
it("register / get / list", () => {
|
|
const registry = createSendPolicyRegistry();
|
|
const owner = makePolicy({ kind: "owner_approval", priority: 0 });
|
|
const quiet = makePolicy({ kind: "quiet_hours", priority: 10 });
|
|
registry.register(owner);
|
|
registry.register(quiet);
|
|
|
|
expect(registry.get("owner_approval")).toBe(owner);
|
|
expect(registry.get("missing")).toBeNull();
|
|
|
|
// list returns priority-sorted policies (lower priority first).
|
|
expect(registry.list().map((p) => p.kind)).toEqual([
|
|
"owner_approval",
|
|
"quiet_hours",
|
|
]);
|
|
});
|
|
|
|
it("rejects duplicate kind", () => {
|
|
const registry = createSendPolicyRegistry();
|
|
registry.register(makePolicy({ kind: "x" }));
|
|
expect(() => registry.register(makePolicy({ kind: "x" }))).toThrow(
|
|
/already registered/,
|
|
);
|
|
});
|
|
|
|
it("evaluate returns allow when every policy allows", async () => {
|
|
const registry = createSendPolicyRegistry();
|
|
registry.register(makePolicy({ kind: "p1" }));
|
|
registry.register(makePolicy({ kind: "p2" }));
|
|
await expect(registry.evaluate(baseContext)).resolves.toEqual({
|
|
kind: "allow",
|
|
});
|
|
});
|
|
|
|
it("evaluate short-circuits on first non-allow decision in priority order", async () => {
|
|
const registry = createSendPolicyRegistry();
|
|
let later = false;
|
|
registry.register(
|
|
makePolicy({
|
|
kind: "second",
|
|
priority: 100,
|
|
evaluate: async () => {
|
|
later = true;
|
|
return { kind: "allow" };
|
|
},
|
|
}),
|
|
);
|
|
registry.register(
|
|
makePolicy({
|
|
kind: "first",
|
|
priority: 0,
|
|
evaluate: async () => ({
|
|
kind: "deny",
|
|
reason: "stop",
|
|
userActionable: false,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const decision = await registry.evaluate(baseContext);
|
|
expect(decision).toEqual({
|
|
kind: "deny",
|
|
reason: "stop",
|
|
userActionable: false,
|
|
});
|
|
expect(later).toBe(false);
|
|
});
|
|
|
|
it("evaluate skips policies whose appliesTo returns false", async () => {
|
|
const registry = createSendPolicyRegistry();
|
|
registry.register(
|
|
makePolicy({
|
|
kind: "channel_only",
|
|
appliesTo: (ctx) => ctx.source.kind === "channel",
|
|
evaluate: async () => ({
|
|
kind: "deny",
|
|
reason: "channel_block",
|
|
userActionable: false,
|
|
}),
|
|
}),
|
|
);
|
|
await expect(registry.evaluate(baseContext)).resolves.toEqual({
|
|
kind: "allow",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("decideDispatchPolicy (W1-F dispatch policy)", () => {
|
|
it("ok=true → complete with messageId", () => {
|
|
const result: DispatchResult = { ok: true, messageId: "abc" };
|
|
expect(
|
|
decideDispatchPolicy(result, { currentStepIndex: 0, totalSteps: 3 }),
|
|
).toEqual({ kind: "complete", messageId: "abc" });
|
|
});
|
|
|
|
it("retryAfterMinutes set → retry on the same step (does not advance)", () => {
|
|
const result: DispatchResult = {
|
|
ok: false,
|
|
reason: "transport_error",
|
|
retryAfterMinutes: 15,
|
|
userActionable: false,
|
|
};
|
|
expect(
|
|
decideDispatchPolicy(result, { currentStepIndex: 0, totalSteps: 3 }),
|
|
).toEqual({
|
|
kind: "retry",
|
|
retryAfterMinutes: 15,
|
|
reason: "transport_error",
|
|
});
|
|
});
|
|
|
|
it("rate_limited without retryAfterMinutes → retry with default backoff", () => {
|
|
const result: DispatchResult = {
|
|
ok: false,
|
|
reason: "rate_limited",
|
|
userActionable: false,
|
|
};
|
|
expect(
|
|
decideDispatchPolicy(result, {
|
|
currentStepIndex: 1,
|
|
totalSteps: 3,
|
|
defaultRetryAfterMinutes: 7,
|
|
}),
|
|
).toEqual({
|
|
kind: "retry",
|
|
retryAfterMinutes: 7,
|
|
reason: "rate_limited",
|
|
});
|
|
});
|
|
|
|
it("user-actionable failure (auth_expired) on a non-final step → surface_degraded", () => {
|
|
const result: DispatchResult = {
|
|
ok: false,
|
|
reason: "auth_expired",
|
|
userActionable: true,
|
|
message: "re-auth Gmail",
|
|
};
|
|
expect(
|
|
decideDispatchPolicy(result, { currentStepIndex: 0, totalSteps: 3 }),
|
|
).toEqual({
|
|
kind: "surface_degraded",
|
|
reason: "auth_expired",
|
|
message: "re-auth Gmail",
|
|
});
|
|
});
|
|
|
|
it("permanent failure on a non-final step → advance", () => {
|
|
const result: DispatchResult = {
|
|
ok: false,
|
|
reason: "unknown_recipient",
|
|
userActionable: false,
|
|
message: "no such handle",
|
|
};
|
|
expect(
|
|
decideDispatchPolicy(result, { currentStepIndex: 0, totalSteps: 3 }),
|
|
).toEqual({
|
|
kind: "advance",
|
|
reason: "unknown_recipient",
|
|
message: "no such handle",
|
|
});
|
|
});
|
|
|
|
it("permanent failure on the last step → fail (terminal)", () => {
|
|
const result: DispatchResult = {
|
|
ok: false,
|
|
reason: "transport_error",
|
|
userActionable: false,
|
|
message: "boom",
|
|
};
|
|
expect(
|
|
decideDispatchPolicy(result, { currentStepIndex: 2, totalSteps: 3 }),
|
|
).toEqual({
|
|
kind: "fail",
|
|
reason: "transport_error",
|
|
message: "boom",
|
|
});
|
|
});
|
|
|
|
it("user-actionable failure on the last step → surface_degraded (owner still sees the fix)", () => {
|
|
// #14881 (fix #14714) reordered the policy so a user-actionable failure such
|
|
// as auth_expired surfaces the connector-degradation even on the final rung:
|
|
// the owner must still be told what to re-auth rather than have the
|
|
// degradation surface swallowed by a terminal fail. The userActionable check
|
|
// now precedes the isLastStep terminal check by design.
|
|
const result: DispatchResult = {
|
|
ok: false,
|
|
reason: "auth_expired",
|
|
userActionable: true,
|
|
};
|
|
expect(
|
|
decideDispatchPolicy(result, { currentStepIndex: 0, totalSteps: 1 }),
|
|
).toEqual({
|
|
kind: "surface_degraded",
|
|
reason: "auth_expired",
|
|
message: undefined,
|
|
});
|
|
});
|
|
|
|
it("rate_limited on the last step still retries (does not fall through to fail)", () => {
|
|
// Retry-with-backoff is evaluated BEFORE the isLastStep terminal check, so a
|
|
// transient rate-limit on the final step reschedules the same step rather
|
|
// than failing the whole dispatch. Pins that ordering against a refactor.
|
|
const result: DispatchResult = {
|
|
ok: false,
|
|
reason: "rate_limited",
|
|
userActionable: false,
|
|
};
|
|
expect(
|
|
decideDispatchPolicy(result, {
|
|
currentStepIndex: 2,
|
|
totalSteps: 3,
|
|
defaultRetryAfterMinutes: 7,
|
|
}),
|
|
).toEqual({
|
|
kind: "retry",
|
|
retryAfterMinutes: 7,
|
|
reason: "rate_limited",
|
|
});
|
|
});
|
|
|
|
it("explicit retryAfterMinutes on the last step still retries (beats terminal)", () => {
|
|
const result: DispatchResult = {
|
|
ok: false,
|
|
reason: "transport_error",
|
|
retryAfterMinutes: 15,
|
|
userActionable: false,
|
|
};
|
|
expect(
|
|
decideDispatchPolicy(result, { currentStepIndex: 0, totalSteps: 1 }),
|
|
).toEqual({
|
|
kind: "retry",
|
|
retryAfterMinutes: 15,
|
|
reason: "transport_error",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("PRIORITY_TO_POSTURE", () => {
|
|
it("matches the frozen wave1-interfaces.md §3.3 shape", () => {
|
|
expect(PRIORITY_TO_POSTURE.low).toEqual({
|
|
defaultChannelKeys: ["in_app"],
|
|
banner: false,
|
|
sound: false,
|
|
badge: true,
|
|
mandatoryEscalation: false,
|
|
});
|
|
expect(PRIORITY_TO_POSTURE.medium).toEqual({
|
|
defaultChannelKeys: ["in_app", "push"],
|
|
banner: true,
|
|
sound: false,
|
|
badge: true,
|
|
mandatoryEscalation: false,
|
|
});
|
|
expect(PRIORITY_TO_POSTURE.high).toEqual({
|
|
defaultChannelKeys: ["in_app", "push"],
|
|
banner: true,
|
|
sound: true,
|
|
badge: true,
|
|
mandatoryEscalation: true,
|
|
});
|
|
});
|
|
|
|
it("covers exactly the three priority levels", () => {
|
|
const keys = Object.keys(PRIORITY_TO_POSTURE).sort();
|
|
expect(keys).toEqual<ScheduledTaskPriority[]>(["high", "low", "medium"]);
|
|
});
|
|
});
|
|
|
|
describe("DEFAULT_ESCALATION_LADDERS", () => {
|
|
it("matches the frozen wave1-interfaces.md §3.4 shape", () => {
|
|
expect(DEFAULT_ESCALATION_LADDERS.priority_low_default).toEqual({
|
|
steps: [],
|
|
});
|
|
expect(DEFAULT_ESCALATION_LADDERS.priority_medium_default).toEqual({
|
|
steps: [{ delayMinutes: 30, channelKey: "in_app", intensity: "normal" }],
|
|
});
|
|
expect(DEFAULT_ESCALATION_LADDERS.priority_high_default).toEqual({
|
|
steps: [
|
|
{ delayMinutes: 15, channelKey: "push", intensity: "normal" },
|
|
{ delayMinutes: 45, channelKey: "telegram", intensity: "urgent" },
|
|
{ delayMinutes: 45, channelKey: "signal", intensity: "urgent" },
|
|
{ delayMinutes: 45, channelKey: "whatsapp", intensity: "urgent" },
|
|
{ delayMinutes: 45, channelKey: "discord", intensity: "urgent" },
|
|
{ delayMinutes: 45, channelKey: "sms", intensity: "urgent" },
|
|
{ delayMinutes: 45, channelKey: "voice", intensity: "urgent" },
|
|
{ delayMinutes: 45, channelKey: "imessage", intensity: "urgent" },
|
|
{ delayMinutes: 45, channelKey: "in_app", intensity: "urgent" },
|
|
],
|
|
});
|
|
});
|
|
});
|