mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
feat: Implement actor prompt builder
This commit is contained in:
219
tests/integration/actor-monologue.test.ts
Normal file
219
tests/integration/actor-monologue.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import {
|
||||
WorldState,
|
||||
Entity,
|
||||
SQLiteRepository,
|
||||
AttributeVisibility,
|
||||
} from "@omnia/core";
|
||||
import { MockLLMProvider } from "@omnia/llm";
|
||||
import { IntentSequence } from "@omnia/intent";
|
||||
import { Architect } from "@omnia/architect";
|
||||
import {
|
||||
BufferRepository,
|
||||
BufferEntry,
|
||||
} from "@omnia/memory";
|
||||
import {
|
||||
ActorAgent,
|
||||
ActorResponseSchema,
|
||||
buildBufferEntryForIntent,
|
||||
} from "@omnia/actor";
|
||||
|
||||
describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
|
||||
test("actor produces prose → decoder splits into dialogue/action/monologue → architect bypasses monologue", async () => {
|
||||
const db = new Database(":memory:");
|
||||
const coreRepo = new SQLiteRepository(db);
|
||||
const bufferRepo = new BufferRepository(db);
|
||||
|
||||
const startTime = new Date("2026-07-09T12:00:00.000Z");
|
||||
const world = new WorldState("world-actor", startTime);
|
||||
world.addAttribute("location", "Tavern Cellar", AttributeVisibility.PUBLIC);
|
||||
|
||||
const alice = new Entity("alice", "cellar-1");
|
||||
alice.addAttribute("name", "Alice", AttributeVisibility.PUBLIC);
|
||||
alice.addAttribute("role", "rogue", AttributeVisibility.PUBLIC);
|
||||
// A private, self-visible attribute (explicitly ACL'd to self).
|
||||
alice.addAttribute(
|
||||
"secret_goal",
|
||||
"Steal the ledger without being noticed.",
|
||||
AttributeVisibility.PRIVATE,
|
||||
new Set(["alice"]),
|
||||
);
|
||||
world.addEntity(alice);
|
||||
|
||||
const bob = new Entity("bob", "cellar-1");
|
||||
bob.addAttribute("name", "Bob", AttributeVisibility.PUBLIC);
|
||||
bob.addAttribute("role", "guard", AttributeVisibility.PUBLIC);
|
||||
world.addEntity(bob);
|
||||
|
||||
// Alice knows Bob by name.
|
||||
alice.aliases.set("bob", "Bob");
|
||||
|
||||
coreRepo.saveWorldState(world);
|
||||
|
||||
// --- Mock LLM response queue ---
|
||||
// 1. Actor produces prose containing a thought, a spoken line, and an action.
|
||||
const mockActorProse = { narrativeProse: "I can't believe Bob hasn't noticed me yet, Alice thought. \"Hey Bob,\" she called out softly. She reached for the ledger on the table." };
|
||||
|
||||
// 2. IntentDecoder splits that prose into 3 intents.
|
||||
const mockDecodedSequence: IntentSequence = {
|
||||
intents: [
|
||||
{
|
||||
type: "monologue",
|
||||
originalText: "I can't believe Bob hasn't noticed me yet, Alice thought.",
|
||||
description: "Alice internally reflects that Bob has not noticed her.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
{
|
||||
type: "dialogue",
|
||||
originalText: '"Hey Bob," she called out softly.',
|
||||
description: "Alice softly calls out to Bob.",
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She reached for the ledger on the table.",
|
||||
description: "Alice reaches for the ledger on the table.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 3. Architect: dialogue is always valid (0 min), action is valid (2 min).
|
||||
// NOTE: monologue never reaches the validator/delta generator.
|
||||
const mockDialogueValidation = { isValid: true, reason: "Alice can speak." };
|
||||
const mockDialogueTimeDelta = { minutesToAdvance: 0, explanation: "Speech is instantaneous." };
|
||||
const mockActionValidation = { isValid: true, reason: "The ledger is within reach." };
|
||||
const mockActionTimeDelta = { minutesToAdvance: 2, explanation: "Reaching for the ledger takes 2 minutes." };
|
||||
|
||||
const llmProvider = new MockLLMProvider([
|
||||
mockActorProse, // 1. Actor generation
|
||||
mockDecodedSequence, // 2. IntentDecoder
|
||||
mockDialogueValidation, // 3. Architect.validateIntent (dialogue)
|
||||
mockDialogueTimeDelta, // 4. TimeDeltaGenerator (dialogue)
|
||||
mockActionValidation, // 5. Architect.validateIntent (action)
|
||||
mockActionTimeDelta, // 6. TimeDeltaGenerator (action)
|
||||
]);
|
||||
|
||||
const actor = new ActorAgent(llmProvider, bufferRepo);
|
||||
const architect = new Architect(llmProvider, coreRepo);
|
||||
|
||||
// 1. Actor acts
|
||||
const turn = await actor.act(world, alice);
|
||||
|
||||
expect(turn.narrativeProse).toBe(mockActorProse.narrativeProse);
|
||||
expect(turn.intents.intents).toHaveLength(3);
|
||||
expect(turn.intents.intents[0].type).toBe("monologue");
|
||||
expect(turn.intents.intents[1].type).toBe("dialogue");
|
||||
expect(turn.intents.intents[2].type).toBe("action");
|
||||
|
||||
const intents = turn.intents.intents;
|
||||
const writtenEntries: BufferEntry[] = [];
|
||||
|
||||
// 2. Process each intent through the Architect and write memory.
|
||||
for (const intent of intents) {
|
||||
const result = await architect.processIntent(world, intent);
|
||||
|
||||
const entry = buildBufferEntryForIntent(
|
||||
intent,
|
||||
world.clock.get().toISOString(),
|
||||
alice.locationId,
|
||||
);
|
||||
|
||||
// For action intents, attach the validation outcome.
|
||||
if (intent.type === "action") {
|
||||
entry.outcome = { isValid: result.isValid, reason: result.reason };
|
||||
}
|
||||
|
||||
bufferRepo.save(entry);
|
||||
writtenEntries.push(entry);
|
||||
}
|
||||
|
||||
// 3. Monologue bypassed validation: clock did NOT advance for it,
|
||||
// and no outcome was attached to its buffer entry.
|
||||
expect(intents[0].type).toBe("monologue");
|
||||
expect(writtenEntries[0].outcome).toBeUndefined();
|
||||
|
||||
// 4. Dialogue: valid, 0-minute delta, no outcome field.
|
||||
expect(writtenEntries[1].outcome).toBeUndefined();
|
||||
|
||||
// 5. Action: valid, 2-minute delta, outcome attached.
|
||||
expect(writtenEntries[2].outcome).toEqual({
|
||||
isValid: true,
|
||||
reason: "The ledger is within reach.",
|
||||
});
|
||||
|
||||
// 6. Clock advanced by exactly 2 minutes (dialogue 0 + action 2).
|
||||
const expectedTime = new Date(startTime.getTime() + 2 * 60_000);
|
||||
expect(world.clock.get().toISOString()).toBe(expectedTime.toISOString());
|
||||
|
||||
// 7. All three intents persisted to Alice's memory buffer.
|
||||
const aliceMemory = bufferRepo.listForOwner("alice");
|
||||
expect(aliceMemory).toHaveLength(3);
|
||||
expect(aliceMemory[0].intent.type).toBe("monologue");
|
||||
expect(aliceMemory[1].intent.type).toBe("dialogue");
|
||||
expect(aliceMemory[2].intent.type).toBe("action");
|
||||
|
||||
// 8. Monologue entry has no outcome; action entry does.
|
||||
expect(aliceMemory[0].outcome).toBeUndefined();
|
||||
expect(aliceMemory[2].outcome).toBeDefined();
|
||||
expect(aliceMemory[2].outcome!.isValid).toBe(true);
|
||||
|
||||
// 9. Monologue did NOT touch persisted world clock (only the action did).
|
||||
const reloaded = coreRepo.loadWorldState("world-actor")!;
|
||||
expect(reloaded.clock.get().toISOString()).toBe(expectedTime.toISOString());
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("ActorResponseSchema validates prose output shape", () => {
|
||||
const valid = { narrativeProse: "Alice thought quietly." };
|
||||
expect(ActorResponseSchema.parse(valid)).toEqual(valid);
|
||||
|
||||
expect(() => ActorResponseSchema.parse({})).toThrow();
|
||||
expect(() =>
|
||||
ActorResponseSchema.parse({ narrativeProse: 123 }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("serializeSubjectiveWorldState is epistemically bounded", async () => {
|
||||
const { serializeSubjectiveWorldState } = await import("@omnia/core");
|
||||
|
||||
const world = new WorldState("world-subj");
|
||||
const alice = new Entity("alice", "room-1");
|
||||
alice.addAttribute("name", "Alice", AttributeVisibility.PUBLIC);
|
||||
alice.addAttribute(
|
||||
"secret",
|
||||
"hidden truth",
|
||||
AttributeVisibility.PRIVATE,
|
||||
new Set(["alice"]),
|
||||
);
|
||||
world.addEntity(alice);
|
||||
|
||||
const bob = new Entity("bob", "room-1");
|
||||
bob.addAttribute("name", "Bob", AttributeVisibility.PUBLIC);
|
||||
bob.addAttribute(
|
||||
"bob_secret",
|
||||
"bob's hidden truth",
|
||||
AttributeVisibility.PRIVATE,
|
||||
new Set(["bob"]),
|
||||
);
|
||||
world.addEntity(bob);
|
||||
|
||||
const view = serializeSubjectiveWorldState(world, "alice");
|
||||
|
||||
// Alice sees her own secret (explicitly ACL'd).
|
||||
expect(view).toContain("secret: hidden truth");
|
||||
// Alice sees Bob's public name.
|
||||
expect(view).toContain("name: Bob");
|
||||
// Alice does NOT see Bob's private attribute.
|
||||
expect(view).not.toContain("bob's hidden truth");
|
||||
// Alice perceives herself as "you".
|
||||
expect(view).toContain("Self (you)");
|
||||
// Bob is an unfamiliar figure (no alias set).
|
||||
expect(view).toContain("an unfamiliar figure");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user