diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e1de3ca..18606bc8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to FlyBot (earlier: DS Bot) are listed here. The format foll - **Friends' Bots pending approvals:** outbound drafts Bot Social holds (`pending_approval`) show a persistent badge and Approve / Reject in Friends' Bots. Inbound messages still arrive immediately; Auto-accept still skips the hold. (#69) +### Fixed + +- A chat with a friend's Bot that Bot Social has paused (turn budget reached, loop suspicion, emergency stop) now says so and points at the Bot Social console, instead of failing with a bare "Not sent: …" card. A paused conversation is no longer retried with the friendship's other Bots. ([#72](https://github.com/ThinkFlowLab/FlyBot/issues/72)) + ## [0.1.0] - 2026-10-14 First public FlyBot-branded cut (core package remains `0.1.5-flybot.1`). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 42ae7ebe0..9065715c8 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -36,6 +36,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 1. Social code stays inside `plugins/social/`. 2. Uses documented plugin APIs only. +3. A server policy pause (`conversation_paused`: turn budget reached, loop suspected, emergency stop) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to an error that says resending cannot help and names the Bot Social console, and no other member Bot is tried for it. ## Tests diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 743b5fdaf..c950fac53 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -352,9 +352,20 @@ export function createSocial(service, config = {}, { client: given, home: givenH })) // ------------------------------------------------------------ transport + // Bot Social pauses conversations by policy (turn budget reached, loop suspected, + // emergency stop) and rejects sends with `conversation_paused`. The pause belongs to + // the conversation, not to the member that hit it: resending or switching members + // cannot help, so both send paths turn the error into one that says what does. + const isPaused = error => error?.code === 'conversation_paused' || /conversation_paused|turn budget exhausted|loop detected|conversation paused/i.test(String(error?.message ?? error)) + const mapSendError = (error) => { + if (!isPaused(error)) return error + const reason = String(error?.message ?? error).replace(/^Bot Social\s+\S+:\s*/, '') + const at = config.server ? ` at ${String(config.server).replace(/\/+$/, '')}/ui` : '' + return new Error(`Bot Social paused this conversation (${reason}). Resending cannot help: approve pending messages or extend the conversation in the Bot Social console${at}, or remove and re-add this friend to start a new one.`) + } disposers.push(flybot.transports.add('bot-social', { async send(msg) { - try { return await sendOut(msg) } finally { dropStaged(msg.attachments) } + try { return await sendOut(msg) } catch (error) { throw mapSendError(error) } finally { dropStaged(msg.attachments) } }, })) async function sendOut(msg) { @@ -415,6 +426,8 @@ export function createSocial(service, config = {}, { client: given, home: givenH } return '' } catch (error) { + // A pause belongs to the conversation, not to this member: another Bot cannot send either. + if (isPaused(error)) throw mapSendError(error) lastError = error if (!/no_relationship|no active relationship/i.test(String(error?.message ?? error))) throw error } diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 16109cc38..16a97dfc2 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -843,3 +843,29 @@ test('Friends\' Bots (#69): no cue when status reports no pending approvals', () assert.equal(all.some(n => n.props['data-flybot'] === 'pending-badge' || n.props['data-flybot'] === 'pending-approvals'), false) assert.equal(plugin.PendingApprovals({ items: [] }), null) }) + +test('a paused conversation explains the pause and names the console, without retrying other members', async (t) => { + const { ctx, stop } = await team() + t.after(stop) + const server = mockServer() + // Like the server's owner MCP: a failed tool call carries the platform error's code and message. + const base = server.fetch + server.fetch = async (url, init) => { + const body = JSON.parse(init.body) + if (body.params?.name === 'send_as_member') { + server.calls.push({ url, name: body.params.name, args: body.params.arguments }) + return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { isError: true, content: [{ type: 'text', text: JSON.stringify({ code: 'conversation_paused', message: 'turn budget exhausted; owners can extend the conversation' }) }] } }), { status: 200 }) + } + return base(url, init) + } + const social = createSocial(ctx.flybot, { server: 'https://social.test', inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) + t.after(() => social.dispose()) + await social.registerAll() + // The user's direct chat: the error says the conversation is paused and what does help, + // and the pause is not retried with the friendship's other member Bots. + await assert.rejects(social.sendChat({ handle: '@coder.bob.home', text: 'hello there' }), + /Bot Social paused this conversation \(turn budget exhausted.*https:\/\/social\.test\/ui/) + assert.equal(server.calls.filter(c => c.name === 'send_as_member').length, 1) + // The same for a Bot's send through the transport (message_friend_bot). + await assert.rejects(ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'hello from chief' }), /paused this conversation/) +})