858 lines
30 KiB
TypeScript
858 lines
30 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
||
import type * as groupActivity from './group-activity'
|
||
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, GroupMessage } from './types'
|
||
|
||
// The round engine: who answers a room message, in what order, with what
|
||
// delta, and how a round ends. Every contract here is about a SERIAL loop that
|
||
// can be superseded mid-flight — a second send, a stop, or a hold all have to
|
||
// take effect at a member boundary without losing finished work.
|
||
|
||
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 {
|
||
activity: typeof groupActivity
|
||
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 [activity, chat, rounds, turns, shared] = await Promise.all([
|
||
import('./group-activity'),
|
||
import('./group-chat'),
|
||
import('./group-rounds'),
|
||
import('./group-turns'),
|
||
import('./shared')
|
||
])
|
||
|
||
shared.setPluginCtx(scriptedStorage(gateway.storage))
|
||
|
||
return { activity, chat, gateway, rounds, turns }
|
||
}
|
||
|
||
const MEMBERS: GroupMember[] = [
|
||
{ name: 'research', title: '' },
|
||
{ name: 'builder', title: '' },
|
||
{ name: 'ops', title: 'The Ops' }
|
||
]
|
||
|
||
const IMG: Attachment = { data: 'data:image/png;base64,iVBORw0KGgo=', kind: 'image', name: 'screenshot.png' }
|
||
|
||
const log = (room: Room, group: string) => room.chat.$groupChats.get()[group]?.log || []
|
||
|
||
/** Run the room's drive to completion. */
|
||
async function settle(room: Room, group: string) {
|
||
await drain(() => Boolean(room.chat.$groupChats.get()[group]?.running))
|
||
}
|
||
|
||
beforeEach(() => {
|
||
runTimersInline()
|
||
})
|
||
|
||
describe('routing', () => {
|
||
it('reads (pass), pass, pass. and empty as silence, but not real text', async () => {
|
||
const { turns } = await loadRoom()
|
||
|
||
expect(turns.isGroupPassText('(pass)')).toBe(true)
|
||
expect(turns.isGroupPassText('pass')).toBe(true)
|
||
expect(turns.isGroupPassText('Pass.')).toBe(true)
|
||
expect(turns.isGroupPassText(' ')).toBe(true)
|
||
expect(turns.isGroupPassText('I will pass this to ops')).toBe(false)
|
||
})
|
||
|
||
it('answers only @-mentioned members; @everyone or no mention means all', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
const user = (text: string): GroupMessage[] =>
|
||
[{ at: 1, from: { kind: 'user', name: 'You' }, text }] as GroupMessage[]
|
||
|
||
expect(rounds.resolveGroupResponders(user('@builder take this one'), MEMBERS).map(m => m.name)).toEqual(['builder'])
|
||
expect(rounds.resolveGroupResponders(user('hello team'), MEMBERS)).toHaveLength(3)
|
||
expect(rounds.resolveGroupResponders(user('@everyone standup'), MEMBERS)).toHaveLength(3)
|
||
})
|
||
|
||
it('resolves display titles to the member and never matches @user against a bot', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
const parsed = rounds.parseGroupChatMentions('@theops please check, then ping @user', MEMBERS)
|
||
|
||
expect(parsed.mentioned.has('ops')).toBe(true)
|
||
expect(parsed.mentioned.size).toBe(1)
|
||
})
|
||
|
||
it('resolves @hermes to the default member', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
const members: GroupMember[] = [
|
||
{ name: 'default', title: '' },
|
||
{ name: 'builder', title: '' }
|
||
]
|
||
|
||
const parsed = rounds.parseGroupChatMentions('@hermes take a look', members)
|
||
|
||
expect(parsed.mentioned.has('default')).toBe(true)
|
||
expect(parsed.mentioned.size).toBe(1)
|
||
})
|
||
|
||
it('rotates the lead speaker each round', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
expect(rounds.rotateGroupSpeakers(MEMBERS, 1).map(m => m.name)).toEqual(['builder', 'ops', 'research'])
|
||
expect(rounds.rotateGroupSpeakers([MEMBERS[0]], 3)).toHaveLength(1)
|
||
})
|
||
|
||
it('pulls a member mentioned by another bot into the NEXT round', async () => {
|
||
const room = await loadRoom({
|
||
turn: ({ profile, prompt }) => {
|
||
if (profile === 'research' && !prompt.includes('(you)')) {
|
||
return 'Interesting — @builder should own this.'
|
||
}
|
||
|
||
return profile === 'builder' ? 'On it. OWNER: @builder.' : '(pass)'
|
||
}
|
||
})
|
||
|
||
room.rounds.sendToGroupChat(
|
||
'Core',
|
||
[
|
||
{ name: 'research', title: '' },
|
||
{ name: 'builder', title: '' }
|
||
],
|
||
'@research thoughts?'
|
||
)
|
||
await settle(room, 'Core')
|
||
|
||
const lines = log(room, 'Core').map(entry => `${entry.from.name}: ${entry.text}`)
|
||
|
||
expect(lines.some(line => line.startsWith('research:'))).toBe(true)
|
||
expect(lines.some(line => line.startsWith('builder: On it'))).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('round lifecycle', () => {
|
||
it('settles when everyone passes, logging only the user message', async () => {
|
||
const room = await loadRoom()
|
||
|
||
room.rounds.sendToGroupChat('Quiet', MEMBERS, 'fyi, deploy went out')
|
||
await settle(room, 'Quiet')
|
||
|
||
expect(log(room, 'Quiet')).toHaveLength(1)
|
||
expect(log(room, 'Quiet')[0].from.kind).toBe('user')
|
||
// Every member took exactly one turn (round 1), then the settle exit fired.
|
||
expect(room.gateway.calls).toHaveLength(3)
|
||
})
|
||
|
||
it('stops chatty members at GROUP_CHAT_MAX_MESSAGES', async () => {
|
||
const room = await loadRoom({ turn: ({ n }) => `message ${n} — @everyone keep going` })
|
||
|
||
room.rounds.sendToGroupChat('Loud', MEMBERS, 'go wild')
|
||
await settle(room, 'Loud')
|
||
|
||
const posted = log(room, 'Loud').filter(entry => entry.from.kind === 'member')
|
||
|
||
expect(posted.length).toBeLessThanOrEqual(room.chat.GROUP_CHAT_MAX_MESSAGES)
|
||
})
|
||
|
||
it('treats a failed member turn as a pass, not a room error', async () => {
|
||
const room = await loadRoom({
|
||
turn: ({ profile }) => {
|
||
if (profile === 'builder') {
|
||
throw new Error('gateway hiccup')
|
||
}
|
||
|
||
return '(pass)'
|
||
}
|
||
})
|
||
|
||
room.rounds.sendToGroupChat('Flaky', MEMBERS, 'anyone around?')
|
||
await settle(room, 'Flaky')
|
||
|
||
// Just the user message; no error entries.
|
||
expect(log(room, 'Flaky')).toHaveLength(1)
|
||
})
|
||
|
||
it('badges needs-you when a member addresses @user, and clears it on the next user send', async () => {
|
||
const room = await loadRoom({
|
||
turn: ({ profile }) => (profile === 'research' ? 'Blocked on billing access — @user which account?' : '(pass)')
|
||
})
|
||
|
||
const member: GroupMember[] = [{ name: 'research', title: '' }]
|
||
|
||
room.rounds.sendToGroupChat('Escalate', member, 'sort out the invoices')
|
||
await settle(room, 'Escalate')
|
||
|
||
expect(room.chat.$groupNeedsYou.get().Escalate).toBe(true)
|
||
|
||
room.rounds.sendToGroupChat('Escalate', member, 'use the ops account')
|
||
|
||
expect(room.chat.$groupNeedsYou.get().Escalate).toBe(false)
|
||
})
|
||
|
||
it('converts an "(empty)" member reply like the gateway does, never appending it raw', async () => {
|
||
const room = await loadRoom({ turn: ({ profile }) => (profile === 'research' ? '(empty)' : '(pass)') })
|
||
|
||
room.rounds.sendToGroupChat('Sentinel', [{ name: 'research', title: '' }], '@research thoughts?')
|
||
await settle(room, 'Sentinel')
|
||
|
||
const replies = log(room, 'Sentinel').filter(entry => entry.from.kind === 'member')
|
||
|
||
expect(replies).toHaveLength(1)
|
||
expect(replies[0].text).toContain('The model returned no response after processing tool results')
|
||
expect(replies[0].text).not.toContain('(empty)')
|
||
})
|
||
|
||
it('leaves normal replies untouched', async () => {
|
||
const room = await loadRoom({ turn: ({ profile }) => (profile === 'research' ? 'I am not empty.' : '(pass)') })
|
||
|
||
room.rounds.sendToGroupChat('Sentinel2', [{ name: 'research', title: '' }], '@research hi')
|
||
await settle(room, 'Sentinel2')
|
||
|
||
const replies = log(room, 'Sentinel2').filter(entry => entry.from.kind === 'member')
|
||
|
||
expect(replies).toHaveLength(1)
|
||
expect(replies[0].text).toBe('I am not empty.')
|
||
})
|
||
})
|
||
|
||
describe('per-member delta', () => {
|
||
it('feeds a second send only the NEW messages', async () => {
|
||
const room = await loadRoom()
|
||
const member: GroupMember[] = [{ name: 'research', title: '' }]
|
||
|
||
room.rounds.sendToGroupChat('Delta', member, 'first message')
|
||
await settle(room, 'Delta')
|
||
const firstCount = room.gateway.calls.length
|
||
room.rounds.sendToGroupChat('Delta', member, 'second message')
|
||
await settle(room, 'Delta')
|
||
|
||
const second = room.gateway.calls.slice(firstCount).find(call => call.prompt.includes('second message'))
|
||
|
||
expect(second).toBeDefined()
|
||
expect(second?.prompt).not.toContain('first message')
|
||
})
|
||
|
||
it('keeps concurrent rooms sharing one member isolated in sessions, deltas and context', async () => {
|
||
const room = await loadRoom()
|
||
const shared: GroupMember[] = [{ name: 'research', title: '' }]
|
||
const quiet = () => !room.chat.$groupChats.get().Alpha?.running && !room.chat.$groupChats.get().Beta?.running
|
||
|
||
// Start both rooms without waiting for either drive to finish.
|
||
room.rounds.sendToGroupChat('Alpha', shared, 'ALPHA_ONLY_1')
|
||
room.rounds.sendToGroupChat('Beta', shared, 'BETA_ONLY_1')
|
||
await drain(() => !quiet())
|
||
|
||
const alphaFirst = room.gateway.calls.find(call => call.title === 'Group: Alpha')
|
||
const betaFirst = room.gateway.calls.find(call => call.title === 'Group: Beta')
|
||
|
||
expect(alphaFirst && betaFirst).toBeTruthy()
|
||
expect(alphaFirst?.stored).not.toBe(betaFirst?.stored)
|
||
expect(alphaFirst?.runtime).not.toBe(betaFirst?.runtime)
|
||
expect(alphaFirst?.prompt).toContain('ALPHA_ONLY_1')
|
||
expect(alphaFirst?.prompt).not.toContain('BETA_ONLY_1')
|
||
expect(betaFirst?.prompt).toContain('BETA_ONLY_1')
|
||
expect(betaFirst?.prompt).not.toContain('ALPHA_ONLY_1')
|
||
expect(room.chat.$groupChats.get().Alpha.sessions?.research).toBe(alphaFirst?.stored)
|
||
expect(room.chat.$groupChats.get().Beta.sessions?.research).toBe(betaFirst?.stored)
|
||
|
||
// Interleave a second pair. Each room resumes its own session and receives
|
||
// only its unseen room delta, never the sibling room's messages.
|
||
const firstCallCount = room.gateway.calls.length
|
||
room.rounds.sendToGroupChat('Alpha', shared, 'ALPHA_ONLY_2')
|
||
room.rounds.sendToGroupChat('Beta', shared, 'BETA_ONLY_2')
|
||
await drain(() => !quiet())
|
||
|
||
const second = room.gateway.calls.slice(firstCallCount)
|
||
const alphaSecond = second.find(call => call.title === 'Group: Alpha')
|
||
const betaSecond = second.find(call => call.title === 'Group: Beta')
|
||
|
||
expect(alphaSecond?.stored).toBe(alphaFirst?.stored)
|
||
expect(betaSecond?.stored).toBe(betaFirst?.stored)
|
||
expect(alphaSecond?.prompt).toContain('ALPHA_ONLY_2')
|
||
expect(alphaSecond?.prompt).not.toContain('ALPHA_ONLY_1')
|
||
expect(alphaSecond?.prompt).not.toContain('BETA_ONLY_2')
|
||
expect(betaSecond?.prompt).toContain('BETA_ONLY_2')
|
||
expect(betaSecond?.prompt).not.toContain('BETA_ONLY_1')
|
||
expect(betaSecond?.prompt).not.toContain('ALPHA_ONLY_2')
|
||
|
||
const alphaSession = room.gateway.sessions.get(String(alphaFirst?.stored))
|
||
const betaSession = room.gateway.sessions.get(String(betaFirst?.stored))
|
||
|
||
expect(alphaSession?.messages.some(message => message.content.includes('BETA_ONLY'))).toBe(false)
|
||
expect(betaSession?.messages.some(message => message.content.includes('ALPHA_ONLY'))).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('threads', () => {
|
||
it('mints a new thread per composer send and lands replies in it', async () => {
|
||
const room = await loadRoom()
|
||
const member: GroupMember[] = [{ name: 'research', title: '' }]
|
||
|
||
const first = room.rounds.sendToGroupChat('Rooms', member, 'first topic')
|
||
await settle(room, 'Rooms')
|
||
const second = room.rounds.sendToGroupChat('Rooms', member, 'second topic')
|
||
await settle(room, 'Rooms')
|
||
|
||
expect(first).toBeTruthy()
|
||
expect(second).toBeTruthy()
|
||
expect(first).not.toBe(second)
|
||
expect(log(room, 'Rooms')[0].thread).toBe(first)
|
||
expect(log(room, 'Rooms')[1].thread).toBe(second)
|
||
})
|
||
|
||
it('continues an explicit thread and scopes the member delta to it', async () => {
|
||
const room = await loadRoom({
|
||
turn: ({ prompt }) => (prompt.includes('billing') ? 'On the billing fix.' : '(pass)')
|
||
})
|
||
|
||
const member: GroupMember[] = [{ name: 'research', title: '' }]
|
||
|
||
const billing = room.rounds.sendToGroupChat('Scoped', member, 'fix the billing bug')
|
||
await settle(room, 'Scoped')
|
||
room.rounds.sendToGroupChat('Scoped', member, 'research pricing')
|
||
await settle(room, 'Scoped')
|
||
const again = room.rounds.sendToGroupChat('Scoped', member, 'billing follow-up: ship it', billing)
|
||
await settle(room, 'Scoped')
|
||
|
||
const followUp = room.gateway.calls.find(call => call.prompt.includes('ship it'))
|
||
const replies = log(room, 'Scoped').filter(entry => entry.from.kind === 'member')
|
||
|
||
expect(again).toBe(billing)
|
||
expect(followUp).toBeDefined()
|
||
expect(followUp?.prompt).not.toContain('research pricing')
|
||
expect(replies.length).toBeGreaterThanOrEqual(1)
|
||
expect(replies.every(entry => entry.thread === billing)).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('turn prompt', () => {
|
||
it('addresses the default profile as @hermes', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
const members: GroupMember[] = [
|
||
{ name: 'default', title: '' },
|
||
{ name: 'builder', title: '' }
|
||
]
|
||
|
||
const own = rounds.buildGroupChatTurnPrompt({
|
||
deltaLines: [],
|
||
groupName: 'Core',
|
||
members,
|
||
viewer: { name: 'default', title: '' }
|
||
})
|
||
|
||
expect(own).toMatch(/You are @hermes,/)
|
||
expect(own).not.toMatch(/@default\b/)
|
||
|
||
const peer = rounds.buildGroupChatTurnPrompt({
|
||
deltaLines: [],
|
||
groupName: 'Core',
|
||
members,
|
||
viewer: { name: 'builder', title: '' }
|
||
})
|
||
|
||
expect(peer).toMatch(/group chat with @hermes/)
|
||
})
|
||
|
||
it('asks for full-quality results and short chatter, not short results', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
const prompt = rounds.buildGroupChatTurnPrompt({
|
||
deltaLines: [],
|
||
groupName: 'Core',
|
||
members: [
|
||
{ name: 'research', title: '' },
|
||
{ name: 'builder', title: '' }
|
||
],
|
||
viewer: { name: 'research', title: '' }
|
||
})
|
||
|
||
expect(prompt).toMatch(/never thin out real content/i)
|
||
expect(prompt).toMatch(/Keep chatter short/i)
|
||
})
|
||
})
|
||
|
||
describe('attachments', () => {
|
||
it('stages them into EVERY responding member session before that member submits', async () => {
|
||
const room = await loadRoom()
|
||
|
||
const members: GroupMember[] = [
|
||
{ name: 'research', title: '' },
|
||
{ name: 'builder', title: '' }
|
||
]
|
||
|
||
room.rounds.sendToGroupChat('Vision', members, 'what does this show?', null, [IMG])
|
||
await settle(room, 'Vision')
|
||
|
||
expect(room.gateway.attaches).toHaveLength(2)
|
||
expect(room.gateway.attaches.map(entry => entry.profile).sort()).toEqual(['builder', 'research'])
|
||
|
||
for (const attach of room.gateway.attaches) {
|
||
expect(attach.filename).toBe('screenshot.png')
|
||
expect(attach.data).toBe(IMG.data)
|
||
}
|
||
|
||
// Each attach landed before that member's prompt.submit: the first attach
|
||
// happens before any submit, the second before the second submit.
|
||
expect(room.gateway.attaches[0].order).toBe(0)
|
||
expect(room.gateway.attaches[1].order).toBeLessThanOrEqual(1)
|
||
expect(room.gateway.calls).toHaveLength(2)
|
||
})
|
||
|
||
it('scopes attachments by mention: only the mentioned member receives the image', async () => {
|
||
const room = await loadRoom()
|
||
|
||
room.rounds.sendToGroupChat(
|
||
'Scoped',
|
||
[
|
||
{ name: 'research', title: '' },
|
||
{ name: 'builder', title: '' }
|
||
],
|
||
'@builder look at this',
|
||
null,
|
||
[IMG]
|
||
)
|
||
await settle(room, 'Scoped')
|
||
|
||
expect(room.gateway.attaches.map(entry => entry.profile)).toEqual(['builder'])
|
||
})
|
||
|
||
it('accepts an image-only send and carries the attachment on the room entry', async () => {
|
||
const room = await loadRoom()
|
||
|
||
const minted = room.rounds.sendToGroupChat(
|
||
'Silent',
|
||
[
|
||
{ name: 'research', title: '' },
|
||
{ name: 'builder', title: '' }
|
||
],
|
||
'',
|
||
null,
|
||
[IMG]
|
||
)
|
||
|
||
await settle(room, 'Silent')
|
||
|
||
expect(minted).toBeTruthy()
|
||
expect(log(room, 'Silent')).toHaveLength(1)
|
||
expect(log(room, 'Silent')[0].images?.[0].name).toBe('screenshot.png')
|
||
// Members still got prompted (with the image staged).
|
||
expect(room.gateway.attaches).toHaveLength(2)
|
||
expect(room.gateway.calls).toHaveLength(2)
|
||
})
|
||
|
||
it('never re-attaches an old entry on a later turn', async () => {
|
||
const room = await loadRoom()
|
||
|
||
const members: GroupMember[] = [
|
||
{ name: 'research', title: '' },
|
||
{ name: 'builder', title: '' }
|
||
]
|
||
|
||
room.rounds.sendToGroupChat('Plain', members, 'with image', null, [IMG])
|
||
await settle(room, 'Plain')
|
||
|
||
expect(room.gateway.attaches).toHaveLength(2)
|
||
|
||
// The image already sits behind every member's watermark.
|
||
room.rounds.sendToGroupChat('Plain', members, 'plain follow-up')
|
||
await settle(room, 'Plain')
|
||
|
||
expect(room.gateway.attaches).toHaveLength(2)
|
||
})
|
||
|
||
it('skips an invalid attachment and still runs the member turn text-only', async () => {
|
||
const room = await loadRoom()
|
||
|
||
room.rounds.sendToGroupChat(
|
||
'Degraded',
|
||
[
|
||
{ name: 'research', title: '' },
|
||
{ name: 'builder', title: '' }
|
||
],
|
||
'look',
|
||
null,
|
||
[{ data: null, name: 'bad' } as unknown as Attachment]
|
||
)
|
||
await settle(room, 'Degraded')
|
||
|
||
expect(room.gateway.attaches).toHaveLength(0)
|
||
expect(room.gateway.calls).toHaveLength(2)
|
||
})
|
||
|
||
it('routes PDFs through pdf.attach and files through file.attach, per member', async () => {
|
||
const room = await loadRoom()
|
||
const pdf: Attachment = { data: 'data:application/pdf;base64,JVBERi0=', kind: 'pdf', name: 'spec.pdf' }
|
||
const doc: Attachment = { data: 'data:text/plain;base64,aGVsbG8=', kind: 'file', name: 'notes.txt' }
|
||
|
||
room.rounds.sendToGroupChat(
|
||
'Mixed',
|
||
[
|
||
{ name: 'research', title: '' },
|
||
{ name: 'builder', title: '' }
|
||
],
|
||
'review these',
|
||
null,
|
||
[IMG, pdf, doc]
|
||
)
|
||
await settle(room, 'Mixed')
|
||
|
||
const byMethod: Record<string, number> = {}
|
||
|
||
for (const attach of room.gateway.attaches) {
|
||
byMethod[attach.method] = (byMethod[attach.method] || 0) + 1
|
||
}
|
||
|
||
// 3 attachments × 2 members, each via its own RPC.
|
||
expect(room.gateway.attaches).toHaveLength(6)
|
||
expect(byMethod).toEqual({ 'file.attach': 2, 'image.attach_bytes': 2, 'pdf.attach': 2 })
|
||
|
||
const staged = room.gateway.attaches.find(attach => attach.method === 'pdf.attach')
|
||
|
||
expect(staged?.filename).toBe('spec.pdf')
|
||
expect(staged?.data).toBe(pdf.data)
|
||
})
|
||
|
||
it('appends the file.attach ref_text to the member turn prompt', async () => {
|
||
const room = await loadRoom()
|
||
const doc: Attachment = { data: 'data:text/plain;base64,aGVsbG8=', kind: 'file', name: 'notes.txt' }
|
||
|
||
room.rounds.sendToGroupChat(
|
||
'Refs',
|
||
[
|
||
{ name: 'research', title: '' },
|
||
{ name: 'builder', title: '' }
|
||
],
|
||
'read the notes',
|
||
null,
|
||
[doc]
|
||
)
|
||
await settle(room, 'Refs')
|
||
|
||
expect(room.gateway.calls).toHaveLength(2)
|
||
|
||
for (const call of room.gateway.calls) {
|
||
expect(call.prompt).toContain('Attached files staged in your session workspace:')
|
||
expect(call.prompt).toContain('notes.txt → @file:attachments/notes.txt')
|
||
}
|
||
})
|
||
|
||
it('names attachments in the transcript line, labelling PDFs and files distinctly', async () => {
|
||
const { rounds } = await loadRoom()
|
||
const pdf: Attachment = { data: 'data:application/pdf;base64,JVBERi0=', kind: 'pdf', name: 'spec.pdf' }
|
||
const doc: Attachment = { data: 'data:text/plain;base64,aGVsbG8=', kind: 'file', name: 'notes.txt' }
|
||
const line = (entry: Partial<GroupMessage>) => rounds.formatGroupChatLine(entry as GroupMessage, 'research')
|
||
|
||
expect(line({ from: { kind: 'user', name: 'You' }, images: [IMG], text: 'see attached' })).toBe(
|
||
'You (user): see attached [attached image: screenshot.png]'
|
||
)
|
||
expect(line({ from: { kind: 'user', name: 'You' }, text: 'plain' })).toBe('You (user): plain')
|
||
expect(
|
||
line({
|
||
from: { kind: 'member', name: 'builder' },
|
||
images: [{ data: 'data:image/png;base64,x' } as Attachment],
|
||
text: 'made this'
|
||
})
|
||
).toBe('builder: made this [attached image: image]')
|
||
expect(line({ from: { kind: 'user', name: 'You' }, images: [pdf, doc, IMG], text: 'here' })).toBe(
|
||
'You (user): here [attached PDF: spec.pdf] [attached file: notes.txt] [attached image: screenshot.png]'
|
||
)
|
||
})
|
||
})
|
||
|
||
// #93129: a bot told to stop must STAY stopped.
|
||
describe('member holds (#93129)', () => {
|
||
it('holds the mentioned member on an explicit stop', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
for (const text of ['stop @impl', '@impl stop', '@impl please halt', 'pause @impl for now']) {
|
||
const action = rounds.classifyGroupHoldDirective(text, ['impl'], false)
|
||
|
||
expect([...action.hold]).toEqual(['impl'])
|
||
expect([...action.release]).toEqual([])
|
||
}
|
||
})
|
||
|
||
it('holds nobody when a stop word carries no mention', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
expect([...rounds.classifyGroupHoldDirective('stop', [], false).hold]).toEqual([])
|
||
})
|
||
|
||
it('still holds on "don\'t stop @x" — the documented conservative trade-off', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
expect([...rounds.classifyGroupHoldDirective("don't stop @impl", ['impl'], false).hold]).toEqual(['impl'])
|
||
})
|
||
|
||
it('does not trigger on "stop" inside another word', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
// \b(stop|halt|pause)\b — "unstoppable" is a different token.
|
||
const action = rounds.classifyGroupHoldDirective('@impl unstoppable work ahead', ['impl'], false)
|
||
|
||
expect([...action.hold]).toEqual([])
|
||
// A plain non-stop mention releases instead (direct address overrides hold).
|
||
expect([...action.release]).toEqual(['impl'])
|
||
})
|
||
|
||
it('sets a hold on stop and clears it on resume for the same member', async () => {
|
||
const { rounds } = await loadRoom()
|
||
const stamp = { at: 1000, byMessageId: 'm1', thread: 't1' }
|
||
|
||
const held = rounds.applyGroupHoldDirective({}, { everyone: false, mentioned: ['impl'] }, 'stop @impl', stamp)
|
||
|
||
expect(held.impl).toBeTruthy()
|
||
expect(held.impl.at).toBe(1000)
|
||
expect(
|
||
rounds.applyGroupHoldDirective(held, { everyone: false, mentioned: ['impl'] }, '@impl resume', stamp).impl
|
||
).toBeUndefined()
|
||
})
|
||
|
||
it('releases a held member on a direct non-stop mention', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
const next = rounds.applyGroupHoldDirective(
|
||
{ impl: { at: 1, byMessageId: null, thread: null } },
|
||
{ everyone: false, mentioned: ['impl'] },
|
||
'@impl what is your status?',
|
||
{}
|
||
)
|
||
|
||
expect(next.impl).toBeUndefined()
|
||
})
|
||
|
||
it('releases every hold on @all resume and sets every hold on @all stop', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
expect(
|
||
rounds.applyGroupHoldDirective(
|
||
{ docs: { at: 2 }, impl: { at: 1 } },
|
||
{ everyone: true, mentioned: [] },
|
||
'@all resume',
|
||
{}
|
||
)
|
||
).toEqual({})
|
||
|
||
const held = rounds.applyGroupHoldDirective({}, { everyone: true, mentioned: [] }, '@all stop', { at: 5 }, [
|
||
'impl',
|
||
'docs'
|
||
])
|
||
|
||
expect(held.impl).toBeTruthy()
|
||
expect(held.docs).toBeTruthy()
|
||
expect(held.impl.at).toBe(5)
|
||
})
|
||
|
||
it('leaves holds untouched on an unrelated room message', async () => {
|
||
const { rounds } = await loadRoom()
|
||
const held = { impl: { at: 1 } }
|
||
|
||
expect(rounds.applyGroupHoldDirective(held, { everyone: false, mentioned: [] }, 'receipt round complete', {})).toBe(
|
||
held
|
||
)
|
||
})
|
||
|
||
it("does not disturb another member's hold", async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
const next = rounds.applyGroupHoldDirective(
|
||
{ impl: { at: 1 } },
|
||
{ everyone: false, mentioned: ['docs'] },
|
||
'stop @docs',
|
||
{
|
||
at: 2
|
||
}
|
||
)
|
||
|
||
expect(next.impl).toBeTruthy()
|
||
expect(next.docs).toBeTruthy()
|
||
})
|
||
|
||
it('consumes a held skip exactly once so the loop cannot spin', async () => {
|
||
const { rounds } = await loadRoom()
|
||
|
||
// Fresh delta → advance to log length.
|
||
expect(rounds.heldMemberWatermarkAdvance(3, 7)).toBe(7)
|
||
// Already consumed → no write, no spin.
|
||
expect(rounds.heldMemberWatermarkAdvance(7, 7)).toBeNull()
|
||
expect(rounds.heldMemberWatermarkAdvance(9, 7)).toBeNull()
|
||
// Unset watermark treated as 0.
|
||
expect(rounds.heldMemberWatermarkAdvance(undefined, 2)).toBe(2)
|
||
})
|
||
})
|
||
|
||
// #91868/#94569: a REAL stop path for group-chat rounds. Before
|
||
// stopGroupThread the loop's only cancellation primitives were the epoch bump
|
||
// (checked at member boundaries only) and #93129 holds (which skip FUTURE
|
||
// turns) — the plugin issued ZERO session.interrupt RPCs, so "stop" meant
|
||
// "wait for the in-flight member to finish its whole model turn".
|
||
describe('stopGroupThread (#91868/#94569)', () => {
|
||
const STOP_MEMBERS: GroupMember[] = [
|
||
{ name: 'alpha', title: '' },
|
||
{ name: 'beta', title: '' },
|
||
{ name: 'gamma', title: '' }
|
||
]
|
||
|
||
/** Seed a room mid-round: epoch 3, running, alpha on turn with a live
|
||
* session id, nobody held yet. */
|
||
function seedRoom(room: Room, turn: null | string = 'alpha') {
|
||
room.chat.$groupChats.set({
|
||
Room: {
|
||
epoch: 3,
|
||
holds: {},
|
||
log: [],
|
||
members: STOP_MEMBERS,
|
||
running: true,
|
||
sessions: { alpha: 'live-alpha-sid' },
|
||
turn,
|
||
watermarks: {}
|
||
}
|
||
} as unknown as Record<string, GroupChat>)
|
||
}
|
||
|
||
it('bumps the epoch, clears running/turn, and holds every member', async () => {
|
||
const room = await loadRoom()
|
||
seedRoom(room)
|
||
|
||
await room.rounds.stopGroupThread('Room', 't1', STOP_MEMBERS)
|
||
|
||
const state = room.chat.$groupChats.get().Room
|
||
|
||
expect(state.epoch).toBe(4)
|
||
expect(state.running).toBe(false)
|
||
expect(state.turn).toBeNull()
|
||
|
||
for (const member of STOP_MEMBERS) {
|
||
expect(state.holds?.[member.name]).toBeTruthy()
|
||
expect(state.holds?.[member.name].thread).toBe('t1')
|
||
}
|
||
})
|
||
|
||
it('interrupts the member ON TURN via its live session', async () => {
|
||
const room = await loadRoom()
|
||
seedRoom(room)
|
||
|
||
await room.rounds.stopGroupThread('Room', 't1', STOP_MEMBERS)
|
||
|
||
const interrupts = room.gateway.rpcFor('session.interrupt')
|
||
|
||
// Exactly one — the serial loop has one member in flight.
|
||
expect(interrupts).toHaveLength(1)
|
||
expect(interrupts[0].params.session_id).toBe('live-alpha-sid')
|
||
})
|
||
|
||
it('stops a room with nobody on turn without any interrupt RPC', async () => {
|
||
const room = await loadRoom()
|
||
seedRoom(room, null)
|
||
|
||
await room.rounds.stopGroupThread('Room', 't1', STOP_MEMBERS)
|
||
|
||
expect(room.gateway.rpcFor('session.interrupt')).toHaveLength(0)
|
||
expect(room.chat.$groupChats.get().Room.running).toBe(false)
|
||
expect(room.chat.$groupChats.get().Room.epoch).toBe(4)
|
||
})
|
||
|
||
it('records a stopped activity event visible in the CURRENT run', async () => {
|
||
const room = await loadRoom()
|
||
seedRoom(room)
|
||
|
||
await room.rounds.stopGroupThread('Room', 't1', STOP_MEMBERS)
|
||
|
||
const stopped = room.activity.currentGroupActivity('Room').find(event => event.kind === 'stopped')
|
||
|
||
// Tagged with the POST-bump epoch, so it survives the epoch filter.
|
||
expect(stopped).toBeTruthy()
|
||
expect(stopped?.member).toBe('You')
|
||
expect(stopped?.thread).toBe('t1')
|
||
// The label comes from the shared activity label map and stays plain
|
||
// English — no hardcoded localized text (the #94570 shell shipped a 停止).
|
||
expect(room.activity.groupActivityLabel(stopped!)).toBeTruthy()
|
||
expect(room.activity.groupActivityLabel(stopped!)).not.toMatch(/[\u4e00-\u9fff]/)
|
||
expect(room.activity.GROUP_ACTIVITY_GLYPHS.stopped).toBeTruthy()
|
||
})
|
||
|
||
it('falls back to the durable room roster when called without members', async () => {
|
||
const room = await loadRoom()
|
||
seedRoom(room)
|
||
|
||
await room.rounds.stopGroupThread('Room', 't1')
|
||
|
||
expect(Object.keys(room.chat.$groupChats.get().Room.holds || {})).toHaveLength(STOP_MEMBERS.length)
|
||
expect(room.gateway.rpcFor('session.interrupt')).toHaveLength(1)
|
||
})
|
||
|
||
it('abandons an in-flight turn once a stop bumps the epoch and holds the member', async () => {
|
||
let live: Room | null = null
|
||
|
||
const room = await loadRoom({
|
||
onResumePoll: polls => {
|
||
// Fire the stop from inside the poll cadence, after the second busy
|
||
// poll — exactly the mid-turn click the Stop button produces.
|
||
if (polls === 2) {
|
||
void live?.rounds.stopGroupThread('Room', 't1', [{ name: 'helper', title: '' }])
|
||
}
|
||
},
|
||
pollsBusy: 50,
|
||
turn: () => 'long answer'
|
||
})
|
||
|
||
live = room
|
||
|
||
const reply = await room.turns.runGroupChatMemberTurn('Room', { name: 'helper', title: '' }, 'long task', 't1', [])
|
||
|
||
expect(reply).toBeNull()
|
||
// The poll loop exited promptly after the stop, not at the deadline.
|
||
expect(room.gateway.rpcFor('session.resume').length).toBeLessThanOrEqual(6)
|
||
expect(room.chat.$groupChats.get().Room.running).toBe(false)
|
||
})
|
||
|
||
it('keeps polling through an ordinary newer-send epoch bump so late work still lands', async () => {
|
||
let live: Room | null = null
|
||
|
||
const room = await loadRoom({
|
||
onResumePoll: polls => {
|
||
if (polls === 1) {
|
||
// A newer user send bumps the epoch but holds nobody. The in-flight
|
||
// poll must keep going so the finished reply can still be delivered
|
||
// (the #93127 commit check decides its fate at the boundary).
|
||
live?.chat.updateGroupChat('Room', current => {
|
||
current.epoch = (current.epoch || 0) + 1
|
||
|
||
return current
|
||
})
|
||
}
|
||
},
|
||
pollsBusy: 3,
|
||
turn: () => 'finished anyway'
|
||
})
|
||
|
||
live = room
|
||
|
||
const reply = await room.turns.runGroupChatMemberTurn('Room', { name: 'helper', title: '' }, 'long task', 't1', [])
|
||
|
||
expect(reply).toBe('finished anyway')
|
||
})
|
||
})
|