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/471-inbox-failed-turns.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Agent inbox now lists a turn of your own Bot that failed under **Needs you**, with the reason, until the Bot finishes a turn in that chat again, from the same events as the finished/failed notifications; and each inbox card shows a one-line **Latest:** row with what happened last in its chat ([#471](https://github.com/ThinkFlowLab/FlyBot/issues/471)).
44 changes: 43 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,8 @@ var ZH = {
"1 message waits for your approval": "1 条消息等你批准",
"{count} messages wait for your approval": "{count} 条消息等你批准",
"Approve it in the chat": "请在对话里批准",
"The last turn did not finish": "上一轮没有完成",
"Latest: {text}": "最新:{text}",
"Untitled task": "未命名任务",
"for {time}": "已 {time}",
"just started": "刚开始",
Expand Down Expand Up @@ -6611,6 +6613,19 @@ var roomName = (roster, id) => roster.roomsById?.[id]?.name;
var placeOf = (roster, roomId) => roomId && roomName(roster, roomId) ? t("in {name}", { name: roomName(roster, roomId) }) : "";
var lastWhy = (task) => [...task.actors ?? []].reverse().find((entry) => typeof entry.why === "string" && entry.why !== "")?.why ?? "";
var chatOfTask = (task) => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId;
var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`;
var partOf = (roster, chat) => roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]);
var runningIn = (work, botId, roomId) => roomId === void 0 ? work.bots?.[botId]?.roomId === void 0 ? work.bots?.[botId] : void 0 : (work.rooms?.[roomId] ?? []).find((entry) => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : void 0);
function latestActivity(card, { roster, work, sessions }) {
const options = [];
if (card.kind === "task") options.push(lastWhy(card.task));
if (card.kind === "run" || card.kind === "task") {
const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : void 0) : void 0;
if (entry) options.push(`${workPhrase(entry.tool)}…`);
}
if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat)));
return options.find((text) => typeof text === "string" && text !== "" && text !== card.title && text !== card.reason) ?? "";
}
function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() }) {
const needs = [];
const progress = [];
Expand Down Expand Up @@ -6676,6 +6691,29 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now()
};
for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId);
for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId);
const lastEnded = /* @__PURE__ */ new Map();
for (const entry of work.ended ?? []) {
const key = placeKey(entry.botId, entry.roomId);
if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry);
}
for (const [key, entry] of lastEnded) {
const bot = ownBot(roster, entry.botId);
if (bot === void 0 || entry.failed === void 0 || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue;
const chat = entry.roomId ?? bot.id;
const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : "";
needs.push({
id: `turn:${entry.id}`,
kind: "turn",
status: "failed",
bot,
at: entry.at,
title: asked || t("The last turn did not finish"),
reason: entry.failed,
place: placeOf(roster, entry.roomId),
chat,
...people(roster, bot.id)
});
}
for (const task of tasks) {
const bot = ownBot(roster, task.ownerBotId);
if (bot === void 0) continue;
Expand All @@ -6700,6 +6738,7 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now()
if (sessions?.byId) {
for (const bot of roster.bots ?? []) {
if (ownBot(roster, bot.id) === void 0 || work.bots?.[bot.id] !== void 0 || waitingAt.has(`${bot.id}|`)) continue;
if (lastEnded.get(placeKey(bot.id))?.failed !== void 0) continue;
const part = currentPart(bot);
const at = sessions.byId[part]?.updatedAt;
if (!fresh(at)) continue;
Expand All @@ -6709,6 +6748,7 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now()
}
}
const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity);
for (const card of [...needs, ...progress, ...done]) card.activity = latestActivity(card, { roster, work, sessions });
return { needs: needs.sort(newest), progress: progress.sort(newest), done: done.sort(newest).slice(0, DONE_MAX) };
}
var needsCount = (roster, work, tasks) => inboxModel({ roster, work, tasks }).needs.length;
Expand Down Expand Up @@ -6764,6 +6804,7 @@ function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
),
(0, import_react11.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title),
card.reason ? (0, import_react11.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, card.reason) : null,
card.activity ? (0, import_react11.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null,
card.hint ? (0, import_react11.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null,
error ? (0, import_react11.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null
),
Expand Down Expand Up @@ -6888,7 +6929,8 @@ var INBOX_CSS = `
.bt-inbox-status[data-tone=muted]{background:var(--bt-hover);color:var(--bt-ink-2)}
.bt-inbox-meta{min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-inbox-title{font-size:13.5px;line-height:19px;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-inbox-reason,.bt-inbox-hint{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-inbox-reason,.bt-inbox-hint,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-inbox-activity{color:var(--bt-ink-3)}
.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
.bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center}
.bt-inbox-actions .bt-send{height:28px;font-size:12px}
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,8 @@ const ZH = {
'1 message waits for your approval': '1 条消息等你批准',
'{count} messages wait for your approval': '{count} 条消息等你批准',
'Approve it in the chat': '请在对话里批准',
'The last turn did not finish': '上一轮没有完成',
'Latest: {text}': '最新:{text}',
'Untitled task': '未命名任务',
'for {time}': '已 {time}',
'just started': '刚开始',
Expand Down
50 changes: 48 additions & 2 deletions core/src/client/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// done across my own Bots, opened from the Inbox row at the top of the sidebar. Built
// only from data the client already has: the work snapshot's approvals and running
// turns, the held-message counts on chats, the Bot chats' last activity and the task
// records (src/host/tasks.js). No new host state. A tool approval is answered with the
// records (src/host/tasks.js), and the ended turns the finish/fail notifications use
// (work.ended, #353) for failed turns (#471). A tool approval is answered with the
// same answer the group chat's card gives (actions.answerApproval); everything else,
// and every Bot-sent message, is approved in its chat, so its card only has Open.
import { createElement as h, useCallback, useEffect, useState } from 'react'
Expand Down Expand Up @@ -94,6 +95,27 @@ const roomName = (roster, id) => roster.roomsById?.[id]?.name
const placeOf = (roster, roomId) => (roomId && roomName(roster, roomId) ? t('in {name}', { name: roomName(roster, roomId) }) : '')
const lastWhy = task => [...(task.actors ?? [])].reverse().find(entry => typeof entry.why === 'string' && entry.why !== '')?.why ?? ''
const chatOfTask = task => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId
const placeKey = (botId, roomId) => `${botId}|${roomId ?? ''}`
// The Session a card's chat reads from: a group's own, a Bot's current part, else the id itself.
const partOf = (roster, chat) => (roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]))
// What a Bot runs in a place right now: its own chat's turn or its turn in that group.
const runningIn = (work, botId, roomId) => (roomId === undefined
? (work.bots?.[botId]?.roomId === undefined ? work.bots?.[botId] : undefined)
: (work.rooms?.[roomId] ?? []).find(entry => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : undefined))

// The card's one-line latest activity (#471): a task's newest note, else what its Bot runs
// there now (not for a card that waits for me), else that chat's last line. Left out when it
// only repeats the title or the reason.
function latestActivity(card, { roster, work, sessions }) {
const options = []
if (card.kind === 'task') options.push(lastWhy(card.task))
if (card.kind === 'run' || card.kind === 'task') {
const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : undefined) : undefined
if (entry) options.push(`${workPhrase(entry.tool)}…`)
}
if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat)))
return options.find(text => typeof text === 'string' && text !== '' && text !== card.title && text !== card.reason) ?? ''
}

