From 47882421f4b5e5c4be25d001c6b8fe0d0112b8a8 Mon Sep 17 00:00:00 2001 From: hsliu_ustc Date: Thu, 15 Oct 2026 15:29:49 +0800 Subject: [PATCH] social: map Bot Social conversation pauses to an actionable error A Bot Social server pauses conversations by policy and rejects sends with 409 conversation_paused (turn budget reached, loop suspected, emergency stop). FlyBot surfaced that only as a bare "Not sent: Bot Social send_as_member: ..." card with no way forward, and the user-facing pause reason never explained that resending cannot help (#72). Both send paths (the bot-social transport and the remote-chat sender) now map the pause to an error that names the server's reason and the Bot Social console, and the remote-chat sender no longer tries the friendship's other member Bots for a paused conversation: the pause belongs to the pair, not to the member that hit it. The test fails on the previous tip with the raw send error and passes with the mapped one. --- CHANGELOG.md | 4 ++++ docs/subsystems/social.md | 1 + plugins/social/src/social.js | 15 ++++++++++++++- plugins/social/test/social.test.js | 26 ++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) 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/) +})