feat: FU-13 lobby on all encounters + FU-14 TTL auto-expiry

FU-13 — lobby on ALL encounters + starter NOT pre-joined (PRD addendum 2 A/B):
- encounter.ts handleStart: remove the minPlayers>=2 branch + the solo
  immediate-begin path. EVERY /encounter start opens a lobby. The lobby opens
  EMPTY (joined:[], joinedNames:[]) — the starter (DM) is not pre-joined;
  players Join; Begin enables at minPlayers. Solo (minPlayers:1) needs one
  Join. resolveRandomizables/NPC memories/beginEncounter run on Begin
  (lobbyHandler.handleStartBtn), not in handleStart. Drops the now-dead
  resolveRandomizables import.
- TDD: tests/unit/encounterStartLobby.test.ts (RED pre-join → GREEN no-pre-join).
- Amends FR-19/20/26 — a solo behavior change (ship-and-break family).

FU-14 — TTL auto-expiry + end/expired event (PRD addendum 2 C/D):
- SessionPhase gains 'expired'. config: ENCOUNTER_INACTIVITY_TTL_HOURS (24h).
  LogEncounterParams gains kind:'ended'|'expired' (best-effort to the tool).
- new src/bot/handlers/expirySweep.ts: runExpirySweep finalizes open sessions
  whose updatedAt is past the TTL as phase:'expired' (clears in-flight checks),
  emits a GraphMCP log_encounter kind:'expired', posts an in-world notice, and
  archives the thread. Uses updatedAt (bumped on every mutation) as the activity
  timestamp — no new lastActivityAt field needed. SCAN, race-safe via atomicMutate.
- /encounter end tags log_encounter kind:'ended'.
- bot/index.ts: runExpirySweep at boot + an hourly periodic timer.
- TDD: tests/unit/expirySweep.test.ts (3 tests: idle→expired, recent left
  alone, resolved left alone).

Tests updated: group-encounter-live.test.ts lobby AC for the no-pre-join flow
(0 → player1 join → player2 join → Begin). Verified: tsc clean, 539 unit tests
pass (+4), live test skips CI-safe.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kaysser Kayyali
2026-06-22 21:27:13 +00:00
parent 465b4c80ba
commit 663dc85762
9 changed files with 344 additions and 52 deletions

View File

@@ -12,7 +12,6 @@ import { config } from '../../config.js';
import {
queryAsNPC, formatNPCMemory, logEncounter,
} from '../../graphmcp/client.js';
import { resolveRandomizables } from '../../graphmcp/loreResolver.js';
import { buildOpeningNarrative } from '../../harness/promptBuilder.js';
import { computePassiveReveals } from '../../harness/passiveReveals.js';
import { getPassiveScore } from '../../harness/characterContext.js';
@@ -181,28 +180,18 @@ async function handleStart(
reason: `Encounter: ${spec.encounterId}`,
});
// Feature D: group encounters (minPlayers >= 2) open a lobby; players Join
// and the encounter begins when Begin is pressed (re-resolves the spec then).
// Solo encounters (minPlayers <= 1) begin immediately.
if (spec.minPlayers >= 2) {
const starterProfile = await playerRegistry.get(guildId, interaction.user.id).catch(() => null);
const starterName = starterProfile?.dndName ?? interaction.user.username;
const { embed, components } = buildLobbyEmbed(spec.title, [starterName], spec.minPlayers, spec.maxPlayers, false);
const sent = await thread.send({ embeds: [embed], components });
await setLobby(thread.id, {
specName, guildId, title: spec.title, minPlayers: spec.minPlayers, maxPlayers: spec.maxPlayers,
joined: [interaction.user.id], joinedNames: [starterName], starterId: interaction.user.id, messageId: sent.id,
});
await interaction.editReply(`Lobby opened for **${spec.title}** — <#${thread.id}>. Players press Join; Begin when the minimum is met.`);
return;
}
// Solo: resolve + begin immediately.
const resolvedContext = await resolveRandomizables(spec.randomizable ?? []);
const resolvedSpec = applyResolved(spec, resolvedContext);
const npcMemories = await loadNpcMemories(resolvedSpec);
await beginEncounter(thread, resolvedSpec, resolvedContext, npcMemories, guildId, specName, {}, interaction.user.id);
await interaction.editReply(`Encounter started: <#${thread.id}>`);
// FU-13: EVERY encounter opens a lobby (solo included). The starter (the DM)
// is NOT pre-joined — the lobby opens empty; players Join; Begin enables at
// minPlayers. Solo (minPlayers:1) needs one Join to begin. resolveRandomizables,
// NPC memories, and beginEncounter run on Begin (lobbyHandler.handleStartBtn),
// not here.
const { embed, components } = buildLobbyEmbed(spec.title, [], spec.minPlayers, spec.maxPlayers, false);
const sent = await thread.send({ embeds: [embed], components });
await setLobby(thread.id, {
specName, guildId, title: spec.title, minPlayers: spec.minPlayers, maxPlayers: spec.maxPlayers,
joined: [], joinedNames: [], starterId: interaction.user.id, messageId: sent.id,
});
await interaction.editReply(`Lobby opened for **${spec.title}** — <#${thread.id}>. Players press Join; Begin when the minimum is met.`);
}
// Load NPC memories for a resolved spec (shared by solo start + lobby Begin).
@@ -463,6 +452,7 @@ async function handleEnd(interaction: ChatInputCommandInteraction): Promise<void
summary: dmNotes || `Encounter ended early by ${interaction.user.username}.`,
location: session.spec.setting.location,
type: 'encounter',
kind: 'ended',
}).catch(err => console.error('[encounter end] logEncounter failed:', err));
const dm = await playerRegistry.get(session.guildId, interaction.user.id);

