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
340 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|