Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/954-bot-gone-no-retry.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A message to a friend's Bot that no longer exists now reads one sentence — the Bot is no longer shared with you, so ask its owner for a new invite — instead of repeating "no such remote bot. Try again. Try again.", and the failed hold it leaves offers no **Retry**, because sending again cannot succeed ([#954](https://github.com/ThinkFlowLab/FlyBot/issues/954)).
5 changes: 4 additions & 1 deletion core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,9 @@ export function install(rt) {
} catch (error) {
approval.status = 'failed'
approval.error = error?.message ?? String(error)
// A sender that knows its failure can never succeed says so (#954): the card then offers no Retry, which
// would only repeat the same refusal.
if (error?.noRetry === true) approval.noRetry = true
}
}

Expand Down Expand Up @@ -503,7 +506,7 @@ export function install(rt) {
turn: a.request.meta.replyIn.turn,
to: toRemote(a.request.to)?.handle ?? null,
...(a.recipientName ? { toOwner: a.recipientName } : {}),
...(a.status === 'failed' && a.error ? { error: String(a.error).slice(0, 200) } : {}),
...(a.status === 'failed' && a.error ? { error: String(a.error).slice(0, 200), ...(a.noRetry === true ? { retry: false } : {}) } : {}),
}))
rt.heldRepliesOf = (botId) => [...autoSentOf(botId), ...heldOf(botId)]

Expand Down
10 changes: 9 additions & 1 deletion core/src/host/remote-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export function notSentLine(error) {
if (error?.resend === true) return 'Not sent: Bot Social paused that chat. Send your message again: it starts a new chat.'
return 'Not sent: this chat is paused. Approve any message waiting here, or remove and re-add this friend to start a new chat.'
}
// #954: the friend's Bot itself is gone (Bot Social 404 bot_not_found). One sentence that names the Bot and the
// next step; the social plugin already writes that sentence, so it is reused whole instead of translated again.
if (/bot_not_found|no such remote bot/i.test(blob)) {
if (/no longer shared with you/i.test(raw)) return `Not sent: ${body.replace(/^Not sent:\s*/i, "")}`
const who = typeof error?.handle === "string" && error.handle ? error.handle : "that Bot"
return `Not sent: ${who} is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.`
}
if (/has no grant|has not been granted|cannot message friends|cannot message their Bots/i.test(blob)) {
return 'Not sent: none of your Bots is invited to this friend. Invite them from Bot settings.'
}
Expand All @@ -57,7 +64,8 @@ export function notSentLine(error) {
if (/timeout|no answer/i.test(blob)) {
return 'Not sent: Bot Social did not answer. Try again.'
}
const short = (body || raw).slice(0, 80).replace(/\.$/, '')
// One "Try again." only: an earlier layer may already have said it (#954), and two reads as a stutter.
const short = (body || raw).slice(0, 80).replace(/\.$/, '').replace(/\.?\s*Try again\.?$/i, '').replace(/\.$/, '')
if (!short || /^[a-z0-9_]+$/i.test(short)) {
return 'Not sent: the message could not leave. Try again.'
}
Expand Down
35 changes: 35 additions & 0 deletions core/test/host-approval-status.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,38 @@ test('#754: host and client import the same judgment, and neither keeps a litera
assert.match(files['channel.js'], /isApprovalWaiting\(reply\.status\)/, 'the wait badge asks the rule')
assert.match(files['cells.js'], /isApprovalWaiting\(held\.status\)/, 'the chat bubble asks the rule')
})

