diff --git a/changelog.d/353-finish-fail-notifications.added.md b/changelog.d/353-finish-fail-notifications.added.md new file mode 100644 index 00000000..f960040f --- /dev/null +++ b/changelog.d/353-finish-fail-notifications.added.md @@ -0,0 +1 @@ +FlyBot now sends a system notification when one of your own Bots finishes or fails a turn while you are in another chat or app (`Coder finished`, `Coder failed: `), alongside the existing approval-waiting one; clicking it opens that chat, and it never approves, rejects or sends anything. Settings → General has a **Notifications** switch, on by default, that turns all of them off ([#353](https://github.com/ThinkFlowLab/FlyBot/issues/353)). diff --git a/core/client.js b/core/client.js index 8211cb2c..07dc3570 100644 --- a/core/client.js +++ b/core/client.js @@ -691,6 +691,10 @@ var ZH = { "Asked to make the tool call it wrote as text": "工具调用被写成了文字,已让它重新调用", "A Bot": "一个 Bot", "{name} is waiting for your approval": "{name} 等你批准", + "{name} finished": "{name} 已完成", + "{name} failed: {reason}": "{name} 失败:{reason}", + Notifications: "通知", + "When your Bots finish, fail, or wait for your approval while you are in another chat or app.": "你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。", "{first} and {second} are waiting for your approval": "{first}、{second} 等你批准", "{first}, {second} and {count} more are waiting for your approval": "{first}、{second} 等 {total} 个等你批准", "Waiting for your approval": "等你批准", @@ -13628,6 +13632,18 @@ function GeneralPage({ roster, actions, run }) { ) ) ) + ), + // One switch for every system notification: approvals waiting, Bots finished or failed (#353). + (0, import_react33.createElement)( + SetSection, + { key: "notify", title: t("Notifications") }, + (0, import_react33.createElement)(SwitchRow, { + label: t("Notifications"), + showLabel: false, + hint: t("When your Bots finish, fail, or wait for your approval while you are in another chat or app."), + on: prefs.notifications !== false, + onChange: (on) => run(actions.setPrefs({ notifications: on })) + }) ) ]; } @@ -16488,22 +16504,33 @@ var USER_MESSAGE_CSS = ` function noticeApprovals({ work, roster, shownSession, openSession, since: since2 = Date.now() }) { const told = /* @__PURE__ */ new Set(); const notify = () => { - const approvals = work.getSnapshot().approvals ?? []; + const { approvals = [], ended = [] } = work.getSnapshot(); + const events = [ + ...approvals.map((entry) => ({ entry, kind: "approval" })), + ...ended.map((entry) => ({ entry, kind: entry.failed === void 0 ? "finished" : "failed" })) + ]; + const key = ({ entry, kind }) => `${kind === "approval" ? "approval" : "turn"}-${entry.id}`; + const current = new Set(events.map(key)); for (const id of told) { - if (!approvals.some((entry) => entry.id === id)) told.delete(id); + if (!current.has(id)) told.delete(id); } if (typeof Notification !== "function" || Notification.permission !== "granted") return; - for (const entry of approvals) { - if (told.has(entry.id)) continue; - told.add(entry.id); - if ((entry.at ?? 0) < since2) continue; + const { byId, roomsById, prefs } = roster.getSnapshot(); + const on = prefs?.notifications !== false; + for (const event of events) { + const id = key(event); + if (told.has(id)) continue; + told.add(id); + const { entry, kind } = event; + if (!on || (entry.at ?? 0) < since2) continue; const place = entry.roomId ?? entry.sessionId; if (document.visibilityState === "visible" && shownSession() === place) continue; - const { byId, roomsById } = roster.getSnapshot(); const room2 = entry.roomId ? roomsById[entry.roomId] : void 0; - const note = new Notification(t("{name} is waiting for your approval", { name: byId[entry.botId]?.name ?? t("A Bot") }), { - body: [room2?.name, entry.tool].filter(Boolean).join(" · "), - tag: `ds-bot-approval-${entry.id}` + const name = byId[entry.botId]?.name ?? t("A Bot"); + const title = kind === "approval" ? t("{name} is waiting for your approval", { name }) : kind === "finished" ? t("{name} finished", { name }) : t("{name} failed: {reason}", { name, reason: entry.failed }); + const note = new Notification(title, { + body: [room2?.name, kind === "approval" ? entry.tool : void 0].filter(Boolean).join(" · "), + tag: `ds-bot-${id}` }); note.onclick = () => { window.focus(); diff --git a/core/src/client/approval-notices.js b/core/src/client/approval-notices.js index 4b863698..a1754d36 100644 --- a/core/src/client/approval-notices.js +++ b/core/src/client/approval-notices.js @@ -1,28 +1,44 @@ import { t } from './i18n.js' -// A system notification for each approval that starts waiting while this window is in -// the background or shows another conversation. Only where the platform already allows -// notifications: DS Bot never asks for the permission. +// A system notification for each approval that starts waiting, and each turn of the user's +// own Bots that finishes or fails (#353), while this window is in the background or shows +// another conversation. Only where the platform already allows notifications: DS Bot never +// asks for the permission. Settings → General → Notifications turns them all off. A +// notification only opens its chat: it never approves, rejects or sends anything. export function noticeApprovals({ work, roster, shownSession, openSession, since = Date.now() }) { const told = new Set() const notify = () => { - const approvals = work.getSnapshot().approvals ?? [] + const { approvals = [], ended = [] } = work.getSnapshot() + const events = [ + ...approvals.map(entry => ({ entry, kind: 'approval' })), + ...ended.map(entry => ({ entry, kind: entry.failed === undefined ? 'finished' : 'failed' })), + ] + const key = ({ entry, kind }) => `${kind === 'approval' ? 'approval' : 'turn'}-${entry.id}` + const current = new Set(events.map(key)) for (const id of told) { - if (!approvals.some(entry => entry.id === id)) told.delete(id) + if (!current.has(id)) told.delete(id) } if (typeof Notification !== 'function' || Notification.permission !== 'granted') return - for (const entry of approvals) { - if (told.has(entry.id)) continue - told.add(entry.id) - // Waiting before this window opened is no news: the sidebar shows it. - if ((entry.at ?? 0) < since) continue + const { byId, roomsById, prefs } = roster.getSnapshot() + const on = prefs?.notifications !== false + for (const event of events) { + const id = key(event) + if (told.has(id)) continue + told.add(id) + const { entry, kind } = event + // Waiting or ending before this window opened is no news: the sidebar shows it. + // Switched off, it stays told, so switching back on brings no old ones. + if (!on || (entry.at ?? 0) < since) continue const place = entry.roomId ?? entry.sessionId if (document.visibilityState === 'visible' && shownSession() === place) continue - const { byId, roomsById } = roster.getSnapshot() const room = entry.roomId ? roomsById[entry.roomId] : undefined - const note = new Notification(t('{name} is waiting for your approval', { name: byId[entry.botId]?.name ?? t('A Bot') }), { - body: [room?.name, entry.tool].filter(Boolean).join(' · '), - tag: `ds-bot-approval-${entry.id}`, + const name = byId[entry.botId]?.name ?? t('A Bot') + const title = kind === 'approval' ? t('{name} is waiting for your approval', { name }) + : kind === 'finished' ? t('{name} finished', { name }) + : t('{name} failed: {reason}', { name, reason: entry.failed }) + const note = new Notification(title, { + body: [room?.name, kind === 'approval' ? entry.tool : undefined].filter(Boolean).join(' · '), + tag: `ds-bot-${id}`, }) note.onclick = () => { window.focus() diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index f1c8773c..0a94d4e3 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -635,6 +635,10 @@ const ZH = { 'Asked to make the tool call it wrote as text': '工具调用被写成了文字,已让它重新调用', 'A Bot': '一个 Bot', '{name} is waiting for your approval': '{name} 等你批准', + '{name} finished': '{name} 已完成', + '{name} failed: {reason}': '{name} 失败:{reason}', + Notifications: '通知', + 'When your Bots finish, fail, or wait for your approval while you are in another chat or app.': '你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。', '{first} and {second} are waiting for your approval': '{first}、{second} 等你批准', '{first}, {second} and {count} more are waiting for your approval': '{first}、{second} 等 {total} 个等你批准', 'Waiting for your approval': '等你批准', diff --git a/core/src/client/settings.js b/core/src/client/settings.js index aafb68f5..93bbb4d5 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -7,7 +7,7 @@ import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' import { openHarnessSettings } from './sidebar.js' import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js' import { useEscape } from './overlay-hooks.js' -import { BotSettingsForm } from './bot-settings.js' +import { BotSettingsForm, SwitchRow } from './bot-settings.js' import { Segmented, useGlide } from './motion.js' import { GroupSettingsForm, NewGroupForm } from './group-settings.js' import { CreateBot } from './create-bot.js' @@ -36,7 +36,7 @@ function SetRow({ label, hint, children }) { children ? h('div', { className: 'bt-set-control' }, children) : null) } -// General is Appearance only; the switch to classic Agent mode is the account menu's (#393). +// General is Appearance and Notifications; the switch to classic Agent mode is the account menu's (#393). function GeneralPage({ roster, actions, run }) { const prefs = roster.prefs ?? {} const themes = allThemes() @@ -63,6 +63,13 @@ function GeneralPage({ roster, actions, run }) { })), h('label', { className: 'bt-swatch bt-swatch-custom', title: t('Custom accent'), 'data-on': Boolean(prefs.accent) && !ACCENT_INKS.some(ink => INKS[ink][0] === prefs.accent) }, h('input', { type: 'color', value: custom, 'aria-label': t('Custom accent'), onChange: event => pickCustom(event.target.value) }))))), + // One switch for every system notification: approvals waiting, Bots finished or failed (#353). + h(SetSection, { key: 'notify', title: t('Notifications') }, + h(SwitchRow, { + label: t('Notifications'), showLabel: false, + hint: t('When your Bots finish, fail, or wait for your approval while you are in another chat or app.'), + on: prefs.notifications !== false, onChange: on => run(actions.setPrefs({ notifications: on })), + })), ] } diff --git a/core/src/host/authz.js b/core/src/host/authz.js index dd302635..9054da37 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -181,6 +181,7 @@ export function install(rt) { bots: pick(full.work.bots), rooms: Object.fromEntries(Object.entries(full.work.rooms ?? {}).filter(([id]) => roomMemberOf(actor, id) || roomOwnedBy(actor, id))), approvals: full.work.approvals.filter(entry => mine(entry.botId)), + ended: (full.work.ended ?? []).filter(entry => mine(entry.botId)), }, memoryNews: pick(full.memoryNews), memoryReviews: alone() ? full.memoryReviews : 0, diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 690789b2..ebd96ccf 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -113,11 +113,11 @@ export function install(rt) { // What changes without a new revision, sent with every state answer. `memoryNews`: // per Bot, its latest memory change; `memoryReviews`: memory reviews waiting or // running; `work`: per Bot, the turn it runs and its tool; per group, the members whose - // turn there runs; and the approvals waiting. + // turn there runs; the approvals waiting; and the turns that just finished or failed. const live = actor => { const full = { activity: rt.activityView(), - work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView() }, + work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView(), ended: rt.endedView?.() ?? [] }, memoryNews: rt.memoryNews(), memoryReviews: rt.memoryReviews(), connectors: rt.connectorsView(), @@ -164,6 +164,12 @@ export function install(rt) { else if (body.motion === 'quiet' || body.motion === 'lively') state.prefs.motion = body.motion else throw invalid('Motion must be quiet, normal or lively') } + // Notifications are on unless switched off (#353). + if (body.notifications !== undefined) { + if (body.notifications === null || body.notifications === true) delete state.prefs.notifications + else if (body.notifications === false) state.prefs.notifications = false + else throw invalid('Notifications must be on or off') + } return rt.save() } diff --git a/core/src/host/turns.js b/core/src/host/turns.js index a5bbe7df..6f031bcd 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -253,6 +253,31 @@ export function install(rt) { return { ...(text ? { text: text.trim().slice(0, 300) } : {}), ...(code ? { code: code.trim().slice(0, 400) } : {}) } } + // The latest turns of the user's own Bots that finished or failed, for a system notification + // (#353). Process-local and bounded; a window skips the ones from before it opened. Left out: + // a friend's Bots, turns a friend started, turns another Bot asked for (hidden in the chat), + // rooms shared with other people, and turns the user stopped. + const ENDED_KEPT = 20 + let ended = [] + const noteEnded = (id, turn, info, reason, failure) => { + if (failure === undefined && reason?.kind !== 'completed') return + const owner = rt.groupOwners.get(id) + const bot = rt.botOf(owner?.botId ?? rt.selfOf(id)) + if (bot === undefined || bot.remote !== undefined) return + if (info !== undefined && (fromRemote(info) || info.role === 'remote' || info.form === 'remote-chat')) return + if (owner === undefined && state.exchangeTurns?.[id]?.includes(turn)) return + if (owner !== undefined && rt.roomOf(owner.roomId)?.origin !== undefined) return + const at = Date.now() + ended = [...ended, { + id: `${id}:${turn}:${at}`, + botId: bot.id, + ...(owner ? { roomId: owner.roomId } : { sessionId: id }), + at, + ...(failure ? { failed: (failure.message || failure.code).slice(0, 160) } : {}), + }].slice(-ENDED_KEPT) + } + const endedView = () => ended + // Where a Session speaks, for the diagnostics log. const placeOf = (id) => { const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id) @@ -460,6 +485,7 @@ export function install(rt) { const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText) const retry = wroteCall && info?.retried !== true && !(info?.role === 'room' && stoppedRoomTurns.has(info.exchangeId)) if (retry) void retryWrittenCall(id, info, origin).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error)) + if (!retry) noteEnded(id, answeredTurn, info, reason, failure) const replyText = () => { const reply = (wroteCall ? texts.map(stripWrittenCalls) : texts).join('\n\n').trim() return wroteCall && reply === '' ? 'Tried to run a tool but wrote the call as text, so it did not run.' : reply @@ -566,5 +592,5 @@ export function install(rt) { return found } - Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, callDetail, stopRoomTurn }) + Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, endedView, callDetail, stopRoomTurn }) } diff --git a/core/test/approval-notices.test.js b/core/test/approval-notices.test.js index c7792aed..99e281e4 100644 --- a/core/test/approval-notices.test.js +++ b/core/test/approval-notices.test.js @@ -3,6 +3,8 @@ import { test } from 'node:test' import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' import { build } from 'esbuild' +import { SOURCE_KIND } from '../src/host/constants.js' +import { install as installTurns } from '../src/host/turns.js' const bundle = await build({ entryPoints: [fileURLToPath(new URL('../src/client/approval-notices.js', import.meta.url))], @@ -10,7 +12,7 @@ const bundle = await build({ }) // One window: the platform's Notification, whether the page shows, and the work source. -function windowWith({ permission = 'granted', visibility = 'visible' } = {}) { +function windowWith({ permission = 'granted', visibility = 'visible', prefs = {} } = {}) { const shown = [] const opened = [] function Notification(title, options) { @@ -26,7 +28,7 @@ function windowWith({ permission = 'granted', visibility = 'visible' } = {}) { subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener) }, set: (next) => { snapshot = next; for (const listener of listeners) listener() }, } - const roster = { getSnapshot: () => ({ byId: { b: { id: 'b', name: 'Writer' } }, roomsById: { r1: { id: 'r1', name: 'Ops' } } }) } + const roster = { prefs, getSnapshot: () => ({ byId: { b: { id: 'b', name: 'Writer' } }, roomsById: { r1: { id: 'r1', name: 'Ops' } }, prefs: roster.prefs }) } const module = { exports: {} } runInNewContext(bundle.outputFiles[0].text, { module, exports: module.exports, Notification, @@ -62,3 +64,109 @@ test('no notification for the conversation on screen, for approvals older than t denied.work.set({ bots: {}, approvals: [{ id: 'x1', botId: 'b', roomId: 'r1', tool: 'pwsh', at: 200 }] }) assert.equal(denied.shown.length, 0) }) + +const ended = (entries) => ({ bots: {}, approvals: [], ended: entries }) + +test('a turn of my own Bot that finishes or fails elsewhere gets one notification that says why and opens its chat (#353)', () => { + const page = windowWith() + page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100 }) + const done = { id: 'chat-b:1:200', botId: 'b', sessionId: 'chat-b', at: 200 } + const broke = { id: 'g:2:300', botId: 'b', roomId: 'r1', at: 300, failed: 'upstream busy' } + page.work.set(ended([done])) + page.work.set(ended([done, broke])) + page.work.set(ended([done, broke])) + assert.deepEqual(page.shown.map(note => [note.title, note.body, note.tag]), [ + ['Writer finished', '', 'ds-bot-turn-chat-b:1:200'], + ['Writer failed: upstream busy', 'Ops', 'ds-bot-turn-g:2:300'], + ]) + page.shown[0].onclick() + page.shown[1].onclick() + assert.deepEqual([page.opened, page.shown.map(note => note.closed)], [['b', 'r1'], [true, true]]) + // A notification carries nothing to act on: no buttons, no reply. + assert.equal(page.shown.some(note => 'actions' in note), false) +}) + +test('no turn notification for the chat on screen, for turns that ended before the window opened, or without permission', () => { + const onScreen = windowWith() + onScreen.noticeApprovals({ work: onScreen.work, roster: onScreen.roster, shownSession: () => 'chat-b', openSession: onScreen.openSession, since: 100 }) + onScreen.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }, { id: 'e2', botId: 'b', sessionId: 'other', at: 50 }])) + assert.equal(onScreen.shown.length, 0) + + const hidden = windowWith({ visibility: 'hidden' }) + hidden.noticeApprovals({ work: hidden.work, roster: hidden.roster, shownSession: () => 'chat-b', openSession: hidden.openSession, since: 100 }) + hidden.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }])) + assert.equal(hidden.shown.length, 1) + + const denied = windowWith({ permission: 'denied' }) + denied.noticeApprovals({ work: denied.work, roster: denied.roster, shownSession: () => 'a', openSession: denied.openSession, since: 100 }) + denied.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }])) + assert.equal(denied.shown.length, 0) +}) + +test('the Notifications switch turns off every kind, and switching it back on brings no old ones', () => { + const page = windowWith({ prefs: { notifications: false } }) + page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100 }) + const approval = { id: 'x1', botId: 'b', roomId: 'r1', tool: 'pwsh', at: 200 } + page.work.set({ bots: {}, approvals: [approval], ended: [{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }] }) + assert.equal(page.shown.length, 0) + page.roster.prefs = {} + page.work.set({ bots: {}, approvals: [approval], ended: [{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }, { id: 'e2', botId: 'b', sessionId: 'chat-b', at: 300 }] }) + assert.deepEqual(page.shown.map(note => note.tag), ['ds-bot-turn-e2']) +}) + +// The host side: which ended turns the window hears of. +function turnsHost({ bots, rooms = {}, groupOwners = new Map() } = {}) { + let listener + const rt = { + ctx: { on: (name, fn) => { if (name === 'session/event') listener = fn } }, + state: { exchangeTurns: {}, questions: {} }, + isBotSession: () => true, + roomOf: id => rooms[id], + groupOwners, + chatOwners: new Map(), + botOf: id => bots[id], + selfOf: id => groupOwners.get(id)?.botId ?? id, + save: async () => {}, + deliver: async () => {}, + noteFailure() {}, + warn() {}, + switchJobs: () => [], + dropKickoff() {}, + flybotReplyRemote: async () => ({}), + } + installTurns(rt) + let seq = 0 + const emit = (id, type, data) => listener({ id }, { type, seq: ++seq, data }) + const turn = (id, n, reason, source) => { + emit(id, 'turn/start', { turn: n }) + if (source) emit(id, 'user/message', { content: [{ type: 'text', text: 'hi' }], source }) + emit(id, 'turn/end', { turn: n, reason }) + } + return { rt, turn } +} + +const completed = { kind: 'completed' } +const failed = { kind: 'error', error: { code: 'SERVER', message: 'upstream busy', status: 503 } } + +test('the host lists finished and failed turns of my own Bots, not stopped, asked-by-a-Bot, friend or shared-room turns', () => { + const { rt, turn } = turnsHost({ + bots: { a: { id: 'a', name: 'Chief' }, b: { id: 'b', name: 'Coder' }, f: { id: 'f', name: 'Pal', remote: { handle: '@pal' } } }, + rooms: { r1: { id: 'r1' }, face: { id: 'face', origin: 'face-to-face' } }, + groupOwners: new Map([['g1', { botId: 'b', roomId: 'r1' }], ['g2', { botId: 'b', roomId: 'face' }]]), + }) + turn('b', 1, completed) + turn('b', 2, failed) + turn('b', 3, { kind: 'aborted', reason: { kind: 'user' } }) + turn('b', 4, completed, { kind: SOURCE_KIND, role: 'request', senderSessionId: 'a', exchangeId: 'x1', hop: 1 }) + turn('b', 5, completed, { kind: SOURCE_KIND, role: 'request', remote: { handle: '@pal' }, exchangeId: 'x2', hop: 1 }) + turn('f', 1, completed) + turn('g1', 1, failed, { kind: SOURCE_KIND, role: 'room', exchangeId: 'r1x' }) + turn('g2', 1, completed, { kind: SOURCE_KIND, role: 'room', exchangeId: 'r2x' }) + assert.deepEqual(rt.endedView().map(({ id, at, ...entry }) => [id.split(':').slice(0, 2).join(':'), entry]), [ + ['b:1', { botId: 'b', sessionId: 'b' }], + ['b:2', { botId: 'b', sessionId: 'b', failed: 'upstream busy' }], + ['g1:1', { botId: 'b', roomId: 'r1', failed: 'upstream busy' }], + ]) + for (let n = 10; n < 40; n++) turn('b', n, completed) + assert.equal(rt.endedView().length, 20) +}) diff --git a/core/test/authz.test.js b/core/test/authz.test.js index a9c3f0b7..949b513f 100644 --- a/core/test/authz.test.js +++ b/core/test/authz.test.js @@ -327,7 +327,7 @@ test('views keep the sole owner whole and narrow per owner with two members', as } const fullLive = { activity: { a1: 'busy', b1: 'idle' }, - work: { bots: { a1: {}, b1: {} }, rooms: {}, approvals: [{ botId: 'a1' }, { botId: 'b1' }] }, + work: { bots: { a1: {}, b1: {} }, rooms: {}, approvals: [{ botId: 'a1' }, { botId: 'b1' }], ended: [{ botId: 'a1' }, { botId: 'b1' }] }, memoryNews: { a1: 1, b1: 2 }, memoryReviews: 3, connectors: [{ id: 'c' }], @@ -357,6 +357,7 @@ test('views keep the sole owner whole and narrow per owner with two members', as const live2 = rt.ownedLive(fullLive, solo) assert.deepEqual(Object.keys(live2.activity), ['a1']) assert.deepEqual(live2.work.approvals.map(entry => entry.botId), ['a1']) + assert.deepEqual(live2.work.ended.map(entry => entry.botId), ['a1']) assert.deepEqual(live2.connectors, []) assert.equal(live2.memoryReviews, 0) }) diff --git a/core/test/install.test.js b/core/test/install.test.js index b41d4d54..7c384212 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -144,6 +144,10 @@ test('the client channel answers from the saved state', async (t) => { assert.equal((await call('set-prefs', { motion: 'lively' })).value.motion, 'lively') assert.equal('motion' in (await call('set-prefs', { motion: 'normal' })).value, false) assert.deepEqual((await call('set-prefs', { motion: 'wild' })).error, { code: 'bot/invalid', message: 'Motion must be quiet, normal or lively', details: {} }) + // Notifications are on unless switched off; only off is kept (#353). + assert.equal((await call('set-prefs', { notifications: false })).value.notifications, false) + assert.equal('notifications' in (await call('set-prefs', { notifications: true })).value, false) + assert.deepEqual((await call('set-prefs', { notifications: 'loud' })).error, { code: 'bot/invalid', message: 'Notifications must be on or off', details: {} }) // A refusal from the team code keeps its message for the user. assert.deepEqual((await call('delete-bot', { id: 'nobody' })).error, { code: 'bot/refused', message: 'Unknown Bot', details: {} }) // Off the Desktop profile there are no Bot browsers. diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 955cac80..8510da74 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -108,6 +108,10 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A Bot row uses the same gold wait dot as a room row when `bot.pendingCount > 0` (Bot Social holds on that friend's chat) or a local hold is open. Shared-room overview pending lines stay one line with ellipsis (`bt-shared-wait`). +### System notifications (#353) + +- `approval-notices.js` `noticeApprovals` raises one system notification per event while the window is in the background or shows another chat: an approval that starts waiting (`{name} is waiting for your approval`), and a turn of one of the user's own Bots that finished (`{name} finished`) or failed (`{name} failed: `), from `work.ended` (`turns.js` `endedView`: the last 20, process-local; no friend's Bot, no turn a friend started, no hidden turn another Bot asked for, no face-to-face or inbound-share room, no turn the user stopped). Only when the platform already allows notifications (never asks), one per event (`tag` `ds-bot-approval-` / `ds-bot-turn-`), nothing from before the window opened; a click focuses the window and opens the chat or group, and a notification never approves, rejects or sends. Settings → General → **Notifications** (`prefs.notifications`, on unless `false`) switches every kind off; events seen while off stay told. Quiet hours, mute and *waiting too long* are later slices. Covered by `core/test/approval-notices.test.js`. + ### Bot tree chevron - The sidebar Bot-list tree (`sidebar.js` `TreeNode`, from slot `flybot.sidebar.structure`; optional `include(botId)` filters the default list, #452) draws collapse/expand as `.bt-tree-toggle`: a 28×28 hit target with a 16px `.bt-tree-chev` glyph, hover background, and a `:focus-visible` accent ring. The glyph rotates when `aria-expanded` is false; the button stays upright so hover and focus stay square. Expand/Collapse `{name}` labels and `aria-expanded` are unchanged. Leaf rows use the same 28px width as a spacer (`.bt-tree-leaf`) so names stay aligned; indent is still `depth * 16px` and the Bot row keeps its height. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 4c77b413..66df7aca 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -134,6 +134,10 @@ credentials and team mode remain separate work under #55/#198. - `rt.testModel(botId)` (`models.js`, endpoint `test-model`, Bot-owner only in `authz.js` `BOT_TARGETED`) sends one real request on the route the Bot's next run takes (`desiredModel`): a one-line prompt, `maxTokens` 16, the model's least thinking (`off`, else its first level), a 30 s timeout, tagged to the Bot's chat as `other` usage. Nothing retries it, and a Bot runs at most one test at a time (a second click is refused). Any non-error finish, a reply cut off at the limit included, is a pass; a stream with no finish is `UNKNOWN` (`{ ok: true, ref, ms }`). A failure is `{ ok: false, ref, code }`, where `testFailure` maps the DSH failure (code, status, a missing-key message) to a fixed code; the provider's message is never returned or logged, since it may echo a key. A Bot with no model answers `NO_MODEL` without a request; a friend's Bot is refused. +### Ended turns for notifications (#353) + +- `turns.js` keeps `endedView()`: the last 20 turns of the user's own Bots that finished (`reason.kind === 'completed'`) or failed (`failed`: the failure's message, else its code, at most 160 characters; an AUTH message stays out as in `turnFailure`), as `{ id: '::', botId, sessionId | roomId, at }`. Process-local, never saved. Not listed: a turn stopped, switched to a new part or retried for a tool call written as text; a friend's Bot (`bot.remote`); a turn a friend started (`fromRemote`, a remote-chat line); a hidden turn another Bot asked for (`exchangeTurns`); a group turn in a room with an `origin` (face-to-face, inbound-share). It rides `live().work.ended`; `rt.ownedLive` keeps the actor's own Bots' entries. `set-prefs` takes `notifications: false` (kept) or `true` / `null` (cleared: on by default). The client's use is in [core-client](core-client.md#system-notifications-353). + ### Diagnostics - Host diagnostics never depend on `ctx.logger`: DSH may export no logger, and a test or plugin-local host may carry one without `warn`. `rt.warn` still writes stderr and the diagnostics log in that case (`src/host/diagnostics.js`). Plugins reach the same log through `flybot.warn` (#68); see [plugin-runtime](plugin-runtime.md). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 87703ea6..96b6746c 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -31,6 +31,68 @@ diff --git a/README.md b/README.md - Give every model a `contextWindow`. DS Bot takes its lines from it: the usable window is the window minus the reply reserve (`maxTokens`), the hard line is 95% of that, and the check line is `checkRatio` of it. A model without `contextWindow` gets the adapter's default (262,144 tokens for `llm-pi-ai`), which may be wrong for that model; a model that reports no window at all counts as 500,000. - To send images, the provider needs `defaultInput: [ text, image ]`. +diff --git a/src/client/approval-notices.js b/src/client/approval-notices.js +--- a/src/client/approval-notices.js ++++ b/src/client/approval-notices.js +@@ -1,28 +1,44 @@ + import { t } from './i18n.js' + +-// A system notification for each approval that starts waiting while this window is in +-// the background or shows another conversation. Only where the platform already allows +-// notifications: DS Bot never asks for the permission. ++// A system notification for each approval that starts waiting, and each turn of the user's ++// own Bots that finishes or fails (#353), while this window is in the background or shows ++// another conversation. Only where the platform already allows notifications: DS Bot never ++// asks for the permission. Settings → General → Notifications turns them all off. A ++// notification only opens its chat: it never approves, rejects or sends anything. + export function noticeApprovals({ work, roster, shownSession, openSession, since = Date.now() }) { + const told = new Set() + const notify = () => { +- const approvals = work.getSnapshot().approvals ?? [] ++ const { approvals = [], ended = [] } = work.getSnapshot() ++ const events = [ ++ ...approvals.map(entry => ({ entry, kind: 'approval' })), ++ ...ended.map(entry => ({ entry, kind: entry.failed === undefined ? 'finished' : 'failed' })), ++ ] ++ const key = ({ entry, kind }) => `${kind === 'approval' ? 'approval' : 'turn'}-${entry.id}` ++ const current = new Set(events.map(key)) + for (const id of told) { +- if (!approvals.some(entry => entry.id === id)) told.delete(id) ++ if (!current.has(id)) told.delete(id) + } + if (typeof Notification !== 'function' || Notification.permission !== 'granted') return +- for (const entry of approvals) { +- if (told.has(entry.id)) continue +- told.add(entry.id) +- // Waiting before this window opened is no news: the sidebar shows it. +- if ((entry.at ?? 0) < since) continue ++ const { byId, roomsById, prefs } = roster.getSnapshot() ++ const on = prefs?.notifications !== false ++ for (const event of events) { ++ const id = key(event) ++ if (told.has(id)) continue ++ told.add(id) ++ const { entry, kind } = event ++ // Waiting or ending before this window opened is no news: the sidebar shows it. ++ // Switched off, it stays told, so switching back on brings no old ones. ++ if (!on || (entry.at ?? 0) < since) continue + const place = entry.roomId ?? entry.sessionId + if (document.visibilityState === 'visible' && shownSession() === place) continue +- const { byId, roomsById } = roster.getSnapshot() + const room = entry.roomId ? roomsById[entry.roomId] : undefined +- const note = new Notification(t('{name} is waiting for your approval', { name: byId[entry.botId]?.name ?? t('A Bot') }), { +- body: [room?.name, entry.tool].filter(Boolean).join(' · '), +- tag: `ds-bot-approval-${entry.id}`, ++ const name = byId[entry.botId]?.name ?? t('A Bot') ++ const title = kind === 'approval' ? t('{name} is waiting for your approval', { name }) ++ : kind === 'finished' ? t('{name} finished', { name }) ++ : t('{name} failed: {reason}', { name, reason: entry.failed }) ++ const note = new Notification(title, { ++ body: [room?.name, kind === 'approval' ? entry.tool : undefined].filter(Boolean).join(' · '), ++ tag: `ds-bot-${id}`, + }) + note.onclick = () => { + window.focus() diff --git a/src/client/bot-browsers.js b/src/client/bot-browsers.js --- a/src/client/bot-browsers.js +++ b/src/client/bot-browsers.js @@ -611,7 +673,14 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Stopped by': '叫停者:', '{count} messages with': '{count} 条消息,来自', 'Routine started': '例行任务开始', -@@ -453,6 +453,9 @@ const ZH = { +@@ -450,9 +450,16 @@ const ZH = { + 'Asked to make the tool call it wrote as text': '工具调用被写成了文字,已让它重新调用', + 'A Bot': '一个 Bot', + '{name} is waiting for your approval': '{name} 等你批准', ++ '{name} finished': '{name} 已完成', ++ '{name} failed: {reason}': '{name} 失败:{reason}', ++ Notifications: '通知', ++ 'When your Bots finish, fail, or wait for your approval while you are in another chat or app.': '你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。', '{first} and {second} are waiting for your approval': '{first}、{second} 等你批准', '{first}, {second} and {count} more are waiting for your approval': '{first}、{second} 等 {total} 个等你批准', 'Waiting for your approval': '等你批准', @@ -2633,6 +2702,38 @@ diff --git a/src/host/turns.js b/src/host/turns.js markExchangeTurn(sessionId, entry.turn) } } +@@ -238,6 +238,31 @@ export function install(rt) { + return { ...(text ? { text: text.trim().slice(0, 300) } : {}), ...(code ? { code: code.trim().slice(0, 400) } : {}) } + } + ++ // The latest turns of the user's own Bots that finished or failed, for a system notification ++ // (#353). Process-local and bounded; a window skips the ones from before it opened. Left out: ++ // a friend's Bots, turns a friend started, turns another Bot asked for (hidden in the chat), ++ // rooms shared with other people, and turns the user stopped. ++ const ENDED_KEPT = 20 ++ let ended = [] ++ const noteEnded = (id, turn, info, reason, failure) => { ++ if (failure === undefined && reason?.kind !== 'completed') return ++ const owner = rt.groupOwners.get(id) ++ const bot = rt.botOf(owner?.botId ?? rt.selfOf(id)) ++ if (bot === undefined || bot.remote !== undefined) return ++ if (info !== undefined && (fromRemote(info) || info.role === 'remote' || info.form === 'remote-chat')) return ++ if (owner === undefined && state.exchangeTurns?.[id]?.includes(turn)) return ++ if (owner !== undefined && rt.roomOf(owner.roomId)?.origin !== undefined) return ++ const at = Date.now() ++ ended = [...ended, { ++ id: `${id}:${turn}:${at}`, ++ botId: bot.id, ++ ...(owner ? { roomId: owner.roomId } : { sessionId: id }), ++ at, ++ ...(failure ? { failed: (failure.message || failure.code).slice(0, 160) } : {}), ++ }].slice(-ENDED_KEPT) ++ } ++ const endedView = () => ended ++ + // Where a Session speaks, for the diagnostics log. + const placeOf = (id) => { + const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id) @@ -293,6 +293,10 @@ export function install(rt) { entry.answers = false entry.opened = false @@ -2720,12 +2821,13 @@ diff --git a/src/host/turns.js b/src/host/turns.js const reason = event.data?.reason const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined if (failure !== undefined) noteTurnFailure(id, failure) -@@ -411,7 +411,7 @@ export function install(rt) { +@@ -411,7 +411,8 @@ export function install(rt) { // the call. const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText) const retry = wroteCall && info?.retried !== true && !(info?.role === 'room' && stoppedRoomTurns.has(info.exchangeId)) - if (retry) void retryWrittenCall(id, info).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error)) + if (retry) void retryWrittenCall(id, info, origin).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error)) ++ if (!retry) noteEnded(id, answeredTurn, info, reason, failure) const replyText = () => { const reply = (wroteCall ? texts.map(stripWrittenCalls) : texts).join('\n\n').trim() return wroteCall && reply === '' ? 'Tried to run a tool but wrote the call as text, so it did not run.' : reply @@ -2801,7 +2903,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js + return found + } + -+ Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, callDetail, stopRoomTurn }) ++ Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, endedView, callDetail, stopRoomTurn }) } diff --git a/src/host/usage.js b/src/host/usage.js --- a/src/host/usage.js @@ -2843,6 +2945,146 @@ diff --git a/src/shared/mentions.js b/src/shared/mentions.js + } + return missing +} +diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js +--- a/test/approval-notices.test.js ++++ b/test/approval-notices.test.js +@@ -3,6 +3,8 @@ import { test } from 'node:test' + import { fileURLToPath } from 'node:url' + import { runInNewContext } from 'node:vm' + import { build } from 'esbuild' ++import { SOURCE_KIND } from '../src/host/constants.js' ++import { install as installTurns } from '../src/host/turns.js' + + const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../src/client/approval-notices.js', import.meta.url))], +@@ -10,7 +10,7 @@ const bundle = await build({ + }) + + // One window: the platform's Notification, whether the page shows, and the work source. +-function windowWith({ permission = 'granted', visibility = 'visible' } = {}) { ++function windowWith({ permission = 'granted', visibility = 'visible', prefs = {} } = {}) { + const shown = [] + const opened = [] + function Notification(title, options) { +@@ -26,7 +26,7 @@ function windowWith({ permission = 'granted', visibility = 'visible' } = {}) { + subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener) }, + set: (next) => { snapshot = next; for (const listener of listeners) listener() }, + } +- const roster = { getSnapshot: () => ({ byId: { b: { id: 'b', name: 'Writer' } }, roomsById: { r1: { id: 'r1', name: 'Ops' } } }) } ++ const roster = { prefs, getSnapshot: () => ({ byId: { b: { id: 'b', name: 'Writer' } }, roomsById: { r1: { id: 'r1', name: 'Ops' } }, prefs: roster.prefs }) } + const module = { exports: {} } + runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, Notification, +@@ -62,3 +62,109 @@ test('no notification for the conversation on screen, for approvals older than t + denied.work.set({ bots: {}, approvals: [{ id: 'x1', botId: 'b', roomId: 'r1', tool: 'pwsh', at: 200 }] }) + assert.equal(denied.shown.length, 0) + }) ++ ++const ended = (entries) => ({ bots: {}, approvals: [], ended: entries }) ++ ++test('a turn of my own Bot that finishes or fails elsewhere gets one notification that says why and opens its chat (#353)', () => { ++ const page = windowWith() ++ page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100 }) ++ const done = { id: 'chat-b:1:200', botId: 'b', sessionId: 'chat-b', at: 200 } ++ const broke = { id: 'g:2:300', botId: 'b', roomId: 'r1', at: 300, failed: 'upstream busy' } ++ page.work.set(ended([done])) ++ page.work.set(ended([done, broke])) ++ page.work.set(ended([done, broke])) ++ assert.deepEqual(page.shown.map(note => [note.title, note.body, note.tag]), [ ++ ['Writer finished', '', 'ds-bot-turn-chat-b:1:200'], ++ ['Writer failed: upstream busy', 'Ops', 'ds-bot-turn-g:2:300'], ++ ]) ++ page.shown[0].onclick() ++ page.shown[1].onclick() ++ assert.deepEqual([page.opened, page.shown.map(note => note.closed)], [['b', 'r1'], [true, true]]) ++ // A notification carries nothing to act on: no buttons, no reply. ++ assert.equal(page.shown.some(note => 'actions' in note), false) ++}) ++ ++test('no turn notification for the chat on screen, for turns that ended before the window opened, or without permission', () => { ++ const onScreen = windowWith() ++ onScreen.noticeApprovals({ work: onScreen.work, roster: onScreen.roster, shownSession: () => 'chat-b', openSession: onScreen.openSession, since: 100 }) ++ onScreen.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }, { id: 'e2', botId: 'b', sessionId: 'other', at: 50 }])) ++ assert.equal(onScreen.shown.length, 0) ++ ++ const hidden = windowWith({ visibility: 'hidden' }) ++ hidden.noticeApprovals({ work: hidden.work, roster: hidden.roster, shownSession: () => 'chat-b', openSession: hidden.openSession, since: 100 }) ++ hidden.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }])) ++ assert.equal(hidden.shown.length, 1) ++ ++ const denied = windowWith({ permission: 'denied' }) ++ denied.noticeApprovals({ work: denied.work, roster: denied.roster, shownSession: () => 'a', openSession: denied.openSession, since: 100 }) ++ denied.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }])) ++ assert.equal(denied.shown.length, 0) ++}) ++ ++test('the Notifications switch turns off every kind, and switching it back on brings no old ones', () => { ++ const page = windowWith({ prefs: { notifications: false } }) ++ page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100 }) ++ const approval = { id: 'x1', botId: 'b', roomId: 'r1', tool: 'pwsh', at: 200 } ++ page.work.set({ bots: {}, approvals: [approval], ended: [{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }] }) ++ assert.equal(page.shown.length, 0) ++ page.roster.prefs = {} ++ page.work.set({ bots: {}, approvals: [approval], ended: [{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }, { id: 'e2', botId: 'b', sessionId: 'chat-b', at: 300 }] }) ++ assert.deepEqual(page.shown.map(note => note.tag), ['ds-bot-turn-e2']) ++}) ++ ++// The host side: which ended turns the window hears of. ++function turnsHost({ bots, rooms = {}, groupOwners = new Map() } = {}) { ++ let listener ++ const rt = { ++ ctx: { on: (name, fn) => { if (name === 'session/event') listener = fn } }, ++ state: { exchangeTurns: {}, questions: {} }, ++ isBotSession: () => true, ++ roomOf: id => rooms[id], ++ groupOwners, ++ chatOwners: new Map(), ++ botOf: id => bots[id], ++ selfOf: id => groupOwners.get(id)?.botId ?? id, ++ save: async () => {}, ++ deliver: async () => {}, ++ noteFailure() {}, ++ warn() {}, ++ switchJobs: () => [], ++ dropKickoff() {}, ++ flybotReplyRemote: async () => ({}), ++ } ++ installTurns(rt) ++ let seq = 0 ++ const emit = (id, type, data) => listener({ id }, { type, seq: ++seq, data }) ++ const turn = (id, n, reason, source) => { ++ emit(id, 'turn/start', { turn: n }) ++ if (source) emit(id, 'user/message', { content: [{ type: 'text', text: 'hi' }], source }) ++ emit(id, 'turn/end', { turn: n, reason }) ++ } ++ return { rt, turn } ++} ++ ++const completed = { kind: 'completed' } ++const failed = { kind: 'error', error: { code: 'SERVER', message: 'upstream busy', status: 503 } } ++ ++test('the host lists finished and failed turns of my own Bots, not stopped, asked-by-a-Bot, friend or shared-room turns', () => { ++ const { rt, turn } = turnsHost({ ++ bots: { a: { id: 'a', name: 'Chief' }, b: { id: 'b', name: 'Coder' }, f: { id: 'f', name: 'Pal', remote: { handle: '@pal' } } }, ++ rooms: { r1: { id: 'r1' }, face: { id: 'face', origin: 'face-to-face' } }, ++ groupOwners: new Map([['g1', { botId: 'b', roomId: 'r1' }], ['g2', { botId: 'b', roomId: 'face' }]]), ++ }) ++ turn('b', 1, completed) ++ turn('b', 2, failed) ++ turn('b', 3, { kind: 'aborted', reason: { kind: 'user' } }) ++ turn('b', 4, completed, { kind: SOURCE_KIND, role: 'request', senderSessionId: 'a', exchangeId: 'x1', hop: 1 }) ++ turn('b', 5, completed, { kind: SOURCE_KIND, role: 'request', remote: { handle: '@pal' }, exchangeId: 'x2', hop: 1 }) ++ turn('f', 1, completed) ++ turn('g1', 1, failed, { kind: SOURCE_KIND, role: 'room', exchangeId: 'r1x' }) ++ turn('g2', 1, completed, { kind: SOURCE_KIND, role: 'room', exchangeId: 'r2x' }) ++ assert.deepEqual(rt.endedView().map(({ id, at, ...entry }) => [id.split(':').slice(0, 2).join(':'), entry]), [ ++ ['b:1', { botId: 'b', sessionId: 'b' }], ++ ['b:2', { botId: 'b', sessionId: 'b', failed: 'upstream busy' }], ++ ['g1:1', { botId: 'b', roomId: 'r1', failed: 'upstream busy' }], ++ ]) ++ for (let n = 10; n < 40; n++) turn('b', n, completed) ++ assert.equal(rt.endedView().length, 20) ++}) diff --git a/test/client-bundle.test.js b/test/client-bundle.test.js --- a/test/client-bundle.test.js +++ b/test/client-bundle.test.js @@ -3463,6 +3705,17 @@ diff --git a/test/install.test.js b/test/install.test.js 'effect ds-bot: api route', 'route POST /api/bot', 'effect ds-bot: load state', +@@ -137,6 +137,10 @@ test('the client channel answers from the saved state', async (t) => { + assert.equal((await call('set-prefs', { motion: 'lively' })).value.motion, 'lively') + assert.equal('motion' in (await call('set-prefs', { motion: 'normal' })).value, false) + assert.deepEqual((await call('set-prefs', { motion: 'wild' })).error, { code: 'bot/invalid', message: 'Motion must be quiet, normal or lively', details: {} }) ++ // Notifications are on unless switched off; only off is kept (#353). ++ assert.equal((await call('set-prefs', { notifications: false })).value.notifications, false) ++ assert.equal('notifications' in (await call('set-prefs', { notifications: true })).value, false) ++ assert.deepEqual((await call('set-prefs', { notifications: 'loud' })).error, { code: 'bot/invalid', message: 'Notifications must be on or off', details: {} }) + // A refusal from the team code keeps its message for the user. + assert.deepEqual((await call('delete-bot', { id: 'nobody' })).error, { code: 'bot/refused', message: 'Unknown Bot', details: {} }) + // Off the Desktop profile there are no Bot browsers. diff --git a/test/memory.test.js b/test/memory.test.js --- a/test/memory.test.js +++ b/test/memory.test.js diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 4e6a5870..010f9a60 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -331,7 +331,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js // What changes without a new revision, sent with every state answer. `memoryNews`: // per Bot, its latest memory change; `memoryReviews`: memory reviews waiting or // running; `work`: per Bot, the turn it runs and its tool; per group, the members whose - // turn there runs; and the approvals waiting. +- // turn there runs; and the approvals waiting. - const live = () => ({ - activity: rt.activityView(), - work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView() }, @@ -340,10 +340,11 @@ diff --git a/src/host/channel.js b/src/host/channel.js - connectors: rt.connectorsView(), - capabilityStatuses: rt.capabilityStatusesView(), - }) ++ // turn there runs; the approvals waiting; and the turns that just finished or failed. + const live = actor => { + const full = { + activity: rt.activityView(), -+ work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView() }, ++ work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView(), ended: rt.endedView?.() ?? [] }, + memoryNews: rt.memoryNews(), + memoryReviews: rt.memoryReviews(), + connectors: rt.connectorsView(), @@ -378,7 +379,16 @@ diff --git a/src/host/channel.js b/src/host/channel.js function setPrefs(body) { if (body.theme !== undefined) { -@@ -100,25 +100,34 @@ export function install(rt) { +@@ -97,28 +97,43 @@ export function install(rt) { + else if (body.motion === 'quiet' || body.motion === 'lively') state.prefs.motion = body.motion + else throw invalid('Motion must be quiet, normal or lively') + } ++ // Notifications are on unless switched off (#353). ++ if (body.notifications !== undefined) { ++ if (body.notifications === null || body.notifications === true) delete state.prefs.notifications ++ else if (body.notifications === false) state.prefs.notifications = false ++ else throw invalid('Notifications must be on or off') ++ } return rt.save() } diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 31b3296e..466dc73a 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -367,7 +367,14 @@ diff --git a/client.js b/client.js "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", -@@ -509,6 +509,9 @@ var ZH = { +@@ -506,9 +506,16 @@ var ZH = { + "Asked to make the tool call it wrote as text": "工具调用被写成了文字,已让它重新调用", + "A Bot": "一个 Bot", + "{name} is waiting for your approval": "{name} 等你批准", ++ "{name} finished": "{name} 已完成", ++ "{name} failed: {reason}": "{name} 失败:{reason}", ++ Notifications: "通知", ++ "When your Bots finish, fail, or wait for your approval while you are in another chat or app.": "你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。", "{first} and {second} are waiting for your approval": "{first}、{second} 等你批准", "{first}, {second} and {count} more are waiting for your approval": "{first}、{second} 等 {total} 个等你批准", "Waiting for your approval": "等你批准", @@ -11413,7 +11420,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +10626,67 @@ function GeneralPage({ roster, actions, run }) { +@@ -10626,75 +10626,79 @@ function GeneralPage({ roster, actions, run }) { style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -11426,15 +11433,25 @@ diff --git a/client.js b/client.js ) ) ) -- ), + ), - (0, import_react28.createElement)( -- SetSection, ++ // One switch for every system notification: approvals waiting, Bots finished or failed (#353). ++ (0, import_react33.createElement)( + SetSection, - { key: "mode", title: t("Mode") }, - (0, import_react28.createElement)( - SetRow, - { label: t("Classic Agent mode") }, - (0, import_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.setSurface("agent") }, t("Switch")) - ) ++ { key: "notify", title: t("Notifications") }, ++ (0, import_react33.createElement)(SwitchRow, { ++ label: t("Notifications"), ++ showLabel: false, ++ hint: t("When your Bots finish, fail, or wait for your approval while you are in another chat or app."), ++ on: prefs.notifications !== false, ++ onChange: (on) => run(actions.setPrefs({ notifications: on })) ++ }) ) ]; } @@ -13323,6 +13340,50 @@ diff --git a/client.js b/client.js .bt-ubody a{color:inherit} .bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap} .bt-umention:hover{text-decoration:underline;text-underline-offset:2px} +@@ -13219,22 +13219,33 @@ var USER_MESSAGE_CSS = ` + function noticeApprovals({ work, roster, shownSession, openSession, since: since2 = Date.now() }) { + const told = /* @__PURE__ */ new Set(); + const notify = () => { +- const approvals = work.getSnapshot().approvals ?? []; ++ const { approvals = [], ended = [] } = work.getSnapshot(); ++ const events = [ ++ ...approvals.map((entry) => ({ entry, kind: "approval" })), ++ ...ended.map((entry) => ({ entry, kind: entry.failed === void 0 ? "finished" : "failed" })) ++ ]; ++ const key = ({ entry, kind }) => `${kind === "approval" ? "approval" : "turn"}-${entry.id}`; ++ const current = new Set(events.map(key)); + for (const id of told) { +- if (!approvals.some((entry) => entry.id === id)) told.delete(id); ++ if (!current.has(id)) told.delete(id); + } + if (typeof Notification !== "function" || Notification.permission !== "granted") return; +- for (const entry of approvals) { +- if (told.has(entry.id)) continue; +- told.add(entry.id); +- if ((entry.at ?? 0) < since2) continue; ++ const { byId, roomsById, prefs } = roster.getSnapshot(); ++ const on = prefs?.notifications !== false; ++ for (const event of events) { ++ const id = key(event); ++ if (told.has(id)) continue; ++ told.add(id); ++ const { entry, kind } = event; ++ if (!on || (entry.at ?? 0) < since2) continue; + const place = entry.roomId ?? entry.sessionId; + if (document.visibilityState === "visible" && shownSession() === place) continue; +- const { byId, roomsById } = roster.getSnapshot(); + const room2 = entry.roomId ? roomsById[entry.roomId] : void 0; +- const note = new Notification(t("{name} is waiting for your approval", { name: byId[entry.botId]?.name ?? t("A Bot") }), { +- body: [room2?.name, entry.tool].filter(Boolean).join(" · "), +- tag: `ds-bot-approval-${entry.id}` ++ const name = byId[entry.botId]?.name ?? t("A Bot"); ++ const title = kind === "approval" ? t("{name} is waiting for your approval", { name }) : kind === "finished" ? t("{name} finished", { name }) : t("{name} failed: {reason}", { name, reason: entry.failed }); ++ const note = new Notification(title, { ++ body: [room2?.name, kind === "approval" ? entry.tool : void 0].filter(Boolean).join(" · "), ++ tag: `ds-bot-${id}` + }); + note.onclick = () => { + window.focus(); @@ -13247,11 +13247,45 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since return work.subscribe(notify); } diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index c19ae909..59449b2a 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -179,14 +179,17 @@ diff --git a/src/client/diagnostics.js b/src/client/diagnostics.js diff --git a/src/client/settings.js b/src/client/settings.js --- a/src/client/settings.js +++ b/src/client/settings.js -@@ -5,6 +5,7 @@ import { allThemes } from './themes.js' +@@ -5,8 +5,9 @@ import { allThemes } from './themes.js' import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' import { openHarnessSettings } from './sidebar.js' +import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js' import { useEscape } from './overlay-hooks.js' - import { BotSettingsForm } from './bot-settings.js' +-import { BotSettingsForm } from './bot-settings.js' ++import { BotSettingsForm, SwitchRow } from './bot-settings.js' import { Segmented, useGlide } from './motion.js' + import { GroupSettingsForm, NewGroupForm } from './group-settings.js' + import { CreateBot } from './create-bot.js' @@ -23,7 +23,7 @@ const capitalize = word => word[0].toUpperCase() + word.slice(1) // Settings dialog: a 198px page list beside the page. Bots and group chats each list @@ -200,17 +203,24 @@ diff --git a/src/client/settings.js b/src/client/settings.js children ? h('div', { className: 'bt-set-control' }, children) : null) } -+// General is Appearance only; the switch to classic Agent mode is the account menu's (#393). ++// General is Appearance and Notifications; the switch to classic Agent mode is the account menu's (#393). function GeneralPage({ roster, actions, run }) { const prefs = roster.prefs ?? {} const themes = allThemes() -@@ -61,9 +61,6 @@ function GeneralPage({ roster, actions, run }) { +@@ -61,9 +61,13 @@ function GeneralPage({ roster, actions, run }) { })), h('label', { className: 'bt-swatch bt-swatch-custom', title: t('Custom accent'), 'data-on': Boolean(prefs.accent) && !ACCENT_INKS.some(ink => INKS[ink][0] === prefs.accent) }, h('input', { type: 'color', value: custom, 'aria-label': t('Custom accent'), onChange: event => pickCustom(event.target.value) }))))), - h(SetSection, { key: 'mode', title: t('Mode') }, - h(SetRow, { label: t('Classic Agent mode') }, - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => actions.setSurface('agent') }, t('Switch')))), ++ // One switch for every system notification: approvals waiting, Bots finished or failed (#353). ++ h(SetSection, { key: 'notify', title: t('Notifications') }, ++ h(SwitchRow, { ++ label: t('Notifications'), showLabel: false, ++ hint: t('When your Bots finish, fail, or wait for your approval while you are in another chat or app.'), ++ on: prefs.notifications !== false, onChange: on => run(actions.setPrefs({ notifications: on })), ++ })), ] } diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index dd302635..9054da37 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -181,6 +181,7 @@ export function install(rt) { bots: pick(full.work.bots), rooms: Object.fromEntries(Object.entries(full.work.rooms ?? {}).filter(([id]) => roomMemberOf(actor, id) || roomOwnedBy(actor, id))), approvals: full.work.approvals.filter(entry => mine(entry.botId)), + ended: (full.work.ended ?? []).filter(entry => mine(entry.botId)), }, memoryNews: pick(full.memoryNews), memoryReviews: alone() ? full.memoryReviews : 0, diff --git a/patches/ds-bot/files/test/authz.test.js b/patches/ds-bot/files/test/authz.test.js index a9c3f0b7..949b513f 100644 --- a/patches/ds-bot/files/test/authz.test.js +++ b/patches/ds-bot/files/test/authz.test.js @@ -327,7 +327,7 @@ test('views keep the sole owner whole and narrow per owner with two members', as } const fullLive = { activity: { a1: 'busy', b1: 'idle' }, - work: { bots: { a1: {}, b1: {} }, rooms: {}, approvals: [{ botId: 'a1' }, { botId: 'b1' }] }, + work: { bots: { a1: {}, b1: {} }, rooms: {}, approvals: [{ botId: 'a1' }, { botId: 'b1' }], ended: [{ botId: 'a1' }, { botId: 'b1' }] }, memoryNews: { a1: 1, b1: 2 }, memoryReviews: 3, connectors: [{ id: 'c' }], @@ -357,6 +357,7 @@ test('views keep the sole owner whole and narrow per owner with two members', as const live2 = rt.ownedLive(fullLive, solo) assert.deepEqual(Object.keys(live2.activity), ['a1']) assert.deepEqual(live2.work.approvals.map(entry => entry.botId), ['a1']) + assert.deepEqual(live2.work.ended.map(entry => entry.botId), ['a1']) assert.deepEqual(live2.connectors, []) assert.equal(live2.memoryReviews, 0) }) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 0ec2d3b1..77fcb9bb 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -33,7 +33,9 @@ "src/client/schedules.js", "README.md", "src/host/schedules.js", - "test/schedules.test.js" + "test/schedules.test.js", + "src/client/approval-notices.js", + "test/approval-notices.test.js" ], "0002-flybot-api-host": [ "index.js",