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

340 lines
11 KiB
TypeScript

/**
* Integration tests for the real `threadOpsFieldEvaluator` (app-lifeops).
*
* These tests use a minimal fake IAgentRuntime — they do NOT spin up a real
* Postgres adapter. The goal is to verify the evaluator's contract:
*
* - shouldRun gates correctly on owner access + active work
* - schema slice declares all op types including abort
* - parse normalizes the LLM output shape
* - handle dispatches abort via runtime.turnControllers.abortTurn
* - non-abort ops stage candidateActionNames + contexts for the planner
*
* For full atomic-merge + concurrency tests, see `work-threads.integration.test.ts`.
*/
import type {
ResponseHandlerFieldContext,
ResponseHandlerFieldHandleContext,
} from "@elizaos/core";
import { describe, expect, it } from "vitest";
import { threadOpsFieldEvaluator } from "../src/lifeops/work-threads/field-evaluator-thread-ops";
interface FakeRuntimeOverrides {
ownerAccess?: boolean;
activeThreads?: number;
pendingPrompts?: number;
hasActiveTurn?: boolean;
abortTurnReturn?: boolean;
onAbortTurn?: (roomId: string, reason: string) => void;
}
function buildFakeRuntime(overrides: FakeRuntimeOverrides = {}): unknown {
const {
ownerAccess = true,
activeThreads = 0,
pendingPrompts = 0,
hasActiveTurn = false,
abortTurnReturn = true,
onAbortTurn,
} = overrides;
return {
agentId: "00000000-0000-0000-0000-000000000001",
logger: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
},
// hasOwnerAccess() in @elizaos/agent reads from runtime.character.owners
// and the message's entityId. We bypass it by monkey-patching the module
// import at test boundary — simpler is to intercept via the fake's
// owner/entity helpers. The implementation we care about is that
// hasOwnerAccess returns ownerAccess.
character: {
owners: ownerAccess ? ["00000000-0000-0000-0000-deadbeefdead"] : [],
},
adapter: {
db: {
execute: async () => {
// The store list() will run a query. Return enough rows to satisfy
// the count without doing real work.
if (activeThreads > 0) {
return Array.from({ length: activeThreads }, (_, i) => ({
id: `wt-${i}`,
agent_id: "00000000-0000-0000-0000-000000000001",
status: "active",
title: "stub",
summary: "",
current_plan_summary: null,
primary_source_ref_json: JSON.stringify({
connector: "test",
roomId: "room-1",
canRead: true,
canMutate: true,
}),
source_refs_json: "[]",
participant_entity_ids_json: "[]",
metadata_json: "{}",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
last_activity_at: new Date().toISOString(),
version: 1,
}));
}
return [];
},
},
},
turnControllers: {
hasActiveTurn: (roomId: string) =>
Boolean(hasActiveTurn) && roomId === "room-1",
abortTurn: (roomId: string, reason: string) => {
if (onAbortTurn) onAbortTurn(roomId, reason);
return abortTurnReturn;
},
},
// Stubs needed by createPendingPromptsStore.list():
_pendingPromptCount: pendingPrompts,
};
}
function buildMessage(text: string, roomId = "room-1"): unknown {
return {
id: "msg-1",
roomId,
entityId: "00000000-0000-0000-0000-deadbeefdead",
content: { text },
createdAt: Date.now(),
};
}
function buildState(): unknown {
return { values: {}, data: {}, text: "" };
}
function buildCtx(
runtime: unknown,
message: unknown = buildMessage("hi"),
): ResponseHandlerFieldContext {
return {
runtime: runtime as ResponseHandlerFieldContext["runtime"],
message: message as ResponseHandlerFieldContext["message"],
state: buildState() as ResponseHandlerFieldContext["state"],
senderRole: "OWNER",
turnSignal: new AbortController().signal,
};
}
describe("threadOpsFieldEvaluator", () => {
describe("identity", () => {
it("has the expected name, priority, and description", () => {
expect(threadOpsFieldEvaluator.name).toBe("threadOps");
expect(threadOpsFieldEvaluator.priority).toBe(30);
expect(threadOpsFieldEvaluator.description).toContain("abort");
expect(threadOpsFieldEvaluator.description).toContain("steer");
expect(threadOpsFieldEvaluator.description).toContain("merge");
expect(threadOpsFieldEvaluator.description).toContain(
'goals ("I want a goal", "my goal is", "count it if", "track this goal")',
);
expect(threadOpsFieldEvaluator.description).toContain(
"route through their OWNER_* actions instead of threadOps",
);
});
it("declares a strict-mode schema with all op types including abort", () => {
const schema = threadOpsFieldEvaluator.schema as Record<string, unknown>;
expect(schema.type).toBe("array");
const items = schema.items as Record<string, unknown>;
expect(items.additionalProperties).toBe(false);
const props = items.properties as Record<string, Record<string, unknown>>;
const enumValues = (props.type as { enum?: string[] }).enum ?? [];
expect(enumValues).toContain("abort");
expect(enumValues).toContain("create");
expect(enumValues).toContain("steer");
expect(enumValues).toContain("merge");
expect(enumValues).toContain("mark_completed");
});
});
describe("parse", () => {
it("returns empty array for non-array input", () => {
const result = threadOpsFieldEvaluator.parse?.(
null,
buildCtx(buildFakeRuntime()),
);
expect(result).toEqual([]);
});
it("filters out ops with unknown types", () => {
const result = threadOpsFieldEvaluator.parse?.(
[{ type: "unknown" }, { type: "abort", reason: "stop" }],
buildCtx(buildFakeRuntime()),
);
expect(result).toEqual([{ type: "abort", reason: "stop" }]);
});
it("normalizes sourceWorkThreadIds and trims whitespace", () => {
const result = threadOpsFieldEvaluator.parse?.(
[
{
type: "merge",
workThreadId: "wt-target",
sourceWorkThreadIds: ["wt-a", "wt-b", "", " wt-c "],
},
],
buildCtx(buildFakeRuntime()),
);
expect(result).toHaveLength(1);
expect(
(result as Array<{ sourceWorkThreadIds: string[] }>)[0]
.sourceWorkThreadIds,
).toEqual(["wt-a", "wt-b", "wt-c"]);
});
it("parses sourceRef when valid", () => {
const result = threadOpsFieldEvaluator.parse?.(
[
{
type: "attach_source",
workThreadId: "wt-1",
sourceRef: {
connector: "telegram",
roomId: "room-2",
canMutate: true,
},
},
],
buildCtx(buildFakeRuntime()),
);
expect(result).toHaveLength(1);
const op = (result as Array<Record<string, unknown>>)[0];
expect(op.sourceRef).toEqual({
connector: "telegram",
channelName: undefined,
channelKind: undefined,
roomId: "room-2",
externalThreadId: undefined,
accountId: undefined,
grantId: undefined,
canRead: undefined,
canMutate: true,
});
});
});
describe("handle — abort path", () => {
it("calls runtime.abortTurn and returns ack-and-stop preempt", async () => {
let abortedRoomId: string | null = null;
let abortedReason: string | null = null;
const runtime = buildFakeRuntime({
onAbortTurn: (roomId, reason) => {
abortedRoomId = roomId;
abortedReason = reason;
},
});
const handleCtx: ResponseHandlerFieldHandleContext<unknown> = {
...buildCtx(runtime, buildMessage("nvm", "room-1")),
value: [{ type: "abort", reason: "user said nvm" }],
parsed: {
shouldRespond: "RESPOND",
contexts: [],
intents: [],
candidateActionNames: [],
replyText: "",
facts: [],
relationships: [],
addressedTo: [],
},
};
const effect = await threadOpsFieldEvaluator.handle?.(handleCtx);
expect(effect).toBeDefined();
expect(effect?.preempt).toEqual({
mode: "ack-and-stop",
reason: "user said nvm",
});
expect(abortedRoomId).toBe("room-1");
expect(abortedReason).toBe("user said nvm");
});
it("sets a default replyText and forces contexts=['simple']", async () => {
const runtime = buildFakeRuntime();
const handleCtx: ResponseHandlerFieldHandleContext<unknown> = {
...buildCtx(runtime, buildMessage("stop")),
value: [{ type: "abort", reason: "abort" }],
parsed: {
shouldRespond: "RESPOND",
contexts: ["general"],
intents: [],
candidateActionNames: [],
replyText: "",
facts: [],
relationships: [],
addressedTo: [],
},
};
const effect = await threadOpsFieldEvaluator.handle?.(handleCtx);
const mutated = { ...handleCtx.parsed } as Record<string, unknown>;
effect?.mutateResult?.(mutated as never);
expect(mutated.replyText).toBe("Stopped — partial work preserved.");
expect((mutated.contexts as string[]).includes("simple")).toBe(true);
});
});
describe("handle — non-abort ops", () => {
it("stages WORK_THREAD candidateActionNames and task contexts", async () => {
const runtime = buildFakeRuntime();
const handleCtx: ResponseHandlerFieldHandleContext<unknown> = {
...buildCtx(runtime, buildMessage("steer the research thread")),
value: [
{
type: "steer",
workThreadId: "wt-1",
instruction: "focus on bean-to-cup",
},
],
parsed: {
shouldRespond: "RESPOND",
contexts: ["simple"],
intents: [],
candidateActionNames: [],
replyText: "",
facts: [],
relationships: [],
addressedTo: [],
},
};
const effect = await threadOpsFieldEvaluator.handle?.(handleCtx);
const mutated = { ...handleCtx.parsed } as Record<string, unknown>;
effect?.mutateResult?.(mutated as never);
expect(mutated.candidateActionNames).toContain("WORK_THREAD");
expect(mutated.contexts).toContain("tasks");
expect(mutated.contexts).toContain("messaging");
// Should drop 'simple' since we need the planner
expect(mutated.contexts).not.toContain("simple");
// No preempt for non-abort ops
expect(effect?.preempt).toBeUndefined();
});
it("returns undefined for empty ops array", async () => {
const runtime = buildFakeRuntime();
const handleCtx: ResponseHandlerFieldHandleContext<unknown> = {
...buildCtx(runtime),
value: [],
parsed: {
shouldRespond: "RESPOND",
contexts: [],
intents: [],
candidateActionNames: [],
replyText: "",
facts: [],
relationships: [],
addressedTo: [],
},
};
const effect = await threadOpsFieldEvaluator.handle?.(handleCtx);
expect(effect).toBeUndefined();
});
});
});