// #954: a sender that knows its refusal can never succeed says so, and the card it failed on must not
// offer Retry — the button would only repeat the same refusal. A failure that may pass later keeps it.
test('#954: a final failure reaches the chat as retry: false, an ordinary one keeps Retry', async (t) => {
const { fb, call, stop } = await start()
t.after(stop)
holdFlagged(fb)
// The team is read before a grant can be written, and a remote send needs one.
await call('state', {})
await fb.grants.set('a', [{ grantee: '@bob.bot' }])
let error
fb.transports.add('bot-social', { send: async () => { throw error } })
const holdToBob = () => heldId(fb, {
from: 'a', to: { kind: 'remote', handle: '@bob.bot' }, text: 'are you still there?',
via: 'auto-reply', meta: { hold: true, replyIn: { sessionId: 's1', turn: 4 } },
})
const view = async id => (await call('state', {})).value.bots.find(bot => bot.id === 'a').heldReplies.find(entry => entry.id === id)

error = Object.assign(new Error('@bob.bot is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.'), { code: 'bot_not_found', handle: '@bob.bot', noRetry: true })
const id = await holdToBob()
const approved = await call('flybot/decide', { id, decision: 'approve' })
assert.equal(approved.ok, true, JSON.stringify(approved))
assert.equal(approved.value.status, 'failed', JSON.stringify(approved))
const failed = await view(id)
assert.equal(failed.retry, false, 'the sender said this can never succeed, so no Retry is offered')
assert.match(failed.error, /no longer shared with you/)

// The negative half: a failure that may pass later keeps the Retry it always had.
error = new Error('Bot Social did not answer')
const retryId = await holdToBob()
assert.equal((await call('flybot/decide', { id: retryId, decision: 'approve' })).value.status, 'failed')
const retryable = await view(retryId)
assert.equal(retryable.error, 'Bot Social did not answer')
assert.notEqual(retryable.retry, false, 'a retryable failure still offers Retry')
})
9 changes: 9 additions & 0 deletions core/test/remote-chat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ test('a failed 1:1 send is one short English line with a next step', () => {
// The plugin's pause says to send again: the next send opens a fresh chat; nothing names Extend or turns.
assert.equal(notSentLine(Object.assign(new Error('Bot Social paused that chat (loop detected). Send your message again: it starts a new chat.'), { code: 'conversation_paused', resend: true })), 'Not sent: Bot Social paused that chat. Send your message again: it starts a new chat.')
assert.doesNotMatch(notSentLine(Object.assign(new Error('paused'), { resend: true })), /Extend|turns/)
// #954: a Bot that no longer exists says one sentence with the next step, names the Bot when the sender
// knows which one it is, and never repeats "Try again." (the plugin's sentence already ended the line).
const gone = notSentLine(Object.assign(new Error('Bot Social send_as_member: no such remote bot'), { code: 'bot_not_found', handle: '@coder.bob.home' }))
assert.equal(gone, 'Not sent: @coder.bob.home is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.')
assert.doesNotMatch(gone, /no such remote bot|Try again/)
const relayed = notSentLine(Object.assign(new Error('Coder (Bob) (@coder.bob.home) is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.'), { code: 'bot_not_found', handle: '@coder.bob.home' }))
assert.equal(relayed, 'Not sent: Coder (Bob) (@coder.bob.home) is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.')
// One "Try again." only, whatever the layer below already wrote (#954).
assert.equal(notSentLine(new Error('Bot Social send_as_member: gateway hiccup. Try again.')), 'Not sent: gateway hiccup. Try again.')
assert.equal(notSentLine(new Error('Bot Social send_as_member: weird_code')), 'Not sent: the message could not leave. Try again.')
assert.doesNotMatch(notSentLine(new Error('something odd happened')), /console/)
});
Expand Down
4 changes: 2 additions & 2 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,13 +264,13 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi

### Remote chats and mentions