View File

@@ -0,0 +1,94 @@
import type { Client, ThreadChannel } from 'discord.js';
import { redis } from '../../db/redis.js';
import { sessionManager } from '../../session/sessionManager.js';
import { config } from '../../config.js';
import { logEncounter } from '../../graphmcp/client.js';
import { log } from '../../lib/logger.js';
// Inactivity expiry sweep (FU-14). An open encounter with no activity for
// longer than ENCOUNTER_INACTIVITY_TTL_HOURS is finalized as phase:'expired'.
// "Activity" = any session mutation — `updatedAt` is bumped on every
// atomicMutate (messages, LLM turns, pending-check changes), so a stale
// `updatedAt` means a genuinely idle encounter.
//
// On expiry: phase → 'expired', in-flight pending checks cleared (an expired
// encounter can't still be awaiting a roll), a GraphMCP log_encounter event
// with kind:'expired', an in-world Discord notice, and the thread archived.
// Run at boot (alongside runRestartSweep) and on a periodic timer from
// src/bot/index.ts. Race-safe: the phase transition runs inside atomicMutate;
// a concurrent player message bumps `updatedAt` and the sweep re-checks phase
// inside the mutator. SCAN (never KEYS) so a growing keyspace doesn't block.
const EXPIRY_TTL_MS = 60 * 60 * 1000 * config.ENCOUNTER_INACTIVITY_TTL_HOURS;
// In-world voice (no utility jargon). Inline until a systemStrings module lands
// (FU-11 verify item); keep it centralized here so it's the one place to edit.
const EXPIRED_NOTICE = '*The moment passes unresolved, and the road moves on. The gathering disperses.*';
export async function runExpirySweep(client?: Client): Promise<{ scanned: number; finalized: number }> {
let scanned = 0;
let finalized = 0;
const keys = await scanSessionKeys();
const now = Date.now();
for (const key of keys) {
scanned++;
const threadId = key.replace(/^session:/, '');
const session = await sessionManager.get(threadId);
if (!session) continue;
// Only in-progress encounters can expire. Resolved/expired are terminal.
if (session.phase !== 'open' && session.phase !== 'active') continue;
const lastActivity = session.updatedAt ?? session.createdAt;
if (now - lastActivity < EXPIRY_TTL_MS) continue; // not idle long enough
// Expire: terminal phase + clear any in-flight checks (OQ-A2: yes).
await sessionManager.atomicMutate(threadId, () => ({
phase: 'expired',
pendingSkillCheck: undefined,
pendingSkillCheckAttempts: undefined,
pendingGroupCheck: undefined,
}));
// GraphMCP event — kind:'expired' (distinct from /encounter end's 'ended').
const participants = [
...session.spec.npcs.map(n => n.name),
...Object.values(session.players).map(p => p.dndName),
].join(', ');
logEncounter({
title: `${session.spec.title} — expired (inactivity)`,
participants,
summary: 'Encounter expired due to inactivity.',
location: session.spec.setting.location,
kind: 'expired',
}).catch(() => null); // best-effort — a GraphMCP failure must not block expiry
// Discord notice + archive (only when a client is available — the unit
// sweep runs without one).
if (client) {
try {
const thread = (await client.channels.fetch(threadId).catch(() => null)) as ThreadChannel | null;
if (thread?.isThread()) {
await thread.send(EXPIRED_NOTICE).catch(() => null);
await thread.setArchived(true).catch(() => null);
}
} catch {
/* best-effort */
}
}
finalized++;
}
log.info('boot', 'expiry sweep complete', { scanned, finalized });
return { scanned, finalized };
}
async function scanSessionKeys(): Promise<string[]> {
const keys: string[] = [];
let cursor = '0';
do {
const [next, batch] = await redis.scan(cursor, 'MATCH', 'session:*', 'COUNT', 100);
cursor = next;
keys.push(...batch);
} while (cursor !== '0');
return keys;
}

