diff --git a/changelog.d/456-face-held-replies.fixed.md b/changelog.d/456-face-held-replies.fixed.md new file mode 100644 index 00000000..655105b5 --- /dev/null +++ b/changelog.d/456-face-held-replies.fixed.md @@ -0,0 +1 @@ +Face-to-face rooms: a Bot reply held for your approval shows only on its approval card, greyed, and becomes a room bubble only after you approve it; a rejected reply collapses to one grey "Not sent" line, also after a reload and in Past rooms. A decided card leaves no "Approved" / "Rejected" stub, the card names the Bot with its person ("Researcher (Alice) → the room"), and each Bot answers a person's line at most once ([#456](https://github.com/ThinkFlowLab/FlyBot/issues/456)). diff --git a/core/client.js b/core/client.js index ef823a0f..1422e46e 100644 --- a/core/client.js +++ b/core/client.js @@ -145,6 +145,7 @@ var ZH = { "Messages waiting for your approval": "等你批准的消息", "Waiting for approval": "等待批准", "Not sent": "未发出", + "{name} · Not sent": "{name} · 未发出", "Sent to {to}": "已发给 {to}", "Auto-sent to {to}": "已自动发给 {to}", "Not sent: delivery failed": "未发出:发送失败", @@ -3163,7 +3164,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} -.bt-bubble-held{background:transparent;border:1px dashed var(--bt-line-2);color:var(--bt-ink-2)} +.bt-event.bt-not-sent{gap:6px;color:var(--bt-ink-3)} .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} @@ -5307,7 +5308,28 @@ function isHeldLine(room2, text, bot) { return held !== "" && (held === line || held.length >= 799 && line.startsWith(held)); }); } -function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +var HELD_SENDING_MS = 6e4; +function heldLineState(room2, { seq, botId, text } = {}, now = Date.now()) { + if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; + const said = String(text ?? "").trim(); + const entry = room2.heldLines.filter((item) => seq !== void 0 && item?.seq !== void 0 ? item.seq === seq : item?.botId === botId && String(item?.text ?? "").trim() === said).at(-1); + if (entry === void 0) return void 0; + if (entry.status === "rejected") return "rejected"; + if (entry.status === "sending" && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return void 0; + if (entry.status !== "pending" && entry.status !== "sending") return void 0; + return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held"; +} +function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi, seq }) { + const held = heldLineState(room2, { seq, botId: bot.id, text }); + if (held === "rejected") { + return (0, import_react8.createElement)( + "div", + { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, + (0, import_react8.createElement)(BotMark, { bot, size: 16 }), + (0, import_react8.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) + ); + } + if (held === "held" || isHeldLine(room2, text, bot)) return null; const lead = (0, import_react8.createElement)( "button", { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, @@ -5319,27 +5341,6 @@ function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi } bot.name, room2?.admin === bot.id ? (0, import_react8.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null ); - if (isHeldLine(room2, text, bot)) { - return (0, import_react8.createElement)( - "div", - { className: "bt-group-msg" }, - name, - (0, import_react8.createElement)( - "div", - { className: "bt-astack", "data-lead": "" }, - (0, import_react8.createElement)( - "div", - { className: "bt-msg" }, - (0, import_react8.createElement)( - "div", - { className: "bt-msg-line" }, - lead, - (0, import_react8.createElement)("div", { className: "bt-bubble bt-bubble-held", "data-flybot": "held-line", role: "status" }, t("Waiting for approval")) - ) - ) - ) - ); - } return (0, import_react8.createElement)( "div", { className: "bt-group-msg" }, @@ -5936,7 +5937,7 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi useUi }); } - return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); }), pending.map((id) => { const bot = roster.byId[id]; diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 1dbcafb9..0dfa500c 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -228,12 +228,12 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { return h('div', { className: 'bt-event' }, parts) } -// A Bot line that Bot Social holds for the owner's approval has its text in the approval card; the -// bubble keeps only the state, so the text shows once (#306). The host's pending list (`pendingApprovals`) +// A Bot line that Bot Social holds for the owner's approval has its text in the approval card only, and +// the room draws no bubble for it until Approve (#306, #456). The host's pending list (`pendingApprovals`) // carries the held text whitespace-collapsed and capped at 800 characters (799 once trimmed), without a "To the room:" lead. const heldForm = text => String(text ?? '').replace(/\s+/g, ' ').trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, '').trim() // The card names the sending Bot in `from`; a bubble is held only when its own Bot sent the held text, so -// another Bot's identical words are not turned into "Waiting for approval" (#306). A card that names no sender +// another Bot's identical words are not hidden (#306). A card that names no sender // matches by text alone, as before. const sameName = (a, b) => String(a ?? '').replace(/\s+/g, ' ').trim().toLowerCase() === String(b ?? '').replace(/\s+/g, ' ').trim().toLowerCase() export function isHeldLine(room, text, bot) { @@ -248,19 +248,39 @@ export function isHeldLine(room, text, bot) { }) } +// A face-room line my own Bot wrote, as the host tracks it on the room (`heldLines`, #456): `held` while it is +// being sent or waits for the owner's Approve / Reject, `rejected` once rejected, or when the room ended before +// anyone decided (it never left), so it reads the same after a reload and in Past rooms. A line drawn with its +// log seq is found by it; a late post (no seq) by its Bot and its exact text. +const HELD_SENDING_MS = 60_000 +export function heldLineState(room, { seq, botId, text } = {}, now = Date.now()) { + if (room?.origin !== 'face-to-face' || !Array.isArray(room.heldLines)) return undefined + const said = String(text ?? '').trim() + const entry = room.heldLines.filter(item => (seq !== undefined && item?.seq !== undefined + ? item.seq === seq + : item?.botId === botId && String(item?.text ?? '').trim() === said)).at(-1) + if (entry === undefined) return undefined + if (entry.status === 'rejected') return 'rejected' + // A send a restart cut off never got an answer: the line is drawn as before. + if (entry.status === 'sending' && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return undefined + if (entry.status !== 'pending' && entry.status !== 'sending') return undefined + return room.face?.status === 'left' || room.face?.status === 'closed' ? 'rejected' : 'held' +} + // One Bot message inside a group chat: avatar, name (with its group role), bubble. -function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { +function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq }) { + const held = heldLineState(room, { seq, botId: bot.id, text }) + // Rejected: one grey line where the bubble would have been, never the words (#456). + if (held === 'rejected') { + return h('div', { className: 'bt-event bt-not-sent', 'data-flybot': 'held-not-sent', role: 'status' }, + h(BotMark, { bot, size: 16 }), h('span', null, t('{name} · Not sent', { name: bot.name }))) + } + // Waiting: the words are on the owner's approval card only; the bubble comes after Approve (#456). + if (held === 'held' || isHeldLine(room, text, bot)) return null const lead = h('button', { type: 'button', className: 'bt-lead', 'aria-label': t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, h(BotMark, { bot, size: 22 })) const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name, room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null) - if (isHeldLine(room, text, bot)) { - return h('div', { className: 'bt-group-msg' }, name, - h('div', { className: 'bt-astack', 'data-lead': '' }, - h('div', { className: 'bt-msg' }, - h('div', { className: 'bt-msg-line' }, lead, - h('div', { className: 'bt-bubble bt-bubble-held', 'data-flybot': 'held-line', role: 'status' }, t('Waiting for approval')))))) - } return h('div', { className: 'bt-group-msg' }, name, h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead })) } @@ -859,7 +879,7 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions useUi, }) } - return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi }) + return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi, seq: reply.seq }) }), pending.map((id) => { const bot = roster.byId[id] diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 261f93d0..60b3c049 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -109,6 +109,7 @@ const ZH = { 'Messages waiting for your approval': '等你批准的消息', 'Waiting for approval': '等待批准', 'Not sent': '未发出', + '{name} · Not sent': '{name} · 未发出', 'Sent to {to}': '已发给 {to}', 'Auto-sent to {to}': '已自动发给 {to}', 'Not sent: delivery failed': '未发出:发送失败', diff --git a/core/src/client/styles.js b/core/src/client/styles.js index ab729f8b..cbe3502f 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -194,7 +194,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} -.bt-bubble-held{background:transparent;border:1px dashed var(--bt-line-2);color:var(--bt-ink-2)} +.bt-event.bt-not-sent{gap:6px;color:var(--bt-ink-3)} .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 55d5fd96..725921c1 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -60,7 +60,7 @@ export function install(rt) { } }), // `parts`: the members' hidden group Sessions and their earlier segments, for usage. - rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, ...room }) => { + rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, faceLine, ...room }) => { const parts = [...Object.values(sessions ?? {}), ...Object.values(segments ?? {}).flat().map(segment => segment.sessionId)] const last = (log ?? []).filter(line => line.kind === undefined).at(-1) const previewText = last ? unwrapUntrusted(last.text) : '' diff --git a/core/src/host/groups.js b/core/src/host/groups.js index d8209d6f..f45d895b 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -56,6 +56,31 @@ const appendRoom = (room, line) => { const stamped = { ...line, seq: room.seq } room.log = [...room.log, stamped].slice(-ROOM_LOG_MAX) void fanoutRoomLine(room, stamped) + return stamped +} + +// One answer per Bot to a person's plain line in a face-to-face room (#295, #456). The room keeps the +// line's seq, the Bots that answered it and the turns it asked for; a Bot asked again for the same line +// (the line delivered twice, or a turn that went on working and posts late after a second one) passes. +// A newer person line ends it, so a follow-up is always answered. +const lastPersonLine = (room) => { + const log = room.log ?? [] + for (let at = log.length - 1; at >= 0; at -= 1) { + const line = log[at] + if (!line.kind && line.botId === undefined && line.authorKind !== 'bot') return line + } + return undefined +} +const lastPersonSeq = room => lastPersonLine(room)?.seq ?? 0 +const FACE_ASKED_KEPT = 24 +const currentFaceLine = (room, seq) => (room.faceLine !== undefined && room.faceLine.seq === seq && lastPersonSeq(room) === seq ? room.faceLine : undefined) +// `authorId`: a friend's line is only tracked once it is in the log (applyFaceLine logs it first). +function openFaceLine(room, authorId) { + const person = lastPersonLine(room) + const seq = person?.seq ?? 0 + if (seq === 0 || (authorId !== undefined && person.authorId !== authorId)) return undefined + if (room.faceLine?.seq !== seq) room.faceLine = { seq, answered: [], asked: [] } + return seq } // Lines a face-room send already put in the log and on the wire; the round that answers them @@ -296,6 +321,7 @@ export function install(rt) { if (route) { round.route = route round.single = true + round.faceLine = openFaceLine(room) speakers = lastFirst(speakers, route) } } @@ -390,7 +416,7 @@ export function install(rt) { } // One answer for a line that is not for everyone (#299): once a Bot has answered, the Bots still waiting are not // asked at all (no model call, no line), so three Bots never post three similar answers. - if (answeredAlready(round)) { + if (answeredAlready(round) || currentFaceLine(room, round.faceLine)?.answered.includes(member.id)) { slot.status = 'passed' round.passed.push(member.name) continue @@ -414,7 +440,11 @@ export function install(rt) { round.shown?.push(member.id) const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}` if (!signal.aborted) room.seen[member.id] = room.seq - const result = await participant.runTurn({ room, member, sessionId: session.id, text, note: slot.note ?? 'your turn', signal, started: round.started, by: slot.by, origin: roundOrigins.get(round) }) + const exchangeId = randomUUID() + // A turn asked for this line may post late; it still counts as this line's answer (#456). + const asked = currentFaceLine(room, round.faceLine) + if (asked !== undefined) asked.asked = [...asked.asked, exchangeId].slice(-FACE_ASKED_KEPT) + const result = await participant.runTurn({ room, member, sessionId: session.id, text, note: slot.note ?? 'your turn', signal, started: round.started, by: slot.by, origin: roundOrigins.get(round), exchangeId }) const reply = result === null || typeof result === 'object' ? result : stripOwnName(member.name, String(result ?? '').trim()) round.speaking = null if (reply?.failure !== undefined) { @@ -442,9 +472,18 @@ export function install(rt) { if (reply?.missed) round.replies.push({ kind: 'missed', botId: member.id, name: member.name, color: member.color, time: Date.now() }) continue } + const heard = currentFaceLine(room, round.faceLine) + // A second answer to the same line is dropped, never posted (#456). + if (heard?.answered.includes(member.id)) { + slot.status = 'passed' + round.passed.push(member.name) + continue + } + heard?.answered.push(member.id) slot.status = 'posted' - appendRoom(room, rt.authoredRoomLine?.(room, { who: member.name, botId: member.id, authorId: member.id, authorKind: 'bot', text: reply }) ?? { who: member.name, botId: member.id, text: reply }) - round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now() }) + const posted = appendRoom(room, rt.authoredRoomLine?.(room, { who: member.name, botId: member.id, authorId: member.id, authorKind: 'bot', text: reply }) ?? { who: member.name, botId: member.id, text: reply }) + // The line's seq lets the chat find its held state (#456). + round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now(), seq: posted.seq }) void rt.save().catch(() => {}) // Only the admin's "@all" calls everyone, so a member cannot set off a storm. const leads = rt.isAdminOf(room, member.id) @@ -517,6 +556,9 @@ export function install(rt) { })), // One pass: a plain line's speakers do not call each other on (#295), so Bots in two homes cannot loop. ...(isPlainFaceLine(room, opener) ? { single: true } : {}), + // A person's plain line, the friend's attested one too, is answered once per Bot (#456). + ...(isPlainFaceLine(room, opener) ? { faceLine: openFaceLine(room) } : {}), + ...(!isPlainFaceLine(room, opener) && opener.plain === true && isFaceRoom(room) ? { faceLine: openFaceLine(room, String(opener.human?.authorId ?? '')) } : {}), speaking: null, turns: 0, replies: [], @@ -720,6 +762,13 @@ export function install(rt) { if (room === undefined || member === undefined || !room.members.includes(member.id)) return const text = stripOwnName(member.name, reply) if (isPass(text)) return + // A late answer to a face-room line this Bot already answered is dropped (#456). + const heard = isFaceRoom(room) && room.faceLine?.asked?.includes(info.exchangeId) ? currentFaceLine(room, room.faceLine.seq) : undefined + if (heard?.answered.includes(member.id)) { + rt.warn('ds-bot: %s already answered this line in %s; its second answer is not posted', member.name, room.name) + return + } + heard?.answered.push(member.id) const quiet = (room.lateRounds ?? 0) >= ROOM_LATE_ROUNDS if (!quiet) room.lateRounds = (room.lateRounds ?? 0) + 1 await rt.save() diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index db76f273..1dcb8814 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -997,7 +997,8 @@ export function install(rt) { dropFace: roomId => rt.dropFaceRoom?.(roomId), // `fn(room, line, { mentions })`: for the room owner's own typed line, `mentions` is the server handle of // every Bot its "@" names (the picker's labels read against this room), so the transport can send the - // explicit list with the line (#293). Bot lines carry none. + // explicit list with the line (#293). Bot lines carry none. `fn` answers `{ held: true, approvalId }` for a + // Bot line the server holds for the owner's approval (#456); the room then shows it on its card only. onFaceLine(fn) { if (typeof fn !== 'function') { rt.faceRoomFanout = undefined; return () => {} } const wrapped = (room, line) => { @@ -1007,6 +1008,9 @@ export function install(rt) { rt.faceRoomFanout = wrapped return () => { if (rt.faceRoomFanout === wrapped) rt.faceRoomFanout = undefined } }, + // The owner's Approve / Reject of a held face-room line (#456), by its Bot Social approval id: an + // approved line is a bubble from then on, a rejected one a grey "Not sent" line. + settleHeld: (approvalId, decision) => rt.settleHeldLine?.(approvalId, decision) === true, postNotice: (roomId, text) => { const room = rt.roomOf(roomId) if (!room || typeof rt.postToRoom !== 'function') return diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 03970a72..2b1c72ac 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -41,6 +41,8 @@ import { hasAnyGrant, mayShareRoom } from './reach.js' const KINDS = new Set(['human', 'bot']) const ROLES = new Set(['admin', 'member']) +// Held face-room lines kept per room (#456): the newest, so a long room cannot grow its record forever. +const HELD_LINES_KEPT = 50 export const PARTICIPANT_KINDS = ['human', 'bot'] export const ROOM_ROLES = ['admin', 'member'] @@ -870,9 +872,23 @@ export function install(rt) { // speak outside (bot.send then runs the reach policy on every single send). rt.fanoutRoomLine = async function fanoutRoomLine(room, line) { if (isFaceRoom(room)) { - try { await rt.faceRoomFanout?.(room, line) } catch (error) { + // A line my own Bot wrote is not a room bubble until it has left (#456): it is `sending` from here, + // `pending` while Bot Social holds it for the owner's Approve / Reject, and dropped from the list + // once it went out. Only the owner's click (settleHeldLine) moves a pending line on. + const held = rt.faceRoomFanout && isLocalBotLine(line) ? holdLine(room, line) : undefined + let result + try { result = await rt.faceRoomFanout?.(room, line) } catch (error) { rt.warn?.('ds-bot: face-room line not sent: %s', error) } + if (held !== undefined) { + if (result?.held === true && typeof result.approvalId === 'string' && result.approvalId) { + held.status = 'pending' + held.approvalId = result.approvalId + } else { + room.heldLines = (room.heldLines ?? []).filter(entry => entry !== held) + } + void Promise.resolve(rt.save?.()).catch(() => {}) + } return } const remotes = currentParticipants(room).filter(isRemoteParticipant) @@ -904,6 +920,29 @@ export function install(rt) { } } + const isLocalBotLine = line => !line?.kind && line?.remote !== true && line?.authorKind === 'bot' + && typeof (line.botId ?? line.authorId) === 'string' && rt.botOf?.(line.botId ?? line.authorId) !== undefined + function holdLine(room, line) { + const entry = { seq: line.seq, botId: line.botId ?? line.authorId, text: String(line.text ?? ''), status: 'sending', at: Date.now() } + room.heldLines = [...(room.heldLines ?? []), entry].slice(-HELD_LINES_KEPT) + return entry + } + // The owner decided a held room line on its card (#456): approved, it is a plain bubble from now on; + // rejected, it stays a grey "Not sent" line, after a reload and in Past rooms too. + rt.settleHeldLine = function settleHeldLine(approvalId, decision) { + const id = String(approvalId ?? '') + if (!id || (decision !== 'approved' && decision !== 'rejected')) return false + for (const room of Object.values(state.rooms ?? {})) { + const entry = (room.heldLines ?? []).find(item => item.approvalId === id) + if (entry === undefined) continue + if (decision === 'approved') room.heldLines = room.heldLines.filter(item => item !== entry) + else entry.status = 'rejected' + void Promise.resolve(rt.save?.()).catch(() => {}) + return true + } + return false + } + // A Bot that no longer qualifies (grant revoked, owner removed) leaves every room // that has friends' Bots or more than one owner, at once; without an id, every such Bot does. rt.evictUnshared = async function evictUnshared(botId) { diff --git a/core/src/host/room-participant.js b/core/src/host/room-participant.js index 116113c9..ffd26bfc 100644 --- a/core/src/host/room-participant.js +++ b/core/src/host/room-participant.js @@ -36,9 +36,8 @@ export function createDshRoomParticipant(rt) { // round, or null when the round was stopped. The wait restarts on each event of the // member's Session, so a member at work is never cut off. `by` is the member that // called it, which answers what the turn posts after the round moved on. - async function runTurn({ room, member, sessionId, text, note, signal, started, by, origin }) { + async function runTurn({ room, member, sessionId, text, note, signal, started, by, origin, exchangeId = randomUUID() }) { if (signal.aborted) return null - const exchangeId = randomUUID() const turn = { botId: member.id, sessionId, exchangeId } started.push(turn) // Checked before the delivery, which may start the turn at once. diff --git a/core/test/held-line.test.js b/core/test/held-line.test.js index 1e2c58d3..d92eec09 100644 --- a/core/test/held-line.test.js +++ b/core/test/held-line.test.js @@ -5,8 +5,8 @@ import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' import { build } from 'esbuild' -// #306 item 5: a Bot line held for approval has its text in the approval card once; its bubble in a -// Face-to-face room says only that it waits. +// #306 item 5, #456: a Bot line held for approval has its text in the approval card only; a Face-to-face +// room draws no bubble for it until Approve, and a rejected one is one grey "Not sent" line. async function load(entry) { const bundle = await build({ @@ -46,7 +46,7 @@ const find = (node, match) => { return [...(match(node) ? [node] : []), ...find(node.children, match)] } -const { GroupReplies, ToolCell, isHeldLine } = await load('cells.js') +const { GroupReplies, ToolCell, isHeldLine, heldLineState } = await load('cells.js') const chief = { id: 'chief', name: 'Chief', color: 'green' } const held = (text, over = {}) => ({ id: 'ap1', kind: 'face_room_message', status: 'pending', from: 'Chief', roomId: 'frm_1', text, ...over }) @@ -56,30 +56,69 @@ const draw = (room, replies) => { const content = [{ type: 'text', text: JSON.stringify({ replies }) }] return expand(GroupReplies({ sessionId: 'g1', callId: 'c1', done: true, content, roster, actions: {}, useUi: () => null }), ['GroupMessage', 'Bubbles']) } -const said = (text) => ({ botId: 'chief', name: 'Chief', text }) +const said = (text, seq) => ({ botId: 'chief', name: 'Chief', text, ...(seq !== undefined ? { seq } : {}) }) const shownText = drawn => find(drawn, node => node.type?.name === 'MarkdownText').map(node => node.props.text) +const notSent = drawn => find(drawn, node => node.props?.['data-flybot'] === 'held-not-sent') +const textOf = node => find(node, () => true).flatMap(item => (item.children ?? []).filter(child => typeof child === 'string')).join('') -test('a held Bot line is one short waiting bubble; its text stays in the approval card', () => { +test('a held Bot line is no bubble at all before Approve; its text stays in the approval card', () => { const drawn = draw(roomWith([held('Draft: ship it on Friday')]), [said('Draft: ship it on Friday')]) - const [bubble] = find(drawn, node => node.props?.['data-flybot'] === 'held-line') - assert.ok(bubble, 'a waiting bubble') - assert.deepEqual(bubble.children, ['Waiting for approval']) assert.equal(shownText(drawn).length, 0, 'the line text is not drawn in the room') - // The Bot's name and avatar stay, so the waiting state is still the Bot's. - assert.equal(find(drawn, node => node.props?.className === 'bt-group-name' && node.children.includes('Chief')).length, 1) - assert.equal(find(drawn, node => node.props?.className === 'bt-lead').length, 1) + assert.equal(find(drawn, node => node.props?.className === 'bt-group-msg').length, 0, 'no bubble, no waiting stub') + assert.equal(find(drawn, node => node.props?.className === 'bt-group-name').length, 0) + assert.equal(notSent(drawn).length, 0) }) -test('only the held line waits: other lines, and once the hold is decided, the line, are full bubbles', () => { +test('only the held line is left out: other lines, and once the hold is decided, the line, are full bubbles', () => { const room = roomWith([held('Draft: ship it on Friday')]) const drawn = draw(room, [said('Earlier note'), said('Draft: ship it on Friday')]) - assert.equal(find(drawn, node => node.props?.['data-flybot'] === 'held-line').length, 1) assert.deepEqual(shownText(drawn), ['Earlier note']) const decided = draw(roomWith([]), [said('Draft: ship it on Friday')]) - assert.equal(find(decided, node => node.props?.['data-flybot'] === 'held-line').length, 0) assert.deepEqual(shownText(decided), ['Draft: ship it on Friday']) }) +// The host's own record of each held line on the room (#456), by log seq. +const withHeld = (heldLines, face = { status: 'open' }) => ({ ...roomWith([]), face, heldLines }) +const entry = (status, over = {}) => ({ seq: 7, botId: 'chief', text: 'Line one: got it, noodles', status, at: Date.now(), ...over }) + +test('#456: a line the host is sending or Bot Social holds is not drawn; approved, it is a bubble', () => { + for (const status of ['sending', 'pending']) { + const drawn = draw(withHeld([entry(status, { approvalId: 'ap1' })]), [said('Line one: got it, noodles', 7)]) + assert.equal(shownText(drawn).length, 0, `${status}: no bubble`) + assert.equal(notSent(drawn).length, 0, `${status}: no Not sent line`) + } + // Approve drops the record: the line is an ordinary bubble. + assert.deepEqual(shownText(draw(withHeld([]), [said('Line one: got it, noodles', 7)])), ['Line one: got it, noodles']) + // Another line of the same Bot (another seq) is drawn. + assert.deepEqual(shownText(draw(withHeld([entry('pending')]), [said('Line one: got it, noodles', 8)])), ['Line one: got it, noodles']) +}) + +test('#456: a rejected line is one grey "Not sent" line, never its words, after a reload and in Past rooms', () => { + const drawn = draw(withHeld([entry('rejected', { approvalId: 'ap1' })]), [said('Line one: got it, noodles', 7)]) + assert.equal(shownText(drawn).length, 0, 'the rejected words are not drawn') + const [line] = notSent(drawn) + assert.ok(line, 'a Not sent line') + assert.match(line.props.className, /bt-event/) + assert.equal(textOf(line), 'Chief · Not sent') + assert.doesNotMatch(textOf(line), /noodles/) + // Past rooms: the same record in a closed room still reads Not sent; a line still waiting when the room + // ended never left, so it reads Not sent too. + for (const status of ['rejected', 'pending']) { + const past = draw(withHeld([entry(status)], { status: 'closed' }), [said('Line one: got it, noodles', 7)]) + assert.equal(shownText(past).length, 0) + assert.equal(notSent(past).length, 1, `${status} in a closed room`) + } +}) + +test('#456: a late post (no seq) is found by its Bot and its exact text; a stale send is drawn as before', () => { + const room = withHeld([entry('rejected', { seq: 9 })]) + assert.equal(heldLineState(room, { botId: 'chief', text: 'Line one: got it, noodles' }), 'rejected') + assert.equal(heldLineState(room, { botId: 'writer', text: 'Line one: got it, noodles' }), undefined, 'another Bot\'s words') + assert.equal(heldLineState(room, { botId: 'chief', text: 'Something else' }), undefined) + assert.equal(heldLineState(withHeld([entry('sending', { at: Date.now() - 120_000 })]), { seq: 7, botId: 'chief' }), undefined, 'a send cut off by a restart') + assert.equal(heldLineState({ ...room, origin: 'group' }, { botId: 'chief', text: 'Line one: got it, noodles' }), undefined, 'only face rooms') +}) + test('the held text matches however the card cleaned it: spacing, the "To the room:" lead, the 800-character cap', () => { assert.equal(isHeldLine(roomWith([held('hello there world')]), 'hello there\nworld'), true) assert.equal(isHeldLine(roomWith([held('hello there')]), 'To face room: hello there'), true) @@ -99,7 +138,7 @@ test('the held line waits only for the Bot that sent it: another Bot saying the { botId: 'chief', name: 'Chief', text: 'Draft: ship it on Friday' }, ] }) }] const drawn = expand(GroupReplies({ sessionId: 'g1', callId: 'c1', done: true, content, roster, actions: {}, useUi: () => null }), ['GroupMessage', 'Bubbles']) - assert.equal(find(drawn, node => node.props?.['data-flybot'] === 'held-line').length, 1, 'only Chief\'s line waits') + assert.equal(find(drawn, node => node.props?.className === 'bt-group-msg').length, 1, 'only Chief\'s line is left out') assert.deepEqual(shownText(drawn), ['Draft: ship it on Friday'], 'Writer\'s identical line is drawn') assert.equal(isHeldLine(room, 'Draft: ship it on Friday', writer), false) assert.equal(isHeldLine(room, 'Draft: ship it on Friday', chief), true) @@ -116,14 +155,14 @@ test('only a Face-to-face room hides a held line, and only for a held room line' assert.equal(isHeldLine({ origin: 'face-to-face' }, 'hello'), false) }) -test('the waiting copy has a Chinese string', () => { +test('the Not sent copy has a Chinese string', () => { const zh = readFileSync(new URL('../src/client/i18n.js', import.meta.url), 'utf8') - assert.match(zh, /'Waiting for approval': '等待批准'/) + assert.match(zh, /'\{name\} · Not sent': '\{name\} · 未发出'/) }) -test('the waiting bubble has a style of its own', () => { +test('the Not sent line is a quiet grey event line', () => { const css = readFileSync(new URL('../src/client/styles.js', import.meta.url), 'utf8') - assert.match(css, /\.bt-bubble-held\{[^}]*dashed/) + assert.match(css, /\.bt-event\.bt-not-sent\{[^}]*color:var\(--bt-ink-3\)/) }) test('a held message to a friend is no bubble at all: the Bot\'s send is not drawn, the card is the one place', () => { diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index 7690fdb4..5e7c7caf 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -592,6 +592,129 @@ test('#287: a face-room line the send path posted is logged and sent once, wheth assert.deepEqual(count('second line'), [1, 1]) }) +test('#456: a Bot line Bot Social holds stays off the room until the owner decides; Reject keeps it Not sent in the saved room', async (t) => { + const { call, emit, userMessage, relay, answer, stop, sent, rt, home } = start() + t.after(stop) + await team(call, sent) + await rt.load() + const chief = (await call('state', {})).value.mainBotId + const hosted = await rt.upsertFaceRoom({ + roomId: 'frm_hold', + localId: 'face-hold', + status: 'open', + members: [{ handle: '@chief.me.home', displayName: 'Chief', ownerName: 'Me', published: false }], + localBots: [{ id: chief, handle: '@chief.me.home', name: 'Chief' }], + }) + let release + const answered = new Promise(resolve => { release = resolve }) + // The transport answers like social.js: a Bot line Bot Social holds comes back with its approval id. + rt.faceRoomFanout = async (room, line) => { + if (line.authorKind !== 'bot') return undefined + await answered + return { held: true, approvalId: 'apr-hold-1' } + } + sent.length = 0 + await emit(hosted.id, userMessage('noodles tonight?')) + const running = relay(hosted.id, 'hold-1') + const turn = await turnFor(sent) + await answer(turn.sessionId, turn.message.source.exchangeId, 'Line one: got it, noodles', 1) + // While the send is out the line is `sending`: no bubble before the server's answer. + const sending = await waitFor(() => hosted.heldLines?.[0]) + assert.deepEqual([sending.botId, sending.text, sending.status], [chief, 'Line one: got it, noodles', 'sending']) + release() + const done = JSON.parse(await running) + const reply = done.replies.find(item => item.botId === chief) + assert.equal(reply.seq, sending.seq, 'the drawn reply carries the seq its held record names') + const pending = await waitFor(() => (hosted.heldLines?.[0]?.status === 'pending' ? hosted.heldLines[0] : undefined)) + assert.equal(pending.approvalId, 'apr-hold-1') + const view = (await call('state', {})).value.rooms.find(room => room.id === hosted.id) + assert.equal(view.heldLines[0].status, 'pending', 'the chat reads the held state from the room') + assert.equal(view.faceLine, undefined, 'the per-line answer record stays on the host') + // Reject: the line stays as a rejected record (a grey Not sent line), in the saved state too. + assert.equal(rt.settleHeldLine('apr-hold-1', 'rejected'), true) + assert.equal(hosted.heldLines[0].status, 'rejected') + await rt.save() + const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).rooms[hosted.id] + assert.equal(saved.heldLines[0].status, 'rejected', 'a reload still knows it was rejected') + // An unknown approval, or a decision that is neither, changes nothing. + assert.equal(rt.settleHeldLine('apr-other', 'approved'), false) + assert.equal(rt.settleHeldLine('apr-hold-1', 'maybe'), false) + + // A line that went out (not held) leaves no record; an approved one is dropped, so it is a plain bubble. + rt.faceRoomFanout = async (room, line) => (line.authorKind === 'bot' && line.text === 'held again' ? { held: true, approvalId: 'apr-hold-2' } : { held: false }) + sent.length = 0 + await emit(hosted.id, userMessage('and dessert?')) + const second = relay(hosted.id, 'hold-2') + const next = await turnFor(sent) + await answer(next.sessionId, next.message.source.exchangeId, 'held again', 2) + await second + await waitFor(() => (hosted.heldLines.some(item => item.approvalId === 'apr-hold-2') ? true : undefined)) + assert.equal(rt.settleHeldLine('apr-hold-2', 'approved'), true) + assert.deepEqual(hosted.heldLines.map(item => item.approvalId), ['apr-hold-1']) + sent.length = 0 + await emit(hosted.id, userMessage('thanks')) + const third = relay(hosted.id, 'hold-3') + const last = await turnFor(sent) + await answer(last.sessionId, last.message.source.exchangeId, 'sent straight away', 3) + await third + await settle() + assert.deepEqual(hosted.heldLines.map(item => item.approvalId), ['apr-hold-1'], 'a line that left needs no record') +}) + +test('#456: each Bot answers a person\'s plain face-room line once: a second delivery and a late second answer are dropped', async (t) => { + const { call, emit, relay, answer, stop, sent, rt } = start() + t.after(stop) + await team(call, sent) + await rt.load() + const chief = (await call('state', {})).value.mainBotId + const hosted = await rt.upsertFaceRoom({ + roomId: 'frm_once', + localId: 'face-once', + status: 'open', + members: [ + { handle: '@chief.me.home', displayName: 'Chief', ownerName: 'Me', published: false }, + { handle: '@chief.bob.home', displayName: 'Chief', ownerName: 'Bob', published: false }, + ], + localBots: [{ id: chief, handle: '@chief.me.home', name: 'Chief' }], + }) + // A friend's attested plain line, as social.js delivers it: logged by event id, then posted for the round. + const friendLine = async (eventId, text) => { + const logged = rt.applyFaceRoomLine(hosted, { eventId, who: 'Bob', text, authorId: '@bob.home', authorKind: 'human', remote: true }) + await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Bob', quiet: true, notice: true, plain: true, human: { authorId: '@bob.home', name: 'Bob' } }, content: [{ type: 'text', text }] } }) + return logged + } + sent.length = 0 + assert.ok(await friendLine('ev-1', 'Line one from A: noodles, budget 50')) + const first = relay(hosted.id, 'once-1') + const turn = await turnFor(sent) + assert.ok(turn, 'my Chief answers the friend\'s plain line') + await answer(turn.sessionId, turn.message.source.exchangeId, 'Noodles it is', 1) + await first + // The same line reaches the room again (two polls read one event): Chief is not asked a second time. + assert.equal(rt.applyFaceRoomLine(hosted, { eventId: 'ev-1', who: 'Bob', text: 'Line one from A: noodles, budget 50', authorId: '@bob.home', authorKind: 'human', remote: true }), undefined, 'the event is logged once') + sent.length = 0 + await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Bob', quiet: true, notice: true, plain: true, human: { authorId: '@bob.home', name: 'Bob' } }, content: [{ type: 'text', text: 'Line one from A: noodles, budget 50' }] } }) + const again = JSON.parse(await relay(hosted.id, 'once-2')) + assert.equal(sent.some(entry => entry.message?.source?.role === 'room'), false, 'no second turn for the same line') + assert.equal(again.replies.filter(item => item.botId === chief && item.kind === undefined).length, 0) + assert.equal(hosted.log.filter(line => line.botId === chief && !line.kind).length, 1, 'one answer in the room') + // A late answer from a turn asked for that same line is not posted either. + sent.length = 0 + await rt.lateRoomReply(turn.sessionId, { exchangeId: turn.message.source.exchangeId, roomId: hosted.id }, 'Line one from B: got it, noodles') + assert.equal(sent.some(entry => entry.message?.source?.role === 'post'), false, 'the second answer is dropped') + // A turn asked for something else still posts what it ends with. + await rt.lateRoomReply(turn.sessionId, { exchangeId: 'some-other-ask', roomId: hosted.id }, 'Here is the menu I looked up') + assert.equal(sent.filter(entry => entry.message?.source?.role === 'post').length, 1) + // A new line from the friend is answered again. + sent.length = 0 + await friendLine('ev-2', 'And dessert?') + const third = relay(hosted.id, 'once-3') + const follow = await turnFor(sent) + assert.ok(follow, 'a new line is answered') + await answer(follow.sessionId, follow.message.source.exchangeId, 'Mango pudding', 2) + await third +}) + test('a message sent between rounds still opens a full round', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent } = start() t.after(stop) diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index aec7f3e1..7c31c0d6 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -398,7 +398,10 @@ export interface FlyBot { applyFaceLine(roomId: string, line: Record): unknown ofFace(serverId: string): { id: string; face?: { roomId?: string; eventCursor?: number } } | undefined dropFace(roomId: string): Promise + /** `fn` answers `{ held: true, approvalId }` for a Bot line the server holds for the owner's approval (#456). */ onFaceLine(fn: ((room: { id: string; face?: { roomId?: string }; members?: string[] }, line: Record) => unknown) | undefined): () => void + /** The owner's decision on a held face-room line, by its approval id; true when a room had it (#456). */ + settleHeld?(approvalId: string, decision: 'approved' | 'rejected'): boolean postNotice(roomId: string, text: string): unknown postRemote(roomId: string, msg: { handle?: string; name?: string; text?: string }): unknown /** Inbox envelope → inbound-share room (join/line/leave). */ diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 27e1a021..a0cf6ee9 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -64,7 +64,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Room remote bubbles unwrap the fence - Group `RemoteMessage` (`cells.js`) shows `remoteMessageText(reply.text)`, the same unwrap the 1:1 friend chat already uses. Stored room lines stay fenced for the model; the bubble shows the inner words, not the `` wrapper (#98). -- A Bot line that Bot Social holds for approval in a Face-to-face room is drawn once, in its approval card (#306): `GroupMessage` (`cells.js`) asks `isHeldLine(room, text, bot)`, which matches the line to a `face_room_message` entry of `room.pendingApprovals` sent by that same Bot (the card's `from` equals the bubble's Bot name, ignoring case and spacing; a card with no `from` matches by text alone, and another Bot's identical words stay a full bubble) (whitespace-collapsed, without a `To the room:` lead, allowing for the 800-character cap), and then draws only a `.bt-bubble-held` bubble reading **Waiting for approval** (`等待批准`) beside the Bot's avatar and name. Once the hold is decided the entry leaves `pendingApprovals` and the line shows in full. Other room kinds, lines the owner typed (approved at once, never listed) and every other Bot line are unchanged. +- A Bot line that Bot Social holds for approval in a Face-to-face room is drawn once, in its approval card (#306): `GroupMessage` (`cells.js`) asks `isHeldLine(room, text, bot)`, which matches the line to a `face_room_message` entry of `room.pendingApprovals` sent by that same Bot (the card's `from` equals the bubble's Bot name, ignoring case and spacing; a card with no `from` matches by text alone, and another Bot's identical words stay a full bubble) (whitespace-collapsed, without a `To the room:` lead, allowing for the 800-character cap), and then draws nothing in the room. #456 makes the room's own record the first source: `heldLineState(room, { seq, botId, text })` (`cells.js`) reads `room.heldLines` (by the line's `seq`, which `GroupReplies` passes from the round reply; else by Bot and exact text) — `sending` / `pending` draws nothing (the card is the line's only place, greyed), `rejected` draws one grey event line ` · Not sent` (`.bt-event.bt-not-sent`, `data-flybot=held-not-sent`, `{name} · 未发出`) with the Bot's mark, also after a reload and in Past rooms; a line still waiting when the room ended reads Not sent too; a `sending` record older than a minute (a restart mid-send) falls back to the bubble. Approve removes the record, so the line becomes a normal bubble. Other room kinds, lines the owner typed (approved at once, never listed) and every other Bot line are unchanged. ### Remote Bot display diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index bdf183e8..534ccd81 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -113,7 +113,7 @@ credentials and team mode remain separate work under #55/#198. - `upsertFaceRoom` keeps the six-digit number a room was joined by (`spec.joinCode` / `spec.code`) on `room.face.joinCode`, and the auto title uses it whenever a later upsert (a poll, a leave) does not repeat it, so the title never falls back from the number to a date (#304). - Another member's own face-room line arrives through `flybot.rooms.postHuman` (#180): a quiet `postToRoom` post carrying `source.human { authorId, name }`. `group_relay` turns it into a round reply `{ kind: 'user', other: true, name, authorId }` (not the grey `notice`), so it draws as a left-hand bubble from that person; it asks no Bot to answer. `postNotice` stays for system events (joined, left, started). Covered by `core/test/rounds.test.js`. - `flybot.chatApprovals.changed()` saves the team (moving the revision) so `bot.pendingCount` reaches the browser at once when a hold arrives or is decided (#226). Covered by `core/test/flybot.test.js`. -- The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. **Routed by intent (#299):** a plain line that is not `@all` (`route` on the round) also tells each Bot in its prompt (`routeNote`) to answer only when the line is theirs — it names them or their role, no other member's role fits better, or it follows up on their own last answer — and to reply `[PASS]` otherwise (the default; no "add something new" licence); the first Bot that posts, works on it or waits for approval ends a non-broadcast routed round (`answeredAlready`): the Bots after it are marked passed without a model call or a line, so three Bots never post three similar answers. The owner's own typed line reaches a face round through the Session inbox (the round's opener is the quiet `name: text` notice), so `drainRoomInbox` routes it (`routeFor`, `single`, `lastFirst`; lines queued together by the newest one) when it starts the round; a friend's attested line routes at the opener. A line to everyone (`ROOM_EVERYONE`) is not cut; a pass (`isPass`) posts, logs and shows nothing. A line to everyone (`ROOM_EVERYONE`: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (`recentSpeaker`, a follow-up) is asked first and named in the note. When every Bot passes on the **owner's own** line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), `fallbackDue` appends one turn so the first Bot that passed answers with no pass option. `everyone` in the post's source marks a friend's `@all` (not routed). Covered by `core/test/rounds.test.js`. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off. The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`. +- The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. **Routed by intent (#299):** a plain line that is not `@all` (`route` on the round) also tells each Bot in its prompt (`routeNote`) to answer only when the line is theirs — it names them or their role, no other member's role fits better, or it follows up on their own last answer — and to reply `[PASS]` otherwise (the default; no "add something new" licence); the first Bot that posts, works on it or waits for approval ends a non-broadcast routed round (`answeredAlready`): the Bots after it are marked passed without a model call or a line, so three Bots never post three similar answers. The owner's own typed line reaches a face round through the Session inbox (the round's opener is the quiet `name: text` notice), so `drainRoomInbox` routes it (`routeFor`, `single`, `lastFirst`; lines queued together by the newest one) when it starts the round; a friend's attested line routes at the opener. A line to everyone (`ROOM_EVERYONE`) is not cut; a pass (`isPass`) posts, logs and shows nothing. A line to everyone (`ROOM_EVERYONE`: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (`recentSpeaker`, a follow-up) is asked first and named in the note. When every Bot passes on the **owner's own** line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), `fallbackDue` appends one turn so the first Bot that passed answers with no pass option. `everyone` in the post's source marks a friend's `@all` (not routed). Covered by `core/test/rounds.test.js`. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off. The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. **One answer per Bot per line (#456):** a person's plain line (the owner's, opened by `drainRoomInbox` or the opener; a friend's attested `plain` line once it is in the log) opens `room.faceLine` (`seq`, `answered`, `asked` exchange ids); a Bot already in `answered` is passed without a model call, a second answer from the same round is dropped, and `lateRoomReply` drops a late answer from a turn asked for that line when the Bot already answered it. A newer person line ends it. **Held Bot lines (#456):** `fanoutRoomLine` records a local Bot's face line in `room.heldLines` (`seq`, `botId`, `text`, `status: sending`, capped at 50) before the transport sends it; a transport answer `{ held: true, approvalId }` makes it `pending`, anything else drops the record. `rt.settleHeldLine(approvalId, decision)` (`flybot.rooms.settleHeld`) removes it on approve and marks it `rejected` on reject; it is saved with the room, and the roster view carries `heldLines` (not `faceLine`). Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`. - New Bot model (#410): `roster.js` `createBot` fixes a Bot's model at creation. A Bot made by another Bot (`create_bot`, a tree child via `flybot.bot.create` with `createdBy`) and given no model starts on its maker's effective model (`desiredModel`) and its thinking level resolved against that model (dropped when that model does not serve it), before the kickoff, so its first request never goes to an unconfigured profile default. A model the caller names wins; a Bot the user makes in the UI keeps the client's start model (Main Bot's, #230). Covered by `core/test/roster.test.js`. - Group Session models (#289): `applyModel` does not trust `appliedModel` while a Bot is `modelStale`. A failed model selection in a group Session (from `applyModel` or from `room-participant.js` `open()`) marks the Bot stale; the next apply (model-list change, restart, or the Bot's next turn in a known group Session) selects every Session again and clears the mark once all took it. Covered by `core/test/models.test.js`. diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index bf583783..a20a4a24 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -82,7 +82,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Face-to-face owner lines -- `onFaceLine` receives every appended room line, including the owner's typed human lines. Channel `send` posts those through `postRoomMessage` (not `sessionController.prompt`) so they reach this hook immediately; the social plugin sends them through Bot Social as the owner's local room Bot with a `FLYBOT-FACE-HUMAN` envelope so every human in the room sees the text; incoming envelopes apply as `authorKind: 'human'` and the stored body is fenced (#98). A held owner line stays on the in-chat approval card (human text, never the raw envelope) unless face Auto-send is on. Bot replies stay bot-authored. The displayed sender is the Bot Social `from` snapshot (`trustedFaceWho`); the envelope `who` is ignored so a peer cannot impersonate another member. An event with no `from` falls back to the authenticated handle's home label, then Someone. Still not team mode. +- `onFaceLine` receives every appended room line, including the owner's typed human lines. Channel `send` posts those through `postRoomMessage` (not `sessionController.prompt`) so they reach this hook immediately; the social plugin sends them through Bot Social as the owner's local room Bot with a `FLYBOT-FACE-HUMAN` envelope so every human in the room sees the text; incoming envelopes apply as `authorKind: 'human'` and the stored body is fenced (#98). A held owner line stays on the in-chat approval card (human text, never the raw envelope) unless face Auto-send is on. Bot replies stay bot-authored. For a Bot line the hook may answer `{ held: true, approvalId }` when Bot Social holds it; the host then keeps the line off the room's bubbles until `flybot.rooms.settleHeld(approvalId, 'approved' | 'rejected')` (#456). The displayed sender is the Bot Social `from` snapshot (`trustedFaceWho`); the envelope `who` is ignored so a peer cannot impersonate another member. An event with no `from` falls back to the authenticated handle's home label, then Someone. Still not team mode. ### Host Bot look for classic-script plugins diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 30e14a7f..dfca6665 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -108,7 +108,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - Join → waiting → leave (#306): **Join** is enabled as soon as a Bot is picked (a short number answers with the inline `Enter a 6-digit number.`; no Bot picked shows `Pick a Bot first.`). The waiting countdown reads `m:ss left to join` and ticks every second. The dialog overlay (`.fs-dialog-back`, z-index above the details panel and the Bot pane) covers the whole window, and while it waits in a room the details panel draws no second waiting card for it (`faceDialog`); my own Bots' waiting tiles use the roster look in both places. Every **Leave** (waiting dialog, waiting overview, open room) asks once (**Leave** / **Stay**, `ConfirmLeave`) and lands on the main Bot (`openMainBot`). In an open room **Leave** sits on the status-chip line, a row above the **Auto-send** toggle. The room subtitle counts what it means: `2 people · 2 Bots` (`faceRoomCount` in core `group-settings.js`). - Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** in sentence case with readable contrast, and the past-room **Open** button keeps its own spacing from avatar and chip (#170). - One card-placement rule (#418): a held message's decision sits where that message is. A Bot's reply to a friend has its buttons on the held reply bubble in place (no copy in the dock of that chat or the friend's chat); a message with no bubble of its own (a Bot's `message_friend_bot`, a typed composer line held by policy, a face-room line) has its card in the dock of the chat it belongs to, as below. -- In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and its room bubble (`GroupMessage`, `core/src/client/cells.js`, matched to the card by `room.pendingApprovals`) shrinks to a **Waiting for approval** state (`data-flybot=held-line`, **等待批准**) and returns to the full line once the hold is decided; a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (`message_friend_bot`) has no bubble in either chat, so the card is already its only place. +- In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and the room draws no bubble for it until it is approved (`GroupMessage`, `core/src/client/cells.js`, matched by `room.heldLines`, #456, and by `room.pendingApprovals` as a fallback); a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (`message_friend_bot`) has no bubble in either chat, so the card is already its only place. #456: the held room line lives only on its card (greyed preview) until **Approve**: `onFaceLine` answers `{ held: true, approvalId }` for a Bot line Bot Social holds, `social/decide-approval` calls `flybot.rooms.settleHeld`, Approve makes it a room bubble and Reject a grey ` · Not sent` line, with no `Rejected.` note in the face room. A decided card leaves the dock at once (no **Approved** / **Rejected** stub, any card kind). A face card's `from` is the room's own #384 label for that local Bot (`Researcher (Alice) → the room`, `faceFromLabel`). `applyFaceSnapshot` posts a human or system event for a round only when the core room logged it now (`applyFaceLine` refuses an event id it has), so an event read twice starts no second round. - The composer **Message {name}** accessible name is set by core `HeaderPill` (placeholder CSS var plus `aria-label` on `[data-composer-input]`, restored on cleanup); chat-header tok jargon stays in pristine DSH / ds-bot. ### Face-room poll identity (#122) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 9606c9c3..39bb9854 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -283,7 +283,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Add: '添加', Close: '关闭', Copy: '复制', -@@ -41,22 +41,66 @@ const ZH = { +@@ -41,22 +41,67 @@ const ZH = { ' and ': '、', and: '和', '{count} members': '{count} 位成员', @@ -331,6 +331,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Messages waiting for your approval': '等你批准的消息', + 'Waiting for approval': '等待批准', + 'Not sent': '未发出', ++ '{name} · Not sent': '{name} · 未发出', + 'Sent to {to}': '已发给 {to}', + 'Auto-sent to {to}': '已自动发给 {to}', + 'Not sent: delivery failed': '未发出:发送失败', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 6d719d2d..0a8bf52c 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -524,16 +524,16 @@ diff --git a/src/client/cells.js b/src/client/cells.js } function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { -@@ -170,16 +170,137 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { +@@ -170,16 +170,157 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { return h('div', { className: 'bt-event' }, parts) } -+// A Bot line that Bot Social holds for the owner's approval has its text in the approval card; the -+// bubble keeps only the state, so the text shows once (#306). The host's pending list (`pendingApprovals`) ++// A Bot line that Bot Social holds for the owner's approval has its text in the approval card only, and ++// the room draws no bubble for it until Approve (#306, #456). The host's pending list (`pendingApprovals`) +// carries the held text whitespace-collapsed and capped at 800 characters (799 once trimmed), without a "To the room:" lead. +const heldForm = text => String(text ?? '').replace(/\s+/g, ' ').trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, '').trim() +// The card names the sending Bot in `from`; a bubble is held only when its own Bot sent the held text, so -+// another Bot's identical words are not turned into "Waiting for approval" (#306). A card that names no sender ++// another Bot's identical words are not hidden (#306). A card that names no sender +// matches by text alone, as before. +const sameName = (a, b) => String(a ?? '').replace(/\s+/g, ' ').trim().toLowerCase() === String(b ?? '').replace(/\s+/g, ' ').trim().toLowerCase() +export function isHeldLine(room, text, bot) { @@ -547,9 +547,37 @@ diff --git a/src/client/cells.js b/src/client/cells.js + return held !== '' && (held === line || (held.length >= 799 && line.startsWith(held))) + }) +} ++ ++// A face-room line my own Bot wrote, as the host tracks it on the room (`heldLines`, #456): `held` while it is ++// being sent or waits for the owner's Approve / Reject, `rejected` once rejected, or when the room ended before ++// anyone decided (it never left), so it reads the same after a reload and in Past rooms. A line drawn with its ++// log seq is found by it; a late post (no seq) by its Bot and its exact text. ++const HELD_SENDING_MS = 60_000 ++export function heldLineState(room, { seq, botId, text } = {}, now = Date.now()) { ++ if (room?.origin !== 'face-to-face' || !Array.isArray(room.heldLines)) return undefined ++ const said = String(text ?? '').trim() ++ const entry = room.heldLines.filter(item => (seq !== undefined && item?.seq !== undefined ++ ? item.seq === seq ++ : item?.botId === botId && String(item?.text ?? '').trim() === said)).at(-1) ++ if (entry === undefined) return undefined ++ if (entry.status === 'rejected') return 'rejected' ++ // A send a restart cut off never got an answer: the line is drawn as before. ++ if (entry.status === 'sending' && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return undefined ++ if (entry.status !== 'pending' && entry.status !== 'sending') return undefined ++ return room.face?.status === 'left' || room.face?.status === 'closed' ? 'rejected' : 'held' ++} + // One Bot message inside a group chat: avatar, name (with its group role), bubble. - function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { +-function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { ++function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq }) { ++ const held = heldLineState(room, { seq, botId: bot.id, text }) ++ // Rejected: one grey line where the bubble would have been, never the words (#456). ++ if (held === 'rejected') { ++ return h('div', { className: 'bt-event bt-not-sent', 'data-flybot': 'held-not-sent', role: 'status' }, ++ h(BotMark, { bot, size: 16 }), h('span', null, t('{name} · Not sent', { name: bot.name }))) ++ } ++ // Waiting: the words are on the owner's approval card only; the bubble comes after Approve (#456). ++ if (held === 'held' || isHeldLine(room, text, bot)) return null const lead = h('button', { type: 'button', className: 'bt-lead', 'aria-label': t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, h(BotMark, { bot, size: 22 })) - return h('div', { className: 'bt-group-msg' }, @@ -557,13 +585,6 @@ diff --git a/src/client/cells.js b/src/client/cells.js - room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null), + const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name, + room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null) -+ if (isHeldLine(room, text, bot)) { -+ return h('div', { className: 'bt-group-msg' }, name, -+ h('div', { className: 'bt-astack', 'data-lead': '' }, -+ h('div', { className: 'bt-msg' }, -+ h('div', { className: 'bt-msg-line' }, lead, -+ h('div', { className: 'bt-bubble bt-bubble-held', 'data-flybot': 'held-line', role: 'status' }, t('Waiting for approval')))))) -+ } + return h('div', { className: 'bt-group-msg' }, name, h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead })) } @@ -786,10 +807,11 @@ diff --git a/src/client/cells.js b/src/client/cells.js t('{name} could not answer: {reason}', { name: bot.name, reason: failureLabel(reply.code, reply.text) })) } if (reply.kind === 'lookup') { -@@ -599,6 +599,23 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -599,7 +599,24 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi if (reply.kind === 'stopped') { return h('div', { key: `${reply.botId}-${index}`, className: 'bt-event' }, t("You stopped {name}'s work", { name: bot.name })) } +- return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi }) + if (reply.kind === 'notice' && room?.origin === 'face-to-face' && String(reply.text ?? '').trim() === WAITING_OTHERS_NOTICE) return null + if (reply.kind === 'notice' || reply.kind === 'joined' || reply.kind === 'left' || reply.kind === 'started') { + return h('div', { key: `notice-${index}`, className: 'bt-event', 'data-flybot': 'face-notice' }, remoteMessageText(reply.text)) @@ -807,9 +829,10 @@ diff --git a/src/client/cells.js b/src/client/cells.js + useUi, + }) + } - return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi }) ++ return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi, seq: reply.seq }) }), pending.map((id) => { + const bot = roster.byId[id] diff --git a/src/client/context-menu.js b/src/client/context-menu.js --- a/src/client/context-menu.js +++ b/src/client/context-menu.js @@ -2151,7 +2174,7 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} -+.bt-bubble-held{background:transparent;border:1px dashed var(--bt-line-2);color:var(--bt-ink-2)} ++.bt-event.bt-not-sent{gap:6px;color:var(--bt-ink-3)} .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index e940d9f2..ce5d58fe 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -263,7 +263,8 @@ diff --git a/src/host/channel.js b/src/host/channel.js + } + }), // `parts`: the members' hidden group Sessions and their earlier segments, for usage. - rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, ...room }) => { +- rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, ...room }) => { ++ rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, faceLine, ...room }) => { const parts = [...Object.values(sessions ?? {}), ...Object.values(segments ?? {}).flat().map(segment => segment.sessionId)] const last = (log ?? []).filter(line => line.kind === undefined).at(-1) - return { ...room, parts, ...(last ? { preview: last.who === 'User' ? last.text : `${last.who}:${last.text}` } : {}) } @@ -840,7 +841,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a plain message steered mid-round is answered by everyone, admin first, inside the round', async (t) => { const { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent } = start() t.after(stop) -@@ -331,6 +331,255 @@ test('a stopped round leaves a waiting message for the next round', async (t) => +@@ -331,6 +331,378 @@ test('a stopped round leaves a waiting message for the next round', async (t) => assert.equal(inboxOf(room.id).nextTurn.length, 1) }) @@ -1092,6 +1093,129 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + await second + assert.deepEqual(count('second line'), [1, 1]) +}) ++ ++test('#456: a Bot line Bot Social holds stays off the room until the owner decides; Reject keeps it Not sent in the saved room', async (t) => { ++ const { call, emit, userMessage, relay, answer, stop, sent, rt, home } = start() ++ t.after(stop) ++ await team(call, sent) ++ await rt.load() ++ const chief = (await call('state', {})).value.mainBotId ++ const hosted = await rt.upsertFaceRoom({ ++ roomId: 'frm_hold', ++ localId: 'face-hold', ++ status: 'open', ++ members: [{ handle: '@chief.me.home', displayName: 'Chief', ownerName: 'Me', published: false }], ++ localBots: [{ id: chief, handle: '@chief.me.home', name: 'Chief' }], ++ }) ++ let release ++ const answered = new Promise(resolve => { release = resolve }) ++ // The transport answers like social.js: a Bot line Bot Social holds comes back with its approval id. ++ rt.faceRoomFanout = async (room, line) => { ++ if (line.authorKind !== 'bot') return undefined ++ await answered ++ return { held: true, approvalId: 'apr-hold-1' } ++ } ++ sent.length = 0 ++ await emit(hosted.id, userMessage('noodles tonight?')) ++ const running = relay(hosted.id, 'hold-1') ++ const turn = await turnFor(sent) ++ await answer(turn.sessionId, turn.message.source.exchangeId, 'Line one: got it, noodles', 1) ++ // While the send is out the line is `sending`: no bubble before the server's answer. ++ const sending = await waitFor(() => hosted.heldLines?.[0]) ++ assert.deepEqual([sending.botId, sending.text, sending.status], [chief, 'Line one: got it, noodles', 'sending']) ++ release() ++ const done = JSON.parse(await running) ++ const reply = done.replies.find(item => item.botId === chief) ++ assert.equal(reply.seq, sending.seq, 'the drawn reply carries the seq its held record names') ++ const pending = await waitFor(() => (hosted.heldLines?.[0]?.status === 'pending' ? hosted.heldLines[0] : undefined)) ++ assert.equal(pending.approvalId, 'apr-hold-1') ++ const view = (await call('state', {})).value.rooms.find(room => room.id === hosted.id) ++ assert.equal(view.heldLines[0].status, 'pending', 'the chat reads the held state from the room') ++ assert.equal(view.faceLine, undefined, 'the per-line answer record stays on the host') ++ // Reject: the line stays as a rejected record (a grey Not sent line), in the saved state too. ++ assert.equal(rt.settleHeldLine('apr-hold-1', 'rejected'), true) ++ assert.equal(hosted.heldLines[0].status, 'rejected') ++ await rt.save() ++ const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).rooms[hosted.id] ++ assert.equal(saved.heldLines[0].status, 'rejected', 'a reload still knows it was rejected') ++ // An unknown approval, or a decision that is neither, changes nothing. ++ assert.equal(rt.settleHeldLine('apr-other', 'approved'), false) ++ assert.equal(rt.settleHeldLine('apr-hold-1', 'maybe'), false) ++ ++ // A line that went out (not held) leaves no record; an approved one is dropped, so it is a plain bubble. ++ rt.faceRoomFanout = async (room, line) => (line.authorKind === 'bot' && line.text === 'held again' ? { held: true, approvalId: 'apr-hold-2' } : { held: false }) ++ sent.length = 0 ++ await emit(hosted.id, userMessage('and dessert?')) ++ const second = relay(hosted.id, 'hold-2') ++ const next = await turnFor(sent) ++ await answer(next.sessionId, next.message.source.exchangeId, 'held again', 2) ++ await second ++ await waitFor(() => (hosted.heldLines.some(item => item.approvalId === 'apr-hold-2') ? true : undefined)) ++ assert.equal(rt.settleHeldLine('apr-hold-2', 'approved'), true) ++ assert.deepEqual(hosted.heldLines.map(item => item.approvalId), ['apr-hold-1']) ++ sent.length = 0 ++ await emit(hosted.id, userMessage('thanks')) ++ const third = relay(hosted.id, 'hold-3') ++ const last = await turnFor(sent) ++ await answer(last.sessionId, last.message.source.exchangeId, 'sent straight away', 3) ++ await third ++ await settle() ++ assert.deepEqual(hosted.heldLines.map(item => item.approvalId), ['apr-hold-1'], 'a line that left needs no record') ++}) ++ ++test('#456: each Bot answers a person\'s plain face-room line once: a second delivery and a late second answer are dropped', async (t) => { ++ const { call, emit, relay, answer, stop, sent, rt } = start() ++ t.after(stop) ++ await team(call, sent) ++ await rt.load() ++ const chief = (await call('state', {})).value.mainBotId ++ const hosted = await rt.upsertFaceRoom({ ++ roomId: 'frm_once', ++ localId: 'face-once', ++ status: 'open', ++ members: [ ++ { handle: '@chief.me.home', displayName: 'Chief', ownerName: 'Me', published: false }, ++ { handle: '@chief.bob.home', displayName: 'Chief', ownerName: 'Bob', published: false }, ++ ], ++ localBots: [{ id: chief, handle: '@chief.me.home', name: 'Chief' }], ++ }) ++ // A friend's attested plain line, as social.js delivers it: logged by event id, then posted for the round. ++ const friendLine = async (eventId, text) => { ++ const logged = rt.applyFaceRoomLine(hosted, { eventId, who: 'Bob', text, authorId: '@bob.home', authorKind: 'human', remote: true }) ++ await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Bob', quiet: true, notice: true, plain: true, human: { authorId: '@bob.home', name: 'Bob' } }, content: [{ type: 'text', text }] } }) ++ return logged ++ } ++ sent.length = 0 ++ assert.ok(await friendLine('ev-1', 'Line one from A: noodles, budget 50')) ++ const first = relay(hosted.id, 'once-1') ++ const turn = await turnFor(sent) ++ assert.ok(turn, 'my Chief answers the friend\'s plain line') ++ await answer(turn.sessionId, turn.message.source.exchangeId, 'Noodles it is', 1) ++ await first ++ // The same line reaches the room again (two polls read one event): Chief is not asked a second time. ++ assert.equal(rt.applyFaceRoomLine(hosted, { eventId: 'ev-1', who: 'Bob', text: 'Line one from A: noodles, budget 50', authorId: '@bob.home', authorKind: 'human', remote: true }), undefined, 'the event is logged once') ++ sent.length = 0 ++ await emit(hosted.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: 'face-notice', senderName: 'Bob', quiet: true, notice: true, plain: true, human: { authorId: '@bob.home', name: 'Bob' } }, content: [{ type: 'text', text: 'Line one from A: noodles, budget 50' }] } }) ++ const again = JSON.parse(await relay(hosted.id, 'once-2')) ++ assert.equal(sent.some(entry => entry.message?.source?.role === 'room'), false, 'no second turn for the same line') ++ assert.equal(again.replies.filter(item => item.botId === chief && item.kind === undefined).length, 0) ++ assert.equal(hosted.log.filter(line => line.botId === chief && !line.kind).length, 1, 'one answer in the room') ++ // A late answer from a turn asked for that same line is not posted either. ++ sent.length = 0 ++ await rt.lateRoomReply(turn.sessionId, { exchangeId: turn.message.source.exchangeId, roomId: hosted.id }, 'Line one from B: got it, noodles') ++ assert.equal(sent.some(entry => entry.message?.source?.role === 'post'), false, 'the second answer is dropped') ++ // A turn asked for something else still posts what it ends with. ++ await rt.lateRoomReply(turn.sessionId, { exchangeId: 'some-other-ask', roomId: hosted.id }, 'Here is the menu I looked up') ++ assert.equal(sent.filter(entry => entry.message?.source?.role === 'post').length, 1) ++ // A new line from the friend is answered again. ++ sent.length = 0 ++ await friendLine('ev-2', 'And dessert?') ++ const third = relay(hosted.id, 'once-3') ++ const follow = await turnFor(sent) ++ assert.ok(follow, 'a new line is answered') ++ await answer(follow.sessionId, follow.message.source.exchangeId, 'Mango pudding', 2) ++ await third ++}) + test('a message sent between rounds still opens a full round', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent } = start() diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 91577e54..0a2f26f8 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -41,7 +41,7 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -78,21 +78,65 @@ var ZH = { +@@ -78,21 +78,66 @@ var ZH = { " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -88,6 +88,7 @@ diff --git a/client.js b/client.js + "Messages waiting for your approval": "等你批准的消息", + "Waiting for approval": "等待批准", + "Not sent": "未发出", ++ "{name} · Not sent": "{name} · 未发出", + "Sent to {to}": "已发给 {to}", + "Auto-sent to {to}": "已自动发给 {to}", + "Not sent: delivery failed": "未发出:发送失败", @@ -1002,14 +1003,6 @@ diff --git a/client.js b/client.js - const byId = {}; - for (const bot of value.bots) { - for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; -- } -- const roomsById = {}; -- for (const room2 of value.rooms) roomsById[room2.id] = room2; -- const agentsById = {}; -- const agentOf = {}; -- for (const agent of value.agents ?? []) { -- agentsById[agent.id] = agent; -- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -1043,29 +1036,23 @@ diff --git a/client.js b/client.js + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); } -- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; +- const roomsById = {}; +- for (const room2 of value.rooms) roomsById[room2.id] = room2; +- const agentsById = {}; +- const agentOf = {}; +- for (const agent of value.agents ?? []) { +- agentsById[agent.id] = agent; +- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); - } --function isAgentSession(roster, cache, sessionId) { -- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; -- if (roster.ready) return roster.agentOf[sessionId] !== void 0; -- return cache.has(sessionId); ++} +function StarIcon({ size = 8 }) { + return (0, import_react.createElement)( + "svg", + { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + ); - } --function countCards(state, sessionId, callIds, delta) { -- const own = { ...state[sessionId] }; -- for (const callId of callIds) { -- const count = (own[callId] ?? 0) + delta; -- if (count > 0) own[callId] = count; -- else delete own[callId]; -- } -- return { ...state, [sessionId]: own }; ++} +function PersonMark({ size = 22 }) { + return (0, import_react.createElement)( + "span", @@ -1077,93 +1064,10 @@ diff --git a/client.js b/client.js + (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) + ) + ); - } -- --// src/client/themes.js --var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; --var THEMES = { -- deepseek: { -- label: "DeepSeek", -- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, -- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } -- }, -- mono: { -- label: "Mono", -- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, -- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } -- }, -- paper: { -- label: "Paper", -- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, -- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } -- }, -- moonlight: { -- label: "Moonlight", -- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, -- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -- } --}; --var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; --function themeVars(mode, dark, accent = mode.accent) { -- return { -- "--bt-ink": mode.text, -- "--bt-ink-2": rgba(mode.text, 0.6), -- "--bt-ink-3": rgba(mode.text, 0.36), -- "--bt-bubble": mode.bubble, -- "--bt-sidebar": mode.sidebar, -- "--bt-main": mode.bg, -- "--bt-card": mode.card, -- "--bt-line": rgba(mode.text, 0.08), -- "--bt-line-2": rgba(mode.text, 0.15), -- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", -- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", -- "--bt-accent": accent, -- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", -- "--bt-user": mode.user, -- "--bt-user-ink": mode.userText, -- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), -- "--bt-tag-ink": rgba(mode.text, 0.58) -- }; ++} +function AdminStar() { + return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); - } --var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); --var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} --html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} --${theme.css ?? ""}`; --var shellTokens = ({ light, dark }) => ({ -- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, -- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, -- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, -- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, -- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, -- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } --}); --var customThemes = {}; --var registry = createSource(0); --var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); --function registerShape(id, shape) { -- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); -- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); -- const at = shape.eyes?.at; -- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); -- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; -- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; -- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); -- const previous = SHAPES[id]; -- SHAPES[id] = { -- label: String(shape.label ?? id), -- size, -- body: shape.body, -- eyes, -- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} -- }; -- const refresh = () => { -- markupCache.clear(); -- maskCache.delete(id); -- registry.set((value) => value + 1); -- }; -- refresh(); ++} +function MainStar() { + return (0, import_react.createElement)( + "svg", @@ -1209,10 +1113,15 @@ diff --git a/client.js b/client.js + const size = frame * 5 / 9; + const rest = frame - size; + return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; -+ } + } +- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; -+} + } +-function isAgentSession(roster, cache, sessionId) { +- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; +- if (roster.ready) return roster.agentOf[sessionId] !== void 0; +- return cache.has(sessionId); +function roomAvatarPeople(room2, roster) { + const members = (room2?.members ?? []).map((id) => roster?.byId?.[id]).filter(Boolean); + const seen = new Set(members.map((bot) => bot.id)); @@ -1245,7 +1154,13 @@ diff --git a/client.js b/client.js + } + if (members.length === 1 && remotes.length === 0) return [members[0], null]; + return [...members, ...remotes]; -+} + } +-function countCards(state, sessionId, callIds, delta) { +- const own = { ...state[sessionId] }; +- for (const callId of callIds) { +- const count = (own[callId] ?? 0) + delta; +- if (count > 0) own[callId] = count; +- else delete own[callId]; +function RoomAvatar({ room: room2, roster, size = 36 }) { + const people = roomAvatarPeople(room2, roster); + const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); @@ -1257,7 +1172,8 @@ diff --git a/client.js b/client.js + people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), + people.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null + ); -+ } + } +- return { ...state, [sessionId]: own }; + const seats = clusterSeats(Math.min(people.length, 4), size); + const overflow = people.length > 4 ? people.length - 3 : 0; + const gap = size / 14; @@ -1276,15 +1192,97 @@ diff --git a/client.js b/client.js + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); -+} -+ + } + +-// src/client/themes.js +-var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +-var THEMES = { +- deepseek: { +- label: "DeepSeek", +- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, +- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } +- }, +- mono: { +- label: "Mono", +- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, +- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } +- }, +- paper: { +- label: "Paper", +- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, +- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } +- }, +- moonlight: { +- label: "Moonlight", +- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, +- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } +- } +// src/client/approval-claims.js +var claims = /* @__PURE__ */ new Map(); +var claimListeners = /* @__PURE__ */ new Set(); +var openChats = /* @__PURE__ */ new Map(); +var changed = () => { + for (const fn of [...claimListeners]) fn(); -+}; + }; +-var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +-function themeVars(mode, dark, accent = mode.accent) { +- return { +- "--bt-ink": mode.text, +- "--bt-ink-2": rgba(mode.text, 0.6), +- "--bt-ink-3": rgba(mode.text, 0.36), +- "--bt-bubble": mode.bubble, +- "--bt-sidebar": mode.sidebar, +- "--bt-main": mode.bg, +- "--bt-card": mode.card, +- "--bt-line": rgba(mode.text, 0.08), +- "--bt-line-2": rgba(mode.text, 0.15), +- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", +- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", +- "--bt-accent": accent, +- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", +- "--bt-user": mode.user, +- "--bt-user-ink": mode.userText, +- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), +- "--bt-tag-ink": rgba(mode.text, 0.58) +- }; +-} +-var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +-var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +-html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} +-${theme.css ?? ""}`; +-var shellTokens = ({ light, dark }) => ({ +- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, +- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, +- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, +- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, +- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, +- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } +-}); +-var customThemes = {}; +-var registry = createSource(0); +-var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); +-function registerShape(id, shape) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); +- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); +- const at = shape.eyes?.at; +- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); +- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; +- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; +- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); +- const previous = SHAPES[id]; +- SHAPES[id] = { +- label: String(shape.label ?? id), +- size, +- body: shape.body, +- eyes, +- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} +- }; +- const refresh = () => { +- markupCache.clear(); +- maskCache.delete(id); +- registry.set((value) => value + 1); +- }; +- refresh(); +function chatOpened(sessionId, delta) { + const count = (openChats.get(sessionId) ?? 0) + delta; + if (count > 0) openChats.set(sessionId, count); @@ -1471,7 +1469,7 @@ diff --git a/client.js b/client.js + const inOpenChat = (approval) => { + const chat = chatOf(approval, roster); + return Boolean(chat) && (claimed.chats?.has(chat) === true || claimed.chats?.has(roster?.byId?.[chat]?.sessionId) === true); - }; ++ }; + const away = open.filter((approval) => policies.has(approval.by) && approval.status !== "failed" && !inOpenChat(approval)); + const awayKey = away.map((approval) => approval.id).join(","); + (0, import_react2.useEffect)(() => { @@ -1495,7 +1493,7 @@ diff --git a/client.js b/client.js + setBusy(""); + await refresh(); + } -+ }; + }; + const byChat = /* @__PURE__ */ new Map(); + for (const approval of pointers) { + const key = chatOf(approval, roster) ?? approval.id; @@ -1767,7 +1765,15 @@ diff --git a/client.js b/client.js + for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; + } + return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; -+} + } +-function previewOf(list, id, hiddenTurns = []) { +- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; +- if (!Array.isArray(outline)) return ""; +- for (let index = outline.length - 1; index >= 0; index -= 1) { +- const entry = outline[index]; +- if (hiddenTurns.includes(entry.turn)) continue; +- const text = entry.response || entry.prompt || ""; +- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; @@ -1779,17 +1785,21 @@ diff --git a/client.js b/client.js + const count = (own[callId] ?? 0) + delta; + if (count > 0) own[callId] = count; + else delete own[callId]; -+ } + } +- return ""; + return { ...state, [sessionId]: own }; } --function previewOf(list, id, hiddenTurns = []) { -- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; -- if (!Array.isArray(outline)) return ""; -- for (let index = outline.length - 1; index >= 0; index -= 1) { -- const entry = outline[index]; -- if (hiddenTurns.includes(entry.turn)) continue; -- const text = entry.response || entry.prompt || ""; -- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); +-var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); +-function usageOf(list, ids) { +- let input = 0; +- let cacheRead = 0; +- let output = 0; +- for (const id of ids) { +- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; +- if (!usage) continue; +- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); +- cacheRead += usage.cacheReadTokens ?? 0; +- output += usage.outputTokens ?? 0; + +// src/client/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -1814,7 +1824,9 @@ diff --git a/client.js b/client.js + light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, + dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } } -- return ""; +- if (input + output === 0) return ""; +- const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; +- return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); +}; +var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +function themeVars(mode, dark, accent = mode.accent) { @@ -1837,18 +1849,7 @@ diff --git a/client.js b/client.js + "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), + "--bt-tag-ink": rgba(mode.text, 0.58) + }; - } --var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); --function usageOf(list, ids) { -- let input = 0; -- let cacheRead = 0; -- let output = 0; -- for (const id of ids) { -- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; -- if (!usage) continue; -- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); -- cacheRead += usage.cacheReadTokens ?? 0; -- output += usage.outputTokens ?? 0; ++} +var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} @@ -1896,10 +1897,7 @@ diff --git a/client.js b/client.js + if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); + for (const mode of ["light", "dark"]) { + for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); - } -- if (input + output === 0) return ""; -- const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; -- return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); ++ } + const previous = customThemes[id]; + customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; + registry3.set((value) => value + 1); @@ -2090,7 +2088,7 @@ diff --git a/client.js b/client.js .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} -+.bt-bubble-held{background:transparent;border:1px dashed var(--bt-line-2);color:var(--bt-ink-2)} ++.bt-event.bt-not-sent{gap:6px;color:var(--bt-ink-3)} .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} @@ -2449,19 +2447,7 @@ diff --git a/client.js b/client.js + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; + return bot?.name || ""; - } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); ++} +function installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -2479,7 +2465,19 @@ diff --git a/client.js b/client.js + offSessions?.(); + offRoster?.(); + }; -+} + } +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); +function installBrandChrome() { + if (typeof document === "undefined") return () => { + }; @@ -3655,8 +3653,30 @@ diff --git a/client.js b/client.js + return held !== "" && (held === line || held.length >= 799 && line.startsWith(held)); + }); } - function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { - const lead = (0, import_react6.createElement)( ++var HELD_SENDING_MS = 6e4; ++function heldLineState(room2, { seq, botId, text } = {}, now = Date.now()) { ++ if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; ++ const said = String(text ?? "").trim(); ++ const entry = room2.heldLines.filter((item) => seq !== void 0 && item?.seq !== void 0 ? item.seq === seq : item?.botId === botId && String(item?.text ?? "").trim() === said).at(-1); ++ if (entry === void 0) return void 0; ++ if (entry.status === "rejected") return "rejected"; ++ if (entry.status === "sending" && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return void 0; ++ if (entry.status !== "pending" && entry.status !== "sending") return void 0; ++ return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held"; ++} ++function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi, seq }) { ++ const held = heldLineState(room2, { seq, botId: bot.id, text }); ++ if (held === "rejected") { ++ return (0, import_react8.createElement)( ++ "div", ++ { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, ++ (0, import_react8.createElement)(BotMark, { bot, size: 16 }), ++ (0, import_react8.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) ++ ); ++ } ++ if (held === "held" || isHeldLine(room2, text, bot)) return null; + const lead = (0, import_react8.createElement)( "button", { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, @@ -3670,27 +3690,6 @@ diff --git a/client.js b/client.js + bot.name, + room2?.admin === bot.id ? (0, import_react8.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + ); -+ if (isHeldLine(room2, text, bot)) { -+ return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-group-msg" }, -+ name, -+ (0, import_react8.createElement)( -+ "div", -+ { className: "bt-astack", "data-lead": "" }, -+ (0, import_react8.createElement)( -+ "div", -+ { className: "bt-msg" }, -+ (0, import_react8.createElement)( -+ "div", -+ { className: "bt-msg-line" }, -+ lead, -+ (0, import_react8.createElement)("div", { className: "bt-bubble bt-bubble-held", "data-flybot": "held-line", role: "status" }, t("Waiting for approval")) -+ ) -+ ) -+ ) -+ ); -+ } + return (0, import_react8.createElement)( "div", { className: "bt-group-msg" }, @@ -4204,7 +4203,7 @@ diff --git a/client.js b/client.js + useUi + }); + } -+ return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); }), pending.map((id) => { const bot = roster.byId[id]; diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 1bb4db85..c978bd10 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -617,7 +617,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Logs written before rooms were versioned get sequence numbers in place. const ensureRoomLog = (room) => { -@@ -41,9 +41,23 @@ const ensureRoomLog = (room) => { +@@ -41,9 +41,48 @@ const ensureRoomLog = (room) => { room.seen ??= {} } @@ -628,6 +628,31 @@ diff --git a/src/host/groups.js b/src/host/groups.js + const stamped = { ...line, seq: room.seq } + room.log = [...room.log, stamped].slice(-ROOM_LOG_MAX) + void fanoutRoomLine(room, stamped) ++ return stamped ++} ++ ++// One answer per Bot to a person's plain line in a face-to-face room (#295, #456). The room keeps the ++// line's seq, the Bots that answered it and the turns it asked for; a Bot asked again for the same line ++// (the line delivered twice, or a turn that went on working and posts late after a second one) passes. ++// A newer person line ends it, so a follow-up is always answered. ++const lastPersonLine = (room) => { ++ const log = room.log ?? [] ++ for (let at = log.length - 1; at >= 0; at -= 1) { ++ const line = log[at] ++ if (!line.kind && line.botId === undefined && line.authorKind !== 'bot') return line ++ } ++ return undefined ++} ++const lastPersonSeq = room => lastPersonLine(room)?.seq ?? 0 ++const FACE_ASKED_KEPT = 24 ++const currentFaceLine = (room, seq) => (room.faceLine !== undefined && room.faceLine.seq === seq && lastPersonSeq(room) === seq ? room.faceLine : undefined) ++// `authorId`: a friend's line is only tracked once it is in the log (applyFaceLine logs it first). ++function openFaceLine(room, authorId) { ++ const person = lastPersonLine(room) ++ const seq = person?.seq ?? 0 ++ if (seq === 0 || (authorId !== undefined && person.authorId !== authorId)) return undefined ++ if (room.faceLine?.seq !== seq) room.faceLine = { seq, answered: [], asked: [] } ++ return seq +} + +// Lines a face-room send already put in the log and on the wire; the round that answers them @@ -825,7 +850,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // A called member moves up if it is still waiting, or gets one more turn if it // already spoke; either way it goes right after slot `index`. `cap` is the caller's // per-member turn bound for the round; `by` is the calling member, if a member called. -@@ -291,12 +291,27 @@ export function install(rt) { +@@ -291,12 +291,28 @@ export function install(rt) { if (lines.length === 0) return 0 const before = round.slots.length const members = room.members.map(rt.botOf).filter(Boolean) @@ -849,6 +874,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js + if (route) { + round.route = route + round.single = true ++ round.faceLine = openFaceLine(room) + speakers = lastFirst(speakers, route) + } + } @@ -942,7 +968,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } + // One answer for a line that is not for everyone (#299): once a Bot has answered, the Bots still waiting are not + // asked at all (no model call, no line), so three Bots never post three similar answers. -+ if (answeredAlready(round)) { ++ if (answeredAlready(round) || currentFaceLine(room, round.faceLine)?.answered.includes(member.id)) { + slot.status = 'passed' + round.passed.push(member.name) + continue @@ -950,26 +976,42 @@ diff --git a/src/host/groups.js b/src/host/groups.js slot.status = 'speaking' round.speaking = member.id round.turns += 1 -@@ -339,7 +339,9 @@ export function install(rt) { +@@ -339,7 +339,13 @@ export function install(rt) { const prompt = roomPrompt(room, member, round) round.shown?.push(member.id) const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}` - const reply = await roomTurn(room, member, session.id, text, slot.note ?? 'your turn', signal, round.started, slot.by) + if (!signal.aborted) room.seen[member.id] = room.seq -+ const result = await participant.runTurn({ room, member, sessionId: session.id, text, note: slot.note ?? 'your turn', signal, started: round.started, by: slot.by, origin: roundOrigins.get(round) }) ++ const exchangeId = randomUUID() ++ // A turn asked for this line may post late; it still counts as this line's answer (#456). ++ const asked = currentFaceLine(room, round.faceLine) ++ if (asked !== undefined) asked.asked = [...asked.asked, exchangeId].slice(-FACE_ASKED_KEPT) ++ const result = await participant.runTurn({ room, member, sessionId: session.id, text, note: slot.note ?? 'your turn', signal, started: round.started, by: slot.by, origin: roundOrigins.get(round), exchangeId }) + const reply = result === null || typeof result === 'object' ? result : stripOwnName(member.name, String(result ?? '').trim()) round.speaking = null if (reply?.failure !== undefined) { // Not a pass: the user sees why the member is silent, and the round goes on. -@@ -367,7 +367,7 @@ export function install(rt) { +@@ -366,9 +366,18 @@ export function install(rt) { + if (reply?.missed) round.replies.push({ kind: 'missed', botId: member.id, name: member.name, color: member.color, time: Date.now() }) continue } ++ const heard = currentFaceLine(room, round.faceLine) ++ // A second answer to the same line is dropped, never posted (#456). ++ if (heard?.answered.includes(member.id)) { ++ slot.status = 'passed' ++ round.passed.push(member.name) ++ continue ++ } ++ heard?.answered.push(member.id) slot.status = 'posted' - appendRoom(room, { who: member.name, botId: member.id, text: reply }) -+ appendRoom(room, rt.authoredRoomLine?.(room, { who: member.name, botId: member.id, authorId: member.id, authorKind: 'bot', text: reply }) ?? { who: member.name, botId: member.id, text: reply }) - round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now() }) +- round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now() }) ++ const posted = appendRoom(room, rt.authoredRoomLine?.(room, { who: member.name, botId: member.id, authorId: member.id, authorKind: 'bot', text: reply }) ?? { who: member.name, botId: member.id, text: reply }) ++ // The line's seq lets the chat find its held state (#456). ++ round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now(), seq: posted.seq }) void rt.save().catch(() => {}) // Only the admin's "@all" calls everyone, so a member cannot set off a storm. + const leads = rt.isAdminOf(room, member.id) @@ -377,7 +377,7 @@ export function install(rt) { const { all, found: named } = mentionsIn(reply, everyone) const found = named.filter(other => other.id !== member.id) @@ -1002,7 +1044,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } if (posts.some(post => !post.botId && !post.kickoff)) { room.botRounds = 0 -@@ -418,17 +418,24 @@ export function install(rt) { +@@ -418,17 +418,27 @@ export function install(rt) { const references = posts.flatMap(post => post.references ?? []) // A member a Bot's post calls by name works for that Bot. const named = opener.botId === undefined ? [] : mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean)).found @@ -1025,6 +1067,9 @@ diff --git a/src/host/groups.js b/src/host/groups.js })), + // One pass: a plain line's speakers do not call each other on (#295), so Bots in two homes cannot loop. + ...(isPlainFaceLine(room, opener) ? { single: true } : {}), ++ // A person's plain line, the friend's attested one too, is answered once per Bot (#456). ++ ...(isPlainFaceLine(room, opener) ? { faceLine: openFaceLine(room) } : {}), ++ ...(!isPlainFaceLine(room, opener) && opener.plain === true && isFaceRoom(room) ? { faceLine: openFaceLine(room, String(opener.human?.authorId ?? '')) } : {}), speaking: null, turns: 0, replies: [], @@ -1135,7 +1180,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js source: { kind: SOURCE_KIND, form: 'relay', role: 'post', exchangeId: randomUUID(), senderName: sender.name, senderSessionId: sender.id, roomId: room.id, hop: 0, ...extra }, })) await ctx.sessions.flush(agent.session) -@@ -577,7 +577,7 @@ export function install(rt) { +@@ -577,17 +577,24 @@ export function install(rt) { // approval) posts what its turn ended with. The member that asked for it and anyone // it names answer; past the late round limit nobody does, so late posts cannot keep a // group talking. @@ -1144,7 +1189,16 @@ diff --git a/src/host/groups.js b/src/host/groups.js const owner = rt.groupOwners.get(sessionId) const room = rt.roomOf(owner?.roomId ?? info.roomId) const member = rt.botOf(owner?.botId) -@@ -587,7 +587,7 @@ export function install(rt) { + if (room === undefined || member === undefined || !room.members.includes(member.id)) return + const text = stripOwnName(member.name, reply) + if (isPass(text)) return ++ // A late answer to a face-room line this Bot already answered is dropped (#456). ++ const heard = isFaceRoom(room) && room.faceLine?.asked?.includes(info.exchangeId) ? currentFaceLine(room, room.faceLine.seq) : undefined ++ if (heard?.answered.includes(member.id)) { ++ rt.warn('ds-bot: %s already answered this line in %s; its second answer is not posted', member.name, room.name) ++ return ++ } ++ heard?.answered.push(member.id) const quiet = (room.lateRounds ?? 0) >= ROOM_LATE_ROUNDS if (!quiet) room.lateRounds = (room.lateRounds ?? 0) + 1 await rt.save() diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index db76f273..1dcb8814 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -997,7 +997,8 @@ export function install(rt) { dropFace: roomId => rt.dropFaceRoom?.(roomId), // `fn(room, line, { mentions })`: for the room owner's own typed line, `mentions` is the server handle of // every Bot its "@" names (the picker's labels read against this room), so the transport can send the - // explicit list with the line (#293). Bot lines carry none. + // explicit list with the line (#293). Bot lines carry none. `fn` answers `{ held: true, approvalId }` for a + // Bot line the server holds for the owner's approval (#456); the room then shows it on its card only. onFaceLine(fn) { if (typeof fn !== 'function') { rt.faceRoomFanout = undefined; return () => {} } const wrapped = (room, line) => { @@ -1007,6 +1008,9 @@ export function install(rt) { rt.faceRoomFanout = wrapped return () => { if (rt.faceRoomFanout === wrapped) rt.faceRoomFanout = undefined } }, + // The owner's Approve / Reject of a held face-room line (#456), by its Bot Social approval id: an + // approved line is a bubble from then on, a rejected one a grey "Not sent" line. + settleHeld: (approvalId, decision) => rt.settleHeldLine?.(approvalId, decision) === true, postNotice: (roomId, text) => { const room = rt.roomOf(roomId) if (!room || typeof rt.postToRoom !== 'function') return diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 03970a72..2b1c72ac 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -41,6 +41,8 @@ import { hasAnyGrant, mayShareRoom } from './reach.js' const KINDS = new Set(['human', 'bot']) const ROLES = new Set(['admin', 'member']) +// Held face-room lines kept per room (#456): the newest, so a long room cannot grow its record forever. +const HELD_LINES_KEPT = 50 export const PARTICIPANT_KINDS = ['human', 'bot'] export const ROOM_ROLES = ['admin', 'member'] @@ -870,9 +872,23 @@ export function install(rt) { // speak outside (bot.send then runs the reach policy on every single send). rt.fanoutRoomLine = async function fanoutRoomLine(room, line) { if (isFaceRoom(room)) { - try { await rt.faceRoomFanout?.(room, line) } catch (error) { + // A line my own Bot wrote is not a room bubble until it has left (#456): it is `sending` from here, + // `pending` while Bot Social holds it for the owner's Approve / Reject, and dropped from the list + // once it went out. Only the owner's click (settleHeldLine) moves a pending line on. + const held = rt.faceRoomFanout && isLocalBotLine(line) ? holdLine(room, line) : undefined + let result + try { result = await rt.faceRoomFanout?.(room, line) } catch (error) { rt.warn?.('ds-bot: face-room line not sent: %s', error) } + if (held !== undefined) { + if (result?.held === true && typeof result.approvalId === 'string' && result.approvalId) { + held.status = 'pending' + held.approvalId = result.approvalId + } else { + room.heldLines = (room.heldLines ?? []).filter(entry => entry !== held) + } + void Promise.resolve(rt.save?.()).catch(() => {}) + } return } const remotes = currentParticipants(room).filter(isRemoteParticipant) @@ -904,6 +920,29 @@ export function install(rt) { } } + const isLocalBotLine = line => !line?.kind && line?.remote !== true && line?.authorKind === 'bot' + && typeof (line.botId ?? line.authorId) === 'string' && rt.botOf?.(line.botId ?? line.authorId) !== undefined + function holdLine(room, line) { + const entry = { seq: line.seq, botId: line.botId ?? line.authorId, text: String(line.text ?? ''), status: 'sending', at: Date.now() } + room.heldLines = [...(room.heldLines ?? []), entry].slice(-HELD_LINES_KEPT) + return entry + } + // The owner decided a held room line on its card (#456): approved, it is a plain bubble from now on; + // rejected, it stays a grey "Not sent" line, after a reload and in Past rooms too. + rt.settleHeldLine = function settleHeldLine(approvalId, decision) { + const id = String(approvalId ?? '') + if (!id || (decision !== 'approved' && decision !== 'rejected')) return false + for (const room of Object.values(state.rooms ?? {})) { + const entry = (room.heldLines ?? []).find(item => item.approvalId === id) + if (entry === undefined) continue + if (decision === 'approved') room.heldLines = room.heldLines.filter(item => item !== entry) + else entry.status = 'rejected' + void Promise.resolve(rt.save?.()).catch(() => {}) + return true + } + return false + } + // A Bot that no longer qualifies (grant revoked, owner removed) leaves every room // that has friends' Bots or more than one owner, at once; without an id, every such Bot does. rt.evictUnshared = async function evictUnshared(botId) { diff --git a/patches/ds-bot/files/src/host/room-participant.js b/patches/ds-bot/files/src/host/room-participant.js index 116113c9..ffd26bfc 100644 --- a/patches/ds-bot/files/src/host/room-participant.js +++ b/patches/ds-bot/files/src/host/room-participant.js @@ -36,9 +36,8 @@ export function createDshRoomParticipant(rt) { // round, or null when the round was stopped. The wait restarts on each event of the // member's Session, so a member at work is never cut off. `by` is the member that // called it, which answers what the turn posts after the round moved on. - async function runTurn({ room, member, sessionId, text, note, signal, started, by, origin }) { + async function runTurn({ room, member, sessionId, text, note, signal, started, by, origin, exchangeId = randomUUID() }) { if (signal.aborted) return null - const exchangeId = randomUUID() const turn = { botId: member.id, sessionId, exchangeId } started.push(turn) // Checked before the delivery, which may start the turn at once. diff --git a/patches/ds-bot/files/test/held-line.test.js b/patches/ds-bot/files/test/held-line.test.js index 1e2c58d3..d92eec09 100644 --- a/patches/ds-bot/files/test/held-line.test.js +++ b/patches/ds-bot/files/test/held-line.test.js @@ -5,8 +5,8 @@ import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' import { build } from 'esbuild' -// #306 item 5: a Bot line held for approval has its text in the approval card once; its bubble in a -// Face-to-face room says only that it waits. +// #306 item 5, #456: a Bot line held for approval has its text in the approval card only; a Face-to-face +// room draws no bubble for it until Approve, and a rejected one is one grey "Not sent" line. async function load(entry) { const bundle = await build({ @@ -46,7 +46,7 @@ const find = (node, match) => { return [...(match(node) ? [node] : []), ...find(node.children, match)] } -const { GroupReplies, ToolCell, isHeldLine } = await load('cells.js') +const { GroupReplies, ToolCell, isHeldLine, heldLineState } = await load('cells.js') const chief = { id: 'chief', name: 'Chief', color: 'green' } const held = (text, over = {}) => ({ id: 'ap1', kind: 'face_room_message', status: 'pending', from: 'Chief', roomId: 'frm_1', text, ...over }) @@ -56,30 +56,69 @@ const draw = (room, replies) => { const content = [{ type: 'text', text: JSON.stringify({ replies }) }] return expand(GroupReplies({ sessionId: 'g1', callId: 'c1', done: true, content, roster, actions: {}, useUi: () => null }), ['GroupMessage', 'Bubbles']) } -const said = (text) => ({ botId: 'chief', name: 'Chief', text }) +const said = (text, seq) => ({ botId: 'chief', name: 'Chief', text, ...(seq !== undefined ? { seq } : {}) }) const shownText = drawn => find(drawn, node => node.type?.name === 'MarkdownText').map(node => node.props.text) +const notSent = drawn => find(drawn, node => node.props?.['data-flybot'] === 'held-not-sent') +const textOf = node => find(node, () => true).flatMap(item => (item.children ?? []).filter(child => typeof child === 'string')).join('') -test('a held Bot line is one short waiting bubble; its text stays in the approval card', () => { +test('a held Bot line is no bubble at all before Approve; its text stays in the approval card', () => { const drawn = draw(roomWith([held('Draft: ship it on Friday')]), [said('Draft: ship it on Friday')]) - const [bubble] = find(drawn, node => node.props?.['data-flybot'] === 'held-line') - assert.ok(bubble, 'a waiting bubble') - assert.deepEqual(bubble.children, ['Waiting for approval']) assert.equal(shownText(drawn).length, 0, 'the line text is not drawn in the room') - // The Bot's name and avatar stay, so the waiting state is still the Bot's. - assert.equal(find(drawn, node => node.props?.className === 'bt-group-name' && node.children.includes('Chief')).length, 1) - assert.equal(find(drawn, node => node.props?.className === 'bt-lead').length, 1) + assert.equal(find(drawn, node => node.props?.className === 'bt-group-msg').length, 0, 'no bubble, no waiting stub') + assert.equal(find(drawn, node => node.props?.className === 'bt-group-name').length, 0) + assert.equal(notSent(drawn).length, 0) }) -test('only the held line waits: other lines, and once the hold is decided, the line, are full bubbles', () => { +test('only the held line is left out: other lines, and once the hold is decided, the line, are full bubbles', () => { const room = roomWith([held('Draft: ship it on Friday')]) const drawn = draw(room, [said('Earlier note'), said('Draft: ship it on Friday')]) - assert.equal(find(drawn, node => node.props?.['data-flybot'] === 'held-line').length, 1) assert.deepEqual(shownText(drawn), ['Earlier note']) const decided = draw(roomWith([]), [said('Draft: ship it on Friday')]) - assert.equal(find(decided, node => node.props?.['data-flybot'] === 'held-line').length, 0) assert.deepEqual(shownText(decided), ['Draft: ship it on Friday']) }) +// The host's own record of each held line on the room (#456), by log seq. +const withHeld = (heldLines, face = { status: 'open' }) => ({ ...roomWith([]), face, heldLines }) +const entry = (status, over = {}) => ({ seq: 7, botId: 'chief', text: 'Line one: got it, noodles', status, at: Date.now(), ...over }) + +test('#456: a line the host is sending or Bot Social holds is not drawn; approved, it is a bubble', () => { + for (const status of ['sending', 'pending']) { + const drawn = draw(withHeld([entry(status, { approvalId: 'ap1' })]), [said('Line one: got it, noodles', 7)]) + assert.equal(shownText(drawn).length, 0, `${status}: no bubble`) + assert.equal(notSent(drawn).length, 0, `${status}: no Not sent line`) + } + // Approve drops the record: the line is an ordinary bubble. + assert.deepEqual(shownText(draw(withHeld([]), [said('Line one: got it, noodles', 7)])), ['Line one: got it, noodles']) + // Another line of the same Bot (another seq) is drawn. + assert.deepEqual(shownText(draw(withHeld([entry('pending')]), [said('Line one: got it, noodles', 8)])), ['Line one: got it, noodles']) +}) + +test('#456: a rejected line is one grey "Not sent" line, never its words, after a reload and in Past rooms', () => { + const drawn = draw(withHeld([entry('rejected', { approvalId: 'ap1' })]), [said('Line one: got it, noodles', 7)]) + assert.equal(shownText(drawn).length, 0, 'the rejected words are not drawn') + const [line] = notSent(drawn) + assert.ok(line, 'a Not sent line') + assert.match(line.props.className, /bt-event/) + assert.equal(textOf(line), 'Chief · Not sent') + assert.doesNotMatch(textOf(line), /noodles/) + // Past rooms: the same record in a closed room still reads Not sent; a line still waiting when the room + // ended never left, so it reads Not sent too. + for (const status of ['rejected', 'pending']) { + const past = draw(withHeld([entry(status)], { status: 'closed' }), [said('Line one: got it, noodles', 7)]) + assert.equal(shownText(past).length, 0) + assert.equal(notSent(past).length, 1, `${status} in a closed room`) + } +}) + +test('#456: a late post (no seq) is found by its Bot and its exact text; a stale send is drawn as before', () => { + const room = withHeld([entry('rejected', { seq: 9 })]) + assert.equal(heldLineState(room, { botId: 'chief', text: 'Line one: got it, noodles' }), 'rejected') + assert.equal(heldLineState(room, { botId: 'writer', text: 'Line one: got it, noodles' }), undefined, 'another Bot\'s words') + assert.equal(heldLineState(room, { botId: 'chief', text: 'Something else' }), undefined) + assert.equal(heldLineState(withHeld([entry('sending', { at: Date.now() - 120_000 })]), { seq: 7, botId: 'chief' }), undefined, 'a send cut off by a restart') + assert.equal(heldLineState({ ...room, origin: 'group' }, { botId: 'chief', text: 'Line one: got it, noodles' }), undefined, 'only face rooms') +}) + test('the held text matches however the card cleaned it: spacing, the "To the room:" lead, the 800-character cap', () => { assert.equal(isHeldLine(roomWith([held('hello there world')]), 'hello there\nworld'), true) assert.equal(isHeldLine(roomWith([held('hello there')]), 'To face room: hello there'), true) @@ -99,7 +138,7 @@ test('the held line waits only for the Bot that sent it: another Bot saying the { botId: 'chief', name: 'Chief', text: 'Draft: ship it on Friday' }, ] }) }] const drawn = expand(GroupReplies({ sessionId: 'g1', callId: 'c1', done: true, content, roster, actions: {}, useUi: () => null }), ['GroupMessage', 'Bubbles']) - assert.equal(find(drawn, node => node.props?.['data-flybot'] === 'held-line').length, 1, 'only Chief\'s line waits') + assert.equal(find(drawn, node => node.props?.className === 'bt-group-msg').length, 1, 'only Chief\'s line is left out') assert.deepEqual(shownText(drawn), ['Draft: ship it on Friday'], 'Writer\'s identical line is drawn') assert.equal(isHeldLine(room, 'Draft: ship it on Friday', writer), false) assert.equal(isHeldLine(room, 'Draft: ship it on Friday', chief), true) @@ -116,14 +155,14 @@ test('only a Face-to-face room hides a held line, and only for a held room line' assert.equal(isHeldLine({ origin: 'face-to-face' }, 'hello'), false) }) -test('the waiting copy has a Chinese string', () => { +test('the Not sent copy has a Chinese string', () => { const zh = readFileSync(new URL('../src/client/i18n.js', import.meta.url), 'utf8') - assert.match(zh, /'Waiting for approval': '等待批准'/) + assert.match(zh, /'\{name\} · Not sent': '\{name\} · 未发出'/) }) -test('the waiting bubble has a style of its own', () => { +test('the Not sent line is a quiet grey event line', () => { const css = readFileSync(new URL('../src/client/styles.js', import.meta.url), 'utf8') - assert.match(css, /\.bt-bubble-held\{[^}]*dashed/) + assert.match(css, /\.bt-event\.bt-not-sent\{[^}]*color:var\(--bt-ink-3\)/) }) test('a held message to a friend is no bubble at all: the Bot\'s send is not drawn, the card is the one place', () => { diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index aec7f3e1..7c31c0d6 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -398,7 +398,10 @@ export interface FlyBot { applyFaceLine(roomId: string, line: Record): unknown ofFace(serverId: string): { id: string; face?: { roomId?: string; eventCursor?: number } } | undefined dropFace(roomId: string): Promise + /** `fn` answers `{ held: true, approvalId }` for a Bot line the server holds for the owner's approval (#456). */ onFaceLine(fn: ((room: { id: string; face?: { roomId?: string }; members?: string[] }, line: Record) => unknown) | undefined): () => void + /** The owner's decision on a held face-room line, by its approval id; true when a room had it (#456). */ + settleHeld?(approvalId: string, decision: 'approved' | 'rejected'): boolean postNotice(roomId: string, text: string): unknown postRemote(roomId: string, msg: { handle?: string; name?: string; text?: string }): unknown /** Inbox envelope → inbound-share room (join/line/leave). */ diff --git a/plugins/social/client.js b/plugins/social/client.js index 25046eaf..75c2b69c 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -909,11 +909,8 @@ window.__ModuleLoader__.load({ } function ChatApproval({ item, canDecide = true, busy, onDecide, t } = {}) { const [open, setOpen] = useState(false) - if (!item) return null - if (item.status === 'approved' || item.status === 'rejected') { - return h('div', { className: 'fs-hold fs-chat-approval', 'data-flybot': 'chat-approval', 'data-state': item.status, role: 'status' }, - h('div', { className: 'fs-name' }, item.status === 'approved' ? 'Approved' : 'Rejected')) - } + // A decided card goes at once, no "Approved" / "Rejected" stub (#456): the chat itself shows the outcome. + if (!item || item.status === 'approved' || item.status === 'rejected') return null if (canDecide !== true) { return h('div', { className: 'fs-hold fs-chat-approval', 'data-flybot': 'chat-approval', 'data-role': 'other', role: 'status' }, h('div', { className: 'fs-name' }, WAITING_OTHERS)) @@ -971,6 +968,16 @@ window.__ModuleLoader__.load({ }) } + // A held room line names its Bot the way the room does, with its person (#384, #456): "Researcher (Alice)". + function faceFromLabel(item, room) { + if (item?.kind !== 'face_room_message' || room?.origin !== 'face-to-face') return '' + const name = String(item.from ?? '').trim().toLowerCase() + if (!name) return '' + const own = (room.presentedParticipants ?? []).find(p => p?.kind === 'bot' && !p.remote && p.leftSeq === undefined + && String(p.displayName || p.name || '').trim().toLowerCase() === name) + return typeof own?.label === 'string' ? own.label.trim() : '' + } + function friendChatOf(roster, handle) { const want = String(handle ?? '').trim().toLowerCase() if (!want.startsWith('@')) return undefined @@ -1139,7 +1146,11 @@ window.__ModuleLoader__.load({ }, []) const session = { sessionId, roster, room, bot, handle: bot?.remote?.handle } // The friend's Bot by name and owner, as the chat list shows it. - const named = list => list.map((item) => { const label = friendLabel(roster, item.to); return label ? { ...item, toName: label } : item }) + const named = list => list.map((item) => { + const label = friendLabel(roster, item.to) + const from = faceFromLabel(item, room) + return label || from ? { ...item, ...(label ? { toName: label } : {}), ...(from ? { from } : {}) } : item + }) // A Bot's reply to a friend is decided on its own bubble in the Bot's chat (#418): no dock draws it. const mine = [ ...approvalsForOpenChat(named(items), session), @@ -1173,9 +1184,10 @@ window.__ModuleLoader__.load({ autoSend = result?.autoSend } const status = mapped === 'reject' || mapped === 'rejected' ? 'rejected' : 'approved' - const mark = list => list.map(entry => entry.id === item.id ? { ...entry, status } : entry) - setItems(mark) - setLocal(mark) + // The decided card leaves now (#456); the next load agrees. + const drop = list => list.filter(entry => entry.id !== item.id) + setItems(drop) + setLocal(drop) if (always && status === 'approved') actions?.notify?.(autoSendToast(autoSend)) } finally { setBusy('') } } diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index ddc82372..06c887c8 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -2217,7 +2217,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } async function postRejectNote(approvalId) { const item = pendingCache.items.find(entry => entry.id === approvalId) - if (!item) return + // A rejected room line already reads "Not sent" where it would have stood (#456): no second note. + if (!item || item.kind === 'face_room_message') return await postChatNote(item, REJECT_NOTE) settings.approvalNotices ??= {} settings.approvalNotices[approvalId] = { at: now(), kind: 'rejected' } @@ -2451,6 +2452,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const item = Array.isArray(pending) ? pending.find(entry => entry.id === id) : undefined if (mapped === 'rejected') await postRejectNote(id) const result = await confirmedCall('decide_approval', { approval_id: id, decision: mapped }) + // A held face-room line follows the click: a bubble once approved, a grey "Not sent" line once rejected (#456). + flybot.rooms?.settleHeld?.(id, mapped) // The decided hold leaves the badge now; the rest stay until the next fetch (#226). setPending(pendingCache.items.filter(entry => entry.id !== id)) pendingCache.at = 0 @@ -2684,6 +2687,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp joinCode: prev?.joinCode, }, asBot) let seen = settings.faceRooms[snapshot.roomId].seen + // The core room keeps every event id it logged: an event it refuses as already logged is not delivered again. + const firstTime = (applied, line) => typeof flybot.rooms.applyFaceLine !== 'function' || !line.eventId || applied !== undefined const joinedFromEvents = new Set() const alreadyJoined = (text) => { const body = String(text ?? '') @@ -2708,15 +2713,16 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (alreadyJoined(line.text)) continue seen = rememberEvent(seen, `join-text:${line.text}`) } - flybot.rooms.applyFaceLine?.(room.id, line) - void flybot.rooms.postNotice?.(room.id, line.text) + if (firstTime(flybot.rooms.applyFaceLine?.(room.id, line), line)) void flybot.rooms.postNotice?.(room.id, line.text) } else if (line.human || line.authorKind === 'human') { const local = locals.some(bot => bot.handle && bot.handle === line.authorId) if (local) continue const who = line.who || 'Someone' - flybot.rooms.applyFaceLine?.(room.id, { ...line, who, remote: true, authorKind: 'human' }) - if (flybot.rooms.postHuman) void flybot.rooms.postHuman(room.id, { authorId: line.authorId, name: who, text: line.text, ...(line.mentions ? { mentions: line.mentions } : {}), ...(line.attested === true ? { attested: true } : {}) }) - else void flybot.rooms.postNotice?.(room.id, `${who}: ${line.text}`) + // A line the room already has (two polls, or a start and a poll, read the same event) starts no + // second round, so each Bot here answers it once (#456). + const fresh = firstTime(flybot.rooms.applyFaceLine?.(room.id, { ...line, who, remote: true, authorKind: 'human' }), line) + if (fresh && flybot.rooms.postHuman) void flybot.rooms.postHuman(room.id, { authorId: line.authorId, name: who, text: line.text, ...(line.mentions ? { mentions: line.mentions } : {}), ...(line.attested === true ? { attested: true } : {}) }) + else if (fresh) void flybot.rooms.postNotice?.(room.id, `${who}: ${line.text}`) } else { const local = locals.some(bot => bot.handle && bot.handle === line.authorId) if (local) continue @@ -2949,6 +2955,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp .catch(error => warn('face-room owner line not approved', error)) } pendingCache.at = 0 + // A Bot's held line lives on its card until the owner decides; the room draws no bubble for it (#456). + if (!outbound.human && sent.approvalId) return { held: true, approvalId: String(sent.approvalId) } } } catch (error) { warn('face-room send failed', error) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index fae8ed33..65ea7357 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -2333,6 +2333,105 @@ test('face-to-face send pending_approval uses decide_approval', async (t) => { assert.equal((await call('flybot/social/status', {})).value.pendingApprovals, 0) }) +test('#456: a Bot line Bot Social holds is answered as held, follows the Approve/Reject click, and a reject leaves no "Rejected." room note', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = withFaceRooms(mockServer()) + const heldItem = { + approval_id: 'apr-face-1', + kind: 'face_room_message', + status: 'pending', + sender_kind: 'bot', + bot_id: 'm1', + room_id: 'frm_123456', + message: { payload: { text: 'Researcher: hold me' } }, + summary: 'To the room: Researcher: hold me', + } + let queue = [] + server.tools.list_approvals = () => ({ approvals: queue }) + server.tools.decide_approval = a => { + if (a.confirm_code === undefined) return { status: 'confirmation_required', confirm_code: 'C0DE02' } + queue = queue.filter(item => item.approval_id !== a.approval_id) + return { approval_id: a.approval_id, decision: a.decision } + } + let fanout + const settled = [] + const notices = [] + const origScope = ctx.flybot.scope.bind(ctx.flybot) + ctx.flybot.scope = (name) => { + const scoped = origScope(name) + const origOn = scoped.rooms.onFaceLine.bind(scoped.rooms) + scoped.rooms.onFaceLine = (fn) => { fanout = fn; return origOn(fn) } + const origSettle = scoped.rooms.settleHeld?.bind(scoped.rooms) + scoped.rooms.settleHeld = (id, decision) => { settled.push([id, decision]); return origSettle?.(id, decision) } + const origNotice = scoped.rooms.postNotice.bind(scoped.rooms) + scoped.rooms.postNotice = (roomId, text) => { notices.push(text); return origNotice(roomId, text) } + return scoped + } + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + await call('flybot/social/face-join', { code: '123456' }) + const room = ctx.flybot.rooms.ofFace('frm_123456') + const chief = 'a' + // The local Bot's line comes back held: the room keeps it off its bubbles until the owner clicks. + const answer = await fanout(room, { authorKind: 'bot', botId: chief, authorId: chief, who: 'Chief', text: 'hold me' }) + assert.deepEqual(answer, { held: true, approvalId: 'apr-face-1' }) + assert.equal(server.calls.some(c => c.name === 'decide_approval'), false, 'a Bot line is never approved for the owner') + // A line that went out is not held. + assert.equal(await fanout(room, { authorKind: 'bot', botId: chief, authorId: chief, who: 'Chief', text: 'plain hello' }), undefined) + queue = [heldItem] + const status = (await call('flybot/social/status', {})).value + assert.equal(status.pendingApprovals, 1) + notices.length = 0 + const rejected = (await call('flybot/social/decide-approval', { approvalId: 'apr-face-1', decision: 'reject' })).value + assert.equal(rejected.status, 'rejected') + assert.deepEqual(settled, [['apr-face-1', 'rejected']], 'the room line turns into "Not sent"') + assert.equal(notices.includes(REJECT_NOTE), false, 'no "Rejected." note in a face room') + queue = [{ ...heldItem, approval_id: 'apr-face-2' }] + await call('flybot/social/status', {}) + await call('flybot/social/decide-approval', { approvalId: 'apr-face-2', decision: 'approve' }) + assert.deepEqual(settled.at(-1), ['apr-face-2', 'approved']) +}) + +test('#456: a face-room event read twice (two polls at once) reaches the room once, so its Bots answer it once', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const humans = [] + const origScope = ctx.flybot.scope.bind(ctx.flybot) + ctx.flybot.scope = (name) => { + const scoped = origScope(name) + const origHuman = scoped.rooms.postHuman?.bind(scoped.rooms) + scoped.rooms.postHuman = (id, line) => { humans.push(line.text); return origHuman(id, line) } + return scoped + } + const server = withFaceRooms(mockServer()) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + await call('flybot/social/face-join', { code: '123456' }) + const body = server.face.rooms.get('frm_123456') + body.event_cursor += 1 + body.events.push({ + event_id: `fev_${body.event_cursor}`, event_cursor: body.event_cursor, type: 'room.message', + from: { handle: '@chief.boxa.home', display_name: 'Chief', owner_name: 'BoxA', published: false, label: 'Chief · BoxA' }, + trust: 'peer_untrusted', untrusted_content: { text: encodeFaceHuman({ who: 'BoxA', text: 'Line one from A' }) }, + }) + await Promise.all([call('flybot/social/face-rooms', { poll: true }), call('flybot/social/face-rooms', { poll: true })]) + await call('flybot/social/face-rooms', { poll: true }) + assert.deepEqual(humans, ['Line one from A'], 'posted for a round once') + const hosted = ctx.flybot.rooms.ofFace('frm_123456') + assert.equal(hosted.log.filter(line => String(line.text).includes('Line one from A')).length, 1, 'logged once') +}) + function loadSocialClient(callImpl) { const registered = [] const entries = [] @@ -3041,6 +3140,28 @@ test('#126: a face-room hold shows in that chat with a readable name', () => { assert.ok(tree.some(n => n.props.className === 'fs-draft' && n.children.includes('hello there'))) }) +test('#456: a face-room hold names its Bot with its person, as the room does; a friend\'s Bot of the same name is not used', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const plugin = pluginOf([ + [{ id: 'apr-face', kind: 'face_room_message', roomId: 'frm_1', from: 'Researcher', toName: 'the room', text: 'Line one: got it' }], + [], + '', + ]) + const room = { + origin: 'face-to-face', id: 'g1', face: { roomId: 'frm_1' }, + presentedParticipants: [ + { kind: 'bot', remote: true, displayName: 'Researcher', label: 'Researcher (Bob)' }, + { kind: 'bot', displayName: 'Researcher', label: 'Researcher (Alice)' }, + { kind: 'human', displayName: 'Alice', label: 'Alice' }, + ], + } + const tree = walk(plugin.ChatApprovalDock({ room })) + assert.ok(tree.some(n => n.props.className === 'fs-sub' && n.children.includes('Researcher (Alice) → the room'))) + // With no label known the card keeps the Bot's name. + const bare = walk(pluginOf([[{ id: 'apr-face', kind: 'face_room_message', roomId: 'frm_1', from: 'Researcher', toName: 'the room', text: 'x' }], [], '']).ChatApprovalDock({ room: { ...room, presentedParticipants: [] } })) + assert.ok(bare.some(n => n.props.className === 'fs-sub' && n.children.includes('Researcher → the room'))) +}) + test('#188: a face-room hold shows only in its own room, never in a newer one', () => { const holds = [ { id: 'apr-old', kind: 'face_room_message', roomId: 'frm_old', from: 'Chief', toName: 'the room', text: 'old line' }, @@ -4194,11 +4315,9 @@ test('#90: a friend\'s Bot settings has Remove this Bot; ChatApproval is owner-o assert.equal(otherNodes.some(n => n.type === 'button' && n.children.includes('Auto-send from now on')), false) ownerNodes.find(n => n.type === 'button' && n.children.includes('Reject')).props.onClick() assert.deepEqual(decided.at(-1), ['apr-hold-1', 'reject']) - const done = plugin.ChatApproval({ item: { ...pending[0], status: 'approved' } }) - const doneNodes = [] - collect(done, doneNodes) - assert.ok(doneNodes.some(n => n.props['data-state'] === 'approved')) - assert.ok(doneNodes.some(n => n.children?.includes?.('Approved') || (Array.isArray(n.children) && n.children.includes('Approved')))) + // A decided card draws nothing: no "Approved" / "Rejected" stub is left in the chat (#456). + assert.equal(plugin.ChatApproval({ item: { ...pending[0], status: 'approved' } }), null) + assert.equal(plugin.ChatApproval({ item: { ...pending[0], status: 'rejected' } }), null) }) test('Remove this Bot leaves rooms only through social/hide-remote, not a second client-side removal', async () => {