Includes full bot source (Phases 1–4), plus five new features: - Epic 1: emoji reaction state machine (👀⏳✅🎲) + burst queue cap at 2 with in-world drop notices - Epic 2: per-encounter tone field in YAML injected into LLM system prompt - Epic 3: player pronouns via modal registration + system prompt players block - Epic 4: strengthened skill_check_emit tool contract + missed-skill-check diagnostic Also includes UX design docs, epics, and story files under Docs/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
85 lines
3.2 KiB
TypeScript
85 lines
3.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import { assembleContext } from '../../src/harness/contextAssembler.js';
|
||
import { mockSession, mockSpec } from '../fixtures/spec.js';
|
||
import type { SessionState, ChatMessage } from '../../src/types/index.js';
|
||
|
||
function makeMessage(role: ChatMessage['role'], content: string, pinned = false): ChatMessage {
|
||
return { role, content, pinned, timestamp: Date.now() };
|
||
}
|
||
|
||
describe('assembleContext', () => {
|
||
it('puts the system message first', () => {
|
||
const context = assembleContext(mockSession);
|
||
expect(context[0].role).toBe('system');
|
||
});
|
||
|
||
it('includes the system prompt content', () => {
|
||
const context = assembleContext(mockSession);
|
||
expect(context[0].content).toContain('narrator');
|
||
});
|
||
|
||
it('always includes pinned messages after system', () => {
|
||
const session: SessionState = {
|
||
...mockSession,
|
||
history: [
|
||
makeMessage('assistant', 'Opening narrative.', true),
|
||
makeMessage('user', 'Player action.'),
|
||
makeMessage('assistant', 'LLM response.'),
|
||
],
|
||
};
|
||
const context = assembleContext(session);
|
||
const pinned = context.filter(m => m.pinned && m.role !== 'system');
|
||
expect(pinned).toHaveLength(1);
|
||
expect(pinned[0].content).toBe('Opening narrative.');
|
||
});
|
||
|
||
it('includes sliding history messages', () => {
|
||
const session: SessionState = {
|
||
...mockSession,
|
||
history: [
|
||
makeMessage('user', 'Player says something.'),
|
||
makeMessage('assistant', 'Narrator responds.'),
|
||
],
|
||
};
|
||
const context = assembleContext(session);
|
||
const nonSystem = context.filter(m => !m.pinned);
|
||
expect(nonSystem.some(m => m.content === 'Player says something.')).toBe(true);
|
||
});
|
||
|
||
it('injects NPC memory into the system prompt', () => {
|
||
const session: SessionState = {
|
||
...mockSession,
|
||
npcMemories: {
|
||
'npc-one': 'Past encounters witnessed:\n - [2026-01-01] Tavern Brawl: A fight broke out.',
|
||
},
|
||
};
|
||
const context = assembleContext(session);
|
||
expect(context[0].content).toContain('Tavern Brawl');
|
||
});
|
||
|
||
it('drops oldest non-pinned pairs when history exceeds budget', () => {
|
||
// Use natural language so BPE tokenisation produces realistic token counts.
|
||
// Repeated single characters compress to almost nothing in BPE.
|
||
const bigContent = 'the quick brown fox jumps over the lazy dog. '.repeat(100);
|
||
const history: ChatMessage[] = [
|
||
makeMessage('assistant', 'Opening narrative pinned.', true),
|
||
];
|
||
// 200 pairs × ~1 000 tokens each ≈ 200 000 tokens >> 114 500 budget
|
||
for (let i = 0; i < 200; i++) {
|
||
history.push(makeMessage('user', `${bigContent} turn ${i}`));
|
||
history.push(makeMessage('assistant', `${bigContent} response ${i}`));
|
||
}
|
||
|
||
const session: SessionState = { ...mockSession, history };
|
||
const context = assembleContext(session);
|
||
|
||
// Pinned message must survive trimming
|
||
const pinnedInContext = context.filter(m => m.pinned && m.role !== 'system');
|
||
expect(pinnedInContext).toHaveLength(1);
|
||
|
||
// Sliding window should be well under the 400 we pushed in
|
||
const sliding = context.filter(m => !m.pinned);
|
||
expect(sliding.length).toBeLessThan(400);
|
||
});
|
||
});
|