View File

@@ -7,6 +7,7 @@ import { handleMention } from './handlers/mentionHandler.js';
import { handleRollInteraction, isSkillCheckInteraction } from './handlers/rollHandler.js';
import { handleLobbyInteraction, isLobbyInteraction } from './handlers/lobbyHandler.js';
import { runRestartSweep } from './handlers/restartSweep.js';
import { runExpirySweep } from './handlers/expirySweep.js';
import * as dndnameCmd from './commands/dndname.js';
import * as encounterCmd from './commands/encounter.js';
import * as characterCmd from './commands/character.js';
@@ -60,6 +61,19 @@ client.once('ready', async () => {
} catch (err) {
log.error('boot', 'restart sweep failed', { error: String(err) });
}
// FU-14: finalize idle encounters as expired at boot, then re-sweep hourly.
// `updatedAt` is bumped on every session mutation, so a stale updatedAt means
// a genuinely idle encounter past ENCOUNTER_INACTIVITY_TTL_HOURS.
try {
await runExpirySweep(client);
} catch (err) {
log.error('boot', 'expiry sweep failed', { error: String(err) });
}
setInterval(() => {
void runExpirySweep(client).catch(err =>
log.error('expiry', 'periodic expiry sweep failed', { error: String(err) }),
);
}, 60 * 60 * 1000); // hourly
});
client.on('interactionCreate', async (interaction) => {

View File

@@ -93,6 +93,13 @@ const EnvSchema = z.object({
// The id allowlist itself is a runtime Set (populated by connectLiveBots
// after login), NOT a config field, because config is parsed once at import.
E2E_ALLOW_PLAYER_BOTS: z.coerce.boolean().default(false),
// ── Encounter inactivity expiry (FU-14) ───────────────────────────────────
// An open encounter with no activity (no message / LLM turn — `updatedAt` is
// bumped on every session mutation) for longer than this many hours is
// finalized by runExpirySweep as phase:'expired' (GraphMCP kind:'expired' +
// an in-world notice + thread archived). Default 24h.
ENCOUNTER_INACTIVITY_TTL_HOURS: z.coerce.number().default(24),
});
export { EnvSchema };

View File

@@ -47,6 +47,10 @@ export interface LogEncounterParams {
summary: string;
location?: string;
type?: string;
// FU-14: distinguishes how the encounter ended — 'ended' (DM ran /encounter
// end) vs 'expired' (auto-expired by inactivity TTL). Best-effort: passed to
// the log_encounter tool; servers that don't know the field drop it.
kind?: 'ended' | 'expired';
}
export interface LogEncounterResult {
@@ -159,6 +163,7 @@ export async function logEncounter(params: LogEncounterParams): Promise<LogEncou
summary: params.summary,
location: params.location ?? '',
type: params.type ?? 'encounter',
...(params.kind ? { kind: params.kind } : {}),
});
return result as LogEncounterResult;
}

View File