- Channel `view()` puts `pendingCount` on a friend's chat (`bot.remote`) from `rt.listChatApprovals`, the same gold wait badge rooms already use. A local team Bot gets the count of its own held sends when the provider names them (`fromId`, #371), plus its own waiting held replies to friends (#418); otherwise 0. Those replies are also listed on the Bot as `heldReplies` (`rt.heldRepliesOf`, hooks.js: id, policy, status pending / approved / rejected / failed, or `auto` for a reply a middleware let go on the owner's Auto-send (#417, `state.flybot.autoSent`, last 200), the chat `sessionId` and `turn` the reply answers, the friend's handle and `toOwner`; no text), for the Bot's owner only under enforcement. `turns.js` passes the answered turn to `flybotReplyRemote` (`replyIn`), which puts it on the auto-reply's `meta.replyIn`; a hold record superseded by a later hold (`next`) is left out. A middleware's `hold` verdict may name its `recipient` (a person's name, at most 120 characters); the record keeps it as `recipientName` for the card. The social plugin supplies the list via `flybot.chatApprovals.setProvider` ([plugin-runtime](plugin-runtime.md)). Still not team mode.
- Channel `view()` puts `pendingCount` on a friend's chat (`bot.remote`) from `rt.listChatApprovals`, the same gold wait badge rooms already use. A local team Bot gets the count of its own held sends when the provider names them (`fromId`, #371), plus its own waiting held replies to friends (#418); otherwise 0. Those replies are also listed on the Bot as `heldReplies` (`rt.heldRepliesOf`, hooks.js: id, policy, status pending / approved / rejected / failed, or `auto` for a reply a middleware let go on the owner's Auto-send (#417, `state.flybot.autoSent`, last 200), the chat `sessionId` and `turn` the reply answers, the friend's handle and `toOwner`; no text), for the Bot's owner only under enforcement. `turns.js` passes the answered turn to `flybotReplyRemote` (`replyIn`), which puts it on the auto-reply's `meta.replyIn`; a hold record superseded by a later hold (`next`) is left out. A middleware's `hold` verdict may name its `recipient` (a person's name, at most 120 characters); the record keeps it as `recipientName` for the card. A failed dispatch keeps its error, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend's Bot no longer shared — is passed on as `retry: false` so the chat draws no Retry (#954); every other failure keeps the Retry it always had. The social plugin supplies the list via `flybot.chatApprovals.setProvider` ([plugin-runtime](plugin-runtime.md)). Still not team mode.
- Channel `live()` also sends `pluginTools`: the names of plugin tools a per-Bot mask may take away (`rt.pluginToolNames`, from `hidePluginTools`' gated set), so the client hides their `Tool added` / `Tool removed` rows (#419). A plugin reload changes them without a new revision, so they ride every `state` answer, an `unchanged` one too.
- Channel `state` notes when a window on screen last read the team (#497): the client sends `watching: !document.hidden` with every poll (a visible window asks every 1.5 s, a hidden one every 10 s), and `watching: false` does not count; an older client that sends nothing counts as on screen. `rt.watched()` is true for 8 s after such a read and reaches plugins as `flybot.watched()` ([plugin-runtime](plugin-runtime.md)). Process-local, never saved. Covered by `core/test/flybot.test.js` #497, which also locks in that a friend's first line and its held reply move the revision, so a window that keeps polling reads `friendLineAt`, `pendingCount` and `heldReplies` with no reload.
- A face-to-face room's presented Bot participants carry `label` = `remoteBotTitle`, `<Bot> (<owner>)`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older `<Bot> · <owner>'s Bot` / `<owner>'s Chief` labels.
- `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66).
- `remoteChats.sync(plugin, list)` (`src/host/remote-chat.js`) mirrors a chat whose Bot left the published list (`gone`) into `bot.hidden`, so it leaves the sidebar without deleting its history, and drops that hide when the Bot is published again. It remembers which chats were `gone` before `open()` clears the marker, and only a real change of `gone` touches `hidden`: a chat the user hid himself (`roster.setFlags`, "Hide from sidebar") while its Bot is still published stays hidden instead of returning on the next publish. The marker `bot.remote.hiddenByGone` records a hide the sync itself imposed (and only when the chat was not already hidden), so returning drops that hide alone and a hide the user set survives the Bot leaving the list and coming back (#631).
- `notSentLine` (`src/host/remote-chat.js`) is the one short line for a failed send to a friend's Bot. It names only steps a FlyBot owner can take (#404): a paused chat says to approve a waiting message or remove and re-add the friend (or, when the plugin's error says `resend`, to send the message again, which starts a new chat; there is no Extend and no turn limit), and the fallbacks end in *Try again.*; it never points to the Bot Social console, which a FlyBot owner cannot sign in to.
- `notSentLine` (`src/host/remote-chat.js`) is the one short line for a failed send to a friend's Bot. It names only steps a FlyBot owner can take (#404): a paused chat says to approve a waiting message or remove and re-add the friend (or, when the plugin's error says `resend`, to send the message again, which starts a new chat; there is no Extend and no turn limit), and the fallbacks end in *Try again.*; it never points to the Bot Social console, which a FlyBot owner cannot sign in to. A friend's Bot Bot Social no longer knows (`bot_not_found` / "no such remote bot") gets its own sentence — *the Bot is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.* — which the social plugin also writes and the host reuses whole instead of translating again, and no line carries *Try again.* twice (#954).
- A friend chat waits on its reply (#443): a successful `forward` sets `state.remoteAwaiting[chatId]` (saved before the send turn ends), the friend's reply clears it as it arrives (`deliver`, before the reply is written into the chat) and again in the echo turn. A failed send never sets it. `channel.js` exposes it as `roster.remoteAwaiting`, and `authz.js` narrows it per owner like `remoteTypedTurns`.
- Remote-chat `open` copies optional published `color` / `avatar` / `avatarUpdatedAt` onto the chat record (cached by `avatarUpdatedAt`) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137).
- `0002-flybot-api-host` `cleanRemote` sanitizes those peer-controlled `name` / `homeName` / `owner` labels (strip `@` and controls/newlines, 60-char cap) before they are stored. Details in [plugin-runtime](plugin-runtime.md) (#75).
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/plugin-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots

| File | Role |
| --- | --- |
| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). A hold's waiting status is the shared rule `src/shared/approval-waiting.js` `isApprovalWaiting()`: `decide()`, `heldOf()` (the chat card), `PENDING_MAX`, the in-flight filter and the `APPROVALS_KEPT` cleanup all ask it instead of comparing to `pending`, so a `pending_approval` hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). |
| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). A hold's waiting status is the shared rule `src/shared/approval-waiting.js` `isApprovalWaiting()`: `decide()`, `heldOf()` (the chat card), `PENDING_MAX`, the in-flight filter and the `APPROVALS_KEPT` cleanup all ask it instead of comparing to `pending`, so a `pending_approval` hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). A dispatch that fails keeps its error on the record, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend’s Bot no longer shared — reaches the chat view as `retry: false` so the card draws no Retry (#954); every other failure stays retryable. |
| `core/src/host/flybot-feed.js` | Activity feed: folds a Bot Session's `session/event`s into `flybot.on('turn.start' \| 'turn.end' \| 'tool.start' \| 'tool.end' \| 'exchange.delivered' \| 'decision')` (#241). `turn.end` also carries the turn's token counts (summed from its `assistant/message` usage, floored and capped, counts only) under `tokens` (#357). `decision` is fired by `decisions.js` (#396), not folded from a Session event. |
| `core/src/client/flybot-slots.js` | Client slots, including the approval card queue (`flybot.approvals`, `flybot/decide`) and `flybot.sidebar.structure` (`parentOf`, optional `include`, `menuItems`, `nodeActions`). Every `flybot.sidebar.section` entry is wrapped in `div.bt-flybot-section`, and a wrapper a section leaves empty (its component renders null) draws no separator: the `:empty` rule zeroes that margin, border and padding, keyed on emptiness rather than on a section id, so the next null-rendering section is covered too (#818; `core/test/flybot-section-empty.test.js` renders the host entry and pins both rules). Its Approve / Reject and the held-reply card both ask `approval-waiting.js` `isApprovalWaiting()` (`pending` and `pending_approval` wait), so a hold carrying the spelling Bot Social's own outbound hold uses stays answerable (#734). |
| `core/src/client/flybot-registry.js` | Registry. |
Expand Down
Loading
Loading