diff --git a/changelog.d/1267-mid-turn-line.fixed.md b/changelog.d/1267-mid-turn-line.fixed.md new file mode 100644 index 000000000..bd713ef4b --- /dev/null +++ b/changelog.d/1267-mid-turn-line.fixed.md @@ -0,0 +1 @@ +A line sent into a group chat while a member is speaking now reaches that member inside its turn: the round watches the same Session queue it drains, logs the line at once and steers it into the speaker's running turn with a header saying it arrived after the turn started, so the member's next step answers it instead of reading it after a long turn as if it had been there all along. The line leaves the relay queue, so it is neither logged nor answered twice. The one exception is an explicit stop (停, 停下, 停止, 别做了, 不要做了, stop, stop it): the speaking slot is marked stopped by the user, its turn is cancelled and its answer is not posted, and the line stays queued to be answered as a fresh line by the room ([#1267](https://github.com/ThinkFlowLab/FlyBot/issues/1267)). diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 89cd61077..9dad4a376 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -164,6 +164,16 @@ const ROOM_REPLY_BOARD = 'Reply with your message for the group, no name prefix: const ROOM_REPLY_NOW = 'Reply now with your message for the group, no name prefix, or [PASS] to stay silent.' // Words that address the whole room: a plain line with one of them may get an answer from each Bot (#299). const ROOM_EVERYONE = /大家|你们|各位|所有人|所有 ?[Bb]ot|每个人|每位|各自|分别|everyone|everybody|y'?all|you all|all of you|each of you|introduce yourselves/i +// A line that only means "stop what you are doing" (#1267): in a group it ends the speaking member's +// turn instead of being handed to it. It is the one place where a person's line supersedes work in +// flight, and the words are the ones a person uses to call stop_bot on a Bot. +const ROOM_STOP = /^(?:停|停下|停止|别做了|不要做了|stop|stop it)[\s。.!!?]*$/i +// What a member reads when a person's line reaches it mid-turn (#1267): it has to know the line came +// after its turn started, so it never answers it as if it had been there from the start. +const ROOM_MID_LINE = 'the user sent this while you were speaking; answer it now' +// How often a running round looks at its own Session's inbox for that line (#1267): the member's turn +// cannot be interrupted, so the round watches the one queue it drains at its head. +const ROOM_MID_LINE_POLL_MS = 100 const ROOM_REPLY_FORCED = 'This turn is the exception to the [PASS] rule: reply with your message for the group, no name prefix, short. Do not reply [PASS].' const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".' // Same wake, on a room that has a board: the call does not assign or record progress. @@ -173,6 +183,7 @@ const slotLabel = (slot) => { if (slot.status === 'speaking') return `${slot.name} (you, now)` if (slot.status === 'posted') return `${slot.name} (posted)` if (slot.status === 'passed') return `${slot.name} (passed)` + if (slot.status === 'superseded') return `${slot.name} (stopped by the user)` if (slot.status === 'missed') return `${slot.name} (no answer)` if (slot.status === 'working') return `${slot.name} (working on it; posts when done)` if (slot.status === 'waiting-approval') return `${slot.name} (waiting for the user's approval; posts when done)` @@ -456,8 +467,13 @@ export function install(rt) { const starting = round.route === undefined && round.slots.length === 0 delete round.route for (const line of lines) { - if (!takePosted(room.id, line.text)) appendRoom(room, rt.authoredRoomLine?.(room, { who: line.authorName || 'User', text: line.text, authorId: line.authorId, authorKind: line.authorKind || 'human' }) ?? { who: 'User', text: line.text }) - round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) + // A line the round already showed when it arrived (#1267): its stop left it in this queue, so the + // head drain opens the fresh line without showing it a second time. + const shown = round.midLineIds?.delete(line.id) === true + if (!shown) { + if (!takePosted(room.id, line.text)) appendRoom(room, rt.authoredRoomLine?.(room, { who: line.authorName || 'User', text: line.text, authorId: line.authorId, authorKind: line.authorKind || 'human' }) ?? { who: 'User', text: line.text }) + round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) + } const { all, found } = mentionsIn(line.text, members) const note = all || found.length > 0 ? 'the user mentioned you' : 'new message from the user' let speakers = openingSpeakers(room, { text: line.text }) @@ -481,6 +497,58 @@ export function install(rt) { return round.slots.length - before } + // One line that reaches the round while a member speaks (#1267). It is logged at once — the room + // shows it when it is sent, not at the next slot — and `notePostedLine` keeps the next relay from + // logging it a second time. Its pending copy leaves the relay's queue, because this round answers + // it: the head drain must not open a second slot for the same line. + function speakMidLine(room, round, slot, member, sessionId, exchangeId, line) { + if (!takePosted(room.id, line.text)) appendRoom(room, rt.authoredRoomLine?.(room, { who: line.authorName || 'User', text: line.text, authorId: line.authorId, authorKind: line.authorKind || 'human' }) ?? { who: 'User', text: line.text }) + rt.notePostedLine?.(room.id, line.text) + const queued = rt.roomInbox?.get(room.id) + const at = queued?.findIndex(item => item.botId === undefined && item.text === line.text) ?? -1 + if (at !== -1) rt.roomInbox.set(room.id, queued.toSpliced(at, 1)) + round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) + // The user spoke in this room, so late replies may be answered again. + room.lateRounds = 0 + void rt.save().catch(() => {}) + // The one line that ends work in flight (#1267 B): the slot is superseded, its turn is cancelled, + // and nothing it says when it stops is posted. The line stays in the Session's queue, so the head + // drain opens it as a fresh line (admin first) for the room to answer. + if (ROOM_STOP.test(String(line.text).trim())) { + round.midLineIds ??= new Set() + round.midLineIds.add(line.id) + slot.status = 'superseded' + round.replies.push({ kind: 'stopped', botId: member.id, name: member.name, color: member.color, time: Date.now() }) + void participant.cancel({ sessionId, exchangeId }).catch(error => rt.warn('ds-bot: stopping %s in %s failed: %s', member.name, room.name, error)) + return + } + // Every other line is the default shape (#1267 A): the member that is speaking gets it at its next + // step, marked as a line that arrived after its turn started. + void rt.deliver({ fromId: undefined, toId: member.id, sessionId, role: 'room', roomId: room.id, text: line.text, steer: true, note: ROOM_MID_LINE }) + .catch(error => rt.warn('ds-bot: a line sent while %s was speaking did not reach it: %s', member.name, error)) + } + + // While a member's turn runs, the round watches the queue it drains at its head for the user's lines + // (#1267): a member turn is not interrupted from outside, so the wait is watched, and what arrives is + // handed over by speakMidLine. The queue is left alone for a stop line, whose slot it supersedes. + function watchSpeakerLines(room, round, slot, member, sessionId, exchangeId, agent) { + let stopped = false + const take = () => { + if (stopped) return + for (const line of waitingLines(agent)) { + if (ROOM_STOP.test(String(line.text).trim())) { + stopped = true + speakMidLine(room, round, slot, member, sessionId, exchangeId, line) + return + } + if (agent.inbox.remove(line.id)) speakMidLine(room, round, slot, member, sessionId, exchangeId, line) + } + } + const timer = setInterval(take, ROOM_MID_LINE_POLL_MS) + timer.unref?.() + return () => { stopped = true; clearInterval(timer) } + } + // The routing of a person's plain line in a face-to-face room (not @all): who decides, whether it is to everyone, // who spoke last, and whether the user must be answered if every Bot passes (the owner's own line, not a friend's). function routeFor(room, opener, startSeq) { @@ -623,9 +691,18 @@ export function install(rt) { // 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 }) + // A line the user sends while this member speaks reaches it at its next step (#1267). + const stopWatching = watchSpeakerLines(room, round, slot, member, session.id, exchangeId, agent) + let result + try { + 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 }) + } finally { + stopWatching() + } const reply = result === null || typeof result === 'object' ? result : stripOwnName(member.name, String(result ?? '').trim()) round.speaking = null + // A stop line ended this turn (#1267): what it had in flight is not this room's answer. + if (slot.status === 'superseded') continue if (reply?.failure !== undefined) { // Not a pass: the user sees why the member is silent, and the round goes on. slot.status = 'failed' diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index 92dc04bd1..1b0cf7b9b 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -16,6 +16,9 @@ function start() { const ctx = fakeContext() const workspaces = new Map() const sent = [] + // A line steered into a running turn is a different thing from a turn's own delivery (#1267): the + // member's Session gets it at its next step, so it is kept apart from `sent`. + const steered = [] const inboxes = new Map() const inboxOf = (sessionId) => { if (!inboxes.has(sessionId)) { @@ -53,7 +56,7 @@ function start() { }, sessionController: { create: async () => ({ sessionId: `s${++sessions}` }), - resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, followup: message => sent.push({ sessionId, message }) } }), + resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, followup: message => sent.push({ sessionId, message }), steer: message => steered.push({ sessionId, message }), cancel: () => {} } }), selectModel: async () => {}, prompt: async (request) => { const inbox = inboxOf(request.sessionId) @@ -101,7 +104,7 @@ function start() { await ctx.dispose() rmSync(home, { recursive: true, force: true, maxRetries: 5 }) } - return { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent, ctx, home, rt } + return { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent, steered, ctx, home, rt } } const settle = () => new Promise(resolve => setTimeout(resolve, 20)) @@ -182,6 +185,95 @@ test('a message the composer queues mid-round joins the round and leaves the Ses assert.deepEqual(inboxOf(room.id).nextTurn, []) }) +test('#1267: a line the user sends while a member speaks reaches that member at its next step, not after its turn', async (t) => { + const { call, emit, userMessage, compose, relay, answer, stop, sent, steered, rt } = start() + t.after(stop) + const room = await team(call, sent) + + await emit(room.id, userMessage('Tell me a long story')) + const running = relay(room.id, 'relay') + const first = await turnFor(sent) + assert.ok(first, 'the admin speaks first') + + // The user types while the admin is still inside its turn. + await compose(room.id, 'One thing: say the conclusion first') + const mid = await waitFor(() => steered.find(entry => entry.message.content[0].text.includes('say the conclusion first'))) + assert.ok(mid, 'the line reaches the member that is speaking, not the next slot') + assert.equal(mid.sessionId, first.sessionId) + // It says the line arrived after the turn started, so it is never answered as if it had been there from the start. + assert.match(mid.message.content[0].text, /^\[Group chat "R1" · the user sent this while you were speaking; answer it now\]\nOne thing: say the conclusion first$/) + // The room shows it at once: the log and the running round carry it before the turn ends. + assert.ok(rt.state.rooms[room.id].log.some(line => line.text === 'One thing: say the conclusion first'), 'logged while the turn runs') + assert.ok(rt.roomProgress.get('relay').replies.some(reply => reply.kind === 'user' && reply.text === 'One thing: say the conclusion first'), 'in the running round') + // No second slot was opened for it: the member that is speaking got no other delivery. + assert.equal(sent.filter(entry => entry.message.source.role === 'room').length, 1) + + await answer(first.sessionId, first.message.source.exchangeId, 'Conclusion first, then the story.', 1) + const second = await turnFor(sent, first) + assert.notEqual(second.sessionId, first.sessionId, 'the line opened no second turn for the member that was speaking') + + await answer(second.sessionId, second.message.source.exchangeId, 'Noted.', 2) + const result = JSON.parse(await running) + // Answered once: by the member that was speaking mid-turn, not again as the next slot's opener. + assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['User', 'Chief', 'Writer']) + assert.equal(result.replies.filter(reply => reply.text === 'One thing: say the conclusion first').length, 1) +}) + +test('#1267: the line reaches the room once and leaves the relay queue behind', async (t) => { + const { call, emit, userMessage, compose, relay, answer, stop, sent, steered, rt } = start() + t.after(stop) + const room = await team(call, sent) + + await emit(room.id, userMessage('Start')) + const running = relay(room.id, 'relay') + const first = await turnFor(sent) + // The shell both prompts the room's Session and announces the line to it, as the real one does. + await compose(room.id, 'while you speak') + await emit(room.id, userMessage('while you speak')) + assert.ok(await waitFor(() => steered.find(entry => entry.message.content[0].text.includes('while you speak'))), 'the speaker has it') + + // The relay's pending copy goes with it, so the next relay can neither log it again nor answer it again. + assert.equal((rt.roomInbox.get(room.id) ?? []).some(item => item.text === 'while you speak'), false) + await answer(first.sessionId, first.message.source.exchangeId, 'one', 1) + const second = await turnFor(sent, first) + await answer(second.sessionId, second.message.source.exchangeId, 'two', 2) + const result = JSON.parse(await running) + assert.equal(result.replies.filter(reply => reply.text === 'while you speak').length, 1) + assert.equal(rt.state.rooms[room.id].log.filter(line => line.text === 'while you speak').length, 1) +}) + +test('#1267: a line that only says stop supersedes the speaking slot, and its answer is not posted', async (t) => { + const { call, emit, userMessage, compose, relay, answer, stop, sent, steered, rt } = start() + t.after(stop) + const room = await team(call, sent) + + await emit(room.id, userMessage('Long job')) + const running = relay(room.id, 'relay') + const first = await turnFor(sent) + // The member's turn runs with this delivery, and the person's stop lands inside it. + await emit(first.sessionId, { type: 'turn/start', data: { turn: 1 } }) + await emit(first.sessionId, { type: 'user/message', data: { source: first.message.source, content: first.message.content } }) + await compose(room.id, '停') + const slot = await waitFor(() => rt.roomProgress.get('relay').slots.find(entry => entry.status === 'superseded')) + assert.ok(slot, 'the speaking slot is superseded') + assert.equal(steered.length, 0, 'a stop line is not handed to the member as work to answer') + + // The stopped turn's own text never reaches the room. + await emit(first.sessionId, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: 'Half done' }] } } }) + await emit(first.sessionId, { type: 'turn/end', data: { reason: { kind: 'stop' } } }) + // The line stayed in the Session's queue, so the head drain opens it as a fresh line for the room. + const next = await turnFor(sent, first) + assert.match(next.message.content[0].text, /停/) + await answer(next.sessionId, next.message.source.exchangeId, 'Stopped.', 2) + const third = await turnFor(sent, first, next) + await answer(third.sessionId, third.message.source.exchangeId, 'Nothing running here.', 3) + const result = JSON.parse(await running) + assert.equal(result.replies.some(reply => reply.text === 'Half done'), false, 'the stopped answer is not posted') + assert.equal(rt.state.rooms[room.id].log.some(line => line.text === 'Half done'), false) + assert.ok(result.replies.some(reply => reply.kind === 'stopped')) + assert.equal(result.replies.filter(reply => reply.text === '停').length, 1, 'the line shows once in the round') +}) + test('a display-only shared line is not logged again and calls no Bot', async (t) => { const { call, emit, relay, stop, sent, rt } = start() t.after(stop) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 181d32c20..4563ba873 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -116,6 +116,10 @@ The board says what is open; the turn also says which member holds it, because a `post_to_group` has no cap on the rounds Bots start in one group between the user's lines. The old `ROOM_BOT_ROUNDS` (3) gate was turned off by owner decision 2026-10-21 07:34 Asia/Shanghai: it refused a recurring scheduled task's fourth post with a tool result only, so owner-set runs stopped overnight with nothing shown in the group. A saved room may still carry `botRounds`; nothing reads it, and `channel.js` keeps it out of the roster. The guards that stay: `MAX_HOPS` (6) ends a Bot-to-Bot chain (`message_bot`, and `post_to_group` from a relayed turn); `MAX_UNREAD_REQUESTS` (10) per Bot chat; `ROOM_TURNS_EACH` / `ROOM_ADMIN_TURNS_EACH` (2 / 4) per member per round, except the room's admin when a member names it with `@`: that call is never dropped by the cap (#804), because it is a report to the coordinator rather than a chain, and the round's own turn limit still bounds the round; `ROOM_LATE_ROUNDS` (10) late replies answered before the user speaks again; and a Bot cannot `post_to_group` into the room it is speaking in. Face-to-face rooms take the same path. Covered by `core/test/rounds.test.js`. +### A line sent while a member speaks (#1267) + +A person's line sent into a group while a member is speaking now reaches that member inside its turn, instead of waiting behind the speaker. `groups.js` `runRound` starts `watchSpeakerLines` around the member's `runTurn`: every `ROOM_MID_LINE_POLL_MS` (100ms) it reads the same Session queue the round drains at its loop head (`waitingLines`: queued or steered `user` lines) and hands each new one to `speakMidLine`. That appends the line to the room log at once, puts it in the running round's replies, marks it with `notePostedLine` and drops the relay's pending copy, so the next relay neither logs nor answers it again. The member gets it through `rt.deliver` with `steer: true` and the note `ROOM_MID_LINE` (`[Group chat "..." · the user sent this while you were speaking; answer it now]`), so its next model step sees that the line arrived after it started and never answers it as if it had been there from the start. The one exception is a line that only says stop (`ROOM_STOP`: 停 / 停下 / 停止 / 别做了 / 不要做了 / stop / stop it): the speaking slot is marked `superseded` (`slotLabel`: `(stopped by the user)`), `participant.cancel` cancels that turn, a `stopped` reply replaces its answer, and nothing the stopped turn said is posted. That line stays in the Session queue, and the round's next head drain opens it as a fresh line for the room to answer, admin first; `round.midLineIds` keeps the drain from showing it in the replies a second time. Covered by `core/test/rounds.test.js`. + ### A group round's closing report (#913) When a Bot posts with `report_back`, `reportRound` sends it the round's closing report. A member the round's line named and that ended its turn without a line (a pass, or no reply at all) was addressed and did not answer, which is not the same thing as a member that read the line and had nothing to add: the first gets `No answer to your @: .` (the poster's own line or a person's line named it; another member's call is `Called, but no answer: .`), and only the rest stays under `Stayed silent:`. A slot keeps `addressed: true` when a person's line named it, and `by: ` when a Bot's post did, so the report reads the slots instead of `round.passed` alone. Nothing is re-called: the everyone-passed fallback (`fallbackDue`) keeps its single extra turn, and the member prompt is unchanged. Covered by `core/test/rounds.test.js`. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 28b0f89ec..5991c3383 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -1187,7 +1187,35 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js diff --git a/test/rounds.test.js b/test/rounds.test.js --- a/test/rounds.test.js +++ b/test/rounds.test.js -@@ -114,6 +114,8 @@ const waitFor = async (check) => { +@@ -16,6 +16,9 @@ + const ctx = fakeContext() + const workspaces = new Map() + const sent = [] ++ // A line steered into a running turn is a different thing from a turn's own delivery (#1267): the ++ // member's Session gets it at its next step, so it is kept apart from `sent`. ++ const steered = [] + const inboxes = new Map() + const inboxOf = (sessionId) => { + if (!inboxes.has(sessionId)) { +@@ -53,7 +53,7 @@ + }, + sessionController: { + create: async () => ({ sessionId: `s${++sessions}` }), +- resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, followup: message => sent.push({ sessionId, message }) } }), ++ resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, followup: message => sent.push({ sessionId, message }), steer: message => steered.push({ sessionId, message }), cancel: () => {} } }), + selectModel: async () => {}, + prompt: async (request) => { + const inbox = inboxOf(request.sessionId) +@@ -101,7 +101,7 @@ + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } +- return { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent, ctx, home, rt } ++ return { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent, steered, ctx, home, rt } + } + + const settle = () => new Promise(resolve => setTimeout(resolve, 20)) +@@ -114,6 +114,8 @@ return undefined } const turnFor = (sent, ...earlier) => waitFor(() => sent.find(entry => !earlier.includes(entry) && entry.message.source.role === 'room')) @@ -1196,10 +1224,99 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a new group Session waits for capabilities before its first member prompt', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() -@@ -180,6 +180,69 @@ test('a message the composer queues mid-round joins the round and leaves the Ses +@@ -180,6 +180,158 @@ assert.deepEqual(inboxOf(room.id).nextTurn, []) }) ++test('#1267: a line the user sends while a member speaks reaches that member at its next step, not after its turn', async (t) => { ++ const { call, emit, userMessage, compose, relay, answer, stop, sent, steered, rt } = start() ++ t.after(stop) ++ const room = await team(call, sent) ++ ++ await emit(room.id, userMessage('Tell me a long story')) ++ const running = relay(room.id, 'relay') ++ const first = await turnFor(sent) ++ assert.ok(first, 'the admin speaks first') ++ ++ // The user types while the admin is still inside its turn. ++ await compose(room.id, 'One thing: say the conclusion first') ++ const mid = await waitFor(() => steered.find(entry => entry.message.content[0].text.includes('say the conclusion first'))) ++ assert.ok(mid, 'the line reaches the member that is speaking, not the next slot') ++ assert.equal(mid.sessionId, first.sessionId) ++ // It says the line arrived after the turn started, so it is never answered as if it had been there from the start. ++ assert.match(mid.message.content[0].text, /^\[Group chat "R1" · the user sent this while you were speaking; answer it now\]\nOne thing: say the conclusion first$/) ++ // The room shows it at once: the log and the running round carry it before the turn ends. ++ assert.ok(rt.state.rooms[room.id].log.some(line => line.text === 'One thing: say the conclusion first'), 'logged while the turn runs') ++ assert.ok(rt.roomProgress.get('relay').replies.some(reply => reply.kind === 'user' && reply.text === 'One thing: say the conclusion first'), 'in the running round') ++ // No second slot was opened for it: the member that is speaking got no other delivery. ++ assert.equal(sent.filter(entry => entry.message.source.role === 'room').length, 1) ++ ++ await answer(first.sessionId, first.message.source.exchangeId, 'Conclusion first, then the story.', 1) ++ const second = await turnFor(sent, first) ++ assert.notEqual(second.sessionId, first.sessionId, 'the line opened no second turn for the member that was speaking') ++ ++ await answer(second.sessionId, second.message.source.exchangeId, 'Noted.', 2) ++ const result = JSON.parse(await running) ++ // Answered once: by the member that was speaking mid-turn, not again as the next slot's opener. ++ assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['User', 'Chief', 'Writer']) ++ assert.equal(result.replies.filter(reply => reply.text === 'One thing: say the conclusion first').length, 1) ++}) ++ ++test('#1267: the line reaches the room once and leaves the relay queue behind', async (t) => { ++ const { call, emit, userMessage, compose, relay, answer, stop, sent, steered, rt } = start() ++ t.after(stop) ++ const room = await team(call, sent) ++ ++ await emit(room.id, userMessage('Start')) ++ const running = relay(room.id, 'relay') ++ const first = await turnFor(sent) ++ // The shell both prompts the room's Session and announces the line to it, as the real one does. ++ await compose(room.id, 'while you speak') ++ await emit(room.id, userMessage('while you speak')) ++ assert.ok(await waitFor(() => steered.find(entry => entry.message.content[0].text.includes('while you speak'))), 'the speaker has it') ++ ++ // The relay's pending copy goes with it, so the next relay can neither log it again nor answer it again. ++ assert.equal((rt.roomInbox.get(room.id) ?? []).some(item => item.text === 'while you speak'), false) ++ await answer(first.sessionId, first.message.source.exchangeId, 'one', 1) ++ const second = await turnFor(sent, first) ++ await answer(second.sessionId, second.message.source.exchangeId, 'two', 2) ++ const result = JSON.parse(await running) ++ assert.equal(result.replies.filter(reply => reply.text === 'while you speak').length, 1) ++ assert.equal(rt.state.rooms[room.id].log.filter(line => line.text === 'while you speak').length, 1) ++}) ++ ++test('#1267: a line that only says stop supersedes the speaking slot, and its answer is not posted', async (t) => { ++ const { call, emit, userMessage, compose, relay, answer, stop, sent, steered, rt } = start() ++ t.after(stop) ++ const room = await team(call, sent) ++ ++ await emit(room.id, userMessage('Long job')) ++ const running = relay(room.id, 'relay') ++ const first = await turnFor(sent) ++ // The member's turn runs with this delivery, and the person's stop lands inside it. ++ await emit(first.sessionId, { type: 'turn/start', data: { turn: 1 } }) ++ await emit(first.sessionId, { type: 'user/message', data: { source: first.message.source, content: first.message.content } }) ++ await compose(room.id, '停') ++ const slot = await waitFor(() => rt.roomProgress.get('relay').slots.find(entry => entry.status === 'superseded')) ++ assert.ok(slot, 'the speaking slot is superseded') ++ assert.equal(steered.length, 0, 'a stop line is not handed to the member as work to answer') ++ ++ // The stopped turn's own text never reaches the room. ++ await emit(first.sessionId, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: 'Half done' }] } } }) ++ await emit(first.sessionId, { type: 'turn/end', data: { reason: { kind: 'stop' } } }) ++ // The line stayed in the Session's queue, so the head drain opens it as a fresh line for the room. ++ const next = await turnFor(sent, first) ++ assert.match(next.message.content[0].text, /停/) ++ await answer(next.sessionId, next.message.source.exchangeId, 'Stopped.', 2) ++ const third = await turnFor(sent, first, next) ++ await answer(third.sessionId, third.message.source.exchangeId, 'Nothing running here.', 3) ++ const result = JSON.parse(await running) ++ assert.equal(result.replies.some(reply => reply.text === 'Half done'), false, 'the stopped answer is not posted') ++ assert.equal(rt.state.rooms[room.id].log.some(line => line.text === 'Half done'), false) ++ assert.ok(result.replies.some(reply => reply.kind === 'stopped')) ++ assert.equal(result.replies.filter(reply => reply.text === '停').length, 1, 'the line shows once in the round') ++}) ++ +test('a display-only shared line is not logged again and calls no Bot', async (t) => { + const { call, emit, relay, stop, sent, rt } = start() + t.after(stop) @@ -1266,7 +1383,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) -@@ -253,32 +253,6 @@ test('in admin mode a plain mid-round message goes to the admin', async (t) => { +@@ -253,32 +253,6 @@ assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer', 'User', 'Chief']) }) @@ -1299,7 +1416,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a message with an image or a chat reference waits for its own round, and so does what follows it', async (t) => { const { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent } = start() t.after(stop) -@@ -300,19 +300,26 @@ test('a message with an image or a chat reference waits for its own round, and s +@@ -300,19 +300,26 @@ assert.equal(inboxOf(room.id).nextStep.length, 1) }) @@ -1333,7 +1450,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js }) test('a stopped round leaves a waiting message for the next round', async (t) => { -@@ -331,6 +331,377 @@ test('a stopped round leaves a waiting message for the next round', async (t) => +@@ -331,6 +331,377 @@ assert.equal(inboxOf(room.id).nextTurn.length, 1) }) @@ -1711,7 +1828,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js 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) -@@ -347,3 +347,1325 @@ test('a message sent between rounds still opens a full round', async (t) => { +@@ -347,3 +347,1325 @@ const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) }) diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 7b164b83e..5f97b8ceb 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -798,7 +798,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 -@@ -31,6 +31,10 @@ const ROOM_EXTRA_TURNS = 4 +@@ -31,6 +31,10 @@ const ROOM_ADMIN_EXTRA_TURNS = 4 const ROOM_TURNS_EACH = 2 const ROOM_ADMIN_TURNS_EACH = 4 @@ -809,7 +809,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,64 @@ const ensureRoomLog = (room) => { +@@ -41,9 +41,64 @@ room.seen ??= {} } @@ -875,7 +875,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } // A member's own lines are already in its Session, so only the others' are news. -@@ -58,6 +58,9 @@ const lineText = (line) => { +@@ -58,6 +58,9 @@ if (line.kind === 'resumed') return `[${line.who}'s work here was cut off when DeepSeek Harness restarted; it is picking it back up and posts when done]` if (line.kind === 'cut') return `[${line.who}'s work here was cut off by a restart again, so it stopped; ask it to go on if it still matters]` if (line.kind === 'stopped') return `[The user stopped ${line.who}'s work here]` @@ -885,7 +885,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') -@@ -86,7 +86,17 @@ export const referenceNote = references => (references?.length +@@ -86,12 +86,33 @@ : '') const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' @@ -896,6 +896,16 @@ diff --git a/src/host/groups.js b/src/host/groups.js +const ROOM_REPLY_NOW = 'Reply now with your message for the group, no name prefix, or [PASS] to stay silent.' +// Words that address the whole room: a plain line with one of them may get an answer from each Bot (#299). +const ROOM_EVERYONE = /大家|你们|各位|所有人|所有 ?[Bb]ot|每个人|每位|各自|分别|everyone|everybody|y'?all|you all|all of you|each of you|introduce yourselves/i ++// A line that only means "stop what you are doing" (#1267): in a group it ends the speaking member's ++// turn instead of being handed to it. It is the one place where a person's line supersedes work in ++// flight, and the words are the ones a person uses to call stop_bot on a Bot. ++const ROOM_STOP = /^(?:停|停下|停止|别做了|不要做了|stop|stop it)[\s。.!!?]*$/i ++// What a member reads when a person's line reaches it mid-turn (#1267): it has to know the line came ++// after its turn started, so it never answers it as if it had been there from the start. ++const ROOM_MID_LINE = 'the user sent this while you were speaking; answer it now' ++// How often a running round looks at its own Session's inbox for that line (#1267): the member's turn ++// cannot be interrupted, so the round watches the one queue it drains at its head. ++const ROOM_MID_LINE_POLL_MS = 100 +const ROOM_REPLY_FORCED = 'This turn is the exception to the [PASS] rule: reply with your message for the group, no name prefix, short. Do not reply [PASS].' const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".' +// Same wake, on a room that has a board: the call does not assign or record progress. @@ -903,7 +913,13 @@ diff --git a/src/host/groups.js b/src/host/groups.js const slotLabel = (slot) => { if (slot.status === 'speaking') return `${slot.name} (you, now)` -@@ -100,162 +100,242 @@ const slotLabel = (slot) => { + if (slot.status === 'posted') return `${slot.name} (posted)` + if (slot.status === 'passed') return `${slot.name} (passed)` ++ if (slot.status === 'superseded') return `${slot.name} (stopped by the user)` + if (slot.status === 'missed') return `${slot.name} (no answer)` + if (slot.status === 'working') return `${slot.name} (working on it; posts when done)` + if (slot.status === 'waiting-approval') return `${slot.name} (waiting for the user's approval; posts when done)` +@@ -100,162 +100,242 @@ return slot.name } @@ -945,12 +961,6 @@ diff --git a/src/host/groups.js b/src/host/groups.js // A member's group Session, created on its first turn there. A new one starts with // a fresh look at the log and a brief of the member's own chat; the brief is the // first message, so it stays fixed and the prefix stays cacheable. -- async function groupSessionFor(room, member) { -- room.sessions ??= {} -- const known = room.sessions[member.id] -- if (known !== undefined) { -- await rt.ensureCapabilities(known) -- return { id: known, brief: undefined } + // The brief is the member's owner's private chat with the Bot. In a room where + // another owner sits it stays out unless that owner shared it with this room (#53). + const briefAllowed = (room, member) => { @@ -962,7 +972,19 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // A granted remote Bot speaks for another home; its handle stands for that owner. + else if (p.remote) owners.add(p.handle) + else owners.add(rt.botOf(p.id)?.ownerId) - } ++ } ++ owners.delete(undefined) ++ if (owners.size <= 1) return true ++ return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) ++ } ++ + async function groupSessionFor(room, member) { +- room.sessions ??= {} +- const known = room.sessions[member.id] +- if (known !== undefined) { +- await rt.ensureCapabilities(known) +- return { id: known, brief: undefined } +- } - const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) }) - room.sessions[member.id] = id - rt.groupOwners.set(id, { botId: member.id, roomId: room.id }) @@ -971,12 +993,6 @@ diff --git a/src/host/groups.js b/src/host/groups.js - if (ref !== undefined) { - await rt.selectSessionModel(id, splitRef(ref)) - .catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error)) -+ owners.delete(undefined) -+ if (owners.size <= 1) return true -+ return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) -+ } -+ -+ async function groupSessionFor(room, member) { + const session = await participant.open({ room, member }) + return { id: session.id, brief: session.created && briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } + } @@ -1252,7 +1268,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (by === undefined) delete called.by else called.by = by round.slots.splice(next, 0, called) -@@ -285,34 +285,138 @@ export function install(rt) { +@@ -285,34 +285,195 @@ // Session's inbox, so it does not open another round when this one ends. Returns // the number of turns added, which the round's limit allows for. function drainRoomInbox(room, round, turns, index, agent) { @@ -1270,8 +1286,13 @@ diff --git a/src/host/groups.js b/src/host/groups.js for (const line of lines) { - appendRoom(room, { who: 'User', text: line.text }) - round.replies.push({ kind: 'user', text: line.text, time: Date.now() }) -+ if (!takePosted(room.id, line.text)) appendRoom(room, rt.authoredRoomLine?.(room, { who: line.authorName || 'User', text: line.text, authorId: line.authorId, authorKind: line.authorKind || 'human' }) ?? { who: 'User', text: line.text }) -+ round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) ++ // A line the round already showed when it arrived (#1267): its stop left it in this queue, so the ++ // head drain opens the fresh line without showing it a second time. ++ const shown = round.midLineIds?.delete(line.id) === true ++ if (!shown) { ++ if (!takePosted(room.id, line.text)) appendRoom(room, rt.authoredRoomLine?.(room, { who: line.authorName || 'User', text: line.text, authorId: line.authorId, authorKind: line.authorKind || 'human' }) ?? { who: 'User', text: line.text }) ++ round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) ++ } const { all, found } = mentionsIn(line.text, members) const note = all || found.length > 0 ? 'the user mentioned you' : 'new message from the user' - callMembers(round, turns, index - 1, openingSpeakers(room, { text: line.text }), note, ROOM_ADMIN_TURNS_EACH) @@ -1298,6 +1319,58 @@ diff --git a/src/host/groups.js b/src/host/groups.js return round.slots.length - before } ++ // One line that reaches the round while a member speaks (#1267). It is logged at once — the room ++ // shows it when it is sent, not at the next slot — and `notePostedLine` keeps the next relay from ++ // logging it a second time. Its pending copy leaves the relay's queue, because this round answers ++ // it: the head drain must not open a second slot for the same line. ++ function speakMidLine(room, round, slot, member, sessionId, exchangeId, line) { ++ if (!takePosted(room.id, line.text)) appendRoom(room, rt.authoredRoomLine?.(room, { who: line.authorName || 'User', text: line.text, authorId: line.authorId, authorKind: line.authorKind || 'human' }) ?? { who: 'User', text: line.text }) ++ rt.notePostedLine?.(room.id, line.text) ++ const queued = rt.roomInbox?.get(room.id) ++ const at = queued?.findIndex(item => item.botId === undefined && item.text === line.text) ?? -1 ++ if (at !== -1) rt.roomInbox.set(room.id, queued.toSpliced(at, 1)) ++ round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) ++ // The user spoke in this room, so late replies may be answered again. ++ room.lateRounds = 0 ++ void rt.save().catch(() => {}) ++ // The one line that ends work in flight (#1267 B): the slot is superseded, its turn is cancelled, ++ // and nothing it says when it stops is posted. The line stays in the Session's queue, so the head ++ // drain opens it as a fresh line (admin first) for the room to answer. ++ if (ROOM_STOP.test(String(line.text).trim())) { ++ round.midLineIds ??= new Set() ++ round.midLineIds.add(line.id) ++ slot.status = 'superseded' ++ round.replies.push({ kind: 'stopped', botId: member.id, name: member.name, color: member.color, time: Date.now() }) ++ void participant.cancel({ sessionId, exchangeId }).catch(error => rt.warn('ds-bot: stopping %s in %s failed: %s', member.name, room.name, error)) ++ return ++ } ++ // Every other line is the default shape (#1267 A): the member that is speaking gets it at its next ++ // step, marked as a line that arrived after its turn started. ++ void rt.deliver({ fromId: undefined, toId: member.id, sessionId, role: 'room', roomId: room.id, text: line.text, steer: true, note: ROOM_MID_LINE }) ++ .catch(error => rt.warn('ds-bot: a line sent while %s was speaking did not reach it: %s', member.name, error)) ++ } ++ ++ // While a member's turn runs, the round watches the queue it drains at its head for the user's lines ++ // (#1267): a member turn is not interrupted from outside, so the wait is watched, and what arrives is ++ // handed over by speakMidLine. The queue is left alone for a stop line, whose slot it supersedes. ++ function watchSpeakerLines(room, round, slot, member, sessionId, exchangeId, agent) { ++ let stopped = false ++ const take = () => { ++ if (stopped) return ++ for (const line of waitingLines(agent)) { ++ if (ROOM_STOP.test(String(line.text).trim())) { ++ stopped = true ++ speakMidLine(room, round, slot, member, sessionId, exchangeId, line) ++ return ++ } ++ if (agent.inbox.remove(line.id)) speakMidLine(room, round, slot, member, sessionId, exchangeId, line) ++ } ++ } ++ const timer = setInterval(take, ROOM_MID_LINE_POLL_MS) ++ timer.unref?.() ++ return () => { stopped = true; clearInterval(timer) } ++ } ++ + // The routing of a person's plain line in a face-to-face room (not @all): who decides, whether it is to everyone, + // who spoke last, and whether the user must be answered if every Bot passes (the owner's own line, not a friend's). + function routeFor(room, opener, startSeq) { @@ -1399,7 +1472,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (index >= round.slots.length) break const slot = round.slots[index] const member = rt.botOf(slot.id) -@@ -321,6 +321,20 @@ export function install(rt) { +@@ -321,6 +321,20 @@ slot.status = 'left' continue } @@ -1420,7 +1493,7 @@ 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,13 @@ export function install(rt) { +@@ -339,8 +339,23 @@ const prompt = roomPrompt(room, member, round) round.shown?.push(member.id) const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}` @@ -1430,12 +1503,22 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // 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 }) ++ // A line the user sends while this member speaks reaches it at its next step (#1267). ++ const stopWatching = watchSpeakerLines(room, round, slot, member, session.id, exchangeId, agent) ++ let result ++ try { ++ 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 }) ++ } finally { ++ stopWatching() ++ } + const reply = result === null || typeof result === 'object' ? result : stripOwnName(member.name, String(result ?? '').trim()) round.speaking = null ++ // A stop line ended this turn (#1267): what it had in flight is not this room's answer. ++ if (slot.status === 'superseded') continue if (reply?.failure !== undefined) { // Not a pass: the user sees why the member is silent, and the round goes on. -@@ -366,9 +366,18 @@ export function install(rt) { + slot.status = 'failed' +@@ -366,9 +366,18 @@ if (reply?.missed) round.replies.push({ kind: 'missed', botId: member.id, name: member.name, color: member.color, time: Date.now() }) continue } @@ -1456,7 +1539,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js 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,11 @@ export function install(rt) { +@@ -377,7 +377,11 @@ const { all, found: named } = mentionsIn(reply, everyone) const found = named.filter(other => other.id !== member.id) const callees = leads && all ? [...found, ...others.filter(other => !found.includes(other))] : found @@ -1469,7 +1552,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } } -@@ -397,38 +397,45 @@ export function install(rt) { +@@ -397,38 +397,45 @@ rt.roomInbox.delete(room.id) const posts = queued.length > 0 ? queued : [{ text: '' }] const startSeq = room.seq + 1 @@ -1535,7 +1618,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js speaking: null, turns: 0, replies: [], -@@ -436,6 +436,7 @@ export function install(rt) { +@@ -436,6 +436,7 @@ failed: [], started: [], } @@ -1543,7 +1626,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js for (const post of posts.filter(item => item.failed)) { const bot = rt.botOf(post.botId) const { code, status, message } = post.failed -@@ -450,17 +450,25 @@ export function install(rt) { +@@ -450,17 +450,25 @@ appendRoom(room, { who: name, botId: post.botId, kind, text: '' }) round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() }) } @@ -1570,7 +1653,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } await rt.save() for (const post of posts) { -@@ -471,6 +471,41 @@ export function install(rt) { +@@ -471,6 +471,41 @@ }, })) @@ -1612,7 +1695,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Private context enters a group only through this tool, and the group sees each use. ctx.tools.register(defineTool({ name: 'read_own_chat', -@@ -539,7 +539,9 @@ export function install(rt) { +@@ -539,7 +539,9 @@ const { room, error } = rt.pickRoom(args.group, self) if (error) return error ensureRoomLog(room) @@ -1623,7 +1706,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}` }, })) -@@ -548,46 +548,207 @@ export function install(rt) { +@@ -548,46 +548,207 @@ // the admin answers alone in admin mode, or first of everyone. What a member posts // after its round moved on is answered by the member that asked for it; work the // user asked for is left for the user to read. @@ -1836,7 +1919,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: late group reply from %s failed: %s', member.name, error)) } rt.lateRoomReply = lateRoomReply -@@ -599,9 +599,8 @@ export function install(rt) { +@@ -599,9 +599,8 @@ const room = rt.roomOf(roomId) const member = rt.botOf(botId) const sessionId = room?.sessions?.[botId] @@ -1848,7 +1931,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js await postToRoom(room, member, '', { quiet: true, stopped: true }) return true } -@@ -615,37 +615,42 @@ export function install(rt) { +@@ -615,37 +615,42 @@ const member = rt.botOf(owner?.botId) if (room === undefined || member === undefined || !room.members.includes(member.id)) return const { code, status, message } = failure @@ -1907,7 +1990,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: group report to %s failed: %s', post.name, error)) } -@@ -665,7 +665,7 @@ export function install(rt) { +@@ -665,7 +665,7 @@ // message from the user, a scheduled task, or a Bot's post after the last model or // tool step starts a relay. const opens = message => message.role === 'user' @@ -1916,6 +1999,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const latest = [...options.messages].reverse() .find(message => message.role === 'assistant' || message.role === 'tool' || opens(message)) if (latest?.role !== 'user') { + diff --git a/test/at-menu.test.js b/test/at-menu.test.js --- a/test/at-menu.test.js +++ b/test/at-menu.test.js