From c757f99e630bc8b87dcbb45f075615b690f48c26 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:00:35 -0700 Subject: [PATCH] fix(bot-mode): group-chat members' clarify questions surface in the room and are answerable (#90694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group members run in hidden plumbing sessions, so a member's clarify tool blocked server-side with no surface to answer it — the room showed '@lead is thinking…' until the 300s clarify timeout (salihsungur's report). - The turn poll and the stranded-harvest pass mirror each member's `pending_clarify` resume field into $groupClarify and hold the turn deadline open while a question waits (bounded by the existing hard cap). - The room renders a question card per blocked member — choice buttons, free-text, batch sub-questions — and answers route via clarify.respond through the member's OWN source (requestForBot), so cross-connection members work. The answered exchange echoes into the room log. - needs-you badges the roster row while a question waits; disband/rename clear mirrored cards; older backends without pending_clarify no-op. - 7 new tests incl. an end-to-end blocked-turn drive, sabotage-verified (disabling the poll gate fails the drive test). --- .../desktop/src/plugins/hermes-bots/plugin.js | 297 +++++++++++++++++- .../hermes-bots/tests/group-chat.test.mjs | 145 ++++++++- 2 files changed, 431 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index a2a9bb4aed..f363b2e62f 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -205,6 +205,13 @@ const $groupChats = atom({}) const $groupChatWorkspace = atom(null) /** Groups whose latest room activity mentions @user — the needs-you badge. */ const $groupNeedsYou = atom({}) +// Pending clarify questions raised inside hidden group-member sessions, +// keyed `${group}::${memberKey}` (#90694). Members run in invisible plumbing +// sessions, so a member's clarify tool used to block server-side with no +// surface to answer it — the user saw "is thinking…" until the clarify +// timeout. The turn poll mirrors each member's `pending_clarify` resume +// field in here; the room renders answer cards from it. +const $groupClarify = atom({}) // ── group activity feed ───────────────────────────────────────────────────── // Runtime-only, bounded per-room record of turn events that feeds the @@ -4139,6 +4146,7 @@ async function disbandGroupChat(group, members) { delete needs[group] $groupNeedsYou.set(needs) + clearGroupClarify(group) // Persist the room map WITHOUT the disbanded room so it can't come back // on the next window load. @@ -4235,6 +4243,10 @@ async function renameGroupChat(oldName, newName, members) { $groupNeedsYou.set(needs) } + // Mirrored clarify cards key by group name; drop the old room's — the + // next poll re-mirrors any still-blocking question under the new name. + clearGroupClarify(oldName) + // Local memberships: swap the name inside each member's canonical groups // list (syncs cross-machine via ui_meta). Remote members' seating lives in // the room record we just moved. @@ -4347,6 +4359,109 @@ const GROUP_TURN_POLL_MS = 2000 // reached the room (db's Aug 2026 report). const GROUP_TURN_HARD_CAP_MS = 20 * 60000 +/** Mirror a member's pending clarify (if any) from its resume snapshot into + * the room store, keyed `${group}::${memberKey}` (#90694). Returns true + * while a question is blocking, so the turn poll can extend its deadline — + * a waiting question must not be eaten by the group-turn timeout. Feature- + * detected: older backends without `pending_clarify` in the resume payload + * always sync to "no question". */ +function syncGroupClarify(group, member, state) { + const key = `${group}::${groupMemberKey(member)}` + const pending = state && typeof state.pending_clarify === 'object' ? state.pending_clarify : null + const requestId = pending?.request_id || null + const all = $groupClarify.get() + const current = all[key] + + if (!requestId) { + if (current) { + const next = { ...all } + delete next[key] + $groupClarify.set(next) + } + + return false + } + + // Same request already mirrored — keep the object identity so the card + // doesn't lose its draft to a re-render. + if (current?.requestId === requestId) { + return true + } + + $groupClarify.set({ + ...all, + [key]: { + requestId, + group, + member: member.name, + memberKey: groupMemberKey(member), + question: typeof pending.question === 'string' ? pending.question : '', + choices: Array.isArray(pending.choices) ? pending.choices.filter(c => typeof c === 'string' && c) : [], + multiSelect: Boolean(pending.multi_select), + // Batch clarifies carry `questions`; the room card answers them + // one wire call per question, mirroring the 1:1 batch contract. + questions: Array.isArray(pending.questions) ? pending.questions : null, + at: Date.now() + } + }) + // A blocked member is a question for the human — badge the room. + $groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: true }) + + return true +} + +/** Drop every mirrored clarify belonging to `group` (disband/rename). */ +function clearGroupClarify(group) { + const all = $groupClarify.get() + const next = {} + let changed = false + + for (const [key, value] of Object.entries(all)) { + if (value?.group === group) { + changed = true + } else { + next[key] = value + } + } + + if (changed) { + $groupClarify.set(next) + } +} + +/** Answer a member's pending clarify from the room. Routes clarify.respond + * to the member's OWN source (requestForBot), so cross-connection members + * work. Batch questions send one respond per question, sequentially — the + * LAST lock resolves the blocked tool server-side (same contract as the + * 1:1 batch card). allow_expired server-side makes racing the timeout + * harmless. */ +async function answerGroupClarify(entry, member, answers) { + if (entry.questions && entry.questions.length) { + for (const question of entry.questions) { + const qid = question?.qid ?? question?.id + await requestForBot(member, 'clarify.respond', { + request_id: entry.requestId, + question_id: qid, + answer: answers?.[qid] ?? '' + }) + } + } else { + await requestForBot(member, 'clarify.respond', { + request_id: entry.requestId, + answer: typeof answers === 'string' ? answers : '' + }) + } + + const all = $groupClarify.get() + const key = `${entry.group}::${entry.memberKey}` + + if (all[key]?.requestId === entry.requestId) { + const next = { ...all } + delete next[key] + $groupClarify.set(next) + } +} + /** One member turn, gateway-native: submit the room delta as a prompt into * the member's per-group session, then poll the session until a NEW * assistant message lands (or timeout → pass). While the session visibly @@ -4446,7 +4561,11 @@ async function runGroupChatMemberTurn(group, member, prompt, thread, images) { const messages = Array.isArray(state?.messages) ? state.messages : [] const busy = Boolean(state?.inflight || state?.running) - const done = !busy + // A clarify blocking inside the member's session is a question for the + // HUMAN (#90694) — mirror it into the room store so a card renders, and + // hold the turn open: the member isn't stalling, it's waiting on us. + const awaitingUser = syncGroupClarify(group, member, state) + const done = !busy && !awaitingUser if (messages.length > before && done) { for (let i = messages.length - 1; i >= 0; i--) { @@ -4475,16 +4594,20 @@ async function runGroupChatMemberTurn(group, member, prompt, thread, images) { return null } - // Still visibly working: extend the deadline (never past the hard cap). - if (busy) { + // Still visibly working — or waiting on the user's answer to a clarify: + // extend the deadline (never past the hard cap). A pending question must + // outlive the base turn timeout or it dies unanswered at 3 minutes. + if (busy || awaitingUser) { deadline = Math.min(started + GROUP_TURN_HARD_CAP_MS, Math.max(deadline, Date.now() + GROUP_TURN_TIMEOUT_MS)) } } - // Timeout — reads as a pass, but remember the baseline + thread + // Timeout — clear any still-mirrored question card (the server-side + // clarify timeout runs its own course) and read as a pass, but remember the baseline + thread // (runtime-only) so the finished reply can be posted late into the RIGHT // thread instead of vanishing. recordGroupActivity(group, { kind: 'timed-out', member: member.name, thread }) + syncGroupClarify(group, member, null) updateGroupChat(group, r => { r.stranded = { ...(r.stranded || {}), [groupMemberKey(member)]: { before, thread } } return r @@ -4524,6 +4647,12 @@ async function harvestStrandedGroupReply(group, member) { return // still grinding — keep waiting } + // A stranded member blocked on a clarify is not "grinding" — surface the + // question card (#90694) and keep the marker until it resolves. + if (syncGroupClarify(group, member, state)) { + return + } + // Done (or dead): the marker is consumed either way. updateGroupChat(group, r => { const next = { ...(r.stranded || {}) } @@ -8758,6 +8887,150 @@ function GroupMentionInput({ members, onChange, value, ...inputProps }) { }) } +/** One member's pending question, rendered in the room (#90694). Choices + * render as tap buttons (multi-select stages; single-select stages one); + * free text always available; Answer sends via the member's own source. + * Batch clarifies render every sub-question with its own input. */ +function GroupClarifyCard({ entry, members }) { + const { group } = entry + const member = members.find(m => groupMemberKey(m) === entry.memberKey) || members.find(m => m.name === entry.member) + const [drafts, setDrafts] = useState({}) + const [picked, setPicked] = useState({}) + const [sending, setSending] = useState(false) + const questions = entry.questions && entry.questions.length + ? entry.questions.map((q, i) => ({ + qid: q?.qid ?? q?.id ?? `q${i}`, + question: typeof q?.question === 'string' ? q.question : '', + choices: Array.isArray(q?.choices) ? q.choices.filter(c => typeof c === 'string' && c) : [], + multiSelect: Boolean(q?.multi_select ?? q?.multiSelect) + })) + : [{ qid: '__single__', question: entry.question, choices: entry.choices, multiSelect: entry.multiSelect }] + + const answerFor = q => { + const chosen = picked[q.qid] || [] + + if (chosen.length) { + return q.multiSelect ? JSON.stringify(chosen) : chosen[0] + } + + return (drafts[q.qid] || '').trim() + } + + const allAnswered = questions.every(q => answerFor(q)) + + const submit = async () => { + if (!member || sending || !allAnswered) { + return + } + + setSending(true) + + try { + if (entry.questions && entry.questions.length) { + const answers = {} + + for (const q of questions) { + answers[q.qid] = answerFor(q) + } + + await answerGroupClarify(entry, member, answers) + } else { + await answerGroupClarify(entry, member, answerFor(questions[0])) + } + + // Echo the exchange into the room log so the thread reads complete. + const summary = questions + .map(q => (questions.length > 1 ? `${q.question}: ${answerFor(q)}` : answerFor(q))) + .join('\n') + appendGroupChatEntry(group, { kind: 'user', name: 'You' }, summary, entry.thread || 'legacy') + } catch (err) { + host.notify({ kind: 'error', message: `Could not send the answer to @${botHandle(entry.member, member)}: ${err?.message || err}` }) + } finally { + setSending(false) + } + } + + return jsxs('div', { + className: + 'grid gap-1.5 rounded-md border border-(--ui-accent,#4f9cf9)/50 bg-(--ui-accent,#4f9cf9)/5 px-2.5 py-2', + children: [ + jsxs('div', { + className: 'flex items-center gap-1.5 text-xs font-medium', + children: [ + jsx(Codicon, { name: 'question', className: 'shrink-0 text-(--ui-accent,#4f9cf9)' }), + `@${botHandle(entry.member, member)} asks:` + ] + }), + ...questions.map(q => + jsxs('div', { + className: 'grid gap-1', + children: [ + q.question + ? jsx('div', { className: 'text-xs whitespace-pre-wrap', children: q.question }) + : null, + q.choices.length + ? jsx('div', { + className: 'flex flex-wrap gap-1', + children: q.choices.map(choice => { + const chosen = (picked[q.qid] || []).includes(choice) + + return jsx(Button, { + size: 'sm', + variant: chosen ? 'default' : 'secondary', + className: 'h-6 px-2 text-[0.7rem]', + onClick: () => { + setDrafts(prev => ({ ...prev, [q.qid]: '' })) + setPicked(prev => { + const current = prev[q.qid] || [] + const next = q.multiSelect + ? chosen + ? current.filter(c => c !== choice) + : [...current, choice] + : chosen + ? [] + : [choice] + + return { ...prev, [q.qid]: next } + }) + }, + children: choice + }, `choice:${q.qid}:${choice}`) + }) + }) + : null, + jsx(Input, { + 'aria-label': `Answer @${entry.member}`, + placeholder: q.choices.length ? 'Or type your own answer…' : 'Type your answer…', + value: drafts[q.qid] || '', + onChange: event => { + const value = event.target.value + setPicked(prev => ({ ...prev, [q.qid]: [] })) + setDrafts(prev => ({ ...prev, [q.qid]: value })) + }, + onKeyDown: event => { + if (event.key === 'Enter' && questions.length === 1) { + event.preventDefault() + void submit() + } + }, + className: 'h-7 text-xs' + }, `input:${q.qid}`) + ] + }, `q:${q.qid}`) + ), + jsx('div', { + className: 'flex justify-end', + children: jsx(Button, { + size: 'sm', + disabled: sending || !allAnswered || !member, + onClick: () => void submit(), + children: sending ? 'Sending…' : 'Answer' + }) + }) + ] + }) +} + function GroupChatWorkspace({ group, members, onBack }) { const rooms = useValue($groupChats) const allMeta = useValue($botMeta) @@ -8838,6 +9111,11 @@ function GroupChatWorkspace({ group, members, onBack }) { const [activityOpen, setActivityOpen] = useState(false) // Subscribe: activity rows re-render as turn events land. useValue($groupActivity) + // Pending member questions for THIS room (#90694), oldest first. + const clarifyAll = useValue($groupClarify) + const roomClarifies = Object.values(clarifyAll || {}) + .filter(entry => entry?.group === group) + .sort((a, b) => (a.at || 0) - (b.at || 0)) const header = jsxs('div', { className: 'flex items-center gap-2 px-2.5 pt-2.5 pb-2', @@ -9358,12 +9636,17 @@ function GroupChatWorkspace({ group, members, onBack }) { children: 'Say something — every bot in this group hears the room.' }, 'empty') ]), + ...roomClarifies.map(entry => + jsx(GroupClarifyCard, { entry, members }, `clarify:${entry.memberKey}:${entry.requestId}`) + ), room.running ? jsx('div', { className: 'px-2 py-1 text-[0.7rem] italic text-(--ui-text-quaternary)', - children: room.turn - ? `${groupSpeakerLabel(room.turn)} is thinking…` - : 'The room is working…' + children: roomClarifies.length + ? 'Waiting for your answer…' + : room.turn + ? `${groupSpeakerLabel(room.turn)} is thinking…` + : 'The room is working…' }, 'working') : null ] diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs index f5379e994c..bb979b80a2 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs @@ -8,7 +8,7 @@ const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf /** Load the plugin in a vm with a scripted cli.exec so member turns are * deterministic. `turnScript(profile, prompt)` returns the member's reply * text (or throws to simulate a failed turn). */ -function load(turnScript, { busyUntilResumeCall } = {}) { +function load(turnScript, { busyUntilResumeCall, clarifyUntilResumeCall } = {}) { const values = new Map() const atom = initial => { const slot = { get: () => values.get(slot), set: value => values.set(slot, value) } @@ -16,6 +16,7 @@ function load(turnScript, { busyUntilResumeCall } = {}) { return slot } const calls = [] + const clarifyResponds = [] const sessions = new Map() const runtimeToStored = new Map() const titleToStored = new Map() @@ -63,13 +64,19 @@ function load(turnScript, { busyUntilResumeCall } = {}) { resumeCallCounts.set(profile, seen) const limit = busyUntilResumeCall && busyUntilResumeCall[profile] const busy = Boolean(limit && seen <= limit) + // clarifyUntilResumeCall[profile] = {payload, until}: the resume + // snapshot carries pending_clarify for the first `until` calls — + // simulating a member blocked on its clarify tool (#90694). + const clarify = clarifyUntilResumeCall && clarifyUntilResumeCall[profile] + const pendingClarify = clarify && seen <= clarify.until ? clarify.payload : null return { session_id: session.runtime, session_key: session.stored, message_count: session.messages.length, messages: [...session.messages], inflight: busy, - running: busy + running: busy, + ...(pendingClarify ? { pending_clarify: pendingClarify } : {}) } } if (method === 'prompt.submit') { @@ -89,6 +96,10 @@ function load(turnScript, { busyUntilResumeCall } = {}) { session.messages.push({ role: 'assistant', content: reply }) return {} } + if (method === 'clarify.respond') { + clarifyResponds.push({ ...params }) + return { ok: true } + } return {} }, state: { profile: { get: () => 'default', listen: () => undefined }, gateway: { listen: () => undefined } }, @@ -104,7 +115,7 @@ function load(turnScript, { busyUntilResumeCall } = {}) { .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') .replace('export default {', 'globalThis.plugin = {') .concat( - '\nglobalThis.__gc = { sendToGroupChat, runGroupChatRounds, harvestStrandedGroupReply, resolveGroupResponders, parseGroupChatMentions, rotateGroupSpeakers, isGroupPassText, formatGroupChatLine, buildGroupChatTurnPrompt, trimGroupChatLog, disbandGroupChat, updateGroupChat, openGroupChat, closeGroupChatMainTab, shouldRenderGroupChatInPane, $groupChats, $groupNeedsYou, $groupChatWorkspace, $groupMainTabsRev, $botMeta, GROUP_CHAT_MAX_ROUNDS, GROUP_CHAT_MAX_MESSAGES };\n' + '\nglobalThis.__gc = { sendToGroupChat, runGroupChatRounds, harvestStrandedGroupReply, resolveGroupResponders, parseGroupChatMentions, rotateGroupSpeakers, isGroupPassText, formatGroupChatLine, buildGroupChatTurnPrompt, trimGroupChatLog, disbandGroupChat, updateGroupChat, openGroupChat, closeGroupChatMainTab, shouldRenderGroupChatInPane, syncGroupClarify, clearGroupClarify, answerGroupClarify, $groupClarify, $groupChats, $groupNeedsYou, $groupChatWorkspace, $groupMainTabsRev, $botMeta, GROUP_CHAT_MAX_ROUNDS, GROUP_CHAT_MAX_MESSAGES };\n' ) vm.runInNewContext(source, context, { filename: 'plugin.js' }) const storageWrites = new Map() @@ -112,7 +123,7 @@ function load(turnScript, { busyUntilResumeCall } = {}) { storage: { get: () => null, set: (key, value) => storageWrites.set(key, value) }, register: () => undefined }) - return { ...context.__gc, calls, host: context.host, sessions, storageWrites } + return { ...context.__gc, calls, clarifyResponds, host: context.host, sessions, storageWrites } } const MEMBERS = [{ name: 'research', title: '' }, { name: 'builder', title: '' }, { name: 'ops', title: 'The Ops' }] @@ -885,3 +896,129 @@ test('group room preview renders the bot HANDLE, not the raw profile name', () = assert.match(pluginSource, /\? `\$\{last\.from\?\.kind === 'user' \? 'You' : `@\$\{lastHandle\}`\}/) assert.doesNotMatch(pluginSource, /`@\$\{last\.from\?\.name \|\| 'bot'\}`/) }) + +// ── group clarify (#90694): member questions surface in the room ────────── + +const CLARIFY_PAYLOAD = { + request_id: 'req-clarify-1', + question: 'Which env should I target?', + choices: ['staging', 'prod'], + multi_select: false +} + +test('a member blocked on clarify surfaces its question and holds the turn open', async () => { + // research reports pending_clarify on its first two resumes after the + // submit; the turn must NOT read it as done/pass while the question waits. + const gc = load(() => 'targeting staging', { + clarifyUntilResumeCall: { research: { payload: CLARIFY_PAYLOAD, until: 3 } } + }) + + const thread = gc.sendToGroupChat('Core', [MEMBERS[0]], '@research deploy it', null, []) + await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise(resolve => setTimeout(resolve, 0)) + + // Mirrored while blocked, cleared once the resume stops reporting it. + const log = gc.$groupChats.get().Core.log.filter(e => e.thread === thread) + const replies = log.filter(e => e.from.kind === 'member') + + assert.equal(replies.length, 1, 'the finished reply still lands after the clarify clears') + assert.equal(replies[0].text, 'targeting staging') + assert.equal(Object.keys(gc.$groupClarify.get()).length, 0, 'the mirrored question is cleared once resolved') + // The mirror pass ran while the question was blocking: it badges the room + // needs-you. The sabotaged (pre-fix) poll never inspects pending_clarify, + // so this stays unset — the observable proof the gate executed. + assert.equal(gc.$groupNeedsYou.get().Core, true, 'the blocking question badged the room') +}) + +test('syncGroupClarify mirrors, badges needs-you, and is idempotent per request', () => { + const gc = load(() => '(pass)') + const member = { name: 'research', title: '' } + + const blocked = gc.syncGroupClarify('Core', member, { pending_clarify: CLARIFY_PAYLOAD }) + assert.equal(blocked, true) + + const mirrored = Object.values(gc.$groupClarify.get()) + assert.equal(mirrored.length, 1) + assert.equal(mirrored[0].requestId, 'req-clarify-1') + assert.equal(mirrored[0].question, 'Which env should I target?') + assert.equal(JSON.stringify(mirrored[0].choices), JSON.stringify(['staging', 'prod'])) + assert.equal(gc.$groupNeedsYou.get().Core, true) + + // Same request again: no new entry, identity preserved. + const first = mirrored[0] + gc.syncGroupClarify('Core', member, { pending_clarify: CLARIFY_PAYLOAD }) + assert.equal(Object.values(gc.$groupClarify.get())[0], first) + + // Question resolved server-side: the mirror clears. + const still = gc.syncGroupClarify('Core', member, {}) + assert.equal(still, false) + assert.equal(Object.keys(gc.$groupClarify.get()).length, 0) +}) + +test('older backends without pending_clarify never mirror a question', () => { + const gc = load(() => '(pass)') + + assert.equal(gc.syncGroupClarify('Core', { name: 'research' }, { messages: [] }), false) + assert.equal(Object.keys(gc.$groupClarify.get()).length, 0) +}) + +test('answerGroupClarify routes clarify.respond and clears the mirror', async () => { + const gc = load(() => '(pass)') + const member = { name: 'research', title: '' } + + gc.syncGroupClarify('Core', member, { pending_clarify: CLARIFY_PAYLOAD }) + const entry = Object.values(gc.$groupClarify.get())[0] + + await gc.answerGroupClarify(entry, member, 'staging') + + assert.equal(JSON.stringify(gc.clarifyResponds), JSON.stringify([{ request_id: 'req-clarify-1', answer: 'staging' }])) + assert.equal(Object.keys(gc.$groupClarify.get()).length, 0) +}) + +test('answerGroupClarify sends one respond per batch question, in order', async () => { + const gc = load(() => '(pass)') + const member = { name: 'research', title: '' } + const batch = { + request_id: 'req-batch-1', + questions: [ + { qid: 'q0', question: 'Env?', choices: ['staging', 'prod'] }, + { qid: 'q1', question: 'Region?', choices: [] } + ] + } + + gc.syncGroupClarify('Core', member, { pending_clarify: batch }) + const entry = Object.values(gc.$groupClarify.get())[0] + + await gc.answerGroupClarify(entry, member, { q0: 'staging', q1: 'eu-west' }) + + assert.equal( + JSON.stringify(gc.clarifyResponds), + JSON.stringify([ + { request_id: 'req-batch-1', question_id: 'q0', answer: 'staging' }, + { request_id: 'req-batch-1', question_id: 'q1', answer: 'eu-west' } + ]) + ) + assert.equal(Object.keys(gc.$groupClarify.get()).length, 0) +}) + +test('disband clears the room mirrored questions', async () => { + const gc = load(() => '(pass)') + + gc.syncGroupClarify('Core', { name: 'research' }, { pending_clarify: CLARIFY_PAYLOAD }) + gc.syncGroupClarify('Other', { name: 'ops' }, { pending_clarify: { ...CLARIFY_PAYLOAD, request_id: 'req-2' } }) + + await gc.disbandGroupChat('Core', []) + + const remaining = Object.values(gc.$groupClarify.get()) + assert.equal(remaining.length, 1) + assert.equal(remaining[0].group, 'Other') +}) + +test('source contract: room renders clarify cards and the poll gates on them', () => { + assert.match(pluginSource, /function GroupClarifyCard\(/) + assert.match(pluginSource, /syncGroupClarify\(group, member, state\)/) + assert.match(pluginSource, /const done = !busy && !awaitingUser/) + assert.match(pluginSource, /busy \|\| awaitingUser/) + assert.match(pluginSource, /roomClarifies\.map\(entry =>/) + assert.match(pluginSource, /'clarify\.respond'/) +})