Files
aiturk-hermes-ide/apps/desktop/src/plugins/hermes-bots/group-turns.test.ts
T

712 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as groupChat from './group-chat'
import type * as groupRounds from './group-rounds'
import { createGroupGateway, drain, runTimersInline, scriptedStorage } from './group-test-utils'
import type { GatewayOptions, ScriptedGateway } from './group-test-utils'
import type * as groupTurns from './group-turns'
import type { Attachment, GroupChat, GroupMember } from './types'
// One member's turn: resolve its per-room session, stage attachments, submit,
// and poll until the gateway says the member is done. The session is the
// fragile part — it is addressed by a STORED id that outlives every runtime
// id, and the socket it rides can be reaped underneath a turn in flight.
const { host } = vi.hoisted(() => ({ host: {} as Record<string, unknown> }))
vi.mock('@hermes/plugin-sdk', async () => {
const { pluginSdkMock } = await import('./group-test-utils')
return pluginSdkMock(host)
})
interface Room {
chat: typeof groupChat
gateway: ScriptedGateway
rounds: typeof groupRounds
turns: typeof groupTurns
}
async function loadRoom(options: GatewayOptions = {}): Promise<Room> {
vi.resetModules()
const gateway = createGroupGateway(options)
for (const key of Object.keys(host)) {
delete host[key]
}
Object.assign(host, gateway.host)
const [chat, rounds, turns, shared] = await Promise.all([
import('./group-chat'),
import('./group-rounds'),
import('./group-turns'),
import('./shared')
])
shared.setPluginCtx(scriptedStorage(gateway.storage))
return { chat, gateway, rounds, turns }
}
const LOCAL_MEMBER: GroupMember = { name: 'helper', title: '' }
const ROUTED_MEMBER: GroupMember = { connectionId: 'mini', name: 'helper', remoteSource: true }
const IMG: Attachment = { data: 'data:image/png;base64,iVBORw0KGgo=', kind: 'image', name: 'shot.png' }
const log = (room: Room, group: string) => room.chat.$groupChats.get()[group]?.log || []
beforeEach(() => {
runTimersInline()
})
describe('session resolution', () => {
it('pins session titles to the roomId, with a legacy fallback to the display name', async () => {
const room = await loadRoom()
// Rooms persisted before roomIds keep name-based titles so their existing
// "Group: <name>" sessions keep resolving after an upgrade.
room.chat.updateGroupChat('Legacy', current => current)
const legacy = await room.turns.ensureGroupChatSession('Legacy', { name: 'research', title: '' })
expect(room.gateway.sessions.get(String(legacy.stored))?.title).toBe('Group: Legacy')
// New rooms pin the title to the immutable roomId, never the display name.
room.chat.updateGroupChat('New', current => {
current.roomId = 'r-abc'
return current
})
const fresh = await room.turns.ensureGroupChatSession('New', { name: 'research', title: '' })
expect(room.gateway.sessions.get(String(fresh.stored))?.title).toBe('Group: r-abc')
})
it('creates member sessions with the room_plumbing + follow_profile_config contracts', async () => {
// The PR #97008 contracts: room member sessions always rebuild from the
// member profile's CURRENT config on resume, never a stale stored
// model/provider pin. Dropping either param silently regresses rooms to
// the server's hidden + "Group: " title legacy fallback.
const room = await loadRoom()
room.chat.updateGroupChat('Contract', current => {
current.roomId = 'r-contract'
return current
})
const handle = await room.turns.ensureGroupChatSession('Contract', { name: 'research', title: '' })
expect(room.gateway.sessions.get(String(handle.stored))?.contracts).toEqual({
follow_profile_config: true,
room_plumbing: true
})
})
it('mints fresh member sessions when a same-name group is recreated after disband', async () => {
const room = await loadRoom()
const member: GroupMember = { name: 'research', title: '' }
room.chat.updateGroupChat('Alpha', current => {
current.roomId = 'r-one'
return current
})
const first = await room.turns.ensureGroupChatSession('Alpha', member)
// Disband: the room record is gone; the member's gateway session survives.
const rooms = { ...room.chat.$groupChats.get() }
delete rooms.Alpha
room.chat.$groupChats.set(rooms)
// Recreate under the same display name with a freshly minted roomId.
room.chat.updateGroupChat('Alpha', current => {
current.roomId = 'r-two'
return current
})
const second = await room.turns.ensureGroupChatSession('Alpha', member)
expect(first.stored).not.toBe(second.stored)
expect(room.gateway.sessions.get(String(first.stored))?.title).toBe('Group: r-one')
expect(room.gateway.sessions.get(String(second.stored))?.title).toBe('Group: r-two')
})
it('fails closed on a transient resume failure instead of forking the member session', async () => {
// Mirrors findExistingCanonicalChat's fix (87b645f52c): a resume that
// fails for any reason OTHER than "genuinely doesn't exist" (JSON-RPC code
// 4007) must surface, not be read as "no session, mint a new one" — that
// would fork the member's real history and silently overwrite
// room.sessions[key] so the old session becomes unreachable.
const room = await loadRoom()
const member: GroupMember = { name: 'research', title: '' }
room.chat.updateGroupChat('Alpha', current => {
current.roomId = 'r-one'
return current
})
const first = await room.turns.ensureGroupChatSession('Alpha', member)
const before = room.chat.$groupChats.get().Alpha.sessions
// A real gateway error on the NEXT resume of the member's own stored
// session — backend still warming up, a network blip, not "not found".
const request = host.request as (method: string, params: Record<string, unknown>) => Promise<unknown>
host.request = async (method: string, params: Record<string, unknown>) => {
if (method === 'session.resume' && params.session_id === first.stored) {
throw Object.assign(new Error('gateway temporarily unavailable'), { code: 5000 })
}
return request(method, params)
}
await expect(room.turns.ensureGroupChatSession('Alpha', member)).rejects.toThrow(/Could not check .*group session/)
host.request = request
// Nothing was forked: the room's session pointer is untouched, and the
// original session is still the only one on record for this member.
expect(room.chat.$groupChats.get().Alpha.sessions).toEqual(before)
expect(room.gateway.sessions.size).toBe(1)
})
it('falls through a genuine 4007 to the title lookup, then creates', async () => {
const room = await loadRoom()
room.chat.updateGroupChat('Alpha', current => {
current.roomId = 'r-one'
// A stored sid pointing at a session the gateway no longer has (e.g. an
// out-of-band deletion) is a genuine 4007 on the first target.
current.sessions = { research: 'sid-gone' }
return current
})
const result = await room.turns.ensureGroupChatSession('Alpha', { name: 'research', title: '' })
expect(result.stored).toBeTruthy()
expect(result.stored).not.toBe('sid-gone')
})
})
describe('session-gone classification', () => {
it('treats 4001 and "not in memory" as recoverable, 4007 as not', async () => {
const { turns } = await loadRoom()
expect(turns.isSessionGoneError(Object.assign(new Error('x'), { code: 4001 }))).toBe(true)
expect(turns.isSessionGoneError(new Error('session_id=rt-1 not in memory'))).toBe(true)
expect(turns.isSessionGoneError(Object.assign(new Error('session not found'), { code: 4007 }))).toBe(false)
expect(turns.isSessionGoneError(null)).toBe(false)
expect(turns.isSessionGoneError(new Error('network blip'))).toBe(false)
})
it('recovers a 4001 on the first submit via the STORED id and delivers', async () => {
const room = await loadRoom({
failFirstSubmitWith: Object.assign(new Error("session-scoped RPC rejected: session_id='rt-1' not in memory"), {
code: 4001
}),
turn: () => 'recovered reply'
})
const reply = await room.turns.runGroupChatMemberTurn('Room', LOCAL_MEMBER, 'hi there', 't1', [])
expect(reply).toBe('recovered reply')
// One failed submit + exactly one retry — never more.
expect(room.gateway.rpcFor('prompt.submit')).toHaveLength(2)
// The recovery re-resumed the durable stored id, not the dead runtime id.
expect(room.chat.$groupChats.get().Room.sessions?.helper).toBeTruthy()
})
it('does not retry a persistent non-4001 submit failure', async () => {
const room = await loadRoom({ failEverySubmitWith: new Error('backend exploded') })
await expect(room.turns.runGroupChatMemberTurn('Room', LOCAL_MEMBER, 'hi', 't1', [])).rejects.toThrow(
'backend exploded'
)
expect(room.gateway.rpcFor('prompt.submit')).toHaveLength(1)
})
})
describe('per-turn socket lease', () => {
it('is acquired before any session RPC and held across attach + submit', async () => {
const room = await loadRoom({ turn: () => 'routed reply' })
const reply = await room.turns.runGroupChatMemberTurn('Room', ROUTED_MEMBER, 'look at this', 't1', [IMG])
expect(reply).toBe('routed reply')
// The retain landed before the first session-scoped RPC on the route.
expect(room.gateway.timeline[0]).toBe('retain')
// The socket was NEVER disposed mid-turn: after every per-request lease
// released, the turn lease still held the refcount above zero.
for (const call of room.gateway.rpc) {
expect(call.refcountAfter).toBeGreaterThanOrEqual(1)
}
// Exactly one disposal, via the turn lease's own release at the end.
expect(room.gateway.disposals()).toBe(1)
expect(room.gateway.timeline.at(-1)).toBe('release')
})
it('is released after the turn — the refcount returns to zero', async () => {
const room = await loadRoom({ turn: () => 'done' })
await room.turns.runGroupChatMemberTurn('Room', ROUTED_MEMBER, 'hi', 't1', [])
expect(room.gateway.refcount()).toBe(0)
expect(room.gateway.disposals()).toBe(1)
})
it('is released even when the turn fails', async () => {
const room = await loadRoom({ failEverySubmitWith: new Error('backend exploded') })
await expect(room.turns.runGroupChatMemberTurn('Room', ROUTED_MEMBER, 'hi', 't1', [])).rejects.toThrow()
expect(room.gateway.refcount()).toBe(0)
})
it('is feature-detected: hosts without retainProfile still run the turn', async () => {
const room = await loadRoom({ turn: () => 'legacy ok' })
delete host.retainProfile
expect(await room.turns.runGroupChatMemberTurn('Room', ROUTED_MEMBER, 'hi', 't1', [])).toBe('legacy ok')
})
})
describe('push-woken poll', () => {
it("wakes on the member session's message.complete instead of sleeping out the backstop", async () => {
// Real timers here: the contract is about WHEN the poll re-reads.
vi.unstubAllGlobals()
const listeners = new Map<string, Set<(event: unknown) => void>>()
const room = await loadRoom({ pollsBusy: 1, turn: () => 'woken reply' })
host.onEvent = (type: string, listener: (event: unknown) => void) => {
const set = listeners.get(type) ?? new Set()
set.add(listener)
listeners.set(type, set)
return () => set.delete(listener)
}
const started = Date.now()
const turn = room.turns.runGroupChatMemberTurn('Room', LOCAL_MEMBER, 'hi', 't1', [])
// Let the submit land and the first poll wait attach its listeners, then
// fire the terminal frame for the runtime id the submit used.
await new Promise(resolve => setTimeout(resolve, 50))
// The harness mints a fresh runtime id on every resume; the frame carries
// whichever one the session currently answers to.
const runtime = room.gateway.sessions.get(room.gateway.calls[0]?.stored)?.runtime
expect(listeners.get('message.complete')?.size).toBe(1)
for (const listener of listeners.get('message.complete') ?? []) {
listener({ type: 'message.complete', session_id: runtime })
}
expect(await turn).toBe('woken reply')
// Two quick re-reads (busy once, then done) — well under one 5s backstop tick.
expect(Date.now() - started).toBeLessThan(2000)
// Every listener was disposed once the turn finished.
expect(listeners.get('message.complete')?.size ?? 0).toBe(0)
expect(listeners.get('error')?.size ?? 0).toBe(0)
})
})
// #94376: a Codex intent-ack continuation nudge can land a substantive
// answer, then get a synthetic "(pass)" reply to the nudge itself.
describe('reply selection (#94376)', () => {
it('surfaces a substantive answer followed by a synthetic continuation (pass)', async () => {
const answer = "Yes. I welcomed them, and I'll review their first assignments with them."
const room = await loadRoom({
turn: () => [
{ content: answer, role: 'assistant' },
{
content:
'[System: Continue now. Execute the required tool calls and only send your final answer after completing the task.]',
role: 'user'
},
{ content: '(pass)', role: 'assistant' }
]
})
expect(await room.turns.runGroupChatMemberTurn('Room', LOCAL_MEMBER, 'Did you welcome them?', 't1', [])).toBe(
answer
)
})
it('still reads a genuine pass-only turn as silent', async () => {
const room = await loadRoom({ turn: () => '(pass)' })
expect(await room.turns.runGroupChatMemberTurn('Room', LOCAL_MEMBER, 'anything new?', 't1', [])).toBe('(pass)')
})
})
// #90694: a member can block inside its own turn on a clarify question or a
// command approval. Those live in a hidden session the room can't see, so the
// poll mirrors them into the room and holds the turn open until they resolve.
describe('clarify and approvals (#90694)', () => {
const CLARIFY = {
choices: ['staging', 'prod'],
multi_select: false,
question: 'Which env should I target?',
request_id: 'req-clarify-1'
}
const APPROVAL = {
choices: ['once', 'session', 'deny'],
command: 'rm -rf ./build',
description: 'Clean the build directory',
request_id: 'req-approval-1'
}
it('holds the turn open while a member is blocked on clarify, then lands the reply', async () => {
const room = await loadRoom({
clarifyUntil: { research: { payload: CLARIFY, until: 3 } },
turn: () => 'targeting staging'
})
const thread = room.rounds.sendToGroupChat(
'Core',
[{ name: 'research', title: '' }],
'@research deploy it',
null,
[]
)
await drain(() => Boolean(room.chat.$groupChats.get().Core?.running))
const replies = log(room, 'Core').filter(entry => entry.thread === thread && entry.from.kind === 'member')
expect(replies).toHaveLength(1)
expect(replies[0].text).toBe('targeting staging')
expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0)
// The mirror pass ran while the question was blocking, badging the room.
// A poll that never inspects pending_clarify leaves this unset — it is the
// observable proof the gate executed.
expect(room.chat.$groupNeedsYou.get().Core).toBe(true)
})
it('mirrors a question, badges needs-you, and is idempotent per request', async () => {
const { chat, turns } = await loadRoom()
const member: GroupMember = { name: 'research', title: '' }
expect(turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })).toBe(true)
const mirrored = Object.values(chat.$groupClarify.get())
expect(mirrored).toHaveLength(1)
expect(mirrored[0].requestId).toBe('req-clarify-1')
expect(mirrored[0].question).toBe('Which env should I target?')
expect(mirrored[0].choices).toEqual(['staging', 'prod'])
expect(chat.$groupNeedsYou.get().Core).toBe(true)
// Same request again: no new entry, identity preserved.
turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })
expect(Object.values(chat.$groupClarify.get())[0]).toBe(mirrored[0])
// Question resolved server-side: the mirror clears.
expect(turns.syncGroupClarify('Core', member, {})).toBe(false)
expect(Object.keys(chat.$groupClarify.get())).toHaveLength(0)
})
it('never mirrors a question for older backends without pending_clarify', async () => {
const { chat, turns } = await loadRoom()
expect(turns.syncGroupClarify('Core', { name: 'research' }, { messages: [] })).toBe(false)
expect(Object.keys(chat.$groupClarify.get())).toHaveLength(0)
})
it('routes an answer through clarify.respond and clears the mirror', async () => {
const room = await loadRoom()
const member: GroupMember = { name: 'research', title: '' }
room.turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })
await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], member, 'staging')
expect(room.gateway.rpcFor('clarify.respond').map(call => call.params)).toEqual([
{ answer: 'staging', request_id: 'req-clarify-1' }
])
expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0)
})
it('sends one respond per batch question, in order', async () => {
const room = await loadRoom()
const member: GroupMember = { name: 'research', title: '' }
room.turns.syncGroupClarify('Core', member, {
pending_clarify: {
questions: [
{ choices: ['staging', 'prod'], qid: 'q0', question: 'Env?' },
{ choices: [], qid: 'q1', question: 'Region?' }
],
request_id: 'req-batch-1'
}
})
await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], member, {
q0: 'staging',
q1: 'eu-west'
})
expect(room.gateway.rpcFor('clarify.respond').map(call => call.params)).toEqual([
{ answer: 'staging', question_id: 'q0', request_id: 'req-batch-1' },
{ answer: 'eu-west', question_id: 'q1', request_id: 'req-batch-1' }
])
expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0)
})
it('clears only the disbanded room’s mirrored questions', async () => {
const room = await loadRoom()
room.turns.syncGroupClarify('Core', { name: 'research' }, { pending_clarify: CLARIFY })
room.turns.syncGroupClarify('Other', { name: 'ops' }, { pending_clarify: { ...CLARIFY, request_id: 'req-2' } })
room.turns.clearGroupClarify('Core')
const remaining = Object.values(room.chat.$groupClarify.get())
expect(remaining).toHaveLength(1)
expect(remaining[0].group).toBe('Other')
})
it('holds the turn open on a command approval too', async () => {
const room = await loadRoom({
approvalUntil: { research: { payload: APPROVAL, until: 3 } },
turn: () => 'build cleaned'
})
const thread = room.rounds.sendToGroupChat(
'Core',
[{ name: 'research', title: '' }],
'@research clean up',
null,
[]
)
await drain(() => Boolean(room.chat.$groupChats.get().Core?.running))
const replies = log(room, 'Core').filter(entry => entry.thread === thread && entry.from.kind === 'member')
expect(replies).toHaveLength(1)
expect(replies[0].text).toBe('build cleaned')
expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0)
expect(room.chat.$groupNeedsYou.get().Core).toBe(true)
})
it('mirrors an approval with its kind, command and server choices', async () => {
const { chat, turns } = await loadRoom()
expect(
turns.syncGroupClarify(
'Core',
{ name: 'research', title: '' },
{
pending_approval: APPROVAL,
session_id: 'rt-research-1'
}
)
).toBe(true)
const entry = Object.values(chat.$groupClarify.get())[0]
expect(entry.kind).toBe('approval')
expect(entry.command).toBe('rm -rf ./build')
expect(entry.question).toBe('Clean the build directory')
expect(entry.choices).toEqual(['once', 'session', 'deny'])
expect(entry.sessionId).toBe('rt-research-1')
})
it('falls back to once/deny when the server sends no choice set', async () => {
const { chat, turns } = await loadRoom()
turns.syncGroupClarify('Core', { name: 'research' }, { pending_approval: { command: 'ls', request_id: 'req-a2' } })
expect(Object.values(chat.$groupClarify.get())[0].choices).toEqual(['once', 'deny'])
})
it('routes approvals through approval.respond with the session and choice', async () => {
const room = await loadRoom()
const member: GroupMember = { name: 'research', title: '' }
room.turns.syncGroupClarify('Core', member, { pending_approval: APPROVAL, session_id: 'rt-research-1' })
await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], member, 'once')
expect(room.gateway.rpcFor('approval.respond').map(call => call.params)).toEqual([
{ choice: 'once', request_id: 'req-approval-1', session_id: 'rt-research-1' }
])
expect(room.gateway.rpcFor('clarify.respond')).toHaveLength(0)
expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0)
})
it('lets clarify outrank approval when a snapshot carries both', async () => {
const { chat, turns } = await loadRoom()
turns.syncGroupClarify('Core', { name: 'research' }, { pending_approval: APPROVAL, pending_clarify: CLARIFY })
const entry = Object.values(chat.$groupClarify.get())[0]
expect(entry.kind).toBe('clarify')
expect(entry.requestId).toBe('req-clarify-1')
})
})
// A turn that outlives its deadline leaves a "stranded" marker. The member is
// still working; the next round harvests whatever landed instead of throwing
// the finished work away.
describe('stranded harvest', () => {
const seedSession = (room: Room, stored: string, profile: string, title: string, messages: string[][]) => {
room.gateway.sessions.set(stored, {
messages: messages.map(([role, content]) => ({ content, role })),
profile,
runtime: `rt-${profile}`,
stored,
title
})
}
it('posts a late reply into the room and clears the marker', async () => {
const room = await loadRoom()
room.chat.updateGroupChat('Late', current => {
current.sessions = { research: 'sid-research' }
current.stranded = { research: 0 }
return current
})
// The member's session finished after we stopped waiting.
seedSession(room, 'sid-research', 'research', 'Group: Late', [
['user', 'the turn prompt'],
['assistant', 'Here is the full research result, delivered late.']
])
await room.turns.harvestStrandedGroupReply('Late', { name: 'research', title: '' })
expect(log(room, 'Late')).toHaveLength(1)
expect(log(room, 'Late')[0].from.name).toBe('research')
expect(log(room, 'Late')[0].text).toMatch(/delivered late/)
expect(room.chat.$groupChats.get().Late.stranded?.research).toBeUndefined()
})
it('prefers the substantive answer over a trailing synthetic (pass)', async () => {
const room = await loadRoom()
// #94376 class bug at the second call site: the late-landing turn ends
// with a Codex intent-ack continuation nudge that gets a synthetic
// "(pass)" — the harvest must still surface the substantive answer.
room.chat.updateGroupChat('Rescue', current => {
current.sessions = { research: 'sid-research' }
current.stranded = { research: 0 }
return current
})
seedSession(room, 'sid-research', 'research', 'Group: Rescue', [
['user', 'the turn prompt'],
['assistant', 'Here is the full research result, delivered late.'],
[
'user',
'[System: Continue now. Execute the required tool calls and only send your final answer after completing the task.]'
],
['assistant', '(pass)']
])
await room.turns.harvestStrandedGroupReply('Rescue', { name: 'research', title: '' })
expect(log(room, 'Rescue')).toHaveLength(1)
expect(log(room, 'Rescue')[0].text).toMatch(/delivered late/)
expect(room.chat.$groupChats.get().Rescue.stranded?.research).toBeUndefined()
})
it('consumes the marker without posting when the late reply is a pass', async () => {
const room = await loadRoom()
room.chat.updateGroupChat('Quiet2', current => {
current.sessions = { builder: 'sid-builder' }
current.stranded = { builder: 2 }
return current
})
seedSession(room, 'sid-builder', 'builder', 'Group: Quiet2', [
['user', 'p1'],
['user', 'prompt'],
['assistant', '(pass)']
])
await room.turns.harvestStrandedGroupReply('Quiet2', { name: 'builder', title: '' })
expect(log(room, 'Quiet2')).toHaveLength(0)
expect(room.chat.$groupChats.get().Quiet2.stranded?.builder).toBeUndefined()
})
it('never re-submits into a member the harvest just confirmed is still running', async () => {
// research is confirmed busy on exactly its first two session.resume calls
// — the number of harvest-only touches the FIXED code makes across two
// rounds. Without the responder guard research gets re-selected and picks
// up two EXTRA resume calls of its own before its first post-resubmit
// poll, which then reports done — so the unguarded run still finishes fast
// while proving the resubmission happened.
const room = await loadRoom({
busyResumes: { research: 2 },
turn: ({ profile }) => (profile === 'builder' ? 'builder here, all good' : '(pass)')
})
// The marker's pre-thread bare-number shape is still supported: presence
// in `stranded` is what the round-loop guard checks, not the value shape.
room.gateway.sessions.set('sid-research', {
messages: [],
profile: 'research',
runtime: 'rt-research',
stored: 'sid-research',
title: 'Group: Grind'
})
room.chat.updateGroupChat('Grind', current => {
current.log = [{ at: 1, from: { kind: 'user', name: 'You' }, id: 'u1', text: '@research @builder status?' }]
current.sessions = { research: 'sid-research' }
current.stranded = { research: 0 }
current.watermarks = { 'legacy::builder': 0, 'legacy::research': 0 }
return current
})
await room.rounds.runGroupChatRounds(
'Grind',
[
{ name: 'research', title: '' },
{ name: 'builder', title: '' }
],
'legacy'
)
expect(room.gateway.calls.filter(call => call.profile === 'research')).toHaveLength(0)
// The marker survives untouched — the harvest confirmed research is still
// running, so there is nothing to consume yet.
expect(room.chat.$groupChats.get().Grind.stranded?.research).toBe(0)
expect(room.gateway.calls.filter(call => call.profile === 'builder')).toHaveLength(1)
})
})
describe('room record', () => {
it('persists the roomId alongside the room and survives another room’s disband', async () => {
const room = await loadRoom()
room.chat.updateGroupChat('Keep', current => {
current.log = [{ at: 1, from: { kind: 'user', name: 'You' }, id: 'k1', text: 'hi' }]
current.roomId = 'r-keep'
return current
})
room.chat.updateGroupChat('Gone', current => {
current.roomId = 'r-gone'
return current
})
const rooms = { ...room.chat.$groupChats.get() }
delete rooms.Gone
room.chat.$groupChats.set(rooms)
await room.chat.persistGroupChatRooms(rooms)
const durable = (room.gateway.storage.get('group-chats') || {}) as Record<string, GroupChat>
expect(durable.Keep.roomId).toBe('r-keep')
expect('Gone' in durable).toBe(false)
})
})