426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
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
829 lines
25 KiB
TypeScript
829 lines
25 KiB
TypeScript
/**
|
|
* Integration coverage for LifeOps work threads: routing, guarding, follow-up, and
|
|
* active-thread capping, plus multi-user/multi-channel thread-boundary enforcement with
|
|
* current-channel merge. Real scheduler over a mocked runtime.
|
|
*/
|
|
import type {
|
|
ActionResult,
|
|
HandlerOptions,
|
|
IAgentRuntime,
|
|
Memory,
|
|
ResponseHandlerFieldContext,
|
|
ResponseHandlerResult,
|
|
State,
|
|
} from "@elizaos/core";
|
|
import { ChannelType, setEntityRole, stringToUuid } from "@elizaos/core";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createMockedTestRuntime } from "../../../packages/test/mocks/helpers/mock-runtime.ts";
|
|
import { workThreadAction } from "../src/actions/work-thread.ts";
|
|
import { processDueScheduledTasks } from "../src/lifeops/scheduled-task/scheduler.ts";
|
|
import {
|
|
type ThreadOp,
|
|
threadOpsFieldEvaluator,
|
|
} from "../src/lifeops/work-threads/field-evaluator-thread-ops.ts";
|
|
import { createWorkThreadStore } from "../src/lifeops/work-threads/store.ts";
|
|
import { workThreadsProvider } from "../src/providers/work-threads.ts";
|
|
|
|
let cleanupRuntime: (() => Promise<void>) | undefined;
|
|
|
|
vi.setConfig({ testTimeout: 120_000 });
|
|
|
|
afterEach(async () => {
|
|
await cleanupRuntime?.();
|
|
cleanupRuntime = undefined;
|
|
});
|
|
|
|
async function createRuntime(): Promise<IAgentRuntime> {
|
|
const mocked = await createMockedTestRuntime({
|
|
envs: [],
|
|
seedGoogle: false,
|
|
seedX: false,
|
|
seedBenchmarkFixtures: false,
|
|
withLLM: false,
|
|
});
|
|
cleanupRuntime = mocked.cleanup;
|
|
return mocked.runtime;
|
|
}
|
|
|
|
function message(
|
|
runtime: IAgentRuntime,
|
|
roomId: string,
|
|
text: string,
|
|
options: {
|
|
entityId?: string;
|
|
source?: string;
|
|
channelType?: string;
|
|
groupName?: string;
|
|
metadata?: Record<string, unknown>;
|
|
} = {},
|
|
): Memory {
|
|
const source = options.source ?? "test";
|
|
return {
|
|
id: `${roomId}:message:${Math.random().toString(36).slice(2)}` as Memory["id"],
|
|
entityId: (options.entityId ?? runtime.agentId) as Memory["entityId"],
|
|
roomId: roomId as Memory["roomId"],
|
|
agentId: runtime.agentId,
|
|
content: {
|
|
text,
|
|
source,
|
|
channelType: options.channelType ?? "dm",
|
|
},
|
|
metadata: {
|
|
provider: source,
|
|
group: { name: options.groupName ?? roomId },
|
|
...options.metadata,
|
|
},
|
|
createdAt: Date.now(),
|
|
} as Memory;
|
|
}
|
|
|
|
async function connectParticipant(args: {
|
|
runtime: IAgentRuntime;
|
|
worldId: string;
|
|
roomId: string;
|
|
entityId: string;
|
|
source: string;
|
|
name: string;
|
|
channelType?: string;
|
|
}): Promise<void> {
|
|
await args.runtime.ensureConnection({
|
|
entityId: args.entityId as never,
|
|
roomId: args.roomId as never,
|
|
worldId: args.worldId as never,
|
|
worldName: "LifeOps Test World",
|
|
userName: args.name,
|
|
name: args.name,
|
|
source: args.source,
|
|
type: args.channelType ?? ChannelType.GROUP,
|
|
channelId: args.roomId,
|
|
});
|
|
}
|
|
|
|
async function grantWorldRole(
|
|
runtime: IAgentRuntime,
|
|
roomId: string,
|
|
entityId: string,
|
|
role: "OWNER" | "USER",
|
|
): Promise<void> {
|
|
await setEntityRole(
|
|
runtime,
|
|
message(runtime, roomId, "seed role", { entityId }),
|
|
entityId,
|
|
role,
|
|
);
|
|
}
|
|
|
|
async function runThreadAction(
|
|
runtime: IAgentRuntime,
|
|
msg: Memory,
|
|
operations: unknown[],
|
|
): Promise<ActionResult> {
|
|
const result = await workThreadAction.handler?.(
|
|
runtime,
|
|
msg,
|
|
{ values: {}, data: {}, text: "" } as State,
|
|
{ parameters: { operations } } as HandlerOptions,
|
|
undefined,
|
|
);
|
|
if (!result || typeof result !== "object" || !("success" in result)) {
|
|
throw new Error("workThreadAction returned a non-ActionResult");
|
|
}
|
|
return result as ActionResult;
|
|
}
|
|
|
|
function operationResults(
|
|
result: ActionResult,
|
|
): Array<Record<string, unknown>> {
|
|
const operations = result.data?.operations;
|
|
if (!Array.isArray(operations)) {
|
|
throw new Error("expected thread operation results");
|
|
}
|
|
return operations as Array<Record<string, unknown>>;
|
|
}
|
|
|
|
function fieldContext(
|
|
runtime: IAgentRuntime,
|
|
msg: Memory,
|
|
): ResponseHandlerFieldContext {
|
|
return {
|
|
runtime,
|
|
message: msg,
|
|
state: { values: {}, data: {}, text: "" } as State,
|
|
senderRole: "OWNER",
|
|
turnSignal: new AbortController().signal,
|
|
};
|
|
}
|
|
|
|
function responseHandlerResult(): ResponseHandlerResult {
|
|
return {
|
|
shouldRespond: "RESPOND",
|
|
contexts: [],
|
|
intents: [],
|
|
candidateActionNames: [],
|
|
replyText: "",
|
|
facts: [],
|
|
relationships: [],
|
|
addressedTo: [],
|
|
};
|
|
}
|
|
|
|
async function applyThreadOpsField(
|
|
runtime: IAgentRuntime,
|
|
msg: Memory,
|
|
ops: ThreadOp[],
|
|
): Promise<ResponseHandlerResult> {
|
|
const parsed = responseHandlerResult();
|
|
parsed.threadOps = ops;
|
|
const effect = await threadOpsFieldEvaluator.handle?.({
|
|
...fieldContext(runtime, msg),
|
|
value: ops,
|
|
parsed,
|
|
});
|
|
effect?.mutateResult?.(parsed);
|
|
return parsed;
|
|
}
|
|
|
|
describe("LifeOps work threads", () => {
|
|
it("routes, guards, follows up, and caps active thread work", async () => {
|
|
const runtime = await createRuntime();
|
|
const idleRoom = message(runtime, "room-idle", "hello there");
|
|
expect(await workThreadAction.validate?.(runtime, idleRoom)).toBe(false);
|
|
expect(
|
|
await threadOpsFieldEvaluator.shouldRun?.(
|
|
fieldContext(runtime, idleRoom),
|
|
),
|
|
).toBe(false);
|
|
const idleProviderResult = await workThreadsProvider.get(
|
|
runtime,
|
|
idleRoom,
|
|
{ values: {}, data: {}, text: "" } as State,
|
|
);
|
|
expect(idleProviderResult.values?.workThreadCount).toBe(0);
|
|
|
|
const roomA = message(runtime, "room-a", "start a thread for this work");
|
|
const roomB = message(runtime, "room-b", "continue this thread over here");
|
|
expect(await workThreadAction.validate?.(runtime, roomA)).toBe(true);
|
|
|
|
const created = await runThreadAction(runtime, roomA, [
|
|
{
|
|
type: "create",
|
|
title: "Visa renewal",
|
|
summary: "Track the renewal work.",
|
|
instruction: "Keep the renewal moving.",
|
|
},
|
|
]);
|
|
expect(created.success).toBe(true);
|
|
const threadId = operationResults(created)[0].workThreadId as string;
|
|
expect(threadId).toBeTruthy();
|
|
|
|
const providerResult = await workThreadsProvider.get(runtime, roomA, {
|
|
values: {},
|
|
data: {},
|
|
text: "",
|
|
} as State);
|
|
expect(providerResult.values?.workThreadCount).toBe(1);
|
|
expect(providerResult.text).toContain(threadId);
|
|
expect(providerResult.text).toContain("mutable-current-channel");
|
|
|
|
expect(
|
|
await threadOpsFieldEvaluator.shouldRun?.(fieldContext(runtime, roomA)),
|
|
).toBe(true);
|
|
const staged = await applyThreadOpsField(runtime, roomA, [
|
|
{
|
|
type: "steer",
|
|
workThreadId: threadId,
|
|
instruction: "Keep the renewal moving.",
|
|
},
|
|
]);
|
|
expect(staged.candidateActionNames).toContain("work_thread");
|
|
expect(staged.contexts).toEqual(
|
|
expect.arrayContaining(["tasks", "messaging", "automation"]),
|
|
);
|
|
|
|
const crossChannelSteer = await runThreadAction(runtime, roomB, [
|
|
{
|
|
type: "steer",
|
|
workThreadId: threadId,
|
|
instruction: "Move this from room B.",
|
|
},
|
|
]);
|
|
expect(crossChannelSteer.success).toBe(false);
|
|
expect(operationResults(crossChannelSteer)[0].error).toBe(
|
|
"CROSS_CHANNEL_READ_ONLY",
|
|
);
|
|
|
|
const forgedAttach = await runThreadAction(runtime, roomB, [
|
|
{
|
|
type: "attach_source",
|
|
workThreadId: threadId,
|
|
sourceRef: { connector: "test", roomId: "room-c", canMutate: true },
|
|
},
|
|
]);
|
|
expect(forgedAttach.success).toBe(false);
|
|
expect(operationResults(forgedAttach)[0].error).toBe(
|
|
"SOURCE_REF_NOT_CURRENT_CHANNEL",
|
|
);
|
|
|
|
const attached = await runThreadAction(runtime, roomB, [
|
|
{ type: "attach_source", workThreadId: threadId },
|
|
]);
|
|
expect(attached.success).toBe(true);
|
|
|
|
const steered = await runThreadAction(runtime, roomB, [
|
|
{
|
|
type: "steer",
|
|
workThreadId: threadId,
|
|
instruction: "Continue the renewal from room B.",
|
|
},
|
|
]);
|
|
expect(steered.success).toBe(true);
|
|
const store = createWorkThreadStore(runtime);
|
|
const updated = await store.get(threadId);
|
|
expect(updated?.currentPlanSummary).toBe(
|
|
"Continue the renewal from room B.",
|
|
);
|
|
|
|
const noisyThreadIds: string[] = [];
|
|
const noiseRoom = message(runtime, "room-noise", "start a thread");
|
|
for (let i = 0; i < 8; i += 1) {
|
|
const noisy = await runThreadAction(runtime, noiseRoom, [
|
|
{
|
|
type: "create",
|
|
title: `Noisy channel ${i}`,
|
|
summary: "More recent work from another channel.",
|
|
},
|
|
]);
|
|
expect(noisy.success).toBe(true);
|
|
noisyThreadIds.push(operationResults(noisy)[0].workThreadId as string);
|
|
}
|
|
expect(await workThreadAction.validate?.(runtime, roomA)).toBe(true);
|
|
expect(
|
|
await threadOpsFieldEvaluator.shouldRun?.(fieldContext(runtime, roomA)),
|
|
).toBe(true);
|
|
const providerWithNoise = await workThreadsProvider.get(runtime, roomA, {
|
|
values: {},
|
|
data: {},
|
|
text: "",
|
|
} as State);
|
|
expect(providerWithNoise.text).toContain(threadId);
|
|
for (const noisyThreadId of noisyThreadIds) {
|
|
const stoppedNoise = await runThreadAction(runtime, noiseRoom, [
|
|
{ type: "stop", workThreadId: noisyThreadId },
|
|
]);
|
|
expect(stoppedNoise.success).toBe(true);
|
|
}
|
|
|
|
const mergeSource = await runThreadAction(runtime, roomA, [
|
|
{
|
|
type: "create",
|
|
title: "Merge source",
|
|
summary: "Source with refs in two rooms.",
|
|
},
|
|
]);
|
|
const mergeSourceId = operationResults(mergeSource)[0]
|
|
.workThreadId as string;
|
|
const attachedMergeSource = await runThreadAction(runtime, roomB, [
|
|
{ type: "attach_source", workThreadId: mergeSourceId },
|
|
]);
|
|
expect(attachedMergeSource.success).toBe(true);
|
|
const mergeTargetA = await runThreadAction(runtime, roomA, [
|
|
{
|
|
type: "create",
|
|
title: "Merge target A",
|
|
summary: "Competing merge target A.",
|
|
},
|
|
]);
|
|
const mergeTargetAId = operationResults(mergeTargetA)[0]
|
|
.workThreadId as string;
|
|
const mergeTargetB = await runThreadAction(runtime, roomB, [
|
|
{
|
|
type: "create",
|
|
title: "Merge target B",
|
|
summary: "Competing merge target B.",
|
|
},
|
|
]);
|
|
const mergeTargetBId = operationResults(mergeTargetB)[0]
|
|
.workThreadId as string;
|
|
const competingMerges = await Promise.all([
|
|
runThreadAction(runtime, roomA, [
|
|
{
|
|
type: "merge",
|
|
workThreadId: mergeTargetAId,
|
|
sourceWorkThreadIds: [mergeSourceId],
|
|
instruction: "Merge source into target A.",
|
|
},
|
|
]),
|
|
runThreadAction(runtime, roomB, [
|
|
{
|
|
type: "merge",
|
|
workThreadId: mergeTargetBId,
|
|
sourceWorkThreadIds: [mergeSourceId],
|
|
instruction: "Merge source into target B.",
|
|
},
|
|
]),
|
|
]);
|
|
expect(
|
|
competingMerges.filter((result) => result.success === true),
|
|
).toHaveLength(1);
|
|
expect(
|
|
competingMerges.filter((result) => result.success === false),
|
|
).toHaveLength(1);
|
|
expect(
|
|
competingMerges
|
|
.flatMap(operationResults)
|
|
.some((result) => result.error === "SOURCE_THREAD_NOT_ACTIVE"),
|
|
).toBe(true);
|
|
const mergeStore = createWorkThreadStore(runtime);
|
|
expect((await mergeStore.get(mergeSourceId))?.status).toBe("stopped");
|
|
for (const targetId of [mergeTargetAId, mergeTargetBId]) {
|
|
const maybeActive = await mergeStore.get(targetId);
|
|
if (maybeActive?.status === "active") {
|
|
const stoppedTarget = await runThreadAction(runtime, roomA, [
|
|
{ type: "stop", workThreadId: targetId },
|
|
]);
|
|
if (!stoppedTarget.success) {
|
|
await runThreadAction(runtime, roomB, [
|
|
{ type: "stop", workThreadId: targetId },
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
const scheduled = await runThreadAction(runtime, roomB, [
|
|
{
|
|
type: "schedule_followup",
|
|
workThreadId: threadId,
|
|
instruction: "Ask for the next visa document tomorrow.",
|
|
trigger: { kind: "once", atIso: "2026-05-11T18:00:00.000Z" },
|
|
},
|
|
]);
|
|
expect(scheduled.success).toBe(true);
|
|
const taskId = operationResults(scheduled)[0].taskId as string;
|
|
expect(taskId).toBeTruthy();
|
|
const withTask = await store.get(threadId);
|
|
expect(withTask?.currentScheduledTaskId).toBe(taskId);
|
|
|
|
const processed = await processDueScheduledTasks({
|
|
runtime,
|
|
agentId: runtime.agentId,
|
|
now: new Date("2026-05-11T18:01:00.000Z"),
|
|
limit: 5,
|
|
});
|
|
expect(processed.errors).toEqual([]);
|
|
expect(processed.fires).toEqual([
|
|
{
|
|
taskId,
|
|
status: "fired",
|
|
reason: "once_due",
|
|
occurrenceAtIso: "2026-05-11T18:00:00.000Z",
|
|
},
|
|
]);
|
|
|
|
const secondProcess = await processDueScheduledTasks({
|
|
runtime,
|
|
agentId: runtime.agentId,
|
|
now: new Date("2026-05-11T18:02:00.000Z"),
|
|
limit: 5,
|
|
});
|
|
expect(secondProcess.fires).toEqual([]);
|
|
expect(secondProcess.errors).toEqual([]);
|
|
|
|
for (let i = 1; i < 30; i += 1) {
|
|
const fill = await runThreadAction(runtime, roomA, [
|
|
{
|
|
type: "create",
|
|
title: `Pool slot ${i}`,
|
|
summary: "Fill the active work-thread pool.",
|
|
},
|
|
]);
|
|
expect(fill.success).toBe(true);
|
|
}
|
|
|
|
const overflow = await runThreadAction(runtime, roomA, [
|
|
{
|
|
type: "create",
|
|
title: "Overflow",
|
|
summary: "Should not start while the active pool is full.",
|
|
},
|
|
]);
|
|
expect(overflow.success).toBe(false);
|
|
expect(operationResults(overflow)[0].error).toBe("THREAD_POOL_FULL");
|
|
});
|
|
|
|
it("enforces multi-user and multi-channel thread boundaries while allowing current-channel merge", async () => {
|
|
const runtime = await createRuntime();
|
|
const worldId = stringToUuid("lifeops-work-thread-boundary-world");
|
|
const sharedRoom = stringToUuid("lifeops-work-thread-shared-room");
|
|
const ownerAPrivateRoom = stringToUuid(
|
|
"lifeops-work-thread-owner-a-private-room",
|
|
);
|
|
const ownerBPrivateRoom = stringToUuid(
|
|
"lifeops-work-thread-owner-b-private-room",
|
|
);
|
|
const ownerA = stringToUuid("lifeops-work-thread-owner-a");
|
|
const ownerB = stringToUuid("lifeops-work-thread-owner-b");
|
|
const channelUser = stringToUuid("lifeops-work-thread-channel-user");
|
|
|
|
for (const participant of [
|
|
{
|
|
runtime,
|
|
worldId,
|
|
roomId: sharedRoom,
|
|
entityId: ownerA,
|
|
source: "discord",
|
|
name: "Owner A",
|
|
},
|
|
{
|
|
runtime,
|
|
worldId,
|
|
roomId: sharedRoom,
|
|
entityId: ownerB,
|
|
source: "discord",
|
|
name: "Owner B",
|
|
},
|
|
{
|
|
runtime,
|
|
worldId,
|
|
roomId: sharedRoom,
|
|
entityId: channelUser,
|
|
source: "discord",
|
|
name: "Channel User",
|
|
},
|
|
{
|
|
runtime,
|
|
worldId,
|
|
roomId: ownerAPrivateRoom,
|
|
entityId: ownerA,
|
|
source: "signal",
|
|
name: "Owner A",
|
|
channelType: ChannelType.DM,
|
|
},
|
|
{
|
|
runtime,
|
|
worldId,
|
|
roomId: ownerBPrivateRoom,
|
|
entityId: ownerB,
|
|
source: "telegram",
|
|
name: "Owner B",
|
|
channelType: ChannelType.DM,
|
|
},
|
|
]) {
|
|
await connectParticipant(participant);
|
|
}
|
|
|
|
await grantWorldRole(runtime, sharedRoom, ownerA, "OWNER");
|
|
await grantWorldRole(runtime, sharedRoom, ownerB, "OWNER");
|
|
await grantWorldRole(runtime, sharedRoom, channelUser, "USER");
|
|
|
|
const sharedMessageA = (text: string) =>
|
|
message(runtime, sharedRoom, text, {
|
|
entityId: ownerA,
|
|
source: "discord",
|
|
channelType: ChannelType.GROUP,
|
|
groupName: "Project Room",
|
|
});
|
|
const sharedMessageB = (text: string) =>
|
|
message(runtime, sharedRoom, text, {
|
|
entityId: ownerB,
|
|
source: "discord",
|
|
channelType: ChannelType.GROUP,
|
|
groupName: "Project Room",
|
|
});
|
|
const sharedMessageUser = (text: string) =>
|
|
message(runtime, sharedRoom, text, {
|
|
entityId: channelUser,
|
|
source: "discord",
|
|
channelType: ChannelType.GROUP,
|
|
groupName: "Project Room",
|
|
});
|
|
const privateMessageA = (text: string) =>
|
|
message(runtime, ownerAPrivateRoom, text, {
|
|
entityId: ownerA,
|
|
source: "signal",
|
|
channelType: ChannelType.DM,
|
|
groupName: "Owner A Signal",
|
|
});
|
|
const privateMessageB = (text: string) =>
|
|
message(runtime, ownerBPrivateRoom, text, {
|
|
entityId: ownerB,
|
|
source: "telegram",
|
|
channelType: ChannelType.DM,
|
|
groupName: "Owner B Telegram",
|
|
});
|
|
|
|
const createdSharedA = await runThreadAction(
|
|
runtime,
|
|
sharedMessageA("start a thread for the launch plan"),
|
|
[
|
|
{
|
|
type: "create",
|
|
title: "Launch plan",
|
|
summary: "Coordinate the shared launch.",
|
|
instruction: "Track launch milestones.",
|
|
},
|
|
],
|
|
);
|
|
const sharedPlanId = operationResults(createdSharedA)[0]
|
|
.workThreadId as string;
|
|
|
|
const createdSharedB = await runThreadAction(
|
|
runtime,
|
|
sharedMessageB("start a thread for launch budget"),
|
|
[
|
|
{
|
|
type: "create",
|
|
title: "Launch budget",
|
|
summary: "Track the shared launch budget.",
|
|
instruction: "Track budget approvals.",
|
|
},
|
|
],
|
|
);
|
|
const sharedBudgetId = operationResults(createdSharedB)[0]
|
|
.workThreadId as string;
|
|
|
|
const createdPrivateA = await runThreadAction(
|
|
runtime,
|
|
privateMessageA("start a private thread for passport renewal"),
|
|
[
|
|
{
|
|
type: "create",
|
|
title: "Passport renewal",
|
|
summary: "Private owner A renewal work.",
|
|
},
|
|
],
|
|
);
|
|
const privateAId = operationResults(createdPrivateA)[0]
|
|
.workThreadId as string;
|
|
|
|
const createdPrivateB = await runThreadAction(
|
|
runtime,
|
|
privateMessageB("start a private thread for tax paperwork"),
|
|
[
|
|
{
|
|
type: "create",
|
|
title: "Tax paperwork",
|
|
summary: "Private owner B tax work.",
|
|
},
|
|
],
|
|
);
|
|
const privateBId = operationResults(createdPrivateB)[0]
|
|
.workThreadId as string;
|
|
|
|
const state = { values: {}, data: {}, text: "" } as State;
|
|
const ownerAProvider = await workThreadsProvider.get(
|
|
runtime,
|
|
sharedMessageA("continue this thread"),
|
|
state,
|
|
);
|
|
expect(ownerAProvider.text).toContain(sharedPlanId);
|
|
expect(ownerAProvider.text).toContain(sharedBudgetId);
|
|
expect(ownerAProvider.text).toContain(privateAId);
|
|
expect(ownerAProvider.text).not.toContain(privateBId);
|
|
expect(ownerAProvider.text).toContain("read-only-cross-channel");
|
|
|
|
const ownerBProvider = await workThreadsProvider.get(
|
|
runtime,
|
|
sharedMessageB("continue this thread"),
|
|
state,
|
|
);
|
|
expect(ownerBProvider.text).toContain(sharedPlanId);
|
|
expect(ownerBProvider.text).toContain(sharedBudgetId);
|
|
expect(ownerBProvider.text).toContain(privateBId);
|
|
expect(ownerBProvider.text).not.toContain(privateAId);
|
|
|
|
const userMessage = sharedMessageUser("continue this thread");
|
|
expect(await workThreadAction.validate?.(runtime, userMessage)).toBe(false);
|
|
expect(
|
|
await threadOpsFieldEvaluator.shouldRun?.(
|
|
fieldContext(runtime, userMessage),
|
|
),
|
|
).toBe(false);
|
|
const userProvider = await workThreadsProvider.get(
|
|
runtime,
|
|
userMessage,
|
|
state,
|
|
);
|
|
expect(userProvider.values?.workThreadCount).toBe(0);
|
|
const deniedUserAction = await runThreadAction(runtime, userMessage, [
|
|
{
|
|
type: "steer",
|
|
workThreadId: sharedPlanId,
|
|
instruction: "Non-owner should not steer shared work.",
|
|
},
|
|
]);
|
|
expect(deniedUserAction.success).toBe(false);
|
|
expect(deniedUserAction.data?.error).toBe("PERMISSION_DENIED");
|
|
|
|
const ownerBSteersShared = await runThreadAction(
|
|
runtime,
|
|
sharedMessageB("continue this thread"),
|
|
[
|
|
{
|
|
type: "steer",
|
|
workThreadId: sharedPlanId,
|
|
instruction: "Owner B can steer channel-scoped launch work.",
|
|
},
|
|
],
|
|
);
|
|
expect(ownerBSteersShared.success).toBe(true);
|
|
|
|
const ownerBPrivateADenied = await runThreadAction(
|
|
runtime,
|
|
sharedMessageB("continue this thread"),
|
|
[
|
|
{
|
|
type: "steer",
|
|
workThreadId: privateAId,
|
|
instruction: "Owner B must not steer owner A private work.",
|
|
},
|
|
],
|
|
);
|
|
expect(ownerBPrivateADenied.success).toBe(false);
|
|
expect(operationResults(ownerBPrivateADenied)[0].error).toBe(
|
|
"CROSS_CHANNEL_READ_ONLY",
|
|
);
|
|
|
|
const ownerAPrivateADeniedBeforeAttach = await runThreadAction(
|
|
runtime,
|
|
sharedMessageA("continue this thread"),
|
|
[
|
|
{
|
|
type: "steer",
|
|
workThreadId: privateAId,
|
|
instruction: "Owner A still needs current-channel attachment.",
|
|
},
|
|
],
|
|
);
|
|
expect(ownerAPrivateADeniedBeforeAttach.success).toBe(false);
|
|
expect(operationResults(ownerAPrivateADeniedBeforeAttach)[0].error).toBe(
|
|
"CROSS_CHANNEL_READ_ONLY",
|
|
);
|
|
|
|
const attachedPrivateA = await runThreadAction(
|
|
runtime,
|
|
sharedMessageA("continue this thread"),
|
|
[{ type: "attach_source", workThreadId: privateAId }],
|
|
);
|
|
expect(attachedPrivateA.success).toBe(true);
|
|
const ownerASteersAfterAttach = await runThreadAction(
|
|
runtime,
|
|
sharedMessageA("continue this thread"),
|
|
[
|
|
{
|
|
type: "steer",
|
|
workThreadId: privateAId,
|
|
instruction: "Owner A may steer after attaching this channel.",
|
|
},
|
|
],
|
|
);
|
|
expect(ownerASteersAfterAttach.success).toBe(true);
|
|
|
|
const ownerBProviderAfterAttach = await workThreadsProvider.get(
|
|
runtime,
|
|
sharedMessageB("continue this thread"),
|
|
state,
|
|
);
|
|
expect(ownerBProviderAfterAttach.text).toContain(privateAId);
|
|
expect(ownerBProviderAfterAttach.text).toContain("mutable-current-channel");
|
|
|
|
const crossChannelMergeDenied = await runThreadAction(
|
|
runtime,
|
|
sharedMessageB("merge these threads"),
|
|
[
|
|
{
|
|
type: "merge",
|
|
workThreadId: sharedPlanId,
|
|
sourceWorkThreadIds: [privateBId],
|
|
instruction: "Do not merge private cross-channel work directly.",
|
|
},
|
|
],
|
|
);
|
|
expect(crossChannelMergeDenied.success).toBe(false);
|
|
expect(operationResults(crossChannelMergeDenied)[0]).toMatchObject({
|
|
error: "CROSS_CHANNEL_READ_ONLY",
|
|
sourceWorkThreadId: privateBId,
|
|
});
|
|
|
|
const merged = await runThreadAction(
|
|
runtime,
|
|
sharedMessageA("merge these"),
|
|
[
|
|
{
|
|
type: "merge",
|
|
workThreadId: sharedPlanId,
|
|
sourceWorkThreadIds: [sharedBudgetId],
|
|
summary: "Merged launch plan and budget.",
|
|
instruction: "Treat launch plan and budget as one workstream.",
|
|
},
|
|
],
|
|
);
|
|
expect(merged.success).toBe(true);
|
|
|
|
const store = createWorkThreadStore(runtime);
|
|
const mergedTarget = await store.get(sharedPlanId);
|
|
const mergedSource = await store.get(sharedBudgetId);
|
|
const stillPrivateB = await store.get(privateBId);
|
|
expect(mergedTarget?.summary).toBe("Merged launch plan and budget.");
|
|
expect(mergedTarget?.currentPlanSummary).toBe(
|
|
"Treat launch plan and budget as one workstream.",
|
|
);
|
|
expect(mergedTarget?.participantEntityIds).toContain(ownerA);
|
|
expect(mergedTarget?.participantEntityIds).toContain(ownerB);
|
|
expect(mergedTarget?.metadata?.mergedFromWorkThreadIds).toEqual([
|
|
sharedBudgetId,
|
|
]);
|
|
expect(mergedSource?.status).toBe("stopped");
|
|
expect(mergedSource?.metadata?.mergedIntoWorkThreadId).toBe(sharedPlanId);
|
|
expect(stillPrivateB?.status).toBe("active");
|
|
|
|
const stopped = await runThreadAction(
|
|
runtime,
|
|
sharedMessageB("stop this"),
|
|
[
|
|
{
|
|
type: "stop",
|
|
workThreadId: sharedPlanId,
|
|
reason: "Merged work done.",
|
|
},
|
|
],
|
|
);
|
|
expect(stopped.success).toBe(true);
|
|
|
|
const stoppedAgain = await runThreadAction(
|
|
runtime,
|
|
sharedMessageA("stop this"),
|
|
[{ type: "stop", workThreadId: sharedPlanId }],
|
|
);
|
|
expect(stoppedAgain.success).toBe(true);
|
|
expect(operationResults(stoppedAgain)[0]).toMatchObject({
|
|
noop: true,
|
|
status: "stopped",
|
|
});
|
|
|
|
const steerStopped = await runThreadAction(
|
|
runtime,
|
|
sharedMessageA("continue this thread"),
|
|
[
|
|
{
|
|
type: "steer",
|
|
workThreadId: sharedPlanId,
|
|
instruction: "Stopped threads should not be steerable.",
|
|
},
|
|
],
|
|
);
|
|
expect(steerStopped.success).toBe(false);
|
|
expect(operationResults(steerStopped)[0]).toMatchObject({
|
|
error: "THREAD_NOT_ACTIVE",
|
|
status: "stopped",
|
|
});
|
|
|
|
const providerAfterStop = await workThreadsProvider.get(
|
|
runtime,
|
|
sharedMessageA("continue this thread"),
|
|
state,
|
|
);
|
|
expect(providerAfterStop.text).not.toContain(sharedPlanId);
|
|
expect(providerAfterStop.text).not.toContain(sharedBudgetId);
|
|
});
|
|
});
|