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
21 changes: 20 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ var ZH = {
"Approve it in the chat": "请在对话里批准",
"The last turn did not finish": "上一轮没有完成",
"Latest: {text}": "最新:{text}",
"{count} tool calls": "{count} 次工具调用",
"Untitled task": "未命名任务",
"for {time}": "已 {time}",
"just started": "刚开始",
Expand Down Expand Up @@ -6890,6 +6891,19 @@ function latestActivity(card, { roster, work, sessions }) {
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 toolCalls(card, { roster, work }) {
const botId = card.bot?.id;
if (botId === void 0) return 0;
const roomId = roster.roomsById?.[card.chat] ? card.chat : void 0;
const running = runningIn(work, botId, roomId);
if (running !== void 0) return running.calls ?? 0;
let newest;
for (const entry of work.ended ?? []) {
if (entry.botId !== botId || (entry.roomId ?? "") !== (roomId ?? "")) continue;
if ((entry.at ?? 0) >= (newest?.at ?? -Infinity)) newest = entry;
}
return newest?.calls ?? 0;
}
function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() }) {
const needs = [];
const progress = [];
Expand Down Expand Up @@ -7031,7 +7045,10 @@ 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 });
for (const card of [...needs, ...progress, ...done]) {
card.activity = latestActivity(card, { roster, work, sessions });
card.calls = toolCalls(card, { roster, work });
}
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 @@ -7086,6 +7103,7 @@ function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
(0, import_react12.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta)
),
(0, import_react12.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title),
card.calls > 0 ? (0, import_react12.createElement)("span", { className: "bt-inbox-calls" }, t("{count} tool calls", { count: card.calls })) : null,
card.reason ? (0, import_react12.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, card.reason) : null,
card.activity ? (0, import_react12.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null,
card.hint ? (0, import_react12.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null,
Expand Down Expand Up @@ -7214,6 +7232,7 @@ var INBOX_CSS = `
.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,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
.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}
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 @@ -556,6 +556,7 @@ const ZH = {
'Approve it in the chat': '请在对话里批准',
'The last turn did not finish': '上一轮没有完成',
'Latest: {text}': '最新:{text}',
'{count} tool calls': '{count} 次工具调用',
'Untitled task': '未命名任务',
'for {time}': '已 {time}',
'just started': '刚开始',
Expand Down
24 changes: 23 additions & 1 deletion core/src/client/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,23 @@ function latestActivity(card, { roster, work, sessions }) {
return options.find(text => typeof text === 'string' && text !== '' && text !== card.title && text !== card.reason) ?? ''
}

// The tool calls of the turn a card shows for its place (#471): the turn running there now,
// else the newest ended turn there. One place only — never added across places, and 0
// when that turn used no tool or the place has no count.
function toolCalls(card, { roster, work }) {
const botId = card.bot?.id
if (botId === undefined) return 0
const roomId = roster.roomsById?.[card.chat] ? card.chat : undefined
const running = runningIn(work, botId, roomId)
if (running !== undefined) return running.calls ?? 0
let newest
for (const entry of work.ended ?? []) {
if (entry.botId !== botId || (entry.roomId ?? '') !== (roomId ?? '')) continue
if ((entry.at ?? 0) >= (newest?.at ?? -Infinity)) newest = entry
}
return newest?.calls ?? 0
}

/**
* The three groups of cards, newest first in each.
* `sessions` is the shell's Session list ({ byId }); `now` the current time.
Expand Down Expand Up @@ -230,7 +247,10 @@ 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 })
for (const card of [...needs, ...progress, ...done]) {
card.activity = latestActivity(card, { roster, work, sessions })
card.calls = toolCalls(card, { roster, work })
}
return { needs: needs.sort(newest), progress: progress.sort(newest), done: done.sort(newest).slice(0, DONE_MAX) }
}

Expand Down Expand Up @@ -277,6 +297,7 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen })
h(Status, { status: card.status }),
h('span', { className: 'bt-inbox-meta', title: meta }, meta)),
h('span', { className: 'bt-inbox-title', title: card.title }, card.title),
card.calls > 0 ? h('span', { className: 'bt-inbox-calls' }, t('{count} tool calls', { count: card.calls })) : null,
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,
Expand Down Expand Up @@ -385,6 +406,7 @@ export const INBOX_CSS = `
.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,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
.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}
Expand Down
14 changes: 11 additions & 3 deletions core/src/host/turns.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export function install(rt) {
if (botId === undefined || (owner !== undefined && bots[botId] !== undefined)) continue
const step = entry.tool ? stepOf(entry) : undefined
const handed = owner ? undefined : handedFromGroup(id)
bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(step ? { step } : {}), ...(owner ? { roomId: owner.roomId } : {}), ...(handed ? { fromRoomId: handed.room.id } : {}) }
bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(step ? { step } : {}), ...(entry.toolCalls > 0 ? { calls: entry.toolCalls } : {}), ...(owner ? { roomId: owner.roomId } : {}), ...(handed ? { fromRoomId: handed.room.id } : {}) }
}
return bots
}
Expand All @@ -264,6 +264,7 @@ export function install(rt) {
botId: owner.botId,
since: entry.since,
...(entry.tool ? { tool: entry.tool } : {}),
...(entry.toolCalls > 0 ? { calls: entry.toolCalls } : {}),
...(speaking.has(`${owner.roomId}:${owner.botId}`) ? { speaking: true } : {}),
}]
}
Expand Down Expand Up @@ -298,7 +299,7 @@ export function install(rt) {
.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) => {
const noteEnded = (id, turn, info, reason, failure, calls) => {
if (failure === undefined && reason?.kind !== 'completed') return
const owner = rt.groupOwners.get(id)
const bot = rt.botOf(owner?.botId ?? rt.selfOf(id))
Expand All @@ -312,6 +313,7 @@ export function install(rt) {
botId: bot.id,
...(owner ? { roomId: owner.roomId } : { sessionId: id }),
at,
...(calls > 0 ? { calls } : {}),
...(failure ? { failed: (failure.message || failure.code).slice(0, 160) } : {}),
}], at)
}
Expand Down Expand Up @@ -347,6 +349,7 @@ export function install(rt) {
case 'tool/call': {
const name = String(event.data?.name ?? '')
startCall(id, entry, event.data?.callId, name, String(event.data?.arguments ?? ''))
if (entry.turn !== undefined) entry.toolCalls = (entry.toolCalls ?? 0) + 1
// A program is as quick as the tools it runs, so its sub-calls decide.
if (rt.groupOwners.has(id) && name !== PROGRAM_TOOL) rt.workRoomTurn?.(id, name)
break
Expand All @@ -369,6 +372,9 @@ export function install(rt) {
entry.since = event.time ?? Date.now()
entry.texts = []
entry.info = undefined
// The turn's tool-call count for the inbox (#471), one per tool/call event, the same
// events the activity feed folds. A program's sub-calls are not tool/call events.
entry.toolCalls = 0
// A turn turned away before its first step may end without a turn/end.
entry.switched = false
entry.answers = false
Expand Down Expand Up @@ -502,6 +508,7 @@ export function install(rt) {
const switched = entry.switched === true
// The turn whose text answers a friend's line: a held reply is drawn on it, in place (#418).
const answeredTurn = entry.turn
const toolCalls = entry.toolCalls
entry.turn = undefined
entry.info = undefined
entry.texts = []
Expand All @@ -510,6 +517,7 @@ export function install(rt) {
entry.since = undefined
entry.tool = undefined
entry.calls = undefined
entry.toolCalls = undefined
entry.lastText = undefined
entry.lastCalled = undefined
entry.origin = undefined
Expand All @@ -525,7 +533,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)
if (!retry) noteEnded(id, answeredTurn, info, reason, failure, toolCalls)
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
Expand Down
36 changes: 33 additions & 3 deletions core/test/approval-notices.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,15 +203,15 @@ test('notifications from one chat collapse into one that counts the updates unti
})

// The host side: which ended turns the window hears of.
function turnsHost({ bots, rooms = {}, groupOwners = new Map() } = {}) {
function turnsHost({ bots, rooms = {}, groupOwners = new Map(), chatOwners = 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(),
chatOwners,
botOf: id => bots[id],
selfOf: id => groupOwners.get(id)?.botId ?? id,
save: async () => {},
Expand All @@ -230,7 +230,7 @@ function turnsHost({ bots, rooms = {}, groupOwners = new Map() } = {}) {
if (source) emit(id, 'user/message', { content: [{ type: 'text', text: 'hi' }], source })
emit(id, 'turn/end', { turn: n, reason })
}
return { rt, turn }
return { rt, emit, turn }
}

const completed = { kind: 'completed' }
Expand Down Expand Up @@ -264,6 +264,36 @@ test('the host lists finished and failed turns of my own Bots, not stopped, aske
assert.ok(rt.endedView().every(entry => entry.failed === undefined))
})

test("the work view counts each place's tool calls for the inbox, and the ended turn keeps its own (#471)", () => {
const { rt, emit } = turnsHost({
bots: { b: { id: 'b', name: 'Coder' } },
rooms: { r1: { id: 'r1', name: 'Ops' } },
groupOwners: new Map([['g1', { botId: 'b', roomId: 'r1' }]]),
chatOwners: new Map([['b', 'b']]),
})
const work = () => rt.workView().b
emit('b', 'turn/start', { turn: 1 })
assert.equal(work().calls, undefined)
emit('b', 'tool/call', { callId: 'k1', name: 'pwsh' })
emit('b', 'tool/call', { callId: 'k2', name: 'write' })
assert.equal(work().calls, 2)
// A turn without a tool call has no count to show.
emit('b', 'turn/end', { turn: 1, reason: completed })
assert.equal(work(), undefined)
assert.equal(rt.endedView()[0].calls, 2)
emit('b', 'turn/start', { turn: 2 })
emit('b', 'turn/end', { turn: 2, reason: completed })
assert.equal(rt.endedView()[1].calls, undefined)
// A group turn counts for that group only: it is never added to the Bot's own chat.
emit('g1', 'turn/start', { turn: 1 })
emit('g1', 'tool/call', { callId: 'k3', name: 'pwsh' })
assert.deepEqual(rt.roomWorkView().r1.map(entry => [entry.botId, entry.calls]), [['b', 1]])
// The Bot's entry names the group, so no own-chat card picks that count up.
assert.deepEqual([rt.workView().b.roomId, rt.workView().b.calls], ['r1', 1])
emit('g1', 'turn/end', { turn: 1, reason: completed })
assert.deepEqual(rt.endedView().map(entry => [entry.roomId, entry.calls]), [[undefined, 2], [undefined, undefined], ['r1', 1]])
})

test('Settings → General has one switch per kind under the main one, off while the main one is off (#469)', async () => {
const { readFileSync } = await import('node:fs')
const settings = readFileSync(new URL('../src/client/settings.js', import.meta.url), 'utf8')
Expand Down
2 changes: 1 addition & 1 deletion core/test/group-work.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ test('an own-chat turn handed out from a group names that group in the work view
await host.tool('message_bot', { to: 'Tester', message: '把测试跑一遍' }, 'gb')
host.begin('c')
host.emit('c', 'tool/call', { callId: 'k1', name: 'pwsh', arguments: JSON.stringify({ command: 'npm test' }) })
assert.deepEqual(plain((await bots()).c), { since: (await bots()).c.since, tool: 'pwsh', step: 'npm test', fromRoomId: 'r1' })
assert.deepEqual(plain((await bots()).c), { since: (await bots()).c.since, tool: 'pwsh', step: 'npm test', calls: 1, fromRoomId: 'r1' })
host.finish('c', 'ok')
// The user's own line, and a hand-off answered to the user in the Bot's own chat, are the Bot's own.
host.emit('a', 'turn/start', { turn: 1 })
Expand Down
46 changes: 46 additions & 0 deletions core/test/inbox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,52 @@ test('each card has a one-line latest activity that never repeats its title or r
}
})

// #471 v1: one tool-call count per card, for that card's own place, never summed.
const countedWork = {
bots: { b1: { since: NOW - 60_000, tool: 'pwsh', calls: 3 } },
rooms: { r1: [{ botId: 'b3', since: NOW - 30_000, calls: 2 }, { botId: 'b1', since: NOW - 60_000, calls: 5 }] },
approvals: [
{ id: 'c1', botId: 'b1', tool: 'edit', at: NOW - 1000, answerable: false },
{ id: 'c2', botId: 'b3', roomId: 'r1', tool: 'pwsh', at: NOW - 1000, answerable: true },
],
ended: [
{ id: 'x2', botId: 'b2', sessionId: 's2', at: NOW - 600_000 },
{ id: 'x1', botId: 'b2', sessionId: 's2', at: NOW - 120_000, calls: 7 },
{ id: 'x3', botId: 'b2', roomId: 'r1', at: NOW - 300_000, calls: 1 },
],
}
const countedTasks = [task('n1', 'working'), task('n2', 'working', { ownerBotId: 'b2', origin: { roomId: 'r1' } })]
const countedModel = inboxModel({ roster, work: countedWork, tasks: countedTasks, sessions, now: NOW })
const countedCard = id => [...countedModel.needs, ...countedModel.progress, ...countedModel.done].find(card => card.id === id)

test("a card shows its own place's tool-call count, from the running turn there or the last ended one (#471)", () => {
// The turn running in the place now: b1's own chat (3) and b3's turn in Ops (2).
assert.equal(countedCard('approval:c1').calls, 3)
assert.equal(countedCard('approval:c2').calls, 2)
assert.equal(countedCard('run:b1|r1').calls, 5)
// No turn running there: the newest ended turn in that place, never an older or another place's.
assert.equal(countedCard('task:n1').calls, 7)
assert.equal(countedCard('task:n2').calls, 1)
assert.equal(countedCard('chat:b2').calls, 7)
// Unknown (a card with no Bot, a place with no counted turn) is 0, and nothing is summed.
assert.equal(countedCard('held:r1').calls, 0)
assert.equal(inboxModel({ roster, work: { ...countedWork, ended: [] }, tasks: countedTasks, sessions, now: NOW }).progress.find(card => card.id === 'task:n1').calls, 0)
})

test('a count of 0 or unknown draws no line; a counted card draws one `N tool calls` line, read-only', () => {
const callsRows = card => byClass(draw(card), 'bt-inbox-calls')
const [row] = callsRows(countedCard('approval:c1'))
assert.equal(row.props.className, 'bt-inbox-calls')
assert.equal(textOf(row), '3 tool calls')
assert.equal(textOf(callsRows(countedCard('task:n2'))[0]), '1 tool calls')
for (const card of [countedCard('held:r1'), ...inboxModel({ roster, work, tasks, sessions, now: NOW }).done]) {
assert.equal(callsRows(card).length, 0, card.id)
assert.doesNotMatch(textOf(draw(card)), /tool calls/, card.id)
}
// Still read-only: the same single Open action, on a counted card too.
assert.deepEqual(plain(buttons(draw(countedCard('approval:c1'))).map(textOf)), ['Open'])
})

test('a question asked about work handed out from a group is a Needs you card that opens the group (#552)', () => {
const asked = {
...roster,
Expand Down
Loading
Loading