fix(bot-mode): show the current thread and newest unresolved failure

Attribute drive-level errors to the thread being drained, not the send
that created the queue. Reinsert repeat member failures in recency order
so the collapsed activity row cannot show an older sibling failure.

Cover both invariants and the repeated-refusal sequence in native Desktop.
Thanks to @kvnloo for identifying both review findings.
This commit is contained in:
teknium1
2026-09-14 17:18:44 -07:00
committed by Teknium
parent 3040c87ac1
commit 9326d9cdc4
5 changed files with 73 additions and 2 deletions

View File

@@ -166,7 +166,7 @@ test('a rejected member turn stays visible when the room settles', async () => {
(window as any).__rejected = ((window as any).__rejected || 0) + 1
const reject = () => this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ jsonrpc: '2.0', id: frame.id, error: { code: 4003, message: 'Controlled member admission refusal' } }) }))
if ((window as any).__rejected === 1) { (window as any).__releaseRefusal = reject } else { queueMicrotask(reject) }
if ((window as any).__rejected <= 3) { (window as any).__releaseRefusal = reject } else { queueMicrotask(reject) }
} else {
send.call(this, data)
}
@@ -191,6 +191,23 @@ test('a rejected member turn stays visible when the room settles', async () => {
expect((await publicLog(page)).filter(entry => entry.from !== 'You')).toEqual([])
await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error')
await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-error-after.png' })
// One epoch: A fails, B waits, the user retries A, then B and A fail.
// The retry is explicit and after A's failure, unlike the prequeued sends above.
await groupComposer.fill('@programmer LANE_A_FAILURE recency')
await groupComposer.press('Enter')
await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(2)
await groupComposer.fill('@reviewer LANE_A_FAILURE recency')
await groupComposer.press('Enter')
await page.evaluate(() => (window as any).__releaseRefusal())
await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(3)
await groupComposer.fill('@programmer LANE_A_FAILURE explicit retry')
await groupComposer.press('Enter')
await page.evaluate(() => (window as any).__releaseRefusal())
await expect.poll(() => page.evaluate(() => (window as any).__rejected), { timeout: 30000 }).toBe(4)
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error')
console.log('CONTROLLED REFUSAL: A failed, B failed, A failed again; newest unresolved summary:', await page.getByRole('button', { name: /^Activity/ }).textContent())
})
test('Stop clears a queued follow-up and a direct mention resumes the held member', async () => {

View File

@@ -718,6 +718,7 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
const key = event.member || ''
if (event.kind === 'failed' || event.kind === 'timed-out') {
unresolvedFailures.delete(key)
unresolvedFailures.set(key, event)
} else if (event.kind === 'replied' || event.kind === 'passed' || event.kind === 'delivered') {
unresolvedFailures.delete(key)

View File

@@ -147,6 +147,26 @@ describe('durable group holds', () => {
expect(status.textContent).toContain('builder (laptop)')
})
it('summarizes the most recent unresolved failure after a member fails again', async () => {
const [{ GroupChatWorkspace }, chat, activity] = await Promise.all([
import('./group-chat-view'), import('./group-chat'), import('./group-activity')
])
chat.$groupChats.set({ Core: { log: [], members: MEMBERS, watermarks: {} } })
for (const member of ['research', 'builder', 'research']) {
activity.recordGroupActivity('Core', { kind: 'failed', member, thread: member })
}
activity.recordGroupActivity('Core', { kind: 'settled', member: null })
const view = render(<GroupChatWorkspace group="Core" members={MEMBERS} />)
expect(screen.getByRole('button', { name: /^Activity/ }).textContent).toContain('research hit an error')
activity.recordGroupActivity('Core', { kind: 'replied', member: 'research' })
view.rerender(<GroupChatWorkspace group="Core" members={MEMBERS} />)
expect(screen.getByRole('button', { name: /^Activity/ }).textContent).toContain('builder hit an error')
})
it('projects hydrated holds into the real group workspace', async () => {
const [{ GroupChatWorkspace }, chat] = await Promise.all([import('./group-chat-view'), import('./group-chat')])

View File

@@ -264,6 +264,36 @@ describe('round lifecycle', () => {
expect(room.gateway.calls[1].prompt).toMatch(/first[\s\S]*queued same-thread[\s\S]*explicitly retry/)
})
it('attributes a queued drive failure to the thread whose harvest failed', async () => {
let finish!: (reply: string) => void
const held = new Promise<string>(resolve => { finish = resolve })
const room = await loadRoom({ turn: () => held })
const first = room.rounds.sendToGroupChat('Failure', MEMBERS.slice(0, 2), '@research first')!
await drain(() => room.gateway.calls.length < 1)
const queued = room.rounds.sendToGroupChat('Failure', MEMBERS.slice(0, 2), '@builder queued')!
const request = host.request as (...args: unknown[]) => Promise<unknown>
host.request = (...args: unknown[]) => {
const [method, params] = args as [string, { profile?: string }]
// JSON-shaped malformed reply text throws while harvesting, after RPC acceptance.
return method === 'session.resume' && params.profile === 'builder'
? Promise.resolve({ messages: [{ role: 'assistant', text: { toString: 1 } }] })
: request(...args)
}
room.chat.updateGroupChat('Failure', state => ({
...state, stranded: { builder: { before: 0, thread: first } }
}))
finish('(pass)')
await drain(() => !room.activity.currentGroupActivity('Failure').some(event => event.kind === 'failed'))
expect(first).not.toBe(queued)
expect(room.activity.currentGroupActivity('Failure').filter(event => event.kind === 'failed')).toEqual([
expect.objectContaining({ member: null, thread: queued })
])
expect(room.chat.$groupChats.get().Failure.running).toBe(false)
})
it('treats a failed member turn as a pass, not a room error', async () => {
const room = await loadRoom({
turn: ({ profile }) => {

View File

@@ -766,16 +766,19 @@ function queueGroupChatDrive(group: string, members: GroupMember[], thread: stri
updateGroupChat(group, room => ({ ...room, epoch: (room.epoch || 0) + 1 }))
void (async () => {
let currentThread = thread
try {
while (binding.isLive() && drive.pending.size) {
const [nextThread, nextMembers] = drive.pending.entries().next().value!
currentThread = nextThread
drive.pending.delete(nextThread)
updateGroupChat(group, room => ({ ...room, running: true }))
await runGroupChatRounds(group, nextMembers, nextThread, drive.failedMembers)
}
} catch {
if (binding.isLive()) {
recordGroupActivity(group, { kind: 'failed', member: null, thread })
recordGroupActivity(group, { kind: 'failed', member: null, thread: currentThread })
updateGroupChat(group, room => ({ ...room, running: false, turn: null }))
}
} finally {