/**
* The three groups of cards, newest first in each.
Expand Down Expand Up @@ -146,6 +168,25 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date
}
for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId)
for (const [roomId, entries] of Object.entries(work.rooms ?? {})) for (const entry of entries) running(entry.botId, entry, roomId)
// Failed turns (#471), from the ended turns the finish/fail notifications read (#353): a
// failure needs me while it is the latest ended turn in its place (a Bot's own chat or one
// group) and the Bot is not working or waiting there again. A later finished turn clears it.
const lastEnded = new Map()
for (const entry of work.ended ?? []) {
const key = placeKey(entry.botId, entry.roomId)
if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry)
}
for (const [key, entry] of lastEnded) {
const bot = ownBot(roster, entry.botId)
if (bot === undefined || entry.failed === undefined || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue
const chat = entry.roomId ?? bot.id
const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : ''
needs.push({
id: `turn:${entry.id}`, kind: 'turn', status: 'failed', bot, at: entry.at,
title: asked || t('The last turn did not finish'), reason: entry.failed,
place: placeOf(roster, entry.roomId), chat, ...people(roster, bot.id),
})
}
// Task records (#54).
for (const task of tasks) {
const bot = ownBot(roster, task.ownerBotId)
Expand All @@ -164,6 +205,8 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date
if (sessions?.byId) {
for (const bot of roster.bots ?? []) {
if (ownBot(roster, bot.id) === undefined || work.bots?.[bot.id] !== undefined || waitingAt.has(`${bot.id}|`)) continue
// Its last turn failed: that card is in Needs you.
if (lastEnded.get(placeKey(bot.id))?.failed !== undefined) continue
const part = currentPart(bot)
const at = sessions.byId[part]?.updatedAt
if (!fresh(at)) continue
Expand All @@ -173,6 +216,7 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date
}
}
const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity)
for (const card of [...needs, ...progress, ...done]) card.activity = latestActivity(card, { roster, work, sessions })
return { needs: needs.sort(newest), progress: progress.sort(newest), done: done.sort(newest).slice(0, DONE_MAX) }
}

Expand Down Expand Up @@ -220,6 +264,7 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen })
h('span', { className: 'bt-inbox-meta', title: meta }, meta)),
h('span', { className: 'bt-inbox-title', title: card.title }, card.title),
card.reason ? h('span', { className: 'bt-inbox-reason', title: card.reason }, card.reason) : null,
card.activity ? h('span', { className: 'bt-inbox-activity', title: card.activity }, t('Latest: {text}', { text: card.activity })) : null,
card.hint ? h('span', { className: 'bt-inbox-hint' }, card.hint) : null,
error ? h('span', { className: 'bt-inbox-error', role: 'alert' }, error) : null),
h('span', { className: 'bt-inbox-actions' },
Expand Down Expand Up @@ -322,7 +367,8 @@ export const INBOX_CSS = `
.bt-inbox-status[data-tone=muted]{background:var(--bt-hover);color:var(--bt-ink-2)}
.bt-inbox-meta{min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-inbox-title{font-size:13.5px;line-height:19px;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-inbox-reason,.bt-inbox-hint{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-inbox-reason,.bt-inbox-hint,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-inbox-activity{color:var(--bt-ink-3)}
.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
.bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center}
.bt-inbox-actions .bt-send{height:28px;font-size:12px}
Expand Down
21 changes: 16 additions & 5 deletions core/src/host/turns.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,22 @@ export function install(rt) {
}

// 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.
// (#353) and the Agent inbox's failed turns (#471). 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. Past the last 20, a place's (Bot + group) latest turn is kept while it is a
// failure younger than 7 days, so busy Bots elsewhere do not push it out of the inbox.
const ENDED_KEPT = 20
const FAILED_KEPT = 50
const FAILED_MS = 7 * 86_400_000
let ended = []
const placeOfEnded = entry => `${entry.botId}|${entry.roomId ?? ''}`
const trimEnded = (list, now) => {
const latest = new Map(list.map(entry => [placeOfEnded(entry), entry]))
const older = list.slice(0, -ENDED_KEPT)
.filter(entry => entry.failed !== undefined && latest.get(placeOfEnded(entry)) === entry && now - entry.at <= FAILED_MS)
return [...older.slice(-FAILED_KEPT), ...list.slice(-ENDED_KEPT)]
}
const noteEnded = (id, turn, info, reason, failure) => {
if (failure === undefined && reason?.kind !== 'completed') return
const owner = rt.groupOwners.get(id)
Expand All @@ -268,13 +279,13 @@ export function install(rt) {
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, {
ended = trimEnded([...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)
}], at)
}
const endedView = () => ended

Expand Down
5 changes: 5 additions & 0 deletions core/test/approval-notices.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,5 +168,10 @@ test('the host lists finished and failed turns of my own Bots, not stopped, aske
['g1:1', { botId: 'b', roomId: 'r1', failed: 'upstream busy' }],
])
for (let n = 10; n < 40; n++) turn('b', n, completed)
// Past the last 20, the failure in r1 stays: it is still the latest turn there (#471).
assert.deepEqual(rt.endedView().map(entry => entry.id.split(':').slice(0, 2).join(':')), ['g1:1', ...Array.from({ length: 20 }, (_, i) => `b:${20 + i}`)])
turn('g1', 2, completed, { kind: SOURCE_KIND, role: 'room', exchangeId: 'r1y' })
for (let n = 40; n < 60; n++) turn('b', n, completed)
assert.equal(rt.endedView().length, 20)
assert.ok(rt.endedView().every(entry => entry.failed === undefined))
})
Loading
Loading