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/417-auto-sent-marker.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot's reply that went out on its own because Auto-send is on for that pair now says **Auto-sent to …** under its bubble, so you can see in the chat that it left without your approval (part of [#417](https://github.com/ThinkFlowLab/FlyBot/issues/417)).
5 changes: 3 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ var ZH = {
"Waiting for approval": "等待批准",
"Not sent": "未发出",
"Sent to {to}": "已发给 {to}",
"Auto-sent to {to}": "已自动发给 {to}",
"Not sent: delivery failed": "未发出:发送失败",
"{from} → {to}": "{from} → {to}",
"+{count} more": "另有 {count} 条",
Expand Down Expand Up @@ -2568,7 +2569,7 @@ function heldReplyOf(bot, sessionId, turn) {
for (const reply of list) if (reply.sessionId === sessionId && String(reply.turn) === String(turn)) found = reply;
return found;
}
var unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []).filter((reply) => reply.sessionId === sessionId && reply.status !== "approved").map((reply) => reply.turn);
var unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []).filter((reply) => reply.sessionId === sessionId && reply.status !== "approved" && reply.status !== "auto").map((reply) => reply.turn);
function previewOf(list, id, hiddenTurns = [], unsentTurns = []) {
const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline;
if (!Array.isArray(outline)) return "";
Expand Down Expand Up @@ -4994,7 +4995,7 @@ function HeldReplyActions({ held, actions }) {
return (0, import_react7.createElement)(
"div",
{ className: "bt-held-note", "data-state": held.status, title: failed && held.error ? held.error : void 0 },
(0, import_react7.createElement)("span", null, held.status === "approved" ? t("Sent to {to}", { to: held.toName }) : failed ? t("Not sent: delivery failed") : t("Not sent")),
(0, import_react7.createElement)("span", null, held.status === "approved" ? t("Sent to {to}", { to: held.toName }) : held.status === "auto" ? t("Auto-sent to {to}", { to: held.toName }) : failed ? t("Not sent: delivery failed") : t("Not sent")),
failed ? (0, import_react7.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null,
error ? (0, import_react7.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : null
);
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/held-reply.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function HeldReplyActions({ held, actions }) {
if (!waiting) {
const failed = held.status === 'failed'
return h('div', { className: 'bt-held-note', 'data-state': held.status, title: failed && held.error ? held.error : undefined },
h('span', null, held.status === 'approved' ? t('Sent to {to}', { to: held.toName }) : failed ? t('Not sent: delivery failed') : t('Not sent')),
h('span', null, held.status === 'approved' ? t('Sent to {to}', { to: held.toName }) : held.status === 'auto' ? t('Auto-sent to {to}', { to: held.toName }) : failed ? t('Not sent: delivery failed') : t('Not sent')),
failed ? h('button', { type: 'button', className: 'bt-held-retry', disabled: busy, onClick: () => decide('retry') }, t('Retry')) : null,
error ? h('span', { className: 'bt-flybot-approval-error', role: 'alert' }, error) : null)
}
Expand Down
1 change: 1 addition & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const ZH = {
'Waiting for approval': '等待批准',
'Not sent': '未发出',
'Sent to {to}': '已发给 {to}',
'Auto-sent to {to}': '已自动发给 {to}',
'Not sent: delivery failed': '未发出:发送失败',
'{from} → {to}': '{from} → {to}',
'+{count} more': '另有 {count} 条',
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ export function heldReplyOf(bot, sessionId, turn) {
return found
}
export const unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : [])
.filter(reply => reply.sessionId === sessionId && reply.status !== 'approved').map(reply => reply.turn)
.filter(reply => reply.sessionId === sessionId && reply.status !== 'approved' && reply.status !== 'auto').map(reply => reply.turn)

// `unsentTurns`: turns whose reply is held or was not sent; the preview shows their prompt instead.
export function previewOf(list, id, hiddenTurns = [], unsentTurns = []) {
Expand Down
36 changes: 33 additions & 3 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export function install(rt) {
// just after it, so later policies (audit, leak prevention) still run.
const evaluate = async (msg, { after } = {}) => {
const skip = new Set()
let autoSent
let resumed = after === undefined || !chain.some(m => m.name === after)
for (const middleware of chain) {
if (!resumed) { if (middleware.name === after) resumed = true; continue }
Expand All @@ -280,12 +281,18 @@ export function install(rt) {
for (const name of verdict.bypass ?? []) {
if (granted.includes(name) || granted.includes('*')) skip.add(name)
}
// A policy that would have held this but the owner's Auto-send lets it go says so (#417):
// the chat marks the reply Auto-sent. Display only, never authority.
if (verdict.autoSent === true) {
const recipient = typeof verdict.recipient === 'string' ? verdict.recipient.trim().slice(0, 120) : ''
autoSent = { by: middleware.name, ...(recipient ? { recipient } : {}) }
}
continue
}
const recipient = verdict.action === 'hold' && typeof verdict.recipient === 'string' ? verdict.recipient.trim().slice(0, 120) : ''
return { action: verdict.action, reason: verdict.reason || `${verdict.action} by ${middleware.name}`, by: middleware.name, ...(recipient ? { recipient } : {}) }
}
return { action: 'allow' }
return { action: 'allow', ...(autoSent ? { autoSent } : {}) }
}

const transports = new Map()
Expand Down Expand Up @@ -454,7 +461,27 @@ export function install(rt) {
// A local Bot's replies to friends that were held, and what became of them (#418): the chat draws
// each on the turn it answers (pending, sent, not sent). No text: the turn shows it already.
const SENT = new Set(['approved'])
rt.heldRepliesOf = (botId) => Object.values(store())
// A Bot's reply to a friend that left through the owner's Auto-send (#417): kept by the turn it
// answers so the chat marks it Auto-sent where #418 would have held it. No text.
const AUTO_SENT_KEPT = 200
const autoSentList = () => {
state.flybot ??= {}
state.flybot.autoSent ??= []
return state.flybot.autoSent
}
const noteAutoSent = async (msg, { by, recipient }) => {
const replyIn = msg.meta?.replyIn
if (msg.via !== 'auto-reply' || typeof msg.from !== 'string' || typeof replyIn?.sessionId !== 'string' || replyIn.turn === undefined) return
const list = autoSentList()
list.push({ id: msg.exchangeId, by, botId: msg.from, sessionId: replyIn.sessionId, turn: replyIn.turn, to: toRemote(msg.to)?.handle ?? null, ...(recipient ? { recipientName: recipient } : {}), at: Date.now() })
if (list.length > AUTO_SENT_KEPT) list.splice(0, list.length - AUTO_SENT_KEPT)
try { await rt.save() } catch (error) { warn('flybot: auto-sent mark not saved: %s', error) }
}
const autoSentOf = botId => (state.flybot?.autoSent ?? []).filter(entry => entry.botId === botId).map(entry => ({
id: entry.id, by: entry.by, status: 'auto', sessionId: entry.sessionId, turn: entry.turn, to: entry.to,
...(entry.recipientName ? { toOwner: entry.recipientName } : {}),
}))
const heldOf = (botId) => Object.values(store())
// A record held again by a later rule (`next`) is replaced by that newer hold for the same turn.
.filter(a => a.request?.from === botId && a.request?.via === 'auto-reply' && a.request?.meta?.replyIn && a.next === undefined)
.sort((a, b) => a.createdAt - b.createdAt)
Expand All @@ -468,6 +495,7 @@ export function install(rt) {
...(a.recipientName ? { toOwner: a.recipientName } : {}),
...(a.status === 'failed' && a.error ? { error: String(a.error).slice(0, 200) } : {}),
}))
rt.heldRepliesOf = (botId) => [...autoSentOf(botId), ...heldOf(botId)]

const approvals = {
// Under member enforcement a member sees only the holds of their own Bots; the
Expand Down Expand Up @@ -553,7 +581,9 @@ export function install(rt) {
}
return { action: 'hold', approvalId: approval.id, reason: verdict.reason, by: verdict.by }
}
return { action: 'allow', exchangeId: await dispatch(msg) }
const exchangeId = await dispatch(msg)
if (verdict.autoSent) await noteAutoSent(msg, verdict.autoSent)
return { action: 'allow', exchangeId }
}

// File metadata a send carries (names and sizes for the approval card; a path or a
Expand Down
7 changes: 7 additions & 0 deletions core/test/held-reply.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ test('after the decision the reply says Sent or Not sent in place, and they diff
assert.ok(strings(rejected).includes('Not sent'))
assert.deepEqual(find(failed, n => n.type === 'button').map(text), ['Retry'])
assert.equal(find(sent, n => n.type === 'button').length + find(rejected, n => n.type === 'button').length, 0)
// #417: a reply that left through Auto-send looks sent and says so, quietly.
const auto = wrapper(draw(chief([held('auto')])))
assert.equal(auto.props['data-state'], 'auto')
assert.equal(auto.props['data-held'], undefined)
assert.ok(strings(auto).includes('Auto-sent to SelfB1238'))
assert.equal(find(auto, n => n.type === 'button').length, 0)
})

test('a friend\'s Bot with a listed chat is named as the chat list names it; no held reply, no wrapper', () => {
Expand All @@ -112,6 +118,7 @@ test('the list preview shows the friend\'s line, not a reply that was not sent;
assert.deepEqual(unsentReplyTurns(bot, 'chief'), [3])
assert.equal(previewOf(list, 'chief', [], unsentReplyTurns(bot, 'chief')), 'What is 2+2?')
assert.equal(previewOf(list, 'chief', [], unsentReplyTurns({ heldReplies: [held('approved')] }, 'chief')), '4.')
assert.deepEqual(unsentReplyTurns({ heldReplies: [held('auto')] }, 'chief'), [], 'an auto-sent reply went (#417)')
const turns = readFileSync(new URL('../src/host/turns.js', import.meta.url), 'utf8')
assert.match(turns, /replyIn: \{ sessionId: id, turn: answeredTurn \}/)
})
3 changes: 2 additions & 1 deletion core/types/flybot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,8 @@ export interface SendRequest {
* The first 'deny' or 'hold' ends the chain. A throw counts as deny.
*/
export type SendVerdict = undefined | null
| { action: 'allow'; bypass?: string[] }
/** `autoSent`: a hold this middleware would make was skipped by the owner's Auto-send; the chat marks the reply Auto-sent (#417). */
| { action: 'allow'; bypass?: string[]; autoSent?: boolean; recipient?: string }
| { action: 'deny'; reason?: string }
/** `recipient`: who receives it, as the owner knows them (a person's name); the card names it (#418). */
| { action: 'hold'; reason?: string; recipient?: string }
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second

- FlyBot `restrict()`s DSH's `ask_user_question` off Bot sessions (`hideBlockingQuestion`). DSH then appends a developer `tool-removal` context row, and `@deepseek-ai/dsh-client-ui-chat` renders it as "Tool removed: {name}". `ContextCell` shadows `conversation.chat.node` `context` and returns null for those notices (`HIDDEN_REMOVED_TOOLS`). Other tool-change context (tool-added, unrelated removals) still shows; `tool-call` cells are unchanged. Empty shadowed nodes collapse via the existing `:empty` flow-item rule. Covered by `core/test/tool-cells.test.js`.

- A Bot's held reply to a friend is drawn on its own turn (#418): `AssistantCell` (`cells.js`) finds it in the Bot's `heldReplies` (`text.js` `heldReplyOf`, by session and turn) and wraps the bubbles in `HeldReply` (`bt-held-reply`, `data-state` pending / approved / rejected / failed; pending and not-sent bubbles are dashed and muted). The turn's closing text carries `HeldReplyActions` (`held-reply.js`): while it waits, the holding policy's inline `flybot.approvals` entry (`inline: true`, props `{ held, decide, call, actions }`) or plain Approve / Reject, and it claims the id so no overlay card or pointer repeats it; after the decision a quiet `Sent to {to}` / `Not sent` line (Retry for a failed send). The recipient is the friend's Bot by `remoteBotTitle` when its chat is listed, else the person the host recorded (`toOwner`). The sidebar preview (`previewOf` `unsentTurns`) shows the friend's line for a turn whose reply did not go. The overlay ignores inline entries.
- A Bot's held reply to a friend is drawn on its own turn (#418): `AssistantCell` (`cells.js`) finds it in the Bot's `heldReplies` (`text.js` `heldReplyOf`, by session and turn) and wraps the bubbles in `HeldReply` (`bt-held-reply`, `data-state` pending / approved / rejected / failed; pending and not-sent bubbles are dashed and muted). The turn's closing text carries `HeldReplyActions` (`held-reply.js`): while it waits, the holding policy's inline `flybot.approvals` entry (`inline: true`, props `{ held, decide, call, actions }`) or plain Approve / Reject, and it claims the id so no overlay card or pointer repeats it; after the decision a quiet `Sent to {to}` / `Not sent` line (Retry for a failed send); a reply that went through Auto-send (`auto`, #417) is a plain bubble with `Auto-sent to {to}`. The recipient is the friend's Bot by `remoteBotTitle` when its chat is listed, else the person the host recorded (`toOwner`). The sidebar preview (`previewOf` `unsentTurns`) shows the friend's line for a turn whose reply did not go. The overlay ignores inline entries.
- The held-send overlay (`FlyBotApprovals`, `flybot-slots.js`) runs every hook before its `if (pending.length === 0) return null`: a hook after that return made React throw on the first held message and no card ever drew (#254). `test/overlay-approvals.test.js` renders it with a stand-in that enforces React's hook-count rule.

### Connectors page and the Composio row (#311)
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ credentials and team mode remain separate work under #55/#198.

### 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, 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. 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.
- 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).
Expand Down
Loading
Loading