Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,711 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user