Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

@@ -0,0 +1,857 @@
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')
})
})