fix(bot-mode): group-chat members' clarify questions surface in the room and are answerable (#90694)
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).
This commit is contained in:
@@ -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
|
||||
]
|
||||
|
||||
@@ -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'/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user