@@ -34,7 +34,7 @@ export interface Player {
// Session State
// ---------------------------------------------------------------------------
export type SessionPhase = 'open' | 'active' | 'resolved';
export type SessionPhase = 'open' | 'active' | 'resolved' | 'expired';
export interface PendingSkillCheck {
player: string;

View File

@@ -8,12 +8,13 @@
// called on the start path — GRAPHMCP_URL need not be host-reachable here.)
// Skipped by default → CI-safe.
//
// Topology: player1 (E2E_DRIVER_TOKEN) is the STARTER (pre-joined in the lobby
// per /encounter start); player2 (E2E_PLAYER2_TOKEN) joins. Both end up in the
// roster and act as the two players. The test asserts on the REAL lobby embed
// (Seats field + Begin button disabled/enabled) and the REAL scoreboard embed
// (Rolled field + final footer + buttons removed), plus the 👀 reaction on a
// player's message (proves it routed through handleMessage, not skipped).
// Topology (FU-13): player1 (E2E_DRIVER_TOKEN) is the STARTER — NOT pre-joined
// (the lobby opens empty; the DM creates it, players Join). player1 joins too,
// then player2 (E2E_PLAYER2_TOKEN) joins → Begin enables at minPlayers:2. Both
// end up in the roster and act as the two players. The test asserts on the REAL
// lobby embed (Seats field + Begin button disabled/enabled) and the REAL
// scoreboard embed (Rolled field + final footer + buttons removed), plus the 👀
// reaction on a player's message (proves it routed through handleMessage).
//
// The 4 gap-case ACs (FR-1114) remain a follow-up story.
@@ -67,8 +68,8 @@ describe.skipIf(!runE2E)('Multiplayer live E2E — MVP (2 player-bots, driving t
beforeAll(async () => {
bots = await connectLiveBots();
expect(bots.players.length, 'multiplayer E2E needs 2 player-bots').toBeGreaterThanOrEqual(2);
player1Id = bots.players[0].user!.id; // the starter (pre-joined)
player2Id = bots.players[1].user!.id; // the joiner
player1Id = bots.players[0].user!.id; // the starter (joins too — FU-13: no pre-join)
player2Id = bots.players[1].user!.id; // the second joiner
await flushRedisForGuild(bots.guild.id);
}, 120_000);
@@ -84,9 +85,10 @@ describe.skipIf(!runE2E)('Multiplayer live E2E — MVP (2 player-bots, driving t
}
}, 120_000);
// AC-8 — lobby gating + start, asserting on the REAL lobby embed ───────────
it('lobby embed shows the gate: below-min Begin disabled → join → Begin enabled → start', async () => {
// player1 runs /encounter start a lobby opens with the starter pre-joined.
// AC-8 — lobby gating + start (FU-13: no pre-join), asserting on the REAL lobby embed
it('lobby embed shows the gate: 0 joined → join ×2 → Begin enabled → start', async () => {
// FU-13: /encounter start opens a lobby with NO one pre-joined (the starter
// is the DM, not a player). player1 (the starter) runs /encounter start.
const { interaction, lastText } = fakeInteraction({
subcommand: 'start',
stringOptions: { spec: specName },
@@ -102,35 +104,45 @@ describe.skipIf(!runE2E)('Multiplayer live E2E — MVP (2 player-bots, driving t
thread = await bots.channel.threads.fetch(threadId!);
expect(thread, 'lobby thread must exist on the real gateway').toBeTruthy();
// Lobby state: starter pre-joined (1/2).
// Lobby opens EMPTY (FU-13: starter not pre-joined). 0/2, Begin disabled.
const lobby0 = await waitFor(() => getLobby(threadId!).then(l => l ?? null), {
timeoutMs: 15_000, intervalMs: 500,
});
expect(lobby0!.joined, 'starter is pre-joined').toEqual([player1Id]);
expect(lobby0!.joined.length < lobby0!.minPlayers, 'below minPlayers before anyone joins').toBe(true);
// REAL lobby embed: Seats shows "1 / 2 minimum" and Begin is DISABLED.
expect(lobby0!.joined, 'lobby opens empty — starter NOT pre-joined').toEqual([]);
expect(lobby0!.joined.length < lobby0!.minPlayers, 'below minPlayers at 0 joined').toBe(true);
const lobbyMsg0 = await thread!.messages.fetch(lobby0!.messageId!);
expect(seatsField(lobbyMsg0), 'seats below min').toContain('1 / 2 minimum');
expect(beginDisabled(lobbyMsg0), 'Begin disabled below min').toBe(true);
expect(seatsField(lobbyMsg0), 'seats at 0').toContain('0 / 2 minimum');
expect(beginDisabled(lobbyMsg0), 'Begin disabled at 0').toBe(true);
// player2 joins. fakeButton.update EDITS THE REAL lobby embed (messageId
// passed), so the embed reflects the click — the UI is driven, not faked.
// player1 joins → 1/2, Begin still disabled.
await handleLobbyInteraction(
fakeButton(thread!, 'lobby_join', player1Id, 'Player1', lobby0!.messageId).interaction,
bots.botClient,
);
const lobby1 = await waitFor(
() => getLobby(threadId!).then(l => (l && l.joined.length >= 1) ? l : null),
{ timeoutMs: 15_000, intervalMs: 500 },
);
expect(lobby1!.joined, 'player1 joined').toContain(player1Id);
const lobbyMsg1 = await thread!.messages.fetch(lobby0!.messageId!);
expect(seatsField(lobbyMsg1), 'seats at 1').toContain('1 / 2 minimum');
expect(beginDisabled(lobbyMsg1), 'Begin disabled at 1').toBe(true);
// player2 joins → 2/2, Begin enabled. fakeButton.update EDITS THE REAL lobby
// embed (messageId passed), so the embed reflects the click — UI is driven.
await handleLobbyInteraction(
fakeButton(thread!, 'lobby_join', player2Id, 'Player2', lobby0!.messageId).interaction,
bots.botClient,
);
const lobby1 = await waitFor(
const lobby2 = await waitFor(
() => getLobby(threadId!).then(l => (l && l.joined.length >= 2) ? l : null),
{ timeoutMs: 15_000, intervalMs: 500 },
);
expect(lobby1!.joined, 'player2 joined').toEqual(expect.arrayContaining([player1Id, player2Id]));
expect(lobby1!.joined.length >= lobby1!.minPlayers, 'meets minPlayers after one join').toBe(true);
// REAL lobby embed now: Seats shows "min 2 met" and Begin is ENABLED.
const lobbyMsg1 = await thread!.messages.fetch(lobby0!.messageId!);
expect(seatsField(lobbyMsg1), 'seats met').toContain('min 2 met');
expect(beginDisabled(lobbyMsg1), 'Begin enabled at min').toBe(false);
expect(lobby2!.joined, 'both joined').toEqual(expect.arrayContaining([player1Id, player2Id]));
expect(lobby2!.joined.length >= lobby2!.minPlayers, 'meets minPlayers at 2').toBe(true);
const lobbyMsg2 = await thread!.messages.fetch(lobby0!.messageId!);
expect(seatsField(lobbyMsg2), 'seats met').toContain('min 2 met');
expect(beginDisabled(lobbyMsg2), 'Begin enabled at min').toBe(false);
// Begin the encounter from the lobby.
await handleLobbyInteraction(

View File

@@ -0,0 +1,84 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
// Capture the lobby state handleStart writes.
const { mockSetLobby } = vi.hoisted(() => ({ mockSetLobby: vi.fn() }));
vi.mock('../../src/session/lobbyManager.js', () => ({
setLobby: mockSetLobby,
getLobby: vi.fn(),
joinLobby: vi.fn(),
leaveLobby: vi.fn(),
clearLobby: vi.fn(),
}));
// config: the fake channel must be allowed; SPECS_DIR points at the real specs/.
const { mockConfig } = vi.hoisted(() => ({
mockConfig: {
DISCORD_ALLOWED_CHANNELS: ['test-channel'],
DISCORD_ALLOWED_USERS: [],
SPECS_DIR: './specs',
},
}));
vi.mock('../../src/config.js', () => ({ config: mockConfig }));
// Populate the tool registry so handleStart's tools check passes.
import '../../src/harness/tools/index.js';
import { execute } from '../../src/bot/commands/encounter.js';
function fakeStartInteraction(specName: string, userId = 'dm-starter') {
const edits: string[] = [];
const replies: string[] = [];
const fakeThread = {
id: 'thread-123',
send: vi.fn().mockResolvedValue({ id: 'lobby-msg-1' }),
};
const channel = {
id: 'test-channel',
isTextBased: () => true,
isThread: () => false,
threads: { create: vi.fn().mockResolvedValue(fakeThread) },
};
const interaction = {
guildId: 'guild-1',
get channelId() {
return channel.id;
},
channel,
user: { id: userId, username: 'DM' },
options: {
getSubcommand: () => 'start',
getString: (name: string) => (name === 'spec' ? specName : null),
},
async deferReply(_o?: { ephemeral?: boolean }) {},
async editReply(payload: string) {
edits.push(payload);
return {};
},
async reply(payload: { content: string }) {
replies.push(payload.content);
return {};
},
} as never;
return { interaction, edits, replies, fakeThread, lastText: () => edits.at(-1) ?? replies.at(-1) };
}
describe('/encounter start — FU-13: lobby on all encounters + starter NOT pre-joined', () => {
beforeEach(() => {
mockSetLobby.mockReset();
});
it('opens a lobby for a group spec with the starter NOT pre-joined (joined: [])', async () => {
const { interaction, lastText } = fakeStartInteraction('e2e-group-multiplayer');
await execute(interaction);
expect(mockSetLobby, 'a lobby is opened').toHaveBeenCalledTimes(1);
const state = mockSetLobby.mock.calls[0][1] as {
joined: string[]; joinedNames: string[]; minPlayers: number; starterId: string; messageId: string;
};
expect(state.joined, 'starter is NOT pre-joined').toEqual([]);
expect(state.joinedNames, 'no joined names').toEqual([]);
expect(state.minPlayers).toBe(2);
expect(state.starterId, 'starterId still recorded (for Cancel)').toBe('dm-starter');
expect(state.messageId, 'lobby embed message id recorded').toBe('lobby-msg-1');
expect(lastText(), 'reply is the lobby-opened message').toMatch(/Lobby opened/);
});
});

View File

@@ -0,0 +1,86 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
// ioredis-mock backs the session store so the sweep's SCAN + sessionManager
// reads/writes run against a real (in-memory) Redis shape.
const refs = vi.hoisted(() => ({ mockRedis: null as any, mockLogEncounter: vi.fn().mockResolvedValue({}) }));
vi.mock('../../src/db/redis.js', async () => {
const { default: RedisMock } = await import('ioredis-mock');
refs.mockRedis = new RedisMock();
return { redis: refs.mockRedis };
});
// TTL of 1 hour for the test.
vi.mock('../../src/config.js', () => ({
config: {
SESSION_TTL_HOURS: 12,
GRAPHMCP_SCORE_THRESHOLD: 0.68,
ENCOUNTER_INACTIVITY_TTL_HOURS: 1,
},
}));
vi.mock('../../src/graphmcp/client.js', () => ({
logEncounter: refs.mockLogEncounter,
queryAsNPC: vi.fn(),
formatNPCMemory: vi.fn(),
}));
vi.mock('../../src/lib/logger.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { sessionManager } from '../../src/session/sessionManager.js';
import { runExpirySweep } from '../../src/bot/handlers/expirySweep.js';
import { mockSession } from '../fixtures/spec.js';
const HOUR = 60 * 60 * 1000;
beforeEach(async () => {
await refs.mockRedis?.flushall();
refs.mockLogEncounter.mockClear(); // keep mockResolvedValue({}) implementation
});
describe('runExpirySweep (inactivity TTL → expired)', () => {
it('expires an idle open session past the TTL: phase→expired, pending cleared, logEncounter kind=expired', async () => {
const threadId = 't-idle';
await sessionManager.create(threadId, {
...mockSession,
threadId,
encounterId: 'e-idle',
phase: 'open',
updatedAt: Date.now() - 2 * HOUR, // idle 2h, TTL 1h
pendingSkillCheck: { player: 'Aelindra', prompt: 'x', dc: 10, messageId: 'm1' },
});
const { finalized } = await runExpirySweep(); // no client → skip Discord notice/archive
expect(finalized).toBe(1);
const s = await sessionManager.get(threadId);
expect(s?.phase).toBe('expired');
expect(s?.pendingSkillCheck).toBeUndefined();
expect(s?.pendingGroupCheck).toBeUndefined();
expect(refs.mockLogEncounter).toHaveBeenCalledWith(expect.objectContaining({ kind: 'expired' }));
});
it('leaves a recent open session alone (within TTL)', async () => {
const threadId = 't-recent';
await sessionManager.create(threadId, {
...mockSession, threadId, encounterId: 'e-recent',
phase: 'open', updatedAt: Date.now() - 5 * 60 * 1000, // 5 min ago
});
const { finalized } = await runExpirySweep();
expect(finalized).toBe(0);
expect((await sessionManager.get(threadId))?.phase).toBe('open');
});
it('leaves an already-resolved session alone', async () => {
const threadId = 't-resolved';
await sessionManager.create(threadId, {
...mockSession, threadId, encounterId: 'e-resolved',
phase: 'resolved', updatedAt: Date.now() - 10 * HOUR,
});
const { finalized } = await runExpirySweep();
expect(finalized).toBe(0);
expect((await sessionManager.get(threadId))?.phase).toBe('resolved');